Files
plezy/test/widgets/video_controls_test.dart
T
edde746 369c6279d6 fix(i18n): translate the player, downloads and server-setup text left in English
A Portuguese user reported "Skip Intro" rendering in English on Android TV.
The locale files were not the problem - all 22 were structurally complete.
skip_marker_button.dart simply never imported strings.g.dart and assigned
'Skip Intro' / 'Skip Credits' / 'Next Episode' as plain literals. An audit of
lib/ found ~120 more sites in the same state, in four shapes that need
different fixes:

A literal in a file that never imported the i18n layer is the easy one -
skip_marker_button, performance_stats, track_label_builder and codec_utils all
render text with no `t` in the file at all. TrackLabelBuilder._compose now takes
a fallbackLabel builder instead of an English fallbackPrefix, so the caller
supplies t.audioTracks.track / t.videoControls.subtitleTrack and every unnamed
audio and subtitle row in the track menus is localized.

English reaching the user through an exception message is the widest one, and
it needs care: MediaServerException.message feeds both toString() - logs and
Sentry grouping - and verbatim UI display. Localizing it in place would make
bug-report logs follow the user's locale and split one Sentry issue into 22.
The MediaServer and Seerr families instead gain a nullable `display` alongside
the English `message`, and the six screens that print these errors read
`display ?? message`. PlaybackException keeps the opposite rule, because it
already carries a PlaybackFailureReason for logic and classifyPlaybackFailure
already builds it from t.messages: its stragglers are localized at the throw
site. That also removes the literal "Exception: " prefix Live TV users saw on
a tune failure, since PlaybackException.toString() returns the bare message.

Localized parts hand-concatenated with bare English are the shape no search for
Text('...') can find: '${t.common.pause} auto-scroll' on the home carousel,
'${day} at ${time}' on the Live TV schedule row, and an actor-screen count that
hand-rolled its plural as `n == 1 ? 'title' : 'titles'` - wrong for ru and pl
regardless of translation, now a real Slang plural.

Finally a literal assigned to provider state that a widget renders later:
DownloadProgress.errorMessage, and the four background_downloader notification
bodies, which sit inside a plugin config call where no widget-shaped search
reaches them.

Two things surfaced while converting. track_chapter_controls compared a track
label against 'Audio Track N' to swap in a localized version; once the builder
localized its own fallback that branch became unreachable, so it and the
orphaned _joinTrackLabel are gone. And discovery_view's PeerError fallback arm
looks like a leak but is not - its producers already localize, and a test says
so - so it stays as it is.

All 21 non-base locales are translated, including the 21 keys left empty by
earlier commits that were falling back to English. No locale has an empty value.

scripts/check_hardcoded_strings.py guards the three shapes a structural check
can see, and runs in ci_checks.sh after translation hygiene. Its first draft
passed its own tests while missing this very bug, because 'Skip Intro' is bound
to a local rather than handed to Text(); the name-bound rule that closes that
gap is restricted to phrase-shaped literals, or it cannot tell copy from the
identifiers this codebase binds constantly ('cast_row', 'auto', 'liveTv'). It
cannot see English inside a throw or assigned to a provider field - neither is
distinguishable from a log message without dataflow analysis - and the docstring
says so. label: and actionLabel: are deliberately unscanned: here they name a
diagnostic operation, and a check that is chronically red is a check that gets
switched off.

One commit rather than one per area: the keys, the 22 locale files and the
generated output are a single unit, and any partial split fails the repo's own
unused-key scan on the way through.

close #1856
2026-08-10 15:32:43 +02:00

2114 lines
72 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 {}), 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},
);
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 {},
);
expect(result, [text]);
});
test('drops a keyed external row whose sidecar never resolved while transcoding', () {
// The client fetches this one itself, and on a transcode an external file is never a burn
// target - so with no sidecar built, nothing can draw it and reloading cannot help. Offering
// it left a selection that silently showed no caption. Its codec is text, which is exactly why
// the codec fallback must not cover keyed rows.
final unresolved = sub(1, codec: 'srt', key: '/library/streams/1');
final embedded = sub(2, codec: 'srt');
final result = selectableSourceSubtitleTracks(
[unresolved, embedded],
isTranscoding: true,
sidecarSourceIds: const {},
);
expect(result, [embedded], reason: 'the embedded row can still be burned');
});
test('offers a burnable embedded image track that has no sidecar of its own', () {
// The regression this pins: a transcode burns PGS server-side, so the
// track deliberately has no sidecar. Gating selection on a sidecar - or on
// the backend - dropped it from the menu entirely, which is issue #1738's
// "PGS subtitles wont appear at all while transcoding". Every backend can
// be asked to burn, so the codec is the only thing that may exclude it.
final image = sub(1, codec: 'pgssub');
final result = selectableSourceSubtitleTracks([image], isTranscoding: true, sidecarSourceIds: const {});
expect(result, [image]);
});
test('still offers an unresolved external file only once its sidecar exists', () {
final resolved = sub(1, codec: 'srt', key: '/Videos/item/source/Subtitles/1/Stream.srt');
final unresolved = sub(2, codec: 'weird', key: '/missing');
final result = selectableSourceSubtitleTracks(
[resolved, unresolved],
isTranscoding: true,
sidecarSourceIds: {resolved.id},
);
expect(result, [resolved], reason: 'an unresolved key with an unburnable codec has no delivery route');
});
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},
);
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 {},
);
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,
playerEnteredFullscreen: true,
),
isTrue,
);
});
test('preserves fullscreen when HTPC-style player navigation is enabled', () {
expect(
shouldPhysicalEscapeExitFullscreen(
isMacOS: false,
videoPlayerNavigationEnabled: true,
playerEnteredFullscreen: true,
),
isFalse,
);
});
test('preserves native fullscreen inside the macOS player', () {
expect(
shouldPhysicalEscapeExitFullscreen(
isMacOS: true,
videoPlayerNavigationEnabled: false,
playerEnteredFullscreen: true,
),
isFalse,
);
});
test('preserves app-owned fullscreen the player did not enter', () {
expect(
shouldPhysicalEscapeExitFullscreen(
isMacOS: false,
videoPlayerNavigationEnabled: false,
playerEnteredFullscreen: 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('${t.videoControls.skipIntro} (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(t.videoControls.skipIntro), findsOneWidget);
await tester.tap(find.byType(InkWell));
await tester.pump();
expect(activateCount, 1);
});
testWidgets('uses the localized marker label', (tester) async {
await tester.runAsync(() => LocaleSettings.setLocale(AppLocale.pt));
addTearDown(() => LocaleSettings.setLocaleSync(AppLocale.en));
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
await _pumpSkipMarkerButton(tester, focusNode: focusNode, isAutoSkipActive: false, onActivate: () {});
expect(t.videoControls.skipIntro, 'Pular abertura');
expect(find.text('Pular abertura'), findsOneWidget);
expect(find.text('Skip Intro'), findsNothing);
});
});
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(
useDpadNavigation: false,
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);
}