Files
plezy/test/widgets/video_controls_test.dart
T
edde746 bbed260169 fix(player): start the TV player with its chrome down
A television raised the whole OSD and timebar on every playback start. The
chrome controller is born visible, and its auto-hide clock cannot arm until the
first frame lands, so the controls did not merely appear early: they appeared
exactly when the picture did, and then sat over the opening five seconds of
every movie and episode. The timeline is gated behind the first frame, so the
bar materialised on top of the video rather than over the loading spinner,
which is what makes it read as a pop-up rather than as chrome that was already
there.

The route now opens with no chrome on TV. Nothing is lost: the loading spinner
and buffering overlay are their own overlays, the screen focus node owns back,
and the first D-pad press raises the controls the way it already does after
every auto-hide. Pointer and touch platforms keep the chrome, where the
viewer's hand is on the surface and the title and back affordance belong over
the spinner.

Initial presentation now follows initial visibility. They were separate:
seeding only visibility would leave the route claiming its chrome was still
presented, so PlayerNavigationCoordinator would read back as "hide the chrome",
hide() would no-op against chrome that was never up, and the press would be
swallowed instead of leaving the player.

Controls that mount with the chrome already down now claim focus themselves.
Focus normally reaches them through the hide transition, and their own
autofocus cannot win it back because the screen node took it during the loading
phase. Left alone, the screen node kept primary focus and its self-heal raised
the entire OSD on the first D-pad press, which put the chrome straight back
over the picture and bypassed the transient seek and transport indicators.

Both player spinners now carry a label. They were bare progress indicators, so
a screen reader announced nothing at all while the picture was coming up, and
the TV Maestro flows had no way left to tell a loading player from a playing
one once the Pause button stopped appearing on its own.

The two TV flows are repaired to match. They waited on that button, and now
wait for the labelled spinner to clear, which cannot happen before the media is
opened. 05 additionally reaches Search by D-pad rather than a percentage
coordinate, because a tap flips InputModeTracker to pointer mode and collapses
the rail it is aiming at, and it gates on the play-next prompt's own Cancel
action: "Next Episode" is also the credits skip button, so the old assertion
could pass without the prompt ever opening.

close #1765
2026-08-02 11:45:27 +02:00

2056 lines
70 KiB
Dart

