feat(YouTube - Playback speed): Show current playback speed on player speed dialog button (#5607)

Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
This commit is contained in:
MarcaD
2025-08-09 04:27:52 +03:00
committed by GitHub
parent 9c0638d128
commit 30176a3318
38 changed files with 780 additions and 774 deletions

View File

@@ -1,16 +1,20 @@
package app.revanced.extension.youtube
import app.revanced.extension.shared.Logger
import java.util.Collections
/**
* generic event provider class
*/
class Event<T> {
private val eventListeners = mutableSetOf<(T) -> Unit>()
private val eventListeners = Collections.synchronizedSet(mutableSetOf<(T) -> Unit>())
operator fun plusAssign(observer: (T) -> Unit) {
addObserver(observer)
}
fun addObserver(observer: (T) -> Unit) {
Logger.printDebug { "Adding observer: $observer" }
eventListeners.add(observer)
}
@@ -23,7 +27,8 @@ class Event<T> {
}
operator fun invoke(value: T) {
for (observer in eventListeners)
for (observer in eventListeners) {
observer.invoke(value)
}
}
}

View File

@@ -1,12 +1,18 @@
package app.revanced.extension.youtube.patches;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.libraries.youtube.innertube.model.media.VideoQuality;
import java.lang.ref.WeakReference;
import java.util.Arrays;
import java.util.Objects;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.Event;
import app.revanced.extension.youtube.shared.ShortsPlayerState;
import app.revanced.extension.youtube.shared.VideoState;
/**
@@ -16,11 +22,31 @@ import app.revanced.extension.youtube.shared.VideoState;
public final class VideoInformation {
public interface PlaybackController {
// Methods are added to YT classes during patching.
boolean seekTo(long videoTime);
void seekToRelative(long videoTimeOffset);
// Methods are added during patching.
boolean patch_seekTo(long videoTime);
void patch_seekToRelative(long videoTimeOffset);
}
/**
* Interface to use obfuscated methods.
*/
public interface VideoQualityMenuInterface {
// Method is added during patching.
void patch_setQuality(VideoQuality quality);
}
/**
* Video resolution of the automatic quality option..
*/
public static final int AUTOMATIC_VIDEO_QUALITY_VALUE = -2;
/**
* All quality names are the same for all languages.
* VideoQuality also has a resolution enum that can be used if needed.
*/
public static final String VIDEO_QUALITY_1080P_PREMIUM_NAME = "1080p Premium";
private static final float DEFAULT_YOUTUBE_PLAYBACK_SPEED = 1.0f;
/**
* Prefix present in all Short player parameters signature.
@@ -30,12 +56,10 @@ public final class VideoInformation {
private static WeakReference<PlaybackController> playerControllerRef = new WeakReference<>(null);
private static WeakReference<PlaybackController> mdxPlayerDirectorRef = new WeakReference<>(null);
@NonNull
private static String videoId = "";
private static long videoLength = 0;
private static long videoTime = -1;
@NonNull
private static volatile String playerResponseVideoId = "";
private static volatile boolean playerResponseVideoIdIsShort;
private static volatile boolean videoIdIsShort;
@@ -45,6 +69,44 @@ public final class VideoInformation {
*/
private static float playbackSpeed = DEFAULT_YOUTUBE_PLAYBACK_SPEED;
private static int desiredVideoResolution = AUTOMATIC_VIDEO_QUALITY_VALUE;
private static boolean qualityNeedsUpdating;
/**
* The available qualities of the current video.
*/
@Nullable
private static VideoQuality[] currentQualities;
/**
* The current quality of the video playing.
* This is always the actual quality even if Automatic quality is active.
*/
@Nullable
private static VideoQuality currentQuality;
/**
* The current VideoQualityMenuInterface, set during setVideoQuality.
*/
@Nullable
private static VideoQualityMenuInterface currentMenuInterface;
/**
* Callback for when the current quality changes.
*/
public static final Event<VideoQuality> onQualityChange = new Event<>();
@Nullable
public static VideoQuality[] getCurrentQualities() {
return currentQualities;
}
@Nullable
public static VideoQuality getCurrentQuality() {
return currentQuality;
}
/**
* Injection point.
*
@@ -52,12 +114,18 @@ public final class VideoInformation {
*/
public static void initialize(@NonNull PlaybackController playerController) {
try {
Logger.printDebug(() -> "newVideoStarted");
playerControllerRef = new WeakReference<>(Objects.requireNonNull(playerController));
videoTime = -1;
videoLength = 0;
playbackSpeed = DEFAULT_YOUTUBE_PLAYBACK_SPEED;
desiredVideoResolution = AUTOMATIC_VIDEO_QUALITY_VALUE;
currentQualities = null;
currentMenuInterface = null;
setCurrentQuality(null);
} catch (Exception ex) {
Logger.printException(() -> "Failed to initialize", ex);
Logger.printException(() -> "initialize failure", ex);
}
}
@@ -197,14 +265,14 @@ public final class VideoInformation {
if (controller == null) {
Logger.printDebug(() -> "Cannot seekTo because player controller is null");
} else {
if (controller.seekTo(adjustedSeekTime)) return true;
if (controller.patch_seekTo(adjustedSeekTime)) return true;
Logger.printDebug(() -> "seekTo did not succeeded. Trying MXD.");
// Else the video is loading or changing videos, or video is casting to a different device.
}
// Try calling the seekTo method of the MDX player director (called when casting).
// The difference has to be a different second mark in order to avoid infinite skip loops
// as the Lounge API only supports seconds.
// as the Lounge API only supports whole seconds.
if (adjustedSeekTime / 1000 == videoTime / 1000) {
Logger.printDebug(() -> "Skipping seekTo for MDX because seek time is too small "
+ "(" + (adjustedSeekTime - videoTime) + "ms)");
@@ -217,9 +285,9 @@ public final class VideoInformation {
return false;
}
return controller.seekTo(adjustedSeekTime);
return controller.patch_seekTo(adjustedSeekTime);
} catch (Exception ex) {
Logger.printException(() -> "Failed to seek", ex);
Logger.printException(() -> "seekTo failure", ex);
return false;
}
}
@@ -239,7 +307,7 @@ public final class VideoInformation {
if (controller == null) {
Logger.printDebug(() -> "Cannot seek relative as player controller is null");
} else {
controller.seekToRelative(seekTime);
controller.patch_seekToRelative(seekTime);
}
// Adjust the fine adjustment function so it's at least 1 second before/after.
@@ -255,10 +323,10 @@ public final class VideoInformation {
if (controller == null) {
Logger.printDebug(() -> "Cannot seek relative as MXD player controller is null");
} else {
controller.seekToRelative(adjustedSeekTime);
controller.patch_seekToRelative(adjustedSeekTime);
}
} catch (Exception ex) {
Logger.printException(() -> "Failed to seek relative", ex);
Logger.printException(() -> "seekToRelative failure", ex);
}
}
@@ -373,4 +441,123 @@ public final class VideoInformation {
playbackSpeed = newlyLoadedPlaybackSpeed;
}
}
/**
* @param resolution The desired video quality resolution to use.
*/
public static void setDesiredVideoResolution(int resolution) {
Utils.verifyOnMainThread();
Logger.printDebug(() -> "Setting desired video resolution: " + resolution);
desiredVideoResolution = resolution;
qualityNeedsUpdating = true;
}
private static void setCurrentQuality(@Nullable VideoQuality quality) {
Utils.verifyOnMainThread();
if (currentQuality != quality) {
Logger.printDebug(() -> "Current quality changed to: " + quality);
currentQuality = quality;
onQualityChange.invoke(quality);
}
}
/**
* Forcefully changes the video quality of the currently playing video.
*/
public static void changeQuality(VideoQuality quality) {
Utils.verifyOnMainThread();
if (currentMenuInterface == null) {
Logger.printException(() -> "Cannot change quality, menu interface is null");
return;
}
currentMenuInterface.patch_setQuality(quality);
}
/**
* Injection point. Fixes bad data used by YouTube.
*/
public static int fixVideoQualityResolution(String name, int quality) {
final int correctQuality = 480;
if (name.equals("480p") && quality != correctQuality) {
return correctQuality;
}
return quality;
}
/**
* Injection point.
*
* @param qualities Video qualities available, ordered from largest to smallest, with index 0 being the 'automatic' value of -2
* @param originalQualityIndex quality index to use, as chosen by YouTube
*/
public static int setVideoQuality(VideoQuality[] qualities, VideoQualityMenuInterface menu, int originalQualityIndex) {
try {
Utils.verifyOnMainThread();
currentMenuInterface = menu;
final boolean availableQualitiesChanged = (currentQualities == null)
|| !Arrays.equals(currentQualities, qualities);
if (availableQualitiesChanged) {
currentQualities = qualities;
Logger.printDebug(() -> "VideoQualities: " + Arrays.toString(currentQualities));
}
VideoQuality updatedCurrentQuality = qualities[originalQualityIndex];
if (updatedCurrentQuality.patch_getResolution() != AUTOMATIC_VIDEO_QUALITY_VALUE
&& (currentQuality == null || currentQuality != updatedCurrentQuality)) {
setCurrentQuality(updatedCurrentQuality);
}
final int preferredQuality = desiredVideoResolution;
if (preferredQuality == AUTOMATIC_VIDEO_QUALITY_VALUE) {
return originalQualityIndex; // Nothing to do.
}
// After changing videos the qualities can initially be for the prior video.
// If the qualities have changed and the default is not auto then an update is needed.
if (qualityNeedsUpdating) {
qualityNeedsUpdating = false;
} else if (!availableQualitiesChanged) {
return originalQualityIndex;
}
// Find the highest quality that is equal to or less than the preferred.
int i = 0;
final int lastQualityIndex = qualities.length - 1;
for (VideoQuality quality : qualities) {
final int qualityResolution = quality.patch_getResolution();
if ((qualityResolution != AUTOMATIC_VIDEO_QUALITY_VALUE && qualityResolution <= preferredQuality)
// Use the lowest video quality if the default is lower than all available.
|| i == lastQualityIndex) {
final boolean qualityNeedsChange = (i != originalQualityIndex);
Logger.printDebug(() -> qualityNeedsChange
? "Changing video quality from: " + updatedCurrentQuality + " to: " + quality
: "Video is already the preferred quality: " + quality
);
// On first load of a new regular video, if the video is already the
// desired quality then the quality flyout will show 'Auto' (ie: Auto (720p)).
//
// To prevent user confusion, set the video index even if the
// quality is already correct so the UI picker will not display "Auto".
//
// Only change Shorts quality if the quality actually needs to change,
// because the "auto" option is not shown in the flyout
// and setting the same quality again can cause the Short to restart.
if (qualityNeedsChange || !ShortsPlayerState.isOpen()) {
changeQuality(quality);
return i;
}
return originalQualityIndex;
}
i++;
}
} catch (Exception ex) {
Logger.printException(() -> "setVideoQuality failure", ex);
}
return originalQualityIndex;
}
}

