diff --git a/extensions/primevideo/src/main/java/app/revanced/extension/primevideo/videoplayer/PlaybackSpeedPatch.java b/extensions/primevideo/src/main/java/app/revanced/extension/primevideo/videoplayer/PlaybackSpeedPatch.java new file mode 100644 index 0000000000..ad7fd04c16 --- /dev/null +++ b/extensions/primevideo/src/main/java/app/revanced/extension/primevideo/videoplayer/PlaybackSpeedPatch.java @@ -0,0 +1,207 @@ +package app.revanced.extension.primevideo.videoplayer; + +import android.app.AlertDialog; +import android.content.Context; +import android.graphics.RectF; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.ColorFilter; +import android.graphics.PixelFormat; +import java.util.Arrays; + +import app.revanced.extension.shared.Logger; +import app.revanced.extension.shared.Utils; + +import com.amazon.video.sdk.player.Player; + +public class PlaybackSpeedPatch { + private static Player player; + private static final float[] SPEED_VALUES = {0.5f, 0.7f, 0.8f, 0.9f, 0.95f, 1.0f, 1.05f, 1.1f, 1.2f, 1.3f, 1.5f, 2.0f}; + private static final String SPEED_BUTTON_TAG = "speed_overlay"; + + public static void setPlayer(Player playerInstance) { + player = playerInstance; + if (player != null) { + // Reset playback rate when switching between episodes to ensure correct display. + player.setPlaybackRate(1.0f); + } + } + + public static void initializeSpeedOverlay(View userControlsView) { + try { + LinearLayout buttonContainer = Utils.getChildViewByResourceName(userControlsView, "ButtonContainerPlayerTop"); + + // If the speed overlay exists we should return early. + if (Utils.getChildView(buttonContainer, false, child -> + child instanceof ImageView && SPEED_BUTTON_TAG.equals(child.getTag())) != null) { + return; + } + + ImageView speedButton = createSpeedButton(userControlsView.getContext()); + speedButton.setOnClickListener(v -> changePlaybackSpeed(speedButton)); + buttonContainer.addView(speedButton, 0); + + } catch (IllegalArgumentException e) { + Logger.printException(() -> "initializeSpeedOverlay, no button container found", e); + } catch (Exception e) { + Logger.printException(() -> "initializeSpeedOverlay failure", e); + } + } + + private static ImageView createSpeedButton(Context context) { + ImageView speedButton = new ImageView(context); + speedButton.setContentDescription("Playback Speed"); + speedButton.setTag(SPEED_BUTTON_TAG); + speedButton.setClickable(true); + speedButton.setFocusable(true); + speedButton.setScaleType(ImageView.ScaleType.CENTER); + + SpeedIconDrawable speedIcon = new SpeedIconDrawable(); + speedButton.setImageDrawable(speedIcon); + + int buttonSize = Utils.dipToPixels(48); + speedButton.setMinimumWidth(buttonSize); + speedButton.setMinimumHeight(buttonSize); + + return speedButton; + } + + private static String[] getSpeedOptions() { + String[] options = new String[SPEED_VALUES.length]; + for (int i = 0; i < SPEED_VALUES.length; i++) { + options[i] = SPEED_VALUES[i] + "x"; + } + return options; + } + + private static void changePlaybackSpeed(ImageView imageView) { + if (player == null) { + Logger.printException(() -> "Player not available"); + return; + } + + try { + player.pause(); + AlertDialog dialog = createSpeedPlaybackDialog(imageView); + dialog.setOnDismissListener(dialogInterface -> player.play()); + dialog.show(); + + } catch (Exception e) { + Logger.printException(() -> "changePlaybackSpeed", e); + } + } + + private static AlertDialog createSpeedPlaybackDialog(ImageView imageView) { + Context context = imageView.getContext(); + int currentSelection = getCurrentSpeedSelection(); + + return new AlertDialog.Builder(context) + .setTitle("Select Playback Speed") + .setSingleChoiceItems(getSpeedOptions(), currentSelection, + PlaybackSpeedPatch::handleSpeedSelection) + .create(); + } + + private static int getCurrentSpeedSelection() { + try { + float currentRate = player.getPlaybackRate(); + int index = Arrays.binarySearch(SPEED_VALUES, currentRate); + return Math.max(index, 0); // Use slowest speed if not found. + } catch (Exception e) { + Logger.printException(() -> "getCurrentSpeedSelection error getting current playback speed", e); + return 0; + } + } + + private static void handleSpeedSelection(android.content.DialogInterface dialog, int selectedIndex) { + try { + float selectedSpeed = SPEED_VALUES[selectedIndex]; + player.setPlaybackRate(selectedSpeed); + player.play(); + } catch (Exception e) { + Logger.printException(() -> "handleSpeedSelection error setting playback speed", e); + } finally { + dialog.dismiss(); + } + } +} + +class SpeedIconDrawable extends Drawable { + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + + @Override + public void draw(Canvas canvas) { + int w = getBounds().width(); + int h = getBounds().height(); + float centerX = w / 2f; + // Position gauge in lower portion. + float centerY = h * 0.7f; + float radius = Math.min(w, h) / 2f * 0.8f; + + paint.setColor(Color.WHITE); + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(radius * 0.1f); + + // Draw semicircle. + RectF oval = new RectF(centerX - radius, centerY - radius, centerX + radius, centerY + radius); + canvas.drawArc(oval, 180, 180, false, paint); + + // Draw three tick marks. + paint.setStrokeWidth(radius * 0.06f); + for (int i = 0; i < 3; i++) { + float angle = 180 + (i * 45); // 180°, 225°, 270°. + float angleRad = (float) Math.toRadians(angle); + + float startX = centerX + (radius * 0.8f) * (float) Math.cos(angleRad); + float startY = centerY + (radius * 0.8f) * (float) Math.sin(angleRad); + float endX = centerX + radius * (float) Math.cos(angleRad); + float endY = centerY + radius * (float) Math.sin(angleRad); + + canvas.drawLine(startX, startY, endX, endY, paint); + } + + // Draw needle. + paint.setStrokeWidth(radius * 0.08f); + float needleAngle = 200; // Slightly right of center. + float needleAngleRad = (float) Math.toRadians(needleAngle); + + float needleEndX = centerX + (radius * 0.6f) * (float) Math.cos(needleAngleRad); + float needleEndY = centerY + (radius * 0.6f) * (float) Math.sin(needleAngleRad); + + canvas.drawLine(centerX, centerY, needleEndX, needleEndY, paint); + + // Center dot. + paint.setStyle(Paint.Style.FILL); + canvas.drawCircle(centerX, centerY, radius * 0.06f, paint); + } + + @Override + public void setAlpha(int alpha) { + paint.setAlpha(alpha); + } + + @Override + public void setColorFilter(ColorFilter colorFilter) { + paint.setColorFilter(colorFilter); + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + @Override + public int getIntrinsicWidth() { + return Utils.dipToPixels(32); + } + + @Override + public int getIntrinsicHeight() { + return Utils.dipToPixels(32); + } +} \ No newline at end of file diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/VideoPlayer.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/VideoPlayer.java index af3d0bee50..4f82e98727 100644 --- a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/VideoPlayer.java +++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/VideoPlayer.java @@ -4,4 +4,10 @@ public interface VideoPlayer { long getCurrentPosition(); void seekTo(long positionMs); + + void pause(); + + void play(); + + boolean isPlaying(); } \ No newline at end of file diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/video/sdk/player/Player.java b/extensions/primevideo/stub/src/main/java/com/amazon/video/sdk/player/Player.java new file mode 100644 index 0000000000..bd609e1964 --- /dev/null +++ b/extensions/primevideo/stub/src/main/java/com/amazon/video/sdk/player/Player.java @@ -0,0 +1,11 @@ +package com.amazon.video.sdk.player; + +public interface Player { + float getPlaybackRate(); + + void setPlaybackRate(float rate); + + void play(); + + void pause(); +} \ No newline at end of file diff --git a/patches/api/patches.api b/patches/api/patches.api index 0e416b34db..f976256beb 100644 --- a/patches/api/patches.api +++ b/patches/api/patches.api @@ -476,6 +476,10 @@ public final class app/revanced/patches/primevideo/misc/permissions/RenamePermis public static final fun getRenamePermissionsPatch ()Lapp/revanced/patcher/patch/ResourcePatch; } +public final class app/revanced/patches/primevideo/video/speed/PlaybackSpeedPatchKt { + public static final fun getPlaybackSpeedPatch ()Lapp/revanced/patcher/patch/BytecodePatch; +} + public final class app/revanced/patches/protonmail/account/RemoveFreeAccountsLimitPatchKt { public static final fun getRemoveFreeAccountsLimitPatch ()Lapp/revanced/patcher/patch/ResourcePatch; } diff --git a/patches/src/main/kotlin/app/revanced/patches/primevideo/ads/SkipAdsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/primevideo/ads/SkipAdsPatch.kt index e43828932d..c204c729e2 100644 --- a/patches/src/main/kotlin/app/revanced/patches/primevideo/ads/SkipAdsPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/primevideo/ads/SkipAdsPatch.kt @@ -12,7 +12,7 @@ val skipAdsPatch = bytecodePatch( name = "Skip ads", description = "Automatically skips video stream ads.", ) { - compatibleWith("com.amazon.avod.thirdpartyclient"("3.0.403.257")) + compatibleWith("com.amazon.avod.thirdpartyclient"("3.0.412.2947")) dependsOn(sharedExtensionPatch) diff --git a/patches/src/main/kotlin/app/revanced/patches/primevideo/video/speed/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/primevideo/video/speed/Fingerprints.kt new file mode 100644 index 0000000000..bd4431d567 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/primevideo/video/speed/Fingerprints.kt @@ -0,0 +1,23 @@ +package app.revanced.patches.primevideo.video.speed + +import app.revanced.patcher.fingerprint +import com.android.tools.smali.dexlib2.AccessFlags + +internal val playbackUserControlsInitializeFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + parameters("Lcom/amazon/avod/playbackclient/PlaybackInitializationContext;") + returns("V") + custom { method, classDef -> + method.name == "initialize" && classDef.type == "Lcom/amazon/avod/playbackclient/activity/feature/PlaybackUserControlsFeature;" + } +} + +internal val playbackUserControlsPrepareForPlaybackFingerprint = fingerprint { + accessFlags(AccessFlags.PUBLIC) + parameters("Lcom/amazon/avod/playbackclient/PlaybackContext;") + returns("V") + custom { method, classDef -> + method.name == "prepareForPlayback" && + classDef.type == "Lcom/amazon/avod/playbackclient/activity/feature/PlaybackUserControlsFeature;" + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/revanced/patches/primevideo/video/speed/PlaybackSpeedPatch.kt b/patches/src/main/kotlin/app/revanced/patches/primevideo/video/speed/PlaybackSpeedPatch.kt new file mode 100644 index 0000000000..ef318d60cf --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/primevideo/video/speed/PlaybackSpeedPatch.kt @@ -0,0 +1,56 @@ +package app.revanced.patches.primevideo.video.speed + +import app.revanced.patcher.extensions.InstructionExtensions.addInstructions +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.primevideo.misc.extension.sharedExtensionPatch +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference + +private const val EXTENSION_CLASS_DESCRIPTOR = + "Lapp/revanced/extension/primevideo/videoplayer/PlaybackSpeedPatch;" + +val playbackSpeedPatch = bytecodePatch( + name = "Playback speed", + description = "Adds playback speed controls to the video player.", +) { + dependsOn( + sharedExtensionPatch, + ) + + compatibleWith( + "com.amazon.avod.thirdpartyclient"("3.0.412.2947") + ) + + execute { + playbackUserControlsInitializeFingerprint.method.apply { + val getIndex = indexOfFirstInstructionOrThrow { + opcode == Opcode.IPUT_OBJECT && + getReference()?.name == "mUserControls" + } + + val getRegister = getInstruction(getIndex).registerA + + addInstructions( + getIndex + 1, + """ + invoke-static { v$getRegister }, $EXTENSION_CLASS_DESCRIPTOR->initializeSpeedOverlay(Landroid/view/View;)V + """ + ) + } + + playbackUserControlsPrepareForPlaybackFingerprint.method.apply { + addInstructions( + 0, + """ + invoke-virtual { p1 }, Lcom/amazon/avod/playbackclient/PlaybackContext;->getPlayer()Lcom/amazon/video/sdk/player/Player; + move-result-object v0 + invoke-static { v0 }, $EXTENSION_CLASS_DESCRIPTOR->setPlayer(Lcom/amazon/video/sdk/player/Player;)V + """ + ) + } + } +} \ No newline at end of file