feat(youtube/return-youtube-dislike): show dislike as a percentage (#234)

Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
This commit is contained in:
LisousEinaiKyrios 2022-12-03 20:23:00 +04:00 committed by GitHub
parent 718c5a75c1
commit 7840bc48ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 217 additions and 77 deletions

View File

@ -2,6 +2,8 @@ package app.revanced.integrations.returnyoutubedislike;
import android.content.Context; import android.content.Context;
import android.icu.text.CompactDecimalFormat; import android.icu.text.CompactDecimalFormat;
import android.icu.text.DecimalFormat;
import android.icu.text.DecimalFormatSymbols;
import android.os.Build; import android.os.Build;
import android.text.SpannableString; import android.text.SpannableString;
@ -17,6 +19,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import app.revanced.integrations.returnyoutubedislike.requests.RYDVoteData;
import app.revanced.integrations.returnyoutubedislike.requests.ReturnYouTubeDislikeApi; import app.revanced.integrations.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
import app.revanced.integrations.settings.SettingsEnum; import app.revanced.integrations.settings.SettingsEnum;
import app.revanced.integrations.utils.LogHelper; import app.revanced.integrations.utils.LogHelper;
@ -41,7 +44,7 @@ public class ReturnYouTubeDislike {
private static volatile boolean isEnabled = SettingsEnum.RYD_ENABLED.getBoolean(); private static volatile boolean isEnabled = SettingsEnum.RYD_ENABLED.getBoolean();
/** /**
* Used to guard {@link #currentVideoId} and {@link #dislikeFetchFuture}, * Used to guard {@link #currentVideoId} and {@link #voteFetchFuture},
* as multiple threads access this class. * as multiple threads access this class.
*/ */
private static final Object videoIdLockObject = new Object(); private static final Object videoIdLockObject = new Object();
@ -50,10 +53,10 @@ public class ReturnYouTubeDislike {
private static String currentVideoId; private static String currentVideoId;
/** /**
* Stores the results of the dislike fetch, and used as a barrier to wait until fetch completes * Stores the results of the vote api fetch, and used as a barrier to wait until fetch completes
*/ */
@GuardedBy("videoIdLockObject") @GuardedBy("videoIdLockObject")
private static Future<Integer> dislikeFetchFuture; private static Future<RYDVoteData> voteFetchFuture;
public enum Vote { public enum Vote {
LIKE(1), LIKE(1),
@ -73,8 +76,14 @@ public class ReturnYouTubeDislike {
/** /**
* Used to format like/dislike count. * Used to format like/dislike count.
*/ */
@GuardedBy("ReturnYouTubeDislike.class") // number formatter is not thread safe @GuardedBy("ReturnYouTubeDislike.class") // not thread safe
private static CompactDecimalFormat compactNumberFormatter; private static CompactDecimalFormat dislikeCountFormatter;
/**
* Used to format like/dislike count.
*/
@GuardedBy("ReturnYouTubeDislike.class") // not thread safe
private static DecimalFormat dislikePercentageFormatter;
public static void onEnabledChange(boolean enabled) { public static void onEnabledChange(boolean enabled) {
isEnabled = enabled; isEnabled = enabled;
@ -86,9 +95,9 @@ public class ReturnYouTubeDislike {
} }
} }
private static Future<Integer> getDislikeFetchFuture() { private static Future<RYDVoteData> getVoteFetchFuture() {
synchronized (videoIdLockObject) { synchronized (videoIdLockObject) {
return dislikeFetchFuture; return voteFetchFuture;
} }
} }
@ -104,7 +113,7 @@ public class ReturnYouTubeDislike {
currentVideoId = videoId; currentVideoId = videoId;
// no need to wrap the fetchDislike call in a try/catch, // no need to wrap the fetchDislike call in a try/catch,
// as any exceptions are propagated out in the later Future#Get call // as any exceptions are propagated out in the later Future#Get call
dislikeFetchFuture = ReVancedUtils.submitOnBackgroundThread(() -> ReturnYouTubeDislikeApi.fetchDislikes(videoId)); voteFetchFuture = ReVancedUtils.submitOnBackgroundThread(() -> ReturnYouTubeDislikeApi.fetchVotes(videoId));
} }
} catch (Exception ex) { } catch (Exception ex) {
LogHelper.printException(() -> "Failed to load new video: " + videoId, ex); LogHelper.printException(() -> "Failed to load new video: " + videoId, ex);
@ -128,19 +137,19 @@ public class ReturnYouTubeDislike {
// Have to block the current thread until fetching is done // Have to block the current thread until fetching is done
// There's no known way to edit the text after creation yet // There's no known way to edit the text after creation yet
Integer dislikeCount; RYDVoteData votingData;
try { try {
dislikeCount = getDislikeFetchFuture().get(MILLISECONDS_TO_BLOCK_UI_WHILE_WAITING_FOR_DISLIKE_FETCH_TO_COMPLETE, TimeUnit.MILLISECONDS); votingData = getVoteFetchFuture().get(MILLISECONDS_TO_BLOCK_UI_WHILE_WAITING_FOR_DISLIKE_FETCH_TO_COMPLETE, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) { } catch (TimeoutException e) {
LogHelper.printDebug(() -> "UI timed out waiting for dislike fetch to complete"); LogHelper.printDebug(() -> "UI timed out waiting for dislike fetch to complete");
return; return;
} }
if (dislikeCount == null) { if (votingData == null) {
LogHelper.printDebug(() -> "Cannot add dislike count to UI (dislike count not available)"); LogHelper.printDebug(() -> "Cannot add dislike count to UI (RYD data not available)");
return; return;
} }
updateDislike(textRef, isSegmentedButton, dislikeCount); updateDislike(textRef, isSegmentedButton, votingData);
LogHelper.printDebug(() -> "Updated text on component: " + conversionContextString); LogHelper.printDebug(() -> "Updated text on component: " + conversionContextString);
} catch (Exception ex) { } catch (Exception ex) {
LogHelper.printException(() -> "Error while trying to update dislikes text", ex); LogHelper.printException(() -> "Error while trying to update dislikes text", ex);
@ -197,9 +206,11 @@ public class ReturnYouTubeDislike {
return userId; return userId;
} }
private static void updateDislike(AtomicReference<Object> textRef, boolean isSegmentedButton, int dislikeCount) { private static void updateDislike(AtomicReference<Object> textRef, boolean isSegmentedButton, RYDVoteData voteData) {
SpannableString oldSpannableString = (SpannableString) textRef.get(); SpannableString oldSpannableString = (SpannableString) textRef.get();
String newDislikeString = formatDislikeCount(dislikeCount); String newDislikeString = SettingsEnum.RYD_SHOW_DISLIKE_PERCENTAGE.getBoolean()
? formatDislikePercentage(voteData.dislikePercentage)
: formatDislikeCount(voteData.dislikeCount);
if (isSegmentedButton) { // both likes and dislikes are on a custom segmented button if (isSegmentedButton) { // both likes and dislikes are on a custom segmented button
// parse out the like count as a string // parse out the like count as a string
@ -239,16 +250,16 @@ public class ReturnYouTubeDislike {
textRef.set(newSpannableString); textRef.set(newSpannableString);
} }
private static String formatDislikeCount(int dislikeCount) { private static String formatDislikeCount(long dislikeCount) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
String formatted; String formatted;
synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize
if (compactNumberFormatter == null) { if (dislikeCountFormatter == null) {
Locale locale = ReVancedUtils.getContext().getResources().getConfiguration().locale; Locale locale = ReVancedUtils.getContext().getResources().getConfiguration().locale;
LogHelper.printDebug(() -> "Locale: " + locale); LogHelper.printDebug(() -> "Locale: " + locale);
compactNumberFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT); dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT);
} }
formatted = compactNumberFormatter.format(dislikeCount); formatted = dislikeCountFormatter.format(dislikeCount);
} }
LogHelper.printDebug(() -> "Dislike count: " + dislikeCount + " formatted as: " + formatted); LogHelper.printDebug(() -> "Dislike count: " + dislikeCount + " formatted as: " + formatted);
return formatted; return formatted;
@ -257,4 +268,29 @@ public class ReturnYouTubeDislike {
// never will be reached, as the oldest supported YouTube app requires Android N or greater // never will be reached, as the oldest supported YouTube app requires Android N or greater
return String.valueOf(dislikeCount); return String.valueOf(dislikeCount);
} }
private static String formatDislikePercentage(float dislikePercentage) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
String formatted;
synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize
if (dislikePercentageFormatter == null) {
Locale locale = ReVancedUtils.getContext().getResources().getConfiguration().locale;
LogHelper.printDebug(() -> "Locale: " + locale);
dislikePercentageFormatter = new DecimalFormat("", new DecimalFormatSymbols(locale));
}
if (dislikePercentage == 0 || dislikePercentage >= 0.01) { // zero, or at least 1%
dislikePercentageFormatter.applyLocalizedPattern("0"); // show only whole percentage points
} else { // between (0, 1)%
dislikePercentageFormatter.applyLocalizedPattern("0.#"); // show 1 digit precision
}
final char percentChar = dislikePercentageFormatter.getDecimalFormatSymbols().getPercent();
formatted = dislikePercentageFormatter.format(100 * dislikePercentage) + percentChar;
}
LogHelper.printDebug(() -> "Dislike percentage: " + dislikePercentage + " formatted as: " + formatted);
return formatted;
}
// never will be reached, as the oldest supported YouTube app requires Android N or greater
return (int) (100 * dislikePercentage) + "%";
}
} }

