From 3b1e71b3faa716a7e789f076fca6c3dddb88f33d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:37:52 +0200 Subject: [PATCH] feat(player): support playback speeds up to 8x close #1545 --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 6 +- .../exoplayer/ExoPlayerPlaybackSpeedTest.kt | 55 +++++++++++++++++ lib/media/playback_rate.dart | 3 + lib/mpv/player/player.dart | 3 +- lib/services/keyboard_shortcuts_service.dart | 5 +- lib/services/settings_service.dart | 3 +- .../services/host_playback_coordinator.dart | 5 +- .../sheets/video_settings_sheet.dart | 22 ++++++- .../keyboard_shortcuts_service_test.dart | 41 ++++++++++++- .../host_playback_coordinator_test.dart | 6 +- .../watch_together_controller_test.dart | 2 +- test/widgets/video_settings_sheet_test.dart | 59 ++++++++++++++++++- 12 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlaybackSpeedTest.kt create mode 100644 lib/media/playback_rate.dart diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 5543ab1e..76858666 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -111,6 +111,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private const val WATCHDOG_TIMEOUT_MS = 8000L private const val DECODER_HANG_TIMEOUT_MS = 5000L private const val MAX_AUDIO_RECOVERY_ATTEMPTS = 2 + private const val MIN_PLAYBACK_SPEED = 0.25f + private const val MAX_PLAYBACK_SPEED = 8f private const val FPS_SAMPLE_COUNT = 8 private const val AUDIO_BOUNCE_TIMEOUT_MS = 1000L @@ -3270,10 +3272,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } fun setPlaybackSpeed(speed: Float) { - val clampedSpeed = speed.coerceIn(0.25f, 4f) + val clampedSpeed = speed.coerceIn(MIN_PLAYBACK_SPEED, MAX_PLAYBACK_SPEED) exoPlayer?.setPlaybackSpeed(clampedSpeed) updateTunnelingState("speed changed") - delegate?.onPropertyChange("speed", speed.toDouble()) + delegate?.onPropertyChange("speed", clampedSpeed.toDouble()) } fun selectAudioTrack(trackId: String) { diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlaybackSpeedTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlaybackSpeedTest.kt new file mode 100644 index 00000000..2e3ab25f --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlaybackSpeedTest.kt @@ -0,0 +1,55 @@ +package com.edde746.plezy.exoplayer + +import android.app.Activity +import android.os.Looper +import android.widget.FrameLayout +import androidx.media3.exoplayer.ExoPlayer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class ExoPlayerPlaybackSpeedTest { + + @Test + fun eightTimesPlaybackIsAppliedAndOutOfRangeRequestsReportTheClamp() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + activity.setContentView(FrameLayout(activity)) + val core = ExoPlayerCore(activity) + val delegate = RecordingDelegate() + core.delegate = delegate + + try { + assertTrue(core.initialize()) + val player = core.getExoPlayer() + + core.setPlaybackSpeed(8f) + assertEquals(8f, player.playbackParameters.speed, 0f) + + core.setPlaybackSpeed(9f) + assertEquals(8f, player.playbackParameters.speed, 0f) + assertEquals(listOf(8.0, 8.0), delegate.reportedSpeeds) + } finally { + core.dispose() + shadowOf(Looper.getMainLooper()).idle() + } + } + + private fun ExoPlayerCore.getExoPlayer(): ExoPlayer = javaClass.getDeclaredField("exoPlayer").apply { isAccessible = true }.get(this) as ExoPlayer + + private class RecordingDelegate : ExoPlayerDelegate { + val reportedSpeeds = mutableListOf() + + override fun onPropertyChange(name: String, value: Any?) { + if (name == "speed") reportedSpeeds += value as Double + } + + override fun onEvent(name: String, data: Map?) = Unit + } +} diff --git a/lib/media/playback_rate.dart b/lib/media/playback_rate.dart new file mode 100644 index 00000000..a623a976 --- /dev/null +++ b/lib/media/playback_rate.dart @@ -0,0 +1,3 @@ +/// Inclusive playback-rate bounds supported by every in-app player backend. +const double minimumPlaybackRate = 0.25; +const double maximumPlaybackRate = 8.0; diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 877ecda7..5f542e87 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -1,6 +1,7 @@ import 'dart:io' show Platform; import '../../media/media_display_criteria.dart'; +import '../../media/playback_rate.dart'; import '../models.dart'; import 'platform/player_android.dart'; import 'player_native.dart'; @@ -159,7 +160,7 @@ abstract class Player { /// Set the playback rate/speed. /// - /// [rate] - Playback rate from 0.25 to 4.0 (1.0 = normal speed). + /// [rate] - Playback rate from [minimumPlaybackRate] to [maximumPlaybackRate] (1.0 = normal speed). Future setRate(double rate); /// Set the audio output device. diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 06ad7c30..06d53d0d 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../media/playback_rate.dart'; import '../models/hotkey_model.dart'; import '../i18n/strings.g.dart'; import '../mpv/mpv.dart'; @@ -412,12 +413,12 @@ class KeyboardShortcutsService extends ChangeNotifier { onPreviousEpisode?.call(); break; case 'speed_increase': - final newRateUp = (player.state.rate + 0.25).clamp(0.25, 3.0); + final newRateUp = (player.state.rate + 0.25).clamp(minimumPlaybackRate, maximumPlaybackRate); player.setRate(newRateUp); _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateUp); break; case 'speed_decrease': - final newRateDown = (player.state.rate - 0.25).clamp(0.25, 3.0); + final newRateDown = (player.state.rate - 0.25).clamp(minimumPlaybackRate, maximumPlaybackRate); player.setRate(newRateDown); _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateDown); break; diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index fbbdf590..70206cb4 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import '../media/ids.dart'; +import '../media/playback_rate.dart'; import '../media/media_version_preference.dart'; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -476,7 +477,7 @@ class SettingsService extends BaseSharedPreferencesService { static final defaultPlaybackSpeed = DoublePref( 'default_playback_speed', defaultValue: 1.0, - transform: (v) => v.clamp(0.5, 3.0), + transform: (v) => v.clamp(minimumPlaybackRate, maximumPlaybackRate), ); static final defaultBoxFitMode = IntPref('default_box_fit_mode', transform: (v) => v.clamp(0, 2)); static final displaySwitchDelay = IntPref('display_switch_delay', transform: (v) => v.clamp(0, 10)); diff --git a/lib/watch_together/services/host_playback_coordinator.dart b/lib/watch_together/services/host_playback_coordinator.dart index e9c905d2..5765d20d 100644 --- a/lib/watch_together/services/host_playback_coordinator.dart +++ b/lib/watch_together/services/host_playback_coordinator.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math'; +import '../../media/playback_rate.dart'; import '../../utils/app_logger.dart'; import '../models/playback_state.dart'; import '../models/watch_session.dart'; @@ -60,8 +61,8 @@ class HostPlaybackCoordinator { static const int seekDebounceMs = 200; static const int implicitJumpThresholdMs = 1500; static const int selfRecoveryMinBufferAheadMs = 2000; - static const double _minimumRemoteRate = 0.25; - static const double _maximumRemoteRate = 4.0; + static const double _minimumRemoteRate = minimumPlaybackRate; + static const double _maximumRemoteRate = maximumPlaybackRate; final String myPeerId; final void Function(PlaybackState state, {String? toPeerId}) _sendState; diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 3c6d3f41..2ca95c50 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -13,6 +13,7 @@ import 'package:provider/provider.dart'; import '../../../models/shader_preset.dart'; import '../../../models/transcode_quality_preset.dart'; +import '../../../media/playback_rate.dart'; import '../../../media/media_version.dart'; import '../../../mpv/mpv.dart'; import '../../../providers/shader_provider.dart'; @@ -765,7 +766,26 @@ class _VideoSettingsSheetState extends State { initialData: widget.player.state.rate, builder: (context, snapshot) { final currentRate = snapshot.data ?? 1.0; - final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0]; + const speeds = [ + 0.5, + 0.75, + 1.0, + 1.25, + 1.5, + 1.75, + 2.0, + 2.25, + 2.5, + 2.75, + 3.0, + 3.5, + 4.0, + 4.5, + 5.0, + 6.0, + 7.0, + maximumPlaybackRate, + ]; return ListView.builder( itemCount: speeds.length, diff --git a/test/services/keyboard_shortcuts_service_test.dart b/test/services/keyboard_shortcuts_service_test.dart index b2f010f4..fd80e1a2 100644 --- a/test/services/keyboard_shortcuts_service_test.dart +++ b/test/services/keyboard_shortcuts_service_test.dart @@ -207,6 +207,35 @@ void main() { }); }); + testWidgets('speed increase reaches the supported 8x boundary', (tester) async { + final service = await KeyboardShortcutsService.getInstance(); + addTearDown(service.dispose); + await service.setHotkey('speed_increase', const HotKey(key: PhysicalKeyboardKey.f12)); + final player = _FakePlayer(rate: 7.75); + + final result = service.handleVideoPlayerKeyEvent( + const KeyDownEvent( + physicalKey: PhysicalKeyboardKey.f12, + logicalKey: LogicalKeyboardKey.f12, + timeStamp: Duration.zero, + ), + player, + null, + null, + null, + null, + null, + null, + canControlPlayback: true, + canNavigateMediaItems: true, + ); + await tester.pump(); + + expect(result, KeyEventResult.handled); + expect(player.rateChanges, [8.0]); + expect(SettingsService.instance.read(SettingsService.defaultPlaybackSpeed), 8.0); + }); + testWidgets('Ctrl+S takes a screenshot once while held', (tester) async { final service = await KeyboardShortcutsService.getInstance(); addTearDown(service.dispose); @@ -692,11 +721,13 @@ void main() { } class _FakePlayer implements Player { - _FakePlayer({this.volume = 100}); + _FakePlayer({this.volume = 100, this.rate = 1}); final commands = >[]; final volumeChanges = []; + final rateChanges = []; double volume; + double rate; @override Future command(List args) async { @@ -704,7 +735,7 @@ class _FakePlayer implements Player { } @override - PlayerState get state => PlayerState(volume: volume); + PlayerState get state => PlayerState(volume: volume, rate: rate); @override Future setVolume(double volume) async { @@ -712,6 +743,12 @@ class _FakePlayer implements Player { volumeChanges.add(volume); } + @override + Future setRate(double rate) async { + this.rate = rate; + rateChanges.add(rate); + } + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } diff --git a/test/watch_together/host_playback_coordinator_test.dart b/test/watch_together/host_playback_coordinator_test.dart index 9638abc5..65d08647 100644 --- a/test/watch_together/host_playback_coordinator_test.dart +++ b/test/watch_together/host_playback_coordinator_test.dart @@ -468,7 +468,7 @@ void main() { for (final targetMs in [-1, durationMs + 1]) { h.coordinator.onControlRequest('guest', ControlRequest(kind: ControlRequestKind.seek, positionMs: targetMs)); } - for (final rate in [0.25 - 0.000001, 4.0 + 0.000001, double.nan, double.infinity, double.negativeInfinity]) { + for (final rate in [0.25 - 0.000001, 8.0 + 0.000001, double.nan, double.infinity, double.negativeInfinity]) { h.coordinator.onControlRequest('guest', ControlRequest(kind: ControlRequestKind.rate, rate: rate)); } async.flushMicrotasks(); @@ -504,7 +504,7 @@ void main() { expect(h.last.actorPeerId, 'guest'); expect(h.last.actionHint, PlaybackActionHint.seek); } - for (final rate in [0.25, 4.0]) { + for (final rate in [0.25, 8.0]) { h.coordinator.onControlRequest('guest', ControlRequest(kind: ControlRequestKind.rate, rate: rate)); async.flushMicrotasks(); expect(h.last.rate, rate); @@ -512,7 +512,7 @@ void main() { expect(h.last.actionHint, PlaybackActionHint.rate); } - expect(h.player.commandLog, ['seek:0', 'seek:$durationMs', 'rate:0.25', 'rate:4.0']); + expect(h.player.commandLog, ['seek:0', 'seek:$durationMs', 'rate:0.25', 'rate:8.0']); expect(h.last.seq, seqBefore + 4); expect(actions, [ ('guest', PlaybackActionHint.seek), diff --git a/test/watch_together/watch_together_controller_test.dart b/test/watch_together/watch_together_controller_test.dart index c12b6680..ec60b50a 100644 --- a/test/watch_together/watch_together_controller_test.dart +++ b/test/watch_together/watch_together_controller_test.dart @@ -237,7 +237,7 @@ void main() { ); room.guestService.sendTo( 'host', - wireControl(const ControlRequest(kind: ControlRequestKind.rate, rate: 4.000001)), + wireControl(const ControlRequest(kind: ControlRequestKind.rate, rate: 8.000001)), ); async.flushMicrotasks(); diff --git a/test/widgets/video_settings_sheet_test.dart b/test/widgets/video_settings_sheet_test.dart index e45e9965..5be86b00 100644 --- a/test/widgets/video_settings_sheet_test.dart +++ b/test/widgets/video_settings_sheet_test.dart @@ -11,6 +11,7 @@ import 'package:plezy/screens/settings/subtitle_styling_screen.dart'; import 'package:plezy/services/sleep_timer_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_tokens.dart'; +import 'package:plezy/widgets/overlay_sheet.dart'; import 'package:plezy/widgets/video_controls/sheets/video_settings_sheet.dart'; import '../test_helpers/prefs.dart'; @@ -78,6 +79,31 @@ void main() { } }); + testWidgets('offers requested playback speeds through 8x and persists the selection', (tester) async { + final appliedRates = []; + final player = _FakeSettingsPlayer( + onSetRate: (rate) async { + appliedRates.add(rate); + }, + ); + await _pumpHostedSheet(tester, player); + + await tester.tap(find.text('Playback Speed')); + await tester.pumpAndSettle(); + + final scrollable = find.byType(Scrollable).last; + for (final label in ['3.5x', '4x', '4.5x', '5x', '6x', '7x', '8x']) { + await tester.scrollUntilVisible(find.text(label), 200, scrollable: scrollable); + expect(find.text(label), findsOneWidget); + } + + await tester.tap(find.text('8x')); + await tester.pumpAndSettle(); + + expect(appliedRates, [8.0]); + expect(SettingsService.instance.read(SettingsService.defaultPlaybackSpeed), 8.0); + }); + testWidgets('localizes every ASS subtitle override enum label', (tester) async { LocaleSettings.setLocaleSync(AppLocale.ru); await tester.pumpWidget( @@ -178,8 +204,33 @@ Future _pumpSheet( await tester.pumpAndSettle(); } +Future _pumpHostedSheet(WidgetTester tester, Player player) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: const [_testTokens]), + home: OverlaySheetHost( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => unawaited( + OverlaySheetController.of(context).show( + builder: (_) => + VideoSettingsSheet(player: player, audioSyncOffset: 0, subtitleSyncOffset: 0, canControl: true), + ), + ), + child: const Text('Open settings'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open settings')); + await tester.pumpAndSettle(); +} + class _FakeSettingsPlayer implements Player { - _FakeSettingsPlayer({this.onSetProperty}) + _FakeSettingsPlayer({this.onSetProperty, this.onSetRate}) : _streams = PlayerStreams( playing: const Stream.empty(), completed: const Stream.empty(), @@ -203,6 +254,7 @@ class _FakeSettingsPlayer implements Player { final PlayerStreams _streams; final Future Function(String name, String value)? onSetProperty; + final Future Function(double rate)? onSetRate; @override PlayerState get state => const PlayerState(); @@ -221,6 +273,11 @@ class _FakeSettingsPlayer implements Player { return onSetProperty?.call(name, value) ?? Future.value(); } + @override + Future setRate(double rate) { + return onSetRate?.call(rate) ?? Future.value(); + } + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); }