import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/key_event_utils.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/services/playback_subtitle_resolver.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/video_volume_controller.dart';
import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart';
import 'package:plezy/widgets/video_controls/desktop_video_controls.dart';
import 'package:plezy/widgets/video_controls/mobile_video_controls.dart';
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
import 'package:plezy/widgets/video_controls/video_controls.dart';
import 'package:plezy/widgets/video_controls/models/track_controls_state.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/video_control_button.dart';
import 'package:plezy/widgets/video_controls/widgets/video_timeline_bar.dart';
import '../test_helpers/watch_together_fakes.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/theme.dart';
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],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(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.selectedSubtitleChoice, 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],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(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.selectedSubtitleChoice, const PlaybackSourceSubtitleChoice.source(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,
sidecarSourceIds: const {},
supportsEmbeddedTranscodeSelection: false,
),
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,
sidecarSourceIds: {keyed.id},
supportsEmbeddedTranscodeSelection: true,
);
expect(result, [text, image, keyed]);
});
test('drops unsupported embedded codecs while transcoding', () {
final text = sub(1, codec: 'ass');
final unsupported = sub(2, codec: 'weird');
final result = selectableSourceSubtitleTracks(
[text, unsupported],
isTranscoding: true,
sidecarSourceIds: const {},
supportsEmbeddedTranscodeSelection: true,
);
expect(result, [text]);
});
test('only offers resolved sidecars when embedded transcode selection is unsupported', () {
final external = sub(1, codec: 'srt', key: '/Videos/item/source/Subtitles/1/Stream.srt');
final embedded = sub(2, codec: 'srt');
final unavailableExternal = sub(3, codec: 'srt', key: '/missing');
final result = selectableSourceSubtitleTracks(
[external, embedded, unavailableExternal],
isTranscoding: true,
sidecarSourceIds: {external.id},
supportsEmbeddedTranscodeSelection: false,
);
expect(result, [external]);
});
test('only offers resolved file sidecars during direct play', () {
final embedded = sub(1, codec: 'srt');
final availableExternal = sub(2, codec: 'srt', key: '/available');
final unavailableExternal = sub(3, codec: 'srt', key: '/missing');
final result = selectableSourceSubtitleTracks(
[embedded, availableExternal, unavailableExternal],
isTranscoding: false,
sidecarSourceIds: {availableExternal.id},
supportsEmbeddedTranscodeSelection: false,
);
expect(result, [embedded, availableExternal]);
});
test('keeps Jellyfin external-delivery rows that remain embedded during direct play', () {
final deliveryExternalEmbedded = MediaSubtitleTrack(
id: 1,
codec: 'srt',
key: '/Videos/item/source/Subtitles/1/Stream.srt',
usesExternalDelivery: true,
selected: false,
forced: false,
);
final result = selectableSourceSubtitleTracks(
[deliveryExternalEmbedded],
isTranscoding: false,
sidecarSourceIds: const {},
supportsEmbeddedTranscodeSelection: false,
);
expect(result, [deliveryExternalEmbedded]);
});
});
test('findNewExternalSubtitleTrack ignores embedded and existing source rows', () {
final embedded = MediaSubtitleTrack(id: 1, selected: false, forced: false);
final existing = MediaSubtitleTrack(id: 2, external: true, selected: false, forced: false);
final downloaded = MediaSubtitleTrack(id: 3, external: true, selected: false, forced: false);
expect(findNewExternalSubtitleTrack([embedded, existing, downloaded], {1, 2}), downloaded);
});
test('subtitle download treats an already-selected source as applied', () {
expect(
subtitleDownloadApplyOutcomeFor(PlaybackSourceChangeOutcome.unchanged),
SubtitleDownloadApplyOutcome.applied,
);
});
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,
);
}
});
test('surrenders bare Backspace to a focused text editor', () {
final event = _navigationKeyDown(LogicalKeyboardKey.backspace, ui.KeyEventDeviceType.keyboard);
expect(
classifyPlayerNavigationKey(event, isAppleTV: false, hasModifiers: false, textEditingActive: true),
PlayerNavigationKey.none,
);
expect(
classifyPlayerNavigationKey(event, isAppleTV: false, hasModifiers: false, textEditingActive: false),
PlayerNavigationKey.back,
);
});
test('surrenders bare Home to a focused text editor but never browser Home', () {
expect(
classifyPlayerNavigationKey(
_navigationKeyDown(LogicalKeyboardKey.home, ui.KeyEventDeviceType.keyboard),
isAppleTV: false,
hasModifiers: false,
textEditingActive: true,
),
PlayerNavigationKey.none,
);
// browserHome has no caret role, so an editor never takes it.
expect(
classifyPlayerNavigationKey(
_navigationKeyDown(LogicalKeyboardKey.browserHome, ui.KeyEventDeviceType.keyboard),
isAppleTV: false,
hasModifiers: false,
textEditingActive: true,
),
PlayerNavigationKey.home,
);
});
test('keeps simulated remote Home navigating while a text editor has focus', () {
for (final deviceType in [ui.KeyEventDeviceType.directionalPad, ui.KeyEventDeviceType.gamepad]) {
expect(
classifyPlayerNavigationKey(
_navigationKeyDown(LogicalKeyboardKey.home, deviceType),
isAppleTV: false,
hasModifiers: false,
textEditingActive: true,
),
PlayerNavigationKey.home,
reason: 'a synthesized remote press has no caret to move',
);
}
});
});
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++,
);
expect(BackKeyCoordinator.consumeIfHandled(), isTrue, reason: 'parallel route pop is suppressed on key down');
handlePlayerNavigationKeyAction(_keyUp(LogicalKeyboardKey.backspace), PlayerNavigationKey.back, () => actions++);
expect(actions, 1);
await tester.pump();
});
});
group('primePlayerNavigationFocusForEvent', () {
testWidgets('claims loading-route focus on navigation key down', (tester) async {
final playerFocus = FocusNode();
final otherFocus = FocusNode();
addTearDown(playerFocus.dispose);
addTearDown(otherFocus.dispose);
await tester.pumpWidget(
MaterialApp(
home: Column(
children: [
Focus(focusNode: playerFocus, child: const SizedBox()),
Focus(focusNode: otherFocus, child: const SizedBox()),
],
),
),
);
otherFocus.requestFocus();
await tester.pump();
final primed = primePlayerNavigationFocusForEvent(
_keyDown(LogicalKeyboardKey.gameButtonB),
focusNode: playerFocus,
playerReady: false,
isCurrentRoute: true,
isAppleTV: false,
);
await tester.pump();
expect(primed, isTrue);
expect(playerFocus.hasPrimaryFocus, isTrue);
});
testWidgets('does not steal focus after the player is ready', (tester) async {
final playerFocus = FocusNode();
final otherFocus = FocusNode();
addTearDown(playerFocus.dispose);
addTearDown(otherFocus.dispose);
await tester.pumpWidget(
MaterialApp(
home: Column(
children: [
Focus(focusNode: playerFocus, child: const SizedBox()),
Focus(focusNode: otherFocus, child: const SizedBox()),
],
),
),
);
otherFocus.requestFocus();
await tester.pump();
final primed = primePlayerNavigationFocusForEvent(
_keyDown(LogicalKeyboardKey.gameButtonB),
focusNode: playerFocus,
playerReady: true,
isCurrentRoute: true,
isAppleTV: false,
);
await tester.pump();
expect(primed, isFalse);
expect(otherFocus.hasPrimaryFocus, isTrue);
});
testWidgets('does not steal focus from a route above the player', (tester) async {
final playerFocus = FocusNode();
final overlayFocus = FocusNode();
addTearDown(playerFocus.dispose);
addTearDown(overlayFocus.dispose);
await tester.pumpWidget(
MaterialApp(
home: Column(
children: [
Focus(focusNode: playerFocus, child: const SizedBox()),
Focus(focusNode: overlayFocus, child: const SizedBox()),
],
),
),
);
overlayFocus.requestFocus();
await tester.pump();
final primed = primePlayerNavigationFocusForEvent(
_keyDown(LogicalKeyboardKey.gameButtonB),
focusNode: playerFocus,
playerReady: false,
isCurrentRoute: false,
isAppleTV: false,
);
await tester.pump();
expect(primed, isFalse);
expect(overlayFocus.hasPrimaryFocus, isTrue);
});
});
group('PlayerNavigationCoordinator focus dispatch', () {
PlayerNavigationCoordinator coordinatorFor(
PlayerChromeController chromeController, {
bool Function()? isPromptOpen,
VoidCallback? dismissPrompt,
bool Function()? isChromePresented,
Future<bool> Function()? exitFullscreenIfActive,
bool physicalEscapeExitsFullscreen = true,
bool Function()? physicalEscapeExitsFullscreenProvider,
VoidCallback? exitPlayer,
VoidCallback? navigateHome,
bool Function()? isActive,
}) {
return PlayerNavigationCoordinator(
chromeController: chromeController,
isPromptOpen: isPromptOpen ?? () => false,
dismissPrompt: dismissPrompt ?? () {},
isChromePresented: isChromePresented ?? () => chromeController.controlsPresented,
exitFullscreenIfActive: exitFullscreenIfActive ?? () async => false,
physicalEscapeExitsFullscreen: physicalEscapeExitsFullscreenProvider ?? () => physicalEscapeExitsFullscreen,
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('Back exits during pre-first-frame loading even when controls default visible', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
var exits = 0;
final coordinator = coordinatorFor(chromeController, isChromePresented: () => false, exitPlayer: () => exits++);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
expect(chromeController.controlsVisible, isTrue);
expect(exits, 1);
});
testWidgets('Back exits on the first press when the route opened with no chrome', (tester) async {
final chromeController = PlayerChromeController(initiallyVisible: false);
addTearDown(chromeController.dispose);
var exits = 0;
final coordinator = coordinatorFor(chromeController, exitPlayer: () => exits++);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
expect(exits, 1, reason: 'a TV start has no chrome to hide, so back belongs to the route (#1765)');
});
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('enabling player navigation makes physical Escape preserve fullscreen', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
var physicalEscapeExitsFullscreen = true;
var fullscreenChecks = 0;
var exits = 0;
final coordinator = coordinatorFor(
chromeController,
exitFullscreenIfActive: () async {
fullscreenChecks++;
return true;
},
physicalEscapeExitsFullscreenProvider: () => physicalEscapeExitsFullscreen,
exitPlayer: () => exits++,
);
physicalEscapeExitsFullscreen = false;
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
expect(fullscreenChecks, 0);
expect(chromeController.controlsVisible, isFalse);
expect(exits, 0);
});
testWidgets('macOS physical Escape stages through chrome and player without leaving fullscreen', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
var fullscreenChecks = 0;
var exits = 0;
final coordinator = coordinatorFor(
chromeController,
exitFullscreenIfActive: () async {
fullscreenChecks++;
return true;
},
physicalEscapeExitsFullscreen: false,
exitPlayer: () => exits++,
);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
expect(fullscreenChecks, 0);
expect(chromeController.controlsVisible, isFalse);
expect(exits, 0);
chromeController.markControlsHidden();
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
expect(fullscreenChecks, 0);
expect(exits, 1);
});
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,
);
});
});
group('shouldPhysicalEscapeExitFullscreen', () {
test('uses fullscreen-first behavior for normal Windows and Linux navigation', () {
expect(shouldPhysicalEscapeExitFullscreen(isMacOS: false, videoPlayerNavigationEnabled: false), isTrue);
});
test('preserves fullscreen when HTPC-style player navigation is enabled', () {
expect(shouldPhysicalEscapeExitFullscreen(isMacOS: false, videoPlayerNavigationEnabled: true), isFalse);
});
test('preserves native fullscreen inside the macOS player', () {
expect(shouldPhysicalEscapeExitFullscreen(isMacOS: true, videoPlayerNavigationEnabled: false), isFalse);
});
});
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('play/pause callback routing', () {
testWidgets('desktop button delegates without issuing a player command', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
await initializeDateFormatting('en');
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
final settings = await SettingsService.getInstance();
final player = FakeSyncPlayer();
addTearDown(player.dispose);
final volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100);
addTearDown(volume.dispose);
var requests = 0;
final watchTogether = WatchTogetherProvider();
addTearDown(watchTogether.dispose);
await tester.pumpWidget(
ChangeNotifierProvider<WatchTogetherProvider>.value(
value: watchTogether,
child: MaterialApp(
theme: ThemeData(extensions: const [testMonoTokens]),
home: Scaffold(
body: SizedBox(
width: 1000,
height: 700,
child: DesktopVideoControls(
player: player,
volumeController: volume,
metadata: testMediaItem(id: 'desktop'),
onPlayPause: () => requests++,
chapters: const [],
chaptersLoaded: true,
seekTimeSmall: 10,
onSeekToPreviousChapter: () {},
onSeekToNextChapter: () {},
onSeek: (_) {},
onSeekEnd: (_) {},
getReplayIcon: (_) => Icons.replay,
getForwardIcon: (_) => Icons.forward_10,
trackControlsState: const TrackControlsState(canControl: true),
),
),
),
),
),
);
await tester.pump();
await tester.tap(find.bySemanticsLabel(t.videoControls.playButton).last);
await tester.pump();
expect(requests, 1);
expect(player.commandLog.where((entry) => entry == 'play' || entry == 'pause'), isEmpty);
});
testWidgets('mobile button delegates and preserves chrome timer behavior', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
await initializeDateFormatting('en');
tester.view.physicalSize = const Size(800, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
final player = FakeSyncPlayer();
addTearDown(player.dispose);
var requests = 0;
var startAutoHide = 0;
var cancelAutoHide = 0;
final watchTogether = WatchTogetherProvider();
addTearDown(watchTogether.dispose);
await tester.pumpWidget(
ChangeNotifierProvider<WatchTogetherProvider>.value(
value: watchTogether,
child: MaterialApp(
theme: ThemeData(extensions: const [testMonoTokens]),
home: Scaffold(
body: SizedBox(
width: 500,
height: 800,
child: MobileVideoControls(
player: player,
metadata: testMediaItem(id: 'mobile'),
chapters: const [],
chaptersLoaded: true,
seekTimeSmall: 10,
trackChapterControls: const SizedBox.shrink(),
onSeek: (_) {},
onSeekEnd: (_) {},
onPlayPause: () => requests++,
onStartAutoHide: () => startAutoHide++,
onCancelAutoHide: () => cancelAutoHide++,
),
),
),
),
),
);
await tester.pump();
await tester.tap(find.bySemanticsLabel(t.videoControls.playButton).last);
await tester.pump();
expect(requests, 1);
expect(startAutoHide, 1);
expect(cancelAutoHide, 0);
expect(player.commandLog.where((entry) => entry == 'play' || entry == 'pause'), isEmpty);
});
});
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('focused slider owns one adjustable semantics node', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
final semantics = tester.ensureSemantics();
final focusNode = FocusNode(debugLabel: 'semantic_timeline');
addTearDown(focusNode.dispose);
final seekEnds = <Duration>[];
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,
onSeek: (_) {},
onSeekEnd: seekEnds.add,
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
final finder = find.bySemanticsLabel(t.videoControls.timelineSlider);
expect(finder, findsOneWidget);
final node = tester.getSemantics(finder);
final data = node.getSemanticsData();
expect(data.label, t.videoControls.timelineSlider);
expect(data.value, '1:00');
expect(data.increasedValue, '1:10');
expect(data.decreasedValue, '0:50');
expect(data.flagsCollection.isSlider, isTrue);
expect(data.flagsCollection.isEnabled, ui.Tristate.isTrue);
expect(data.flagsCollection.isButton, isFalse);
expect(data.hasAction(ui.SemanticsAction.tap), isFalse);
expect(data.hasAction(ui.SemanticsAction.increase), isTrue);
expect(data.hasAction(ui.SemanticsAction.decrease), isTrue);
node.owner!.performAction(node.id, ui.SemanticsAction.increase);
node.owner!.performAction(node.id, ui.SemanticsAction.decrease);
expect(seekEnds, const [Duration(minutes: 1, seconds: 10), Duration(seconds: 50)]);
semantics.dispose();
});
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 final seek', () {
expect(
shouldSkipDuplicateTimelineSeek(
lastDispatchedSeek: const Duration(minutes: 7, seconds: 30),
finalSeek: const Duration(minutes: 7, seconds: 30),
),
isTrue,
);
});
test('does not skip when no matching seek was already dispatched', () {
expect(
shouldSkipDuplicateTimelineSeek(
lastDispatchedSeek: const Duration(minutes: 7),
finalSeek: const Duration(minutes: 7, seconds: 30),
),
isFalse,
);
expect(
shouldSkipDuplicateTimelineSeek(lastDispatchedSeek: null, finalSeek: const Duration(minutes: 7, seconds: 30)),
isFalse,
);
});
});
group('shouldStartHiddenDirectionalSeek', () {
test('accepts the initial press and its repeats, so a held key keeps seeking in place', () {
expect(shouldStartHiddenDirectionalSeek(_keyDown(LogicalKeyboardKey.arrowRight)), isTrue);
expect(
shouldStartHiddenDirectionalSeek(
const KeyRepeatEvent(
physicalKey: PhysicalKeyboardKey.arrowRight,
logicalKey: LogicalKeyboardKey.arrowRight,
timeStamp: Duration.zero,
),
),
isTrue,
reason: 'hidden-chrome seeking owns the whole burst instead of escalating to the timeline',
);
expect(
shouldStartHiddenDirectionalSeek(_keyUp(LogicalKeyboardKey.arrowRight)),
isFalse,
reason: 'release commits the burst, it does not add another step',
);
});
});
group('subtitle visibility', () {
testWidgets('rolls back a failed latest toggle to the preceding successful mutation', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
await initializeDateFormatting('en');
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
final settings = await SettingsService.getInstance();
final firstWrite = Completer<void>();
final secondWrite = Completer<void>();
final player = _FakeSubtitleVisibilityPlayer(writes: [firstWrite, secondWrite]);
final volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100);
final playbackState = PlaybackStateProvider();
final watchTogether = WatchTogetherProvider();
final chrome = PlayerChromeController();
final toast = PlayerToastController();
addTearDown(volume.dispose);
addTearDown(playbackState.dispose);
addTearDown(watchTogether.dispose);
addTearDown(chrome.dispose);
addTearDown(toast.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playbackState),
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogether),
],
child: MaterialApp(
theme: ThemeData(platform: TargetPlatform.macOS, extensions: const [testMonoTokens]),
home: Scaffold(
body: SizedBox(
width: 1200,
height: 800,
child: PlexVideoControls(
player: player,
volumeController: volume,
metadata: testMediaItem(id: 'subtitle-visibility'),
toastController: toast,
canNavigateMediaItems: false,
chromeController: chrome,
isLive: true,
),
),
),
),
),
);
await tester.pump();
expect(find.byIcon(Symbols.subtitles_rounded), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
await tester.pump();
expect(player.propertyValues, ['no']);
expect(find.byIcon(Symbols.subtitles_off_rounded), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
await tester.pump();
expect(player.propertyValues, ['no'], reason: 'the latest toggle must wait for the in-flight native write');
expect(find.byIcon(Symbols.subtitles_rounded), findsOneWidget);
firstWrite.complete();
await tester.pump();
await tester.pump();
expect(player.propertyValues, ['no', 'yes']);
secondWrite.completeError(PlatformException(code: 'SET_PROPERTY_FAILED'));
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.byIcon(Symbols.subtitles_off_rounded), findsOneWidget);
chrome.cancelAutoHide();
await tester.pumpWidget(const SizedBox.shrink());
});
});
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 [testMonoTokens]),
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));
});
testWidgets('reconciles a failed current write without persisting it', (tester) async {
final propertyWrite = Completer<void>();
final persistedOffsets = <int>[];
final player = _FakeSyncPlayer(onSetProperty: (_, _) => propertyWrite.future);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [testMonoTokens]),
home: Scaffold(
body: SizedBox(
width: 700,
child: SyncOffsetControl(
player: player,
propertyName: 'sub-delay',
initialOffset: 500,
labelText: 'Subtitles',
onOffsetChanged: (offset) async => persistedOffsets.add(offset),
compact: true,
),
),
),
),
);
tester.widget<Slider>(find.byType(Slider)).onChanged!(600);
await tester.pump();
tester.widget<Slider>(find.byType(Slider)).onChangeEnd!(600);
await tester.pump();
expect(tester.widget<Slider>(find.byType(Slider)).value, 600);
expect(persistedOffsets, isEmpty);
propertyWrite.completeError(PlatformException(code: 'SET_PROPERTY_FAILED'));
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull);
expect(tester.widget<Slider>(find.byType(Slider)).value, 500);
expect(persistedOffsets, isEmpty);
});
testWidgets('ignores stale failure and persists the latest accepted offset once', (tester) async {
final staleWrite = Completer<void>();
final persistedOffsets = <int>[];
var writeCount = 0;
final player = _FakeSyncPlayer(
onSetProperty: (_, _) {
writeCount++;
return writeCount == 1 ? staleWrite.future : Future<void>.value();
},
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [testMonoTokens]),
home: Scaffold(
body: SizedBox(
width: 700,
child: SyncOffsetControl(
player: player,
propertyName: 'audio-delay',
initialOffset: 0,
labelText: 'Audio',
onOffsetChanged: (offset) async => persistedOffsets.add(offset),
compact: true,
),
),
),
),
);
tester.widget<Slider>(find.byType(Slider)).onChanged!(100);
await tester.pump();
tester.widget<Slider>(find.byType(Slider)).onChangeEnd!(100);
await tester.pump();
tester.widget<Slider>(find.byType(Slider)).onChanged!(200);
await tester.pump();
tester.widget<Slider>(find.byType(Slider)).onChangeEnd!(200);
await tester.pump();
await tester.pump();
expect(persistedOffsets, isEmpty);
staleWrite.completeError(PlatformException(code: 'SET_PROPERTY_FAILED'));
await tester.pump();
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull);
expect(tester.widget<Slider>(find.byType(Slider)).value, 200);
expect(persistedOffsets, [200]);
});
testWidgets('rolls back a failed latest write to the preceding successful offset', (tester) async {
final firstWrite = Completer<void>();
final secondWrite = Completer<void>();
final propertyValues = <String>[];
final persistedOffsets = <int>[];
final player = _FakeSyncPlayer(
onSetProperty: (_, value) {
propertyValues.add(value);
return propertyValues.length == 1 ? firstWrite.future : secondWrite.future;
},
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [testMonoTokens]),
home: Scaffold(
body: SizedBox(
width: 700,
child: SyncOffsetControl(
player: player,
propertyName: 'sub-delay',
initialOffset: 0,
labelText: 'Subtitles',
onOffsetChanged: (offset) async {
persistedOffsets.add(offset);
},
compact: true,
),
),
),
),
);
tester.widget<Slider>(find.byType(Slider)).onChanged!(100);
tester.widget<Slider>(find.byType(Slider)).onChangeEnd!(100);
await tester.pump();
expect(propertyValues, ['0.1']);
tester.widget<Slider>(find.byType(Slider)).onChanged!(200);
tester.widget<Slider>(find.byType(Slider)).onChangeEnd!(200);
await tester.pump();
expect(propertyValues, ['0.1']);
expect(tester.widget<Slider>(find.byType(Slider)).value, 200);
firstWrite.complete();
await tester.pump();
await tester.pump();
expect(propertyValues, ['0.1', '0.2']);
expect(persistedOffsets, [100]);
secondWrite.completeError(PlatformException(code: 'SET_PROPERTY_FAILED'));
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull);
expect(tester.widget<Slider>(find.byType(Slider)).value, 100);
expect(persistedOffsets, [100]);
});
testWidgets('persists an accepted offset after the control is disposed', (tester) async {
final propertyWrite = Completer<void>();
final persistedOffsets = <int>[];
final player = _FakeSyncPlayer(onSetProperty: (_, _) => propertyWrite.future);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [testMonoTokens]),
home: Scaffold(
body: SizedBox(
width: 700,
child: SyncOffsetControl(
player: player,
propertyName: 'sub-delay',
initialOffset: 0,
labelText: 'Subtitles',
onOffsetChanged: (offset) async => persistedOffsets.add(offset),
compact: true,
),
),
),
),
);
tester.widget<Slider>(find.byType(Slider)).onChanged!(100);
tester.widget<Slider>(find.byType(Slider)).onChangeEnd!(100);
await tester.pumpWidget(const SizedBox.shrink());
propertyWrite.complete();
await tester.pump();
expect(persistedOffsets, [100]);
});
});
group('VideoControlButton semantics', () {
testWidgets('exposes one operable node with value and checked state', (tester) async {
final semantics = tester.ensureSemantics();
final focusNode = FocusNode(debugLabel: 'semantic_video_control');
addTearDown(focusNode.dispose);
var activations = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: VideoControlButton(
icon: Icons.settings,
tooltip: 'Player settings',
semanticValue: '720p',
checked: true,
focusNode: focusNode,
onPressed: () => activations++,
),
),
),
);
final finder = find.bySemanticsLabel('Player settings');
expect(finder, findsOneWidget);
final data = tester.getSemantics(finder).getSemanticsData();
expect(data.value, '720p');
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isChecked, ui.CheckedState.isTrue);
expect(data.hasAction(ui.SemanticsAction.tap), isTrue);
final node = tester.getSemantics(finder);
node.owner!.performAction(node.id, ui.SemanticsAction.tap);
await tester.pump();
expect(activations, 1);
semantics.dispose();
});
testWidgets('keeps disabled controls discoverable without a tap action', (tester) async {
final semantics = tester.ensureSemantics();
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: VideoControlButton(icon: Icons.skip_next, semanticLabel: 'Next item', onPressed: null),
),
),
);
final data = tester.getSemantics(find.bySemanticsLabel('Next item')).getSemanticsData();
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, ui.Tristate.isFalse);
expect(data.hasAction(ui.SemanticsAction.tap), isFalse);
semantics.dispose();
});
});
}
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 [testMonoTokens]),
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 {
_FakeSyncPlayer({this.onSetProperty});
final Future<void> Function(String name, String value)? onSetProperty;
@override
PlayerState get state => PlayerState();
@override
Future<void> setProperty(String name, String value) {
return onSetProperty?.call(name, value) ?? Future<void>.value();
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeSubtitleVisibilityPlayer implements Player {
_FakeSubtitleVisibilityPlayer({required this.writes});
final List<Completer<void>> writes;
final List<String> propertyValues = [];
@override
String get playerType => 'mpv';
@override
PlayerState get state => PlayerState(
duration: const Duration(minutes: 45),
seekable: true,
tracks: const Tracks(
subtitle: [SubtitleTrack(id: 'subtitle-1', language: 'eng')],
),
track: const TrackSelection(
subtitle: SubtitleTrack(id: 'subtitle-1', language: 'eng'),
),
);
@override
PlayerStreams get streams => PlayerStreams(
playing: const Stream<bool>.empty(),
completed: const Stream<bool>.empty(),
buffering: const Stream<bool>.empty(),
position: const Stream<Duration>.empty(),
duration: const Stream<Duration>.empty(),
seekable: const Stream<bool>.empty(),
buffer: const Stream<Duration>.empty(),
volume: const Stream<double>.empty(),
rate: const Stream<double>.empty(),
tracks: const Stream<Tracks>.empty(),
track: const Stream<TrackSelection>.empty(),
log: const Stream<PlayerLog>.empty(),
error: const Stream<PlayerError>.empty(),
audioDevice: const Stream<AudioDevice>.empty(),
audioDevices: const Stream<List<AudioDevice>>.empty(),
bufferRanges: const Stream<List<BufferRange>>.empty(),
playbackRestart: const Stream<void>.empty(),
backendSwitched: const Stream<void>.empty(),
);
@override
Future<void> setProperty(String name, String value) {
expect(name, 'sub-visibility');
propertyValues.add(value);
return writes[propertyValues.length - 1].future;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}