View File

@ -0,0 +1,76 @@
package app.revanced.integrations.returnyoutubedislike.requests;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.Objects;
/**
* ReturnYouTubeDislike API estimated like/dislike/view counts.
*
* ReturnYouTubeDislike does not guarantee when the counts are updated.
* So these values may lag behind what YouTube shows.
*/
public final class RYDVoteData {
public final String videoId;
/**
* Estimated number of views
*/
public final long viewCount;
/**
* Estimated like count
*/
public final long likeCount;
/**
* Estimated dislike count
*/
public final long dislikeCount;
/**
* Estimated percentage of likes for all votes. Value has range of [0, 1]
*
* A video with 400 positive votes, and 100 negative votes, has a likePercentage of 0.8
*/
public final float likePercentage;
/**
* Estimated percentage of dislikes for all votes. Value has range of [0, 1]
*
* A video with 400 positive votes, and 100 negative votes, has a dislikePercentage of 0.2
*/
public final float dislikePercentage;
/**
* @throws JSONException if JSON parse error occurs, or if the values make no sense (ie: negative values)
*/
public RYDVoteData(JSONObject json) throws JSONException {
Objects.requireNonNull(json);
videoId = json.getString("id");
viewCount = json.getLong("viewCount");
likeCount = json.getLong("likes");
dislikeCount = json.getLong("dislikes");
if (likeCount < 0 || dislikeCount < 0 || viewCount < 0) {
throw new JSONException("Unexpected JSON values: " + json);
}
likePercentage = (likeCount == 0 ? 0 : (float)likeCount / (likeCount + dislikeCount));
dislikePercentage = (dislikeCount == 0 ? 0 : (float)dislikeCount / (likeCount + dislikeCount));
}
@Override
public String toString() {
return "RYDVoteData{"
+ "videoId=" + videoId
+ ", viewCount=" + viewCount
+ ", likeCount=" + likeCount
+ ", dislikeCount=" + dislikeCount
+ ", likePercentage=" + likePercentage
+ ", dislikePercentage=" + dislikePercentage
+ '}';
}
// equals and hashcode is not implemented (currently not needed)
}

