@@ -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) {
|
||||
|
||||
@@ -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<Double>()
|
||||
|
||||
override fun onPropertyChange(name: String, value: Any?) {
|
||||
if (name == "speed") reportedSpeeds += value as Double
|
||||
}
|
||||
|
||||
override fun onEvent(name: String, data: Map<String, Any>?) = Unit
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<void> setRate(double rate);
|
||||
|
||||
/// Set the audio output device.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<VideoSettingsSheet> {
|
||||
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 = <double>[
|
||||
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,
|
||||
|
||||
@@ -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 = <List<String>>[];
|
||||
final volumeChanges = <double>[];
|
||||
final rateChanges = <double>[];
|
||||
double volume;
|
||||
double rate;
|
||||
|
||||
@override
|
||||
Future<void> command(List<String> 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<void> setVolume(double volume) async {
|
||||
@@ -712,6 +743,12 @@ class _FakePlayer implements Player {
|
||||
volumeChanges.add(volume);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setRate(double rate) async {
|
||||
this.rate = rate;
|
||||
rateChanges.add(rate);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 = <double>[];
|
||||
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<void> _pumpSheet(
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> _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<void>(
|
||||
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<bool>.empty(),
|
||||
completed: const Stream<bool>.empty(),
|
||||
@@ -203,6 +254,7 @@ class _FakeSettingsPlayer implements Player {
|
||||
|
||||
final PlayerStreams _streams;
|
||||
final Future<void> Function(String name, String value)? onSetProperty;
|
||||
final Future<void> Function(double rate)? onSetRate;
|
||||
|
||||
@override
|
||||
PlayerState get state => const PlayerState();
|
||||
@@ -221,6 +273,11 @@ class _FakeSettingsPlayer implements Player {
|
||||
return onSetProperty?.call(name, value) ?? Future<void>.value();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setRate(double rate) {
|
||||
return onSetRate?.call(rate) ?? Future<void>.value();
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user