Files
plezy/test/widgets/video_controls_test.dart
T

1205 lines
39 KiB
Dart

import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/media/media_version.dart';
import 'package:plezy/models/shader_preset.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/theme/mono_tokens.dart';
import 'package:plezy/widgets/video_controls/video_controls.dart';
import 'package:plezy/widgets/video_controls/player_chrome_controller.dart';
import 'package:plezy/widgets/video_controls/painters/buffer_range_painter.dart';
import 'package:plezy/widgets/video_controls/widgets/mobile_skip_zones.dart';
import 'package:plezy/widgets/video_controls/widgets/skip_marker_button.dart';
import 'package:plezy/widgets/video_controls/widgets/sync_offset_control.dart';
import 'package:plezy/widgets/video_controls/widgets/timeline_slider.dart';
import 'package:plezy/widgets/video_controls/widgets/video_timeline_bar.dart';
import '../test_helpers/watch_together_fakes.dart';
const _testTokens = MonoTokens(
radiusSm: 8,
radiusMd: 12,
radiusLg: 20,
radiusXs: 5,
groupGap: 2,
space: 8,
fast: Duration(milliseconds: 1),
normal: Duration(milliseconds: 1),
slow: Duration(milliseconds: 1),
expressive: Duration(milliseconds: 1),
bg: Colors.black,
surface: Colors.black,
outline: Colors.white24,
text: Colors.white,
textMuted: Colors.white70,
splashFactory: NoSplash.splashFactory,
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('resolveShaderTogglePreset', () {
test('turns shaders off when a shader is currently active', () {
final result = resolveShaderTogglePreset(
currentPreset: ShaderPreset.nvscalerDefault,
savedPreset: ShaderPreset.nvscalerDefault,
allPresets: ShaderPreset.allPresets,
);
expect(result, ShaderPreset.none);
});
test('restores the saved preset when shaders are currently off', () {
final saved = ShaderPreset.artcnnPreset(ArtCNNModel.c4f16, ArtCNNVariant.neutral);
final result = resolveShaderTogglePreset(
currentPreset: ShaderPreset.none,
savedPreset: saved,
allPresets: ShaderPreset.allPresets,
);
expect(result, saved);
});
test('falls back to the first enabled preset when no shader is saved', () {
final result = resolveShaderTogglePreset(
currentPreset: ShaderPreset.none,
savedPreset: ShaderPreset.none,
allPresets: const [ShaderPreset.none, ShaderPreset.nvscalerDefault],
);
expect(result, ShaderPreset.nvscalerDefault);
});
});
group('effectiveVersionQualityControls', () {
test('clears switchable version and quality state during offline playback', () {
final version = MediaVersion(id: 'v1', videoResolution: '1080');
final audio = MediaAudioTrack(id: 1, languageCode: 'eng', selected: false);
final subtitle = MediaSubtitleTrack(id: 2, languageCode: 'eng', selected: false, forced: false);
final result = effectiveVersionQualityControls(
isOfflinePlayback: true,
availableVersions: [version],
serverSupportsTranscoding: true,
isTranscoding: true,
sourceAudioTracks: [audio],
selectedAudioStreamId: 1,
sourceSubtitleTracks: [subtitle],
selectedSubtitleStreamId: 2,
);
expect(result.canSwitch, isFalse);
expect(result.availableVersions, isEmpty);
expect(result.serverSupportsTranscoding, isFalse);
expect(result.isTranscoding, isFalse);
expect(result.sourceAudioTracks, isEmpty);
expect(result.selectedAudioStreamId, isNull);
expect(result.sourceSubtitleTracks, isEmpty);
expect(result.selectedSubtitleStreamId, isNull);
});
test('keeps switchable state during online playback', () {
final version = MediaVersion(id: 'v1', videoResolution: '1080');
final audio = MediaAudioTrack(id: 1, languageCode: 'eng', selected: false);
final subtitle = MediaSubtitleTrack(id: 2, languageCode: 'eng', selected: false, forced: false);
final result = effectiveVersionQualityControls(
isOfflinePlayback: false,
availableVersions: [version],
serverSupportsTranscoding: true,
isTranscoding: true,
sourceAudioTracks: [audio],
selectedAudioStreamId: 1,
sourceSubtitleTracks: [subtitle],
selectedSubtitleStreamId: 2,
);
expect(result.canSwitch, isTrue);
expect(result.availableVersions, [version]);
expect(result.serverSupportsTranscoding, isTrue);
expect(result.isTranscoding, isTrue);
expect(result.sourceAudioTracks, [audio]);
expect(result.selectedAudioStreamId, 1);
expect(result.sourceSubtitleTracks, [subtitle]);
expect(result.selectedSubtitleStreamId, 2);
});
});
group('selectableSourceSubtitleTracks', () {
MediaSubtitleTrack sub(int id, {String? codec, String? key}) =>
MediaSubtitleTrack(id: id, codec: codec, key: key, languageCode: 'eng', selected: false, forced: false);
test('returns the full list unchanged when not transcoding', () {
final tracks = [sub(1, codec: 'srt'), sub(2, codec: 'pgs'), sub(3, codec: 'weird')];
expect(selectableSourceSubtitleTracks(tracks, isTranscoding: false), same(tracks));
});
test('keeps text, image and keyed tracks while transcoding', () {
final text = sub(1, codec: 'srt');
final image = sub(2, codec: 'pgs');
final keyed = sub(3, codec: 'weird', key: '/library/streams/3');
final result = selectableSourceSubtitleTracks([text, image, keyed], isTranscoding: true);
expect(result, [text, image, keyed]);
});
test('drops non-keyed unsupported codecs while transcoding', () {
final text = sub(1, codec: 'ass');
final unsupported = sub(2, codec: 'weird');
final result = selectableSourceSubtitleTracks([text, unsupported], isTranscoding: true);
expect(result, [text]);
});
});
group('shouldShowSkipMarkerButton', () {
test('does not show before the first frame is rendered', () {
expect(
shouldShowSkipMarkerButton(
hasFirstFrame: false,
hasMarker: true,
hasPlayNextPrompt: false,
skipButtonDismissed: false,
controlsVisible: true,
),
isFalse,
);
});
test('shows after first frame when marker is active and not dismissed', () {
expect(
shouldShowSkipMarkerButton(
hasFirstFrame: true,
hasMarker: true,
hasPlayNextPrompt: false,
skipButtonDismissed: false,
controlsVisible: false,
),
isTrue,
);
});
test('does not show when dismissed until controls are visible again', () {
expect(
shouldShowSkipMarkerButton(
hasFirstFrame: true,
hasMarker: true,
hasPlayNextPrompt: false,
skipButtonDismissed: true,
controlsVisible: false,
),
isFalse,
);
expect(
shouldShowSkipMarkerButton(
hasFirstFrame: true,
hasMarker: true,
hasPlayNextPrompt: false,
skipButtonDismissed: true,
controlsVisible: true,
),
isTrue,
);
});
test('does not show while play next prompt is active', () {
expect(
shouldShowSkipMarkerButton(
hasFirstFrame: true,
hasMarker: true,
hasPlayNextPrompt: true,
skipButtonDismissed: false,
controlsVisible: true,
),
isFalse,
);
});
});
group('classifyPlayerNavigationKey', () {
test('reserves only physical keyboard Escape for fullscreen', () {
expect(
classifyPlayerNavigationKey(
_navigationKeyDown(LogicalKeyboardKey.escape, ui.KeyEventDeviceType.keyboard),
isAppleTV: false,
),
PlayerNavigationKey.physicalEscape,
);
expect(
classifyPlayerNavigationKey(
_navigationKeyDown(LogicalKeyboardKey.escape, ui.KeyEventDeviceType.gamepad),
isAppleTV: false,
),
PlayerNavigationKey.back,
);
expect(
classifyPlayerNavigationKey(
_navigationKeyDown(LogicalKeyboardKey.escape, ui.KeyEventDeviceType.directionalPad),
isAppleTV: false,
),
PlayerNavigationKey.back,
);
});
test('treats tvOS keyboard Escape as semantic Back', () {
expect(
classifyPlayerNavigationKey(
_navigationKeyDown(LogicalKeyboardKey.escape, ui.KeyEventDeviceType.keyboard),
isAppleTV: true,
),
PlayerNavigationKey.back,
);
});
test('recognizes controller and browser Back keys', () {
for (final key in [LogicalKeyboardKey.gameButtonB, LogicalKeyboardKey.goBack, LogicalKeyboardKey.browserBack]) {
expect(
classifyPlayerNavigationKey(_navigationKeyDown(key, ui.KeyEventDeviceType.gamepad), isAppleTV: false),
PlayerNavigationKey.back,
);
}
});
test('recognizes only bare physical Backspace as player Back', () {
final event = _navigationKeyDown(LogicalKeyboardKey.backspace, ui.KeyEventDeviceType.keyboard);
expect(classifyPlayerNavigationKey(event, isAppleTV: false, hasModifiers: false), PlayerNavigationKey.back);
expect(classifyPlayerNavigationKey(event, isAppleTV: false, hasModifiers: true), PlayerNavigationKey.none);
});
test('recognizes bare keyboard and browser Home', () {
for (final key in [LogicalKeyboardKey.home, LogicalKeyboardKey.browserHome]) {
expect(
classifyPlayerNavigationKey(
_navigationKeyDown(key, ui.KeyEventDeviceType.keyboard),
isAppleTV: false,
hasModifiers: false,
),
PlayerNavigationKey.home,
);
}
});
});
group('handlePlayerNavigationKeyAction', () {
testWidgets('semantic Back activates once on key up', (tester) async {
var actions = 0;
final downResult = handlePlayerNavigationKeyAction(
_keyDown(LogicalKeyboardKey.gameButtonB),
PlayerNavigationKey.back,
() => actions++,
);
final upResult = handlePlayerNavigationKeyAction(
_keyUp(LogicalKeyboardKey.gameButtonB),
PlayerNavigationKey.back,
() => actions++,
);
expect(downResult, KeyEventResult.handled);
expect(upResult, KeyEventResult.handled);
expect(actions, 1);
await tester.pump();
});
testWidgets('Backspace alias activates once on key up', (tester) async {
var actions = 0;
handlePlayerNavigationKeyAction(
_keyDown(LogicalKeyboardKey.backspace),
PlayerNavigationKey.back,
() => actions++,
);
handlePlayerNavigationKeyAction(_keyUp(LogicalKeyboardKey.backspace), PlayerNavigationKey.back, () => actions++);
expect(actions, 1);
await tester.pump();
});
});
group('PlayerNavigationCoordinator focus dispatch', () {
PlayerNavigationCoordinator coordinatorFor(
PlayerChromeController chromeController, {
bool Function()? isPromptOpen,
VoidCallback? dismissPrompt,
Future<bool> Function()? exitFullscreenIfActive,
VoidCallback? exitPlayer,
VoidCallback? navigateHome,
bool Function()? isActive,
}) {
return PlayerNavigationCoordinator(
chromeController: chromeController,
isPromptOpen: isPromptOpen ?? () => false,
dismissPrompt: dismissPrompt ?? () {},
isChromePresented: () => chromeController.controlsPresented,
exitFullscreenIfActive: exitFullscreenIfActive ?? () async => false,
exitPlayer: exitPlayer ?? () {},
navigateHome: navigateHome ?? () {},
isActive: isActive,
);
}
Future<void> pumpNavigationFocus(WidgetTester tester, PlayerNavigationCoordinator coordinator) async {
await tester.pumpWidget(
MaterialApp(
home: Focus(
autofocus: true,
onKeyEvent: (_, event) {
final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: false);
return handlePlayerNavigationKeyAction(event, navigationKey, () => coordinator.handle(navigationKey));
},
child: const SizedBox.expand(),
),
),
);
await tester.pump();
}
testWidgets('one Back hides presented chrome and the next exits once after fade-out', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
var exits = 0;
final coordinator = coordinatorFor(chromeController, exitPlayer: () => exits++);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
expect(chromeController.controlsVisible, isFalse);
expect(chromeController.controlsPresented, isTrue);
expect(exits, 0);
chromeController.markControlsHidden();
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
expect(exits, 1);
});
testWidgets('physical Escape outside fullscreen hides presented chrome without exiting', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
var fullscreenChecks = 0;
var exits = 0;
final coordinator = coordinatorFor(
chromeController,
exitFullscreenIfActive: () async {
fullscreenChecks++;
return false;
},
exitPlayer: () => exits++,
);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(fullscreenChecks, 1);
expect(chromeController.controlsVisible, isFalse);
expect(exits, 0);
});
testWidgets('physical Escape preserves event-time chrome presentation across fullscreen check', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
final fullscreenResult = Completer<bool>();
var exits = 0;
final coordinator = coordinatorFor(
chromeController,
exitFullscreenIfActive: () => fullscreenResult.future,
exitPlayer: () => exits++,
);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
chromeController.hide();
chromeController.markControlsHidden();
fullscreenResult.complete(false);
await tester.pump();
expect(chromeController.controlsVisible, isFalse);
expect(chromeController.controlsPresented, isFalse);
expect(exits, 0);
});
testWidgets('physical Escape exits native fullscreen before chrome', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
var exits = 0;
final coordinator = coordinatorFor(
chromeController,
exitFullscreenIfActive: () async => true,
exitPlayer: () => exits++,
);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(chromeController.controlsVisible, isTrue);
expect(exits, 0);
});
testWidgets('physical Escape does nothing after its player route is disposed', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
final fullscreenResult = Completer<bool>();
var active = true;
var exits = 0;
final coordinator = coordinatorFor(
chromeController,
exitFullscreenIfActive: () => fullscreenResult.future,
exitPlayer: () => exits++,
isActive: () => active,
);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
active = false;
fullscreenResult.complete(false);
await tester.pump();
expect(chromeController.controlsVisible, isTrue);
expect(exits, 0);
});
testWidgets('Back closes the content strip without hiding chrome or exiting', (tester) async {
final chromeController = PlayerChromeController()..setContentStripVisible(true);
addTearDown(chromeController.dispose);
var exits = 0;
final coordinator = coordinatorFor(chromeController, exitPlayer: () => exits++);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
expect(chromeController.contentStripVisible, isFalse);
expect(chromeController.controlsVisible, isTrue);
expect(exits, 0);
chromeController.cancelAutoHide();
});
testWidgets('Home bypasses staged Back layers', (tester) async {
final chromeController = PlayerChromeController()..setContentStripVisible(true);
addTearDown(chromeController.dispose);
var promptOpen = true;
var promptDismissals = 0;
var homeNavigations = 0;
final coordinator = coordinatorFor(
chromeController,
isPromptOpen: () => promptOpen,
dismissPrompt: () {
promptOpen = false;
promptDismissals++;
},
navigateHome: () => homeNavigations++,
);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.home);
expect(homeNavigations, 1);
expect(promptDismissals, 0);
expect(chromeController.contentStripVisible, isTrue);
expect(chromeController.controlsVisible, isTrue);
});
testWidgets('global observation and focus dispatch produce one native Back action', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
var globalEvents = 0;
var exits = 0;
bool globalHandler(KeyEvent event) {
if (classifyPlayerNavigationKey(event, isAppleTV: false) != PlayerNavigationKey.none) {
globalEvents++;
}
return false;
}
HardwareKeyboard.instance.addHandler(globalHandler);
addTearDown(() => HardwareKeyboard.instance.removeHandler(globalHandler));
await pumpNavigationFocus(tester, coordinatorFor(chromeController, exitPlayer: () => exits++));
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
expect(globalEvents, 2);
expect(chromeController.controlsVisible, isFalse);
expect(exits, 0);
});
});
group('resolvePlayerBackDisposition', () {
test('closes a content strip before other Back behavior', () {
expect(
resolvePlayerBackDisposition(
navigationKey: PlayerNavigationKey.physicalEscape,
contentStripVisible: true,
controlsVisible: true,
),
PlayerBackDisposition.closeContentStrip,
);
});
test('checks fullscreen only for physical Escape', () {
expect(
resolvePlayerBackDisposition(
navigationKey: PlayerNavigationKey.physicalEscape,
contentStripVisible: false,
controlsVisible: false,
),
PlayerBackDisposition.exitFullscreenIfActive,
);
expect(
resolvePlayerBackDisposition(
navigationKey: PlayerNavigationKey.back,
contentStripVisible: false,
controlsVisible: false,
),
PlayerBackDisposition.exitPlayer,
);
});
test('semantic Back hides visible controls then exits when hidden', () {
expect(
resolvePlayerBackDisposition(
navigationKey: PlayerNavigationKey.back,
contentStripVisible: false,
controlsVisible: true,
),
PlayerBackDisposition.hideControls,
);
expect(
resolvePlayerBackDisposition(
navigationKey: PlayerNavigationKey.back,
contentStripVisible: false,
controlsVisible: false,
),
PlayerBackDisposition.exitPlayer,
);
});
});
group('SkipMarkerButton', () {
testWidgets('tap activates skip', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
var activateCount = 0;
await _pumpSkipMarkerButton(
tester,
focusNode: focusNode,
isAutoSkipActive: true,
onActivate: () => activateCount++,
);
expect(find.text('Skip Intro (3)'), findsOneWidget);
await tester.tap(find.byType(InkWell));
await tester.pump();
expect(activateCount, 1);
});
testWidgets('select activates skip', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
var activateCount = 0;
await _pumpSkipMarkerButton(
tester,
focusNode: focusNode,
isAutoSkipActive: true,
onActivate: () => activateCount++,
);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pump();
expect(activateCount, 1);
});
testWidgets('d-pad down moves focus without activating', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
var activateCount = 0;
var focusDownCount = 0;
await _pumpSkipMarkerButton(
tester,
focusNode: focusNode,
isAutoSkipActive: true,
onActivate: () => activateCount++,
onFocusDown: () => focusDownCount++,
);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(activateCount, 0);
expect(focusDownCount, 1);
});
testWidgets('tap activates when auto-skip is inactive', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
var activateCount = 0;
await _pumpSkipMarkerButton(
tester,
focusNode: focusNode,
isAutoSkipActive: false,
onActivate: () => activateCount++,
);
expect(find.text('Skip Intro'), findsOneWidget);
await tester.tap(find.byType(InkWell));
await tester.pump();
expect(activateCount, 1);
});
});
group('mobileSkipZoneForTap', () {
const size = Size(1000, 600);
test('returns backward for left skip zone', () {
expect(mobileSkipZoneForTap(position: const Offset(100, 300), size: size), isFalse);
});
test('returns forward for right skip zone', () {
expect(mobileSkipZoneForTap(position: const Offset(900, 300), size: size), isTrue);
});
test('returns null outside skip zones', () {
expect(mobileSkipZoneForTap(position: const Offset(500, 300), size: size), isNull);
expect(mobileSkipZoneForTap(position: const Offset(100, 20), size: size), isNull);
expect(mobileSkipZoneForTap(position: const Offset(900, 580), size: size), isNull);
});
});
group('TimelineSlider', () {
testWidgets('routes keyboard input through the custom focus handler', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
var keyEvents = 0;
var seekEvents = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
child: TimelineSlider(
position: const Duration(minutes: 1),
duration: const Duration(minutes: 10),
chapters: const [],
chaptersLoaded: true,
focusNode: focusNode,
onKeyEvent: (_, event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowRight) {
keyEvents++;
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
onSeek: (_) => seekEvents++,
onSeekEnd: (_) {},
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(keyEvents, 1);
expect(seekEvents, 0);
});
testWidgets('does not pass chapters to painter when timeline markers are hidden', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
child: TimelineSlider(
position: const Duration(minutes: 1),
duration: const Duration(minutes: 10),
chapters: [MediaChapter(id: 1, startTimeOffset: 300000)],
chaptersLoaded: true,
showChapterMarkersOnTimeline: false,
onSeek: (_) {},
onSeekEnd: (_) {},
),
),
),
),
);
final customPaint = tester.widget<CustomPaint>(
find.byWidgetPredicate((widget) => widget is CustomPaint && widget.painter is BufferRangePainter),
);
expect((customPaint.painter! as BufferRangePainter).chapters, isEmpty);
});
testWidgets('clamps stale position beyond duration before building slider', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
child: TimelineSlider(
position: const Duration(minutes: 12),
duration: const Duration(minutes: 10),
chapters: const [],
chaptersLoaded: true,
onSeek: (_) {},
onSeekEnd: (_) {},
),
),
),
),
);
final slider = tester.widget<Slider>(find.byType(Slider));
expect(slider.value, const Duration(minutes: 10).inMilliseconds.toDouble());
expect(slider.max, const Duration(minutes: 10).inMilliseconds.toDouble());
});
testWidgets('clamps stale position when duration is unknown', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
child: TimelineSlider(
position: const Duration(minutes: 12),
duration: Duration.zero,
chapters: const [],
chaptersLoaded: true,
onSeek: (_) {},
onSeekEnd: (_) {},
),
),
),
),
);
final slider = tester.widget<Slider>(find.byType(Slider));
expect(slider.value, 0.0);
expect(slider.max, 0.0);
});
testWidgets('timeline bar displays pending preview position while player position is stale', (tester) async {
final player = FakeSyncPlayer(position: const Duration(minutes: 1), duration: const Duration(minutes: 10));
addTearDown(player.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
child: VideoTimelineBar(
player: player,
chapters: const [],
chaptersLoaded: true,
previewPosition: const Duration(minutes: 4),
onSeek: (_) {},
onSeekEnd: (_) {},
),
),
),
),
);
final slider = tester.widget<Slider>(find.byType(Slider));
expect(slider.value, const Duration(minutes: 4).inMilliseconds.toDouble());
});
Future<void> pumpScrubSlider(
WidgetTester tester, {
required List<Duration> seeks,
required List<Duration> seekEnds,
Duration duration = const Duration(minutes: 10),
bool enabled = true,
VoidCallback? onScrubStart,
VoidCallback? onScrubEnd,
Widget Function(Widget child)? wrap,
}) async {
Widget slider = SizedBox(
width: 400,
child: TimelineSlider(
position: const Duration(minutes: 1),
duration: duration,
chapters: const [],
chaptersLoaded: true,
enabled: enabled,
onSeek: seeks.add,
onSeekEnd: seekEnds.add,
onScrubStart: onScrubStart,
onScrubEnd: onScrubEnd,
),
);
if (wrap != null) slider = wrap(slider);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(body: Center(child: slider)),
),
);
}
testWidgets('touch drag survives tooltip appearance and finalizes once', (tester) async {
final seeks = <Duration>[];
final seekEnds = <Duration>[];
var scrubStarts = 0;
var scrubEnds = 0;
await pumpScrubSlider(
tester,
seeks: seeks,
seekEnds: seekEnds,
onScrubStart: () => scrubStarts++,
onScrubEnd: () => scrubEnds++,
);
// Down at the center (200/400 → 5min), drag +100px (→ 7.5min). The
// first scrub event makes the tooltip appear; the drag must keep
// tracking through that rebuild and finalize exactly once.
final gesture = await tester.startGesture(tester.getCenter(find.byType(TimelineSlider)));
await tester.pump();
await gesture.moveBy(const Offset(50, 0));
await tester.pump();
await gesture.moveBy(const Offset(50, 0));
await tester.pump();
await gesture.up();
await tester.pump();
expect(seeks, isNotEmpty);
expect(seekEnds, hasLength(1));
expect(scrubStarts, 1);
expect(scrubEnds, 1);
expect(seekEnds.single.inMilliseconds, closeTo(const Duration(minutes: 7, seconds: 30).inMilliseconds, 2000));
});
testWidgets('keyboard input does not start a scrub lifecycle', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
var scrubStarts = 0;
var scrubEnds = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
child: TimelineSlider(
position: const Duration(minutes: 1),
duration: const Duration(minutes: 10),
chapters: const [],
chaptersLoaded: true,
focusNode: focusNode,
onKeyEvent: (_, event) => KeyEventResult.handled,
onSeek: (_) {},
onSeekEnd: (_) {},
onScrubStart: () => scrubStarts++,
onScrubEnd: () => scrubEnds++,
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(scrubStarts, 0);
expect(scrubEnds, 0);
});
testWidgets('disposing mid-drag ends the scrub lifecycle', (tester) async {
final seeks = <Duration>[];
final seekEnds = <Duration>[];
var scrubStarts = 0;
var scrubEnds = 0;
await pumpScrubSlider(
tester,
seeks: seeks,
seekEnds: seekEnds,
onScrubStart: () => scrubStarts++,
onScrubEnd: () => scrubEnds++,
);
final gesture = await tester.startGesture(tester.getCenter(find.byType(TimelineSlider)));
await tester.pump();
expect(scrubStarts, 1);
expect(scrubEnds, 0);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
await gesture.cancel();
expect(scrubEnds, 1);
});
testWidgets('tap seeks to the tapped position', (tester) async {
final seeks = <Duration>[];
final seekEnds = <Duration>[];
await pumpScrubSlider(tester, seeks: seeks, seekEnds: seekEnds);
final topLeft = tester.getTopLeft(find.byType(TimelineSlider));
final size = tester.getSize(find.byType(TimelineSlider));
final gesture = await tester.startGesture(Offset(topLeft.dx + size.width * 0.75, topLeft.dy + size.height / 2));
await tester.pump();
await gesture.up();
await tester.pump();
expect(seekEnds, hasLength(1));
expect(seekEnds.single.inMilliseconds, closeTo(const Duration(minutes: 7, seconds: 30).inMilliseconds, 2000));
});
testWidgets('drag starting on the slider is never stolen by ancestor recognizers', (tester) async {
final seeks = <Duration>[];
final seekEnds = <Duration>[];
var verticalDragUpdates = 0;
var longPresses = 0;
await pumpScrubSlider(
tester,
seeks: seeks,
seekEnds: seekEnds,
wrap: (child) => GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragUpdate: (_) => verticalDragUpdates++,
onLongPressStart: (_) => longPresses++,
child: child,
),
);
// Press-aim-drag: hold past the long-press deadline, then drag with a
// vertical-dominant start. Without the eager claim, the long-press or
// the vertical recognizer wins and the scrub is eaten.
final gesture = await tester.startGesture(tester.getCenter(find.byType(TimelineSlider)));
await tester.pump(const Duration(milliseconds: 600));
for (var i = 0; i < 4; i++) {
await gesture.moveBy(const Offset(8, 12));
await tester.pump();
}
await gesture.moveBy(const Offset(50, 0));
await tester.pump();
await gesture.up();
await tester.pump();
expect(seekEnds, hasLength(1));
expect(verticalDragUpdates, 0);
expect(longPresses, 0);
});
testWidgets('ignores input when disabled', (tester) async {
final seeks = <Duration>[];
final seekEnds = <Duration>[];
await pumpScrubSlider(tester, seeks: seeks, seekEnds: seekEnds, enabled: false);
final gesture = await tester.startGesture(tester.getCenter(find.byType(TimelineSlider)));
await tester.pump();
await gesture.moveBy(const Offset(50, 0));
await tester.pump();
await gesture.up();
await tester.pump();
expect(seeks, isEmpty);
expect(seekEnds, isEmpty);
});
testWidgets('ignores input when duration is unknown', (tester) async {
final seeks = <Duration>[];
final seekEnds = <Duration>[];
await pumpScrubSlider(tester, seeks: seeks, seekEnds: seekEnds, duration: Duration.zero);
final gesture = await tester.startGesture(tester.getCenter(find.byType(TimelineSlider)));
await tester.pump();
await gesture.moveBy(const Offset(50, 0));
await tester.pump();
await gesture.up();
await tester.pump();
expect(seeks, isEmpty);
expect(seekEnds, isEmpty);
});
testWidgets('second finger is ignored mid-drag', (tester) async {
final seeks = <Duration>[];
final seekEnds = <Duration>[];
await pumpScrubSlider(tester, seeks: seeks, seekEnds: seekEnds);
final center = tester.getCenter(find.byType(TimelineSlider));
final first = await tester.startGesture(center);
await tester.pump();
final seeksAfterDown = seeks.length;
final second = await tester.startGesture(center + const Offset(100, 0));
await tester.pump();
await second.moveBy(const Offset(-80, 0));
await tester.pump();
expect(seeks.length, seeksAfterDown, reason: 'second pointer must not drive the scrub');
await first.moveBy(const Offset(40, 0));
await tester.pump();
await first.up();
await second.up();
await tester.pump();
// 240/400 of 10min → 6min: follows the first pointer only.
expect(seekEnds, hasLength(1));
expect(seekEnds.single.inMilliseconds, closeTo(const Duration(minutes: 6).inMilliseconds, 2000));
});
});
group('shouldSkipDuplicateTimelineSeek', () {
test('skips a matching non-transcode final seek', () {
expect(
shouldSkipDuplicateTimelineSeek(
isTranscoding: false,
lastDispatchedSeek: const Duration(minutes: 7, seconds: 30),
finalSeek: const Duration(minutes: 7, seconds: 30),
),
isTrue,
);
});
test('does not skip matching transcode seek', () {
expect(
shouldSkipDuplicateTimelineSeek(
isTranscoding: true,
lastDispatchedSeek: const Duration(minutes: 7, seconds: 30),
finalSeek: const Duration(minutes: 7, seconds: 30),
),
isFalse,
);
});
test('does not skip when no matching seek was already dispatched', () {
expect(
shouldSkipDuplicateTimelineSeek(
isTranscoding: false,
lastDispatchedSeek: const Duration(minutes: 7),
finalSeek: const Duration(minutes: 7, seconds: 30),
),
isFalse,
);
expect(
shouldSkipDuplicateTimelineSeek(
isTranscoding: false,
lastDispatchedSeek: null,
finalSeek: const Duration(minutes: 7, seconds: 30),
),
isFalse,
);
});
});
group('SyncOffsetControl', () {
testWidgets('uses 100ms slider steps without rendering tick marks', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(
body: SizedBox(
width: 700,
child: SyncOffsetControl(
player: _FakeSyncPlayer(),
propertyName: 'sub-delay',
initialOffset: 0,
labelText: 'Subtitles',
onOffsetChanged: (_) async {},
compact: true,
),
),
),
),
);
final slider = tester.widget<Slider>(find.byType(Slider));
final sliderTheme = tester.widget<SliderTheme>(
find.ancestor(of: find.byType(Slider), matching: find.byType(SliderTheme)).first,
);
expect(slider.min, -60000);
expect(slider.max, 60000);
expect(slider.divisions, 1200);
expect((slider.max - slider.min) / slider.divisions!, 100);
expect(sliderTheme.data.tickMarkShape, same(SliderTickMarkShape.noTickMark));
});
});
}
KeyDownEvent _keyDown(LogicalKeyboardKey key) {
return KeyDownEvent(physicalKey: PhysicalKeyboardKey.escape, logicalKey: key, timeStamp: Duration.zero);
}
KeyUpEvent _keyUp(LogicalKeyboardKey key) {
return KeyUpEvent(physicalKey: PhysicalKeyboardKey.escape, logicalKey: key, timeStamp: Duration.zero);
}
KeyDownEvent _navigationKeyDown(LogicalKeyboardKey key, ui.KeyEventDeviceType deviceType) {
return KeyDownEvent(
physicalKey: PhysicalKeyboardKey.escape,
logicalKey: key,
timeStamp: Duration.zero,
deviceType: deviceType,
);
}
Future<void> _pumpSkipMarkerButton(
WidgetTester tester, {
required FocusNode focusNode,
required bool isAutoSkipActive,
required VoidCallback onActivate,
VoidCallback? onFocusDown,
}) {
return tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(
body: Center(
child: SkipMarkerButton(
marker: MediaMarker(id: 1, type: 'intro', startTimeOffset: 10000, endTimeOffset: 45000),
playerDuration: const Duration(minutes: 20),
hasNextEpisode: false,
isAutoSkipActive: isAutoSkipActive,
shouldShowAutoSkip: true,
autoSkipDelay: 5,
autoSkipProgress: 0.4,
focusNode: focusNode,
onActivate: onActivate,
onFocusDown: onFocusDown ?? () {},
),
),
),
),
);
}
class _FakeSyncPlayer implements Player {
@override
PlayerState get state => PlayerState();
@override
Future<void> setProperty(String name, String value) async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}