View File

@ -4,6 +4,7 @@ import android.util.Base64;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.IOException; import java.io.IOException;
@ -26,7 +27,7 @@ public class ReturnYouTubeDislikeApi {
private static final String RYD_API_URL = "https://returnyoutubedislikeapi.com/"; private static final String RYD_API_URL = "https://returnyoutubedislikeapi.com/";
/** /**
* Default connection and response timeout for {@link #fetchDislikes(String)} * Default connection and response timeout for {@link #fetchVotes(String)}
*/ */
private static final int API_GET_DISLIKE_DEFAULT_TIMEOUT_MILLISECONDS = 5000; private static final int API_GET_DISLIKE_DEFAULT_TIMEOUT_MILLISECONDS = 5000;
@ -128,11 +129,10 @@ public class ReturnYouTubeDislikeApi {
} }
/** /**
* @return The number of dislikes. * @return NULL if fetch failed, or if a rate limit is in effect.
* Returns NULL if fetch failed, or a rate limit is in effect.
*/ */
@Nullable @Nullable
public static Integer fetchDislikes(String videoId) { public static RYDVoteData fetchVotes(String videoId) {
ReVancedUtils.verifyOffMainThread(); ReVancedUtils.verifyOffMainThread();
Objects.requireNonNull(videoId); Objects.requireNonNull(videoId);
try { try {
@ -161,10 +161,14 @@ public class ReturnYouTubeDislikeApi {
} }
if (responseCode == SUCCESS_HTTP_STATUS_CODE) { if (responseCode == SUCCESS_HTTP_STATUS_CODE) {
JSONObject json = Requester.getJSONObject(connection); // also disconnects JSONObject json = Requester.getJSONObject(connection); // also disconnects
Integer fetchedDislikeCount = json.getInt("dislikes"); try {
LogHelper.printDebug(() -> "Fetched video: " + videoId RYDVoteData votingData = new RYDVoteData(json);
+ " dislikes: " + fetchedDislikeCount); LogHelper.printDebug(() -> "Voting data fetched: " + votingData);
return fetchedDislikeCount; return votingData;
} catch (JSONException ex) {
LogHelper.printException(() -> "Failed to parse video: " + videoId + " json: " + json, ex);
return null;
}
} }
LogHelper.printDebug(() -> "Failed to fetch dislikes for video: " + videoId LogHelper.printDebug(() -> "Failed to fetch dislikes for video: " + videoId
+ " response code was: " + responseCode); + " response code was: " + responseCode);

View File

@ -115,6 +115,7 @@ public enum SettingsEnum {
// RYD settings // RYD settings
RYD_USER_ID("ryd_userId", null, SharedPrefHelper.SharedPrefNames.RYD, ReturnType.STRING), RYD_USER_ID("ryd_userId", null, SharedPrefHelper.SharedPrefNames.RYD, ReturnType.STRING),
RYD_ENABLED("ryd_enabled", true, SharedPrefHelper.SharedPrefNames.RYD, ReturnType.BOOLEAN), RYD_ENABLED("ryd_enabled", true, SharedPrefHelper.SharedPrefNames.RYD, ReturnType.BOOLEAN),
RYD_SHOW_DISLIKE_PERCENTAGE("ryd_show_dislike_percentage", false, SharedPrefHelper.SharedPrefNames.RYD, ReturnType.BOOLEAN),
// SponsorBlock settings // SponsorBlock settings
SB_ENABLED("sb-enabled", true, SharedPrefHelper.SharedPrefNames.SPONSOR_BLOCK, ReturnType.BOOLEAN), SB_ENABLED("sb-enabled", true, SharedPrefHelper.SharedPrefNames.SPONSOR_BLOCK, ReturnType.BOOLEAN),

View File

@ -3,7 +3,6 @@ package app.revanced.integrations.settingsmenu;
import static app.revanced.integrations.sponsorblock.StringRef.str; import static app.revanced.integrations.sponsorblock.StringRef.str;
import android.app.Activity; import android.app.Activity;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
@ -18,65 +17,89 @@ import app.revanced.integrations.settings.SettingsEnum;
import app.revanced.integrations.utils.SharedPrefHelper; import app.revanced.integrations.utils.SharedPrefHelper;
public class ReturnYouTubeDislikeSettingsFragment extends PreferenceFragment { public class ReturnYouTubeDislikeSettingsFragment extends PreferenceFragment {
/**
* If ReturnYouTubeDislike is enabled
*/
private SwitchPreference enabledPreference;
/**
* If dislikes are shown as percentage
*/
private SwitchPreference percentagePreference;
private void updateUIState() {
final boolean rydIsEnabled = SettingsEnum.RYD_ENABLED.getBoolean();
final boolean dislikePercentageEnabled = SettingsEnum.RYD_SHOW_DISLIKE_PERCENTAGE.getBoolean();
enabledPreference.setSummary(rydIsEnabled
? str("revanced_ryd_enable_summary_on")
: str("revanced_ryd_enable_summary_off"));
percentagePreference.setSummary(dislikePercentageEnabled
? str("revanced_ryd_dislike_percentage_summary_on")
: str("revanced_ryd_dislike_percentage_summary_off"));
percentagePreference.setEnabled(rydIsEnabled);
}
@Override @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
getPreferenceManager().setSharedPreferencesName(SharedPrefHelper.SharedPrefNames.RYD.getName()); getPreferenceManager().setSharedPreferencesName(SharedPrefHelper.SharedPrefNames.RYD.getName());
final Activity context = this.getActivity(); Activity context = this.getActivity();
PreferenceScreen preferenceScreen = getPreferenceManager().createPreferenceScreen(context); PreferenceScreen preferenceScreen = getPreferenceManager().createPreferenceScreen(context);
setPreferenceScreen(preferenceScreen); setPreferenceScreen(preferenceScreen);
// RYD enable toggle enabledPreference = new SwitchPreference(context);
{ enabledPreference.setKey(SettingsEnum.RYD_ENABLED.getPath());
SwitchPreference preference = new SwitchPreference(context); enabledPreference.setDefaultValue(SettingsEnum.RYD_ENABLED.getDefaultValue());
preferenceScreen.addPreference(preference); enabledPreference.setChecked(SettingsEnum.RYD_ENABLED.getBoolean());
preference.setKey(SettingsEnum.RYD_ENABLED.getPath()); enabledPreference.setTitle(str("revanced_ryd_enable_title"));
preference.setDefaultValue(SettingsEnum.RYD_ENABLED.getDefaultValue()); enabledPreference.setOnPreferenceChangeListener((pref, newValue) -> {
preference.setChecked(SettingsEnum.RYD_ENABLED.getBoolean()); final boolean rydIsEnabled = (Boolean) newValue;
preference.setTitle(str("revanced_ryd_title")); SettingsEnum.RYD_ENABLED.saveValue(rydIsEnabled);
preference.setSummary(str("revanced_ryd_summary")); ReturnYouTubeDislike.onEnabledChange(rydIsEnabled);
preference.setOnPreferenceChangeListener((pref, newValue) -> {
final boolean value = (Boolean) newValue; updateUIState();
ReturnYouTubeDislike.onEnabledChange(value); return true;
SettingsEnum.RYD_ENABLED.saveValue(value); });
return true; preferenceScreen.addPreference(enabledPreference);
});
} percentagePreference = new SwitchPreference(context);
percentagePreference.setKey(SettingsEnum.RYD_SHOW_DISLIKE_PERCENTAGE.getPath());
percentagePreference.setDefaultValue(SettingsEnum.RYD_SHOW_DISLIKE_PERCENTAGE.getDefaultValue());
percentagePreference.setChecked(SettingsEnum.RYD_SHOW_DISLIKE_PERCENTAGE.getBoolean());
percentagePreference.setTitle(str("revanced_ryd_dislike_percentage_title"));
percentagePreference.setOnPreferenceChangeListener((pref, newValue) -> {
SettingsEnum.RYD_SHOW_DISLIKE_PERCENTAGE.saveValue((Boolean)newValue);
updateUIState();
return true;
});
preferenceScreen.addPreference(percentagePreference);
updateUIState();
// About category // About category
addAboutCategory(context, preferenceScreen);
PreferenceCategory aboutCategory = new PreferenceCategory(context);
aboutCategory.setTitle(str("about"));
preferenceScreen.addPreference(aboutCategory);
// ReturnYouTubeDislike Website
Preference aboutWebsitePreference = new Preference(context);
aboutWebsitePreference.setTitle(str("revanced_ryd_attribution_title"));
aboutWebsitePreference.setSummary(str("revanced_ryd_attribution_summary"));
aboutWebsitePreference.setOnPreferenceClickListener(pref -> {
Intent i = new Intent(Intent.ACTION_VIEW);
i.setData(Uri.parse("https://returnyoutubedislike.com"));
pref.getContext().startActivity(i);
return false;
});
preferenceScreen.addPreference(aboutWebsitePreference);
} }
private void addAboutCategory(Context context, PreferenceScreen screen) {
PreferenceCategory category = new PreferenceCategory(context);
screen.addPreference(category);
category.setTitle(str("about"));
{
Preference preference = new Preference(context);
screen.addPreference(preference);
preference.setTitle(str("revanced_ryd_attribution_title"));
preference.setSummary(str("revanced_ryd_attribution_summary"));
preference.setOnPreferenceClickListener(pref -> {
Intent i = new Intent(Intent.ACTION_VIEW);
i.setData(Uri.parse("https://returnyoutubedislike.com"));
pref.getContext().startActivity(i);
return false;
});
}
{
Preference preference = new Preference(context);
screen.addPreference(preference);
preference.setTitle("GitHub");
preference.setOnPreferenceClickListener(pref -> {
Intent i = new Intent(Intent.ACTION_VIEW);
i.setData(Uri.parse("https://github.com/Anarios/return-youtube-dislike"));
pref.getContext().startActivity(i);
return false;
});
}
}
} }