View File

@@ -3,12 +3,8 @@ package app.revanced.extension.youtube.patches.playback.quality;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.shared.Utils.NetworkType;
import androidx.annotation.Nullable;
import com.google.android.libraries.youtube.innertube.model.media.VideoQuality;
import java.util.Arrays;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.BooleanSetting;
@@ -16,69 +12,15 @@ import app.revanced.extension.shared.settings.IntegerSetting;
import app.revanced.extension.youtube.patches.VideoInformation;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.ShortsPlayerState;
import app.revanced.extension.youtube.videoplayer.VideoQualityDialogButton;
@SuppressWarnings("unused")
public class RememberVideoQualityPatch {
/**
* Interface to use obfuscated methods.
*/
public interface VideoQualityMenuInterface {
void patch_setQuality(VideoQuality quality);
}
/**
* Video resolution of the automatic quality option..
*/
public static final int AUTOMATIC_VIDEO_QUALITY_VALUE = -2;
/**
* All quality names are the same for all languages.
* VideoQuality also has a resolution enum that can be used if needed.
*/
public static final String VIDEO_QUALITY_1080P_PREMIUM_NAME = "1080p Premium";
private static final IntegerSetting videoQualityWifi = Settings.VIDEO_QUALITY_DEFAULT_WIFI;
private static final IntegerSetting videoQualityMobile = Settings.VIDEO_QUALITY_DEFAULT_MOBILE;
private static final IntegerSetting shortsQualityWifi = Settings.SHORTS_QUALITY_DEFAULT_WIFI;
private static final IntegerSetting shortsQualityMobile = Settings.SHORTS_QUALITY_DEFAULT_MOBILE;
private static boolean qualityNeedsUpdating;
/**
* The available qualities of the current video.
*/
@Nullable
private static VideoQuality[] currentQualities;
/**
* The current quality of the video playing.
* This is always the actual quality even if Automatic quality is active.
*/
@Nullable
private static VideoQuality currentQuality;
/**
* The current VideoQualityMenuInterface, set during setVideoQuality.
*/
@Nullable
private static VideoQualityMenuInterface currentMenuInterface;
@Nullable
public static VideoQuality[] getCurrentQualities() {
return currentQualities;
}
@Nullable
public static VideoQuality getCurrentQuality() {
return currentQuality;
}
@Nullable
public static VideoQualityMenuInterface getCurrentMenuInterface() {
return currentMenuInterface;
}
public static boolean shouldRememberVideoQuality() {
BooleanSetting preference = ShortsPlayerState.isOpen()
@@ -128,87 +70,12 @@ public class RememberVideoQualityPatch {
/**
* Injection point.
*
* @param qualities Video qualities available, ordered from largest to smallest, with index 0 being the 'automatic' value of -2
* @param originalQualityIndex quality index to use, as chosen by YouTube
*/
public static int setVideoQuality(VideoQuality[] qualities, VideoQualityMenuInterface menu, int originalQualityIndex) {
try {
Utils.verifyOnMainThread();
currentMenuInterface = menu;
final boolean availableQualitiesChanged = (currentQualities == null)
|| !Arrays.equals(currentQualities, qualities);
if (availableQualitiesChanged) {
currentQualities = qualities;
Logger.printDebug(() -> "VideoQualities: " + Arrays.toString(currentQualities));
}
VideoQuality updatedCurrentQuality = qualities[originalQualityIndex];
if (updatedCurrentQuality.patch_getResolution() != AUTOMATIC_VIDEO_QUALITY_VALUE
&& (currentQuality == null || currentQuality != updatedCurrentQuality)) {
currentQuality = updatedCurrentQuality;
Logger.printDebug(() -> "Current quality changed to: " + updatedCurrentQuality);
VideoQualityDialogButton.updateButtonIcon(updatedCurrentQuality);
}
final int preferredQuality = getDefaultQualityResolution();
if (preferredQuality == AUTOMATIC_VIDEO_QUALITY_VALUE) {
return originalQualityIndex; // Nothing to do.
}
// After changing videos the qualities can initially be for the prior video.
// If the qualities have changed and the default is not auto then an update is needed.
if (!qualityNeedsUpdating && !availableQualitiesChanged) {
return originalQualityIndex;
}
qualityNeedsUpdating = false;
// Find the highest quality that is equal to or less than the preferred.
int i = 0;
for (VideoQuality quality : qualities) {
final int qualityResolution = quality.patch_getResolution();
if ((qualityResolution != AUTOMATIC_VIDEO_QUALITY_VALUE && qualityResolution <= preferredQuality)
// Use the lowest video quality if the default is lower than all available.
|| i == qualities.length - 1) {
final boolean qualityNeedsChange = (i != originalQualityIndex);
Logger.printDebug(() -> qualityNeedsChange
? "Changing video quality from: " + updatedCurrentQuality + " to: " + quality
: "Video is already the preferred quality: " + quality
);
// On first load of a new regular video, if the video is already the
// desired quality then the quality flyout will show 'Auto' (ie: Auto (720p)).
//
// To prevent user confusion, set the video index even if the
// quality is already correct so the UI picker will not display "Auto".
//
// Only change Shorts quality if the quality actually needs to change,
// because the "auto" option is not shown in the flyout
// and setting the same quality again can cause the Short to restart.
if (qualityNeedsChange || !ShortsPlayerState.isOpen()) {
menu.patch_setQuality(qualities[i]);
return i;
}
return originalQualityIndex;
}
i++;
}
} catch (Exception ex) {
Logger.printException(() -> "setVideoQuality failure", ex);
}
return originalQualityIndex;
}
/**
* Injection point.
* @param userSelectedQualityIndex Element index of {@link #currentQualities}.
* @param userSelectedQualityIndex Element index of {@link VideoInformation#getCurrentQualities()}.
*/
public static void userChangedShortsQuality(int userSelectedQualityIndex) {
try {
if (shouldRememberVideoQuality()) {
VideoQuality[] currentQualities = VideoInformation.getCurrentQualities();
if (currentQualities == null) {
Logger.printDebug(() -> "Cannot save default quality, qualities is null");
return;
@@ -227,6 +94,7 @@ public class RememberVideoQualityPatch {
*/
public static void userChangedQuality(int videoResolution) {
Utils.verifyOnMainThread();
Logger.printDebug(() -> "User changed quality to: " + videoResolution);
if (shouldRememberVideoQuality()) {
saveDefaultQuality(videoResolution);
@@ -237,27 +105,6 @@ public class RememberVideoQualityPatch {
* Injection point.
*/
public static void newVideoStarted(VideoInformation.PlaybackController ignoredPlayerController) {
Utils.verifyOnMainThread();
Logger.printDebug(() -> "newVideoStarted");
currentQualities = null;
currentQuality = null;
currentMenuInterface = null;
qualityNeedsUpdating = true;
// Hide the quality button until playback starts and the qualities are available.
VideoQualityDialogButton.updateButtonIcon(null);
}
/**
* Injection point. Fixes bad data used by YouTube.
*/
public static int fixVideoQualityResolution(String name, int quality) {
final int correctQuality = 480;
if (name.equals("480p") && quality != correctQuality) {
return correctQuality;
}
return quality;
VideoInformation.setDesiredVideoResolution(getDefaultQualityResolution());
}
}

View File

@@ -100,7 +100,8 @@ public class CustomPlaybackSpeedPatch {
private static WeakReference<Dialog> currentDialog = new WeakReference<>(null);
static {
// Cap at 2 decimals (rounds automatically).
// Use same 2 digit format as built in speed picker,
speedFormatter.setMinimumFractionDigits(2);
speedFormatter.setMaximumFractionDigits(2);
final float holdSpeed = Settings.SPEED_TAP_AND_HOLD.get();
@@ -321,7 +322,7 @@ public class CustomPlaybackSpeedPatch {
TextView currentSpeedText = new TextView(context);
float currentSpeed = VideoInformation.getPlaybackSpeed();
// Initially show with only 0 minimum digits, so 1.0 shows as 1x
currentSpeedText.setText(formatSpeedStringX(currentSpeed, 0));
currentSpeedText.setText(formatSpeedStringX(currentSpeed));
currentSpeedText.setTextColor(Utils.getAppForegroundColor());
currentSpeedText.setTextSize(16);
currentSpeedText.setTypeface(Typeface.DEFAULT_BOLD);
@@ -398,10 +399,11 @@ public class CustomPlaybackSpeedPatch {
return null;
}
VideoInformation.overridePlaybackSpeed(roundedSpeed);
RememberPlaybackSpeedPatch.userSelectedPlaybackSpeed(roundedSpeed);
currentSpeedText.setText(formatSpeedStringX(roundedSpeed, 2)); // Update display.
currentSpeedText.setText(formatSpeedStringX(roundedSpeed)); // Update display.
speedSlider.setProgress(speedToProgressValue(roundedSpeed)); // Update slider.
RememberPlaybackSpeedPatch.userSelectedPlaybackSpeed(roundedSpeed);
VideoInformation.overridePlaybackSpeed(roundedSpeed);
return null;
};
@@ -437,7 +439,7 @@ public class CustomPlaybackSpeedPatch {
gridParams.setMargins(0, 0, 0, 0); // No margins around GridLayout.
gridLayout.setLayoutParams(gridParams);
// For all buttons show at least 1 zero in decimal (2 -> "2.0").
// For button use 1 digit minimum.
speedFormatter.setMinimumFractionDigits(1);
// Add buttons for each preset playback speed.
@@ -455,7 +457,7 @@ public class CustomPlaybackSpeedPatch {
// Create speed button.
Button speedButton = new Button(context, null, 0);
speedButton.setText(speedFormatter.format(speed)); // Do not use 'x' speed format.
speedButton.setText(speedFormatter.format(speed));
speedButton.setTextColor(Utils.getAppForegroundColor());
speedButton.setTextSize(12);
speedButton.setAllCaps(false);
@@ -498,6 +500,9 @@ public class CustomPlaybackSpeedPatch {
gridLayout.addView(buttonContainer);
}
// Restore 2 digit minimum.
speedFormatter.setMinimumFractionDigits(2);
// Add in-rows speed buttons layout to main layout.
mainLayout.addView(gridLayout);
@@ -631,8 +636,7 @@ public class CustomPlaybackSpeedPatch {
* @param speed The playback speed value to format.
* @return A string representation of the speed with 'x' (e.g. "1.25x" or "1.00x").
*/
private static String formatSpeedStringX(float speed, int minimumFractionDigits) {
speedFormatter.setMinimumFractionDigits(minimumFractionDigits);
private static String formatSpeedStringX(float speed) {
return speedFormatter.format(speed) + 'x';
}

View File

@@ -26,6 +26,7 @@ public class CreateSegmentButton {
controlsView,
"revanced_sb_create_segment_button",
null,
null,
CreateSegmentButton::shouldBeShown,
v -> SponsorBlockViewController.toggleNewSegmentLayoutVisibility(),
null

View File

@@ -28,6 +28,7 @@ public class VotingButton {
controlsView,
"revanced_sb_voting_button",
null,
null,
VotingButton::shouldBeShown,
v -> SponsorBlockUtils.onVotingClicked(v.getContext()),
null

View File

@@ -7,7 +7,6 @@ import androidx.annotation.Nullable;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.youtube.patches.CopyVideoUrlPatch;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.PlayerType;
@SuppressWarnings("unused")
public class CopyVideoUrlButton {
@@ -23,6 +22,7 @@ public class CopyVideoUrlButton {
controlsView,
"revanced_copy_video_url_button",
"revanced_copy_video_url_button_placeholder",
null,
Settings.COPY_VIDEO_URL::get,
view -> CopyVideoUrlPatch.copyUrl(false),
view -> {

View File

@@ -7,7 +7,6 @@ import androidx.annotation.Nullable;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.youtube.patches.CopyVideoUrlPatch;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.PlayerType;
@SuppressWarnings("unused")
public class CopyVideoUrlTimestampButton {
@@ -23,6 +22,7 @@ public class CopyVideoUrlTimestampButton {
controlsView,
"revanced_copy_video_url_timestamp_button",
"revanced_copy_video_url_timestamp_button_placeholder",
null,
Settings.COPY_VIDEO_URL_TIMESTAMP::get,
view -> CopyVideoUrlPatch.copyUrl(true),
view -> {

View File

@@ -23,6 +23,7 @@ public class ExternalDownloadButton {
controlsView,
"revanced_external_download_button",
"revanced_external_download_button_placeholder",
null,
Settings.EXTERNAL_DOWNLOADER::get,
ExternalDownloadButton::onDownloadClick,
null

View File

@@ -4,16 +4,26 @@ import android.view.View;
import androidx.annotation.Nullable;
import java.text.DecimalFormat;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.patches.VideoInformation;
import app.revanced.extension.youtube.patches.playback.speed.CustomPlaybackSpeedPatch;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class PlaybackSpeedDialogButton {
@Nullable
private static PlayerControlButton instance;
private static final DecimalFormat speedDecimalFormatter = new DecimalFormat();
static {
speedDecimalFormatter.setMinimumFractionDigits(1);
speedDecimalFormatter.setMaximumFractionDigits(2);
}
/**
* Injection point.
*/
@@ -21,8 +31,10 @@ public class PlaybackSpeedDialogButton {
try {
instance = new PlayerControlButton(
controlsView,
"revanced_playback_speed_dialog_button_container",
"revanced_playback_speed_dialog_button",
"revanced_playback_speed_dialog_button_placeholder",
"revanced_playback_speed_dialog_button_text",
Settings.PLAYBACK_SPEED_DIALOG_BUTTON::get,
view -> {
try {
@@ -37,11 +49,11 @@ public class PlaybackSpeedDialogButton {
},
view -> {
try {
final float defaultSpeed = Settings.PLAYBACK_SPEED_DEFAULT.get();
final float speed = (!Settings.REMEMBER_PLAYBACK_SPEED_LAST_SELECTED.get() ||
VideoInformation.getPlaybackSpeed() == Settings.PLAYBACK_SPEED_DEFAULT.get())
VideoInformation.getPlaybackSpeed() == defaultSpeed)
? 1.0f
: Settings.PLAYBACK_SPEED_DEFAULT.get();
: defaultSpeed;
VideoInformation.overridePlaybackSpeed(speed);
} catch (Exception ex) {
Logger.printException(() -> "speed button reset failure", ex);
@@ -49,22 +61,53 @@ public class PlaybackSpeedDialogButton {
return true;
}
);
// Set the appropriate icon.
updateButtonAppearance();
} catch (Exception ex) {
Logger.printException(() -> "initializeButton failure", ex);
}
}
/**
* injection point
* Injection point.
*/
public static void setVisibilityImmediate(boolean visible) {
if (instance != null) instance.setVisibilityImmediate(visible);
if (instance != null) {
instance.setVisibilityImmediate(visible);
}
}
/**
* injection point
* Injection point.
*/
public static void setVisibility(boolean visible, boolean animated) {
if (instance != null) instance.setVisibility(visible, animated);
if (instance != null) {
instance.setVisibility(visible, animated);
}
}
}
/**
* Injection point.
*/
public static void videoSpeedChanged(float currentVideoSpeed) {
updateButtonAppearance();
}
/**
* Updates the button's appearance, including icon and text overlay.
*/
private static void updateButtonAppearance() {
if (instance == null) return;
try {
Utils.verifyOnMainThread();
String speedText = speedDecimalFormatter.format(VideoInformation.getPlaybackSpeed());
instance.setTextOverlay(speedText);
Logger.printDebug(() -> "Updated playback speed button text to: " + speedText);
} catch (Exception ex) {
Logger.printException(() -> "updateButtonAppearance failure", ex);
}
}
}

View File

@@ -3,6 +3,7 @@ package app.revanced.extension.youtube.videoplayer;
import android.view.View;
import android.view.animation.Animation;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
@@ -14,11 +15,12 @@ import app.revanced.extension.youtube.shared.PlayerType;
import kotlin.Unit;
public class PlayerControlButton {
public interface PlayerControlButtonVisibility {
public interface PlayerControlButtonStatus {
/**
* @return If the button should be shown when the player overlay is visible.
*/
boolean shouldBeShown();
boolean buttonEnabled();
}
private static final int fadeInDuration;
@@ -44,23 +46,46 @@ public class PlayerControlButton {
fadeOutImmediate.setDuration(Utils.getResourceInteger("fade_duration_fast"));
}
private final WeakReference<View> containerRef;
private final WeakReference<View> buttonRef;
/**
* Empty view with the same layout size as the button. Used to fill empty space while the
* fade out animation runs. Without this the chapter titles overlapping the button when fading out.
*/
private final WeakReference<View> placeHolderRef;
private final PlayerControlButtonVisibility visibilityCheck;
private final WeakReference<TextView> textOverlayRef;
private final PlayerControlButtonStatus enabledStatus;
private boolean isVisible;
public PlayerControlButton(View controlsViewGroup,
String imageViewButtonId,
String buttonId,
@Nullable String placeholderId,
PlayerControlButtonVisibility buttonVisibility,
@Nullable String textOverlayId,
PlayerControlButtonStatus enabledStatus,
View.OnClickListener onClickListener,
@Nullable View.OnLongClickListener longClickListener) {
ImageView imageView = Utils.getChildViewByResourceName(controlsViewGroup, imageViewButtonId);
imageView.setVisibility(View.GONE);
this(controlsViewGroup, buttonId, buttonId, placeholderId, textOverlayId,
enabledStatus, onClickListener, longClickListener);
}
public PlayerControlButton(View controlsViewGroup,
String viewToHide,
String buttonId,
@Nullable String placeholderId,
@Nullable String textOverlayId,
PlayerControlButtonStatus enabledStatus,
View.OnClickListener onClickListener,
@Nullable View.OnLongClickListener longClickListener) {
View containerView = Utils.getChildViewByResourceName(controlsViewGroup, viewToHide);
containerView.setVisibility(View.GONE);
containerRef = new WeakReference<>(containerView);
View button = Utils.getChildViewByResourceName(controlsViewGroup, buttonId);
button.setOnClickListener(onClickListener);
if (longClickListener != null) {
button.setOnLongClickListener(longClickListener);
}
buttonRef = new WeakReference<>(button);
View tempPlaceholder = null;
if (placeholderId != null) {
@@ -69,19 +94,19 @@ public class PlayerControlButton {
}
placeHolderRef = new WeakReference<>(tempPlaceholder);
imageView.setOnClickListener(onClickListener);
if (longClickListener != null) {
imageView.setOnLongClickListener(longClickListener);
TextView tempTextOverlay = null;
if (textOverlayId != null) {
tempTextOverlay = Utils.getChildViewByResourceName(controlsViewGroup, textOverlayId);
}
textOverlayRef = new WeakReference<>(tempTextOverlay);
visibilityCheck = buttonVisibility;
buttonRef = new WeakReference<>(imageView);
this.enabledStatus = enabledStatus;
isVisible = false;
// Update the visibility after the player type changes.
// This ensures that button animations are cleared and their states are updated correctly
// when switching between states like minimized, maximized, or fullscreen, preventing
// "stuck" animations or incorrect visibility. Without this fix the issue is most noticable
// "stuck" animations or incorrect visibility. Without this fix the issue is most noticeable
// when maximizing type 3 miniplayer.
PlayerType.getOnChange().addObserver((PlayerType type) -> {
playerTypeChanged(type);
@@ -111,33 +136,33 @@ public class PlayerControlButton {
if (isVisible == visible) return;
isVisible = visible;
View button = buttonRef.get();
if (button == null) return;
View container = containerRef.get();
if (container == null) return;
View placeholder = placeHolderRef.get();
final boolean shouldBeShown = visibilityCheck.shouldBeShown();
final boolean buttonEnabled = enabledStatus.buttonEnabled();
if (visible && shouldBeShown) {
button.clearAnimation();
if (visible && buttonEnabled) {
container.clearAnimation();
if (animated) {
button.startAnimation(PlayerControlButton.fadeInAnimation);
container.startAnimation(fadeInAnimation);
}
button.setVisibility(View.VISIBLE);
container.setVisibility(View.VISIBLE);
if (placeholder != null) {
placeholder.setVisibility(View.GONE);
}
} else {
if (button.getVisibility() == View.VISIBLE) {
button.clearAnimation();
if (container.getVisibility() == View.VISIBLE) {
container.clearAnimation();
if (animated) {
button.startAnimation(PlayerControlButton.fadeOutAnimation);
container.startAnimation(fadeOutAnimation);
}
button.setVisibility(View.GONE);
container.setVisibility(View.GONE);
}
if (placeholder != null) {
placeholder.setVisibility(shouldBeShown
placeholder.setVisibility(buttonEnabled
? View.VISIBLE
: View.GONE);
}
@@ -155,36 +180,37 @@ public class PlayerControlButton {
return;
}
View button = buttonRef.get();
if (button == null) return;
View container = containerRef.get();
if (container == null) return;
button.clearAnimation();
container.clearAnimation();
View placeholder = placeHolderRef.get();
if (visibilityCheck.shouldBeShown()) {
if (enabledStatus.buttonEnabled()) {
if (isVisible) {
button.setVisibility(View.VISIBLE);
container.setVisibility(View.VISIBLE);
if (placeholder != null) placeholder.setVisibility(View.GONE);
} else {
button.setVisibility(View.GONE);
container.setVisibility(View.GONE);
if (placeholder != null) placeholder.setVisibility(View.VISIBLE);
}
} else {
button.setVisibility(View.GONE);
container.setVisibility(View.GONE);
if (placeholder != null) placeholder.setVisibility(View.GONE);
}
}
public void hide() {
Utils.verifyOnMainThread();
if (!isVisible) return;
Utils.verifyOnMainThread();
View view = buttonRef.get();
View view = containerRef.get();
if (view == null) return;
view.setVisibility(View.GONE);
view = placeHolderRef.get();
if (view != null) view.setVisibility(View.GONE);
View placeHolder = placeHolderRef.get();
if (placeHolder != null) view.setVisibility(View.GONE);
isVisible = false;
}
@@ -193,50 +219,20 @@ public class PlayerControlButton {
* @param resourceId Drawable identifier, or zero to hide the icon.
*/
public void setIcon(int resourceId) {
try {
View button = buttonRef.get();
if (button instanceof ImageView imageButton) {
imageButton.setImageResource(resourceId);
}
} catch (Exception ex) {
Logger.printException(() -> "setIcon failure", ex);
View button = buttonRef.get();
if (button instanceof ImageView imageButton) {
imageButton.setImageResource(resourceId);
}
}
/**
* Starts an animation on the button.
* @param animation The animation to apply.
* Sets the text to be displayed on the text overlay.
* @param text The text to set on the overlay, or null to clear the text.
*/
public void startAnimation(Animation animation) {
try {
View button = buttonRef.get();
if (button != null) {
button.startAnimation(animation);
}
} catch (Exception ex) {
Logger.printException(() -> "startAnimation failure", ex);
public void setTextOverlay(CharSequence text) {
TextView textOverlay = textOverlayRef.get();
if (textOverlay != null) {
textOverlay.setText(text);
}
}
/**
* Clears any animation on the button.
*/
public void clearAnimation() {
try {
View button = buttonRef.get();
if (button != null) {
button.clearAnimation();
}
} catch (Exception ex) {
Logger.printException(() -> "clearAnimation failure", ex);
}
}
/**
* Returns the View associated with this button.
* @return The button View.
*/
public View getView() {
return buttonRef.get();
}
}

View File

@@ -2,9 +2,8 @@ package app.revanced.extension.youtube.videoplayer;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.shared.Utils.dipToPixels;
import static app.revanced.extension.youtube.patches.playback.quality.RememberVideoQualityPatch.AUTOMATIC_VIDEO_QUALITY_VALUE;
import static app.revanced.extension.youtube.patches.playback.quality.RememberVideoQualityPatch.VIDEO_QUALITY_1080P_PREMIUM_NAME;
import static app.revanced.extension.youtube.patches.playback.quality.RememberVideoQualityPatch.VideoQualityMenuInterface;
import static app.revanced.extension.youtube.patches.VideoInformation.AUTOMATIC_VIDEO_QUALITY_VALUE;
import static app.revanced.extension.youtube.patches.VideoInformation.VIDEO_QUALITY_1080P_PREMIUM_NAME;
import android.app.Dialog;
import android.content.Context;
@@ -14,6 +13,7 @@ import android.graphics.drawable.shapes.RoundRectShape;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.style.ForegroundColorSpan;
import android.text.style.UnderlineSpan;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MotionEvent;
@@ -39,73 +39,25 @@ import java.util.List;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.patches.VideoInformation;
import app.revanced.extension.youtube.patches.playback.quality.RememberVideoQualityPatch;
import app.revanced.extension.youtube.settings.Settings;
import kotlin.Unit;
@SuppressWarnings("unused")
public class VideoQualityDialogButton {
private static final int DRAWABLE_LD = getDrawableIdentifier("revanced_video_quality_dialog_button_ld");
private static final int DRAWABLE_SD = getDrawableIdentifier("revanced_video_quality_dialog_button_sd");
private static final int DRAWABLE_HD = getDrawableIdentifier("revanced_video_quality_dialog_button_hd");
private static final int DRAWABLE_FHD = getDrawableIdentifier("revanced_video_quality_dialog_button_fhd");
private static final int DRAWABLE_FHD_PLUS = getDrawableIdentifier("revanced_video_quality_dialog_button_fhd_plus");
private static final int DRAWABLE_QHD = getDrawableIdentifier("revanced_video_quality_dialog_button_qhd");
private static final int DRAWABLE_4K = getDrawableIdentifier("revanced_video_quality_dialog_button_4k");
private static final int DRAWABLE_UNKNOWN = getDrawableIdentifier("revanced_video_quality_dialog_button_unknown");
@Nullable
private static PlayerControlButton instance;
/**
* The current resource name of the button icon.
*/
private static int currentIconResource;
@Nullable
private static CharSequence currentOverlayText;
private static int getDrawableIdentifier(String resourceName) {
final int resourceId = Utils.getResourceIdentifier(resourceName, "drawable");
if (resourceId == 0) Logger.printException(() -> "Could not find resource: " + resourceName);
return resourceId;
}
/**
* Updates the button icon based on the current video quality.
*/
public static void updateButtonIcon(@Nullable VideoQuality quality) {
try {
Utils.verifyOnMainThread();
if (instance == null) return;
final int resolution = quality == null
? AUTOMATIC_VIDEO_QUALITY_VALUE // Video is still loading.
: quality.patch_getResolution();
final int iconResource = switch (resolution) {
case 144, 240, 360 -> DRAWABLE_LD;
case 480 -> DRAWABLE_SD;
case 720 -> DRAWABLE_HD;
case 1080 -> VIDEO_QUALITY_1080P_PREMIUM_NAME.equals(quality.patch_getQualityName())
? DRAWABLE_FHD_PLUS
: DRAWABLE_FHD;
case 1440 -> DRAWABLE_QHD;
case 2160 -> DRAWABLE_4K;
default -> DRAWABLE_UNKNOWN;
};
if (iconResource != currentIconResource) {
currentIconResource = iconResource;
Utils.runOnMainThreadDelayed(() -> {
if (iconResource != currentIconResource) {
Logger.printDebug(() -> "Ignoring stale button update to: " + quality);
return;
}
instance.setIcon(iconResource);
}, 100);
}
} catch (Exception ex) {
Logger.printException(() -> "updateButtonIcon failure", ex);
}
static {
VideoInformation.onQualityChange.addObserver((@Nullable VideoQuality quality) -> {
updateButtonText(quality);
return Unit.INSTANCE;
});
}
/**
@@ -115,8 +67,10 @@ public class VideoQualityDialogButton {
try {
instance = new PlayerControlButton(
controlsView,
"revanced_video_quality_dialog_button_container",
"revanced_video_quality_dialog_button",
"revanced_video_quality_dialog_button_placeholder",
"revanced_video_quality_dialog_button_text",
Settings.VIDEO_QUALITY_DIALOG_BUTTON::get,
view -> {
try {
@@ -127,9 +81,8 @@ public class VideoQualityDialogButton {
},
view -> {
try {
VideoQuality[] qualities = RememberVideoQualityPatch.getCurrentQualities();
VideoQualityMenuInterface menu = RememberVideoQualityPatch.getCurrentMenuInterface();
if (qualities == null || menu == null) {
VideoQuality[] qualities = VideoInformation.getCurrentQualities();
if (qualities == null) {
Logger.printDebug(() -> "Cannot reset quality, videoQualities is null");
return true;
}
@@ -140,7 +93,7 @@ public class VideoQualityDialogButton {
final int resolution = quality.patch_getResolution();
if (resolution != AUTOMATIC_VIDEO_QUALITY_VALUE && resolution <= defaultResolution) {
Logger.printDebug(() -> "Resetting quality to: " + quality);
menu.patch_setQuality(quality);
VideoInformation.changeQuality(quality);
return true;
}
}
@@ -156,8 +109,8 @@ public class VideoQualityDialogButton {
}
);
// Set initial icon.
updateButtonIcon(RememberVideoQualityPatch.getCurrentQuality());
// Set initial text.
updateButtonText(VideoInformation.getCurrentQuality());
} catch (Exception ex) {
Logger.printException(() -> "initializeButton failure", ex);
}
@@ -181,13 +134,56 @@ public class VideoQualityDialogButton {
}
}
/**
* Updates the button text based on the current video quality.
*/
public static void updateButtonText(@Nullable VideoQuality quality) {
try {
Utils.verifyOnMainThread();
if (instance == null) return;
final int resolution = quality == null
? AUTOMATIC_VIDEO_QUALITY_VALUE // Video is still loading.
: quality.patch_getResolution();
SpannableStringBuilder text = new SpannableStringBuilder();
String qualityText = switch (resolution) {
case AUTOMATIC_VIDEO_QUALITY_VALUE -> "";
case 144, 240, 360 -> "LD";
case 480 -> "SD";
case 720 -> "HD";
case 1080 -> "FHD";
case 1440 -> "QHD";
case 2160 -> "4K";
default -> "?"; // Should never happen.
};
text.append(qualityText);
if (resolution == 1080 && VIDEO_QUALITY_1080P_PREMIUM_NAME.equals(quality.patch_getQualityName())) {
// Underline the entire "FHD" text for 1080p Premium.
text.setSpan(new UnderlineSpan(), 0, qualityText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
currentOverlayText = text;
Utils.runOnMainThreadDelayed(() -> {
if (currentOverlayText != text) {
Logger.printDebug(() -> "Ignoring stale button text update of: " + text);
return;
}
instance.setTextOverlay(text);
}, 100);
} catch (Exception ex) {
Logger.printException(() -> "updateButtonText failure", ex);
}
}
/**
* Shows a dialog with available video qualities, excluding Auto, with a title showing the current quality.
*/
private static void showVideoQualityDialog(Context context) {
try {
VideoQuality[] currentQualities = RememberVideoQualityPatch.getCurrentQualities();
VideoQuality currentQuality = RememberVideoQualityPatch.getCurrentQuality();
VideoQuality[] currentQualities = VideoInformation.getCurrentQualities();
VideoQuality currentQuality = VideoInformation.getCurrentQuality();
if (currentQualities == null || currentQuality == null) {
Logger.printDebug(() -> "Cannot show qualities dialog, videoQualities is null");
return;
@@ -198,12 +194,6 @@ public class VideoQualityDialogButton {
return;
}
VideoQualityMenuInterface menu = RememberVideoQualityPatch.getCurrentMenuInterface();
if (menu == null) {
Logger.printDebug(() -> "Cannot show qualities dialog, menu is null");
return;
}
// -1 adjustment for automatic quality at first index.
int listViewSelectedIndex = -1;
for (VideoQuality quality : currentQualities) {
@@ -317,15 +307,8 @@ public class VideoQualityDialogButton {
try {
final int originalIndex = which + 1; // Adjust for automatic.
VideoQuality selectedQuality = currentQualities[originalIndex];
Logger.printDebug(() -> "User clicked on quality: " + selectedQuality);
if (RememberVideoQualityPatch.shouldRememberVideoQuality()) {
RememberVideoQualityPatch.saveDefaultQuality(selectedQuality.patch_getResolution());
}
// Don't update button icon now. Icon will update when the actual
// quality is changed by YT. This is needed to ensure the icon is correct
// if YT ignores changing from 1080p Premium to regular 1080p.
menu.patch_setQuality(selectedQuality);
RememberVideoQualityPatch.userChangedQuality(selectedQuality.patch_getResolution());
VideoInformation.changeQuality(selectedQuality);
dialog.dismiss();
} catch (Exception ex) {
@@ -356,9 +339,12 @@ public class VideoQualityDialogButton {
portraitWidth = Math.min(
portraitWidth,
context.getResources().getDisplayMetrics().heightPixels);
// Limit height in landscape mode.
params.height = Utils.percentageHeightToPixels(80);
} else {
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
}
params.width = portraitWidth;
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
window.setAttributes(params);
window.setBackgroundDrawable(null);
}
@@ -428,6 +414,15 @@ public class VideoQualityDialogButton {
}
private static class CustomQualityAdapter extends ArrayAdapter<String> {
private static final int CUSTOM_LIST_ITEM_CHECKED_ID = Utils.getResourceIdentifier(
"revanced_custom_list_item_checked", "layout");
private static final int CHECK_ICON_ID = Utils.getResourceIdentifier(
"revanced_check_icon", "id");
private static final int CHECK_ICON_PLACEHOLDER_ID = Utils.getResourceIdentifier(
"revanced_check_icon_placeholder", "id");
private static final int ITEM_TEXT_ID = Utils.getResourceIdentifier(
"revanced_item_text", "id");
private int selectedPosition = -1;
public CustomQualityAdapter(@NonNull Context context, @NonNull List<String> objects) {
@@ -446,20 +441,14 @@ public class VideoQualityDialogButton {
if (convertView == null) {
convertView = LayoutInflater.from(getContext()).inflate(
Utils.getResourceIdentifier("revanced_custom_list_item_checked", "layout"),
CUSTOM_LIST_ITEM_CHECKED_ID,
parent,
false
);
viewHolder = new ViewHolder();
viewHolder.checkIcon = convertView.findViewById(
Utils.getResourceIdentifier("revanced_check_icon", "id")
);
viewHolder.placeholder = convertView.findViewById(
Utils.getResourceIdentifier("revanced_check_icon_placeholder", "id")
);
viewHolder.textView = convertView.findViewById(
Utils.getResourceIdentifier("revanced_item_text", "id")
);
viewHolder.checkIcon = convertView.findViewById(CHECK_ICON_ID);
viewHolder.placeholder = convertView.findViewById(CHECK_ICON_PLACEHOLDER_ID);
viewHolder.textView = convertView.findViewById(ITEM_TEXT_ID);
convertView.setTag(viewHolder);
} else {
viewHolder = (ViewHolder) convertView.getTag();