feat(player): answer transport keys with transient indicators, not the chrome

Pressing pause or seeking while the player's on-screen controls were hidden raised
the entire OSD, covering the subtitles the viewer was rewinding to read. Transport
keys now answer with a transient indicator and leave the chrome down; Select,
D-pad Center and a centre tap remain the deliberate way to bring the controls
back.

Play/pause confirms with an icon-only translucent disc at the centre of the frame,
72px around a 44px glyph, which grows and fades in, holds half a second at rest,
then runs the same motion in reverse. Seeking shows the amount plus a single
chevron on the same line at the edge it travels toward, with no backdrop at all:
anything large enough to read as a surface is large enough to cover picture and
subtitles, so legibility comes from shadows instead. Only the chevron moves, and
it eases outward across most of its cycle and returns briefly, holding a visible
opacity floor rather than blinking out. Type is scaled per platform, since a
television is read from across the room. The existing text pill stays for genuine
notices - rate changes, chapter titles, zoom, errors - because an earlier centred
pill overlapped ASS \an8 subtitle placement, which is the readability complaint
this feedback exists to answer.

Every relative seek entry point now shares one coalescing primitive. The keyboard
shortcuts fell through to KeyboardShortcutsService and previously reported
nothing, and both they and the remote's chapter fallback rebased each press off
player.state.position, so a burst against a slow backend pinned every request near
one step while the indicator climbed to a total that was never committed. A
released key commits its pending target immediately and resets the acceleration
tier, including on live TV where seeks bypass the accumulator. A chapter seek with
nowhere to go, past the last chapter or already at the start, no longer announces a
jump it does not perform.

Rewind-on-resume follows the resolved intent rather than the current state, so a
directed pause on an already-paused video neither resumes nor rewinds. Indicators
carry their own liveRegion semantics nodes: their labels previously merged into the
full-screen "show playback controls" target, corrupting its accessible name, and
they keep announcing "Paused"/"Playing" and the seek amount from icon-only visuals.

close #1676
This commit is contained in:
edde746
2026-07-29 04:24:04 +02:00
parent 0eee9f688d
commit f3795d49eb
38 changed files with 1719 additions and 181 deletions
@@ -23,17 +23,40 @@ tags:
- extendedWaitUntil:
notVisible: "Pause"
timeout: 10000
# Hardware transport keys drive playback without raising the chrome (#1676):
# the player answers with a centred disc (pause) or a side readout (seek), never the
# full chrome, so subtitles stay readable.
# Pause first — it also freezes the clip, so the seek assertions below cannot
# race the end of a short fixture item.
- pressKey: "Remote Media Play Pause"
- extendedWaitUntil:
visible: "Play"
visible: "Paused"
timeout: 10000
- assertNotVisible: "(?s)^Play$"
# D-pad seeking reports through the skip badge, never the scrub bar, and
# consecutive presses in one direction stack into a running total.
- pressKey: "Remote Dpad Right"
- extendedWaitUntil:
visible: "(?s)^Seek forward 10 seconds$"
timeout: 10000
- assertNotVisible: "(?s)^Play$"
- pressKey: "Remote Dpad Right"
- extendedWaitUntil:
visible: "(?s)^Seek forward 20 seconds$"
timeout: 10000
- pressKey: "Remote Media Play Pause"
- extendedWaitUntil:
visible: "Pause"
visible: "Playing"
timeout: 10000
- assertNotVisible: "(?s)^Pause$"
# Select stays the deliberate way to bring the chrome back.
- pressKey: "Remote Dpad Center"
- extendedWaitUntil:
visible: "(?s)^(Play|Pause)$"
timeout: 10000
- pressKey: "back"
- extendedWaitUntil:
notVisible: "Pause"
notVisible: "(?s)^Pause$"
timeout: 10000
- assertNotVisible: "Overview"
- pressKey: "back"
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Növbətini oynat",
"playButton": "Oynat",
"pauseButton": "Fasilə",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Oynatma idarəetmələrini göstər",
"hidePlaybackControls": "Oynatma idarəetmələrini gizlət",
"seekBackwardButton": "${seconds} saniyə geri sar",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Пусни следващото",
"playButton": "Пусни",
"pauseButton": "Пауза",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Покажи контролите за възпроизвеждане",
"hidePlaybackControls": "Скрий контролите за възпроизвеждане",
"seekBackwardButton": "Превърти назад ${seconds} секунди",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Afspil næste",
"playButton": "Afspil",
"pauseButton": "Pause",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Vis afspilningsknapper",
"hidePlaybackControls": "Skjul afspilningsknapper",
"seekBackwardButton": "Spol ${seconds} sekunder tilbage",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Nächstes abspielen",
"playButton": "Abspielen",
"pauseButton": "Pause",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Wiedergabesteuerung anzeigen",
"hidePlaybackControls": "Wiedergabesteuerung ausblenden",
"seekBackwardButton": "${seconds} Sekunden zurück",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Play Next",
"playButton": "Play",
"pauseButton": "Pause",
"playbackPaused": "Paused",
"playbackResumed": "Playing",
"showPlaybackControls": "Show playback controls",
"hidePlaybackControls": "Hide playback controls",
"seekBackwardButton": "Seek backward ${seconds} seconds",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Reproducir siguiente",
"playButton": "Reproducir",
"pauseButton": "Pausar",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Mostrar controles de reproducción",
"hidePlaybackControls": "Ocultar controles de reproducción",
"seekBackwardButton": "Retroceder ${seconds} segundos",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Lire l'épisode suivant",
"playButton": "Lire",
"pauseButton": "Pause",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Afficher les commandes de lecture",
"hidePlaybackControls": "Masquer les commandes de lecture",
"seekBackwardButton": "Reculer de ${seconds} secondes",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Következő lejátszása",
"playButton": "Lejátszás",
"pauseButton": "Szünet",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Lejátszásvezérlők megjelenítése",
"hidePlaybackControls": "Lejátszásvezérlők elrejtése",
"seekBackwardButton": "Tekerés hátra ${seconds} másodperccel",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Riproduci il successivo",
"playButton": "Riproduci",
"pauseButton": "Pausa",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Mostra i controlli di riproduzione",
"hidePlaybackControls": "Nascondi i controlli di riproduzione",
"seekBackwardButton": "Riavvolgi di ${seconds} secondi",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "次を再生",
"playButton": "再生",
"pauseButton": "一時停止",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "再生コントロールを表示",
"hidePlaybackControls": "再生コントロールを非表示",
"seekBackwardButton": "${seconds}秒戻る",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Келесіні ойнату",
"playButton": "Ойнату",
"pauseButton": "Кідірту",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Басқару элементтерін көрсету",
"hidePlaybackControls": "Басқару элементтерін жасыру",
"seekBackwardButton": "${seconds} секунд артқа айналдыру",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "다음 재생",
"playButton": "재생",
"pauseButton": "일시정지",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "재생 컨트롤 표시",
"hidePlaybackControls": "재생 컨트롤 숨기기",
"seekBackwardButton": "${seconds}초 뒤로 이동",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Spill neste",
"playButton": "Spill av",
"pauseButton": "Pause",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Vis avspillingskontroller",
"hidePlaybackControls": "Skjul avspillingskontroller",
"seekBackwardButton": "Spol tilbake ${seconds} sekunder",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Volgende afspelen",
"playButton": "Afspelen",
"pauseButton": "Pauzeren",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Afspeelbediening tonen",
"hidePlaybackControls": "Afspeelbediening verbergen",
"seekBackwardButton": "${seconds} seconden terugspoelen",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Odtwórz następny",
"playButton": "Odtwórz",
"pauseButton": "Pauza",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Pokaż elementy sterujące odtwarzaniem",
"hidePlaybackControls": "Ukryj elementy sterujące odtwarzaniem",
"seekBackwardButton": "Przewiń do tyłu o ${seconds} sekund",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Reproduzir Próximo",
"playButton": "Reproduzir",
"pauseButton": "Pausar",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Mostrar controles de reprodução",
"hidePlaybackControls": "Ocultar controles de reprodução",
"seekBackwardButton": "Retroceder ${seconds} segundos",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Следующее",
"playButton": "Воспроизвести",
"pauseButton": "Пауза",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Показать элементы управления воспроизведением",
"hidePlaybackControls": "Скрыть элементы управления воспроизведением",
"seekBackwardButton": "Перемотать назад на ${seconds} с",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
/// Strings: 32681 (1485 per locale)
/// Strings: 32683 (1485 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+12 -4
View File
@@ -1541,6 +1541,12 @@ class Translations$videoControls$en {
/// en: 'Pause'
String get pauseButton => 'Pause';
/// en: 'Paused'
String get playbackPaused => 'Paused';
/// en: 'Playing'
String get playbackResumed => 'Playing';
/// en: 'Show playback controls'
String get showPlaybackControls => 'Show playback controls';
@@ -5662,6 +5668,8 @@ extension on Translations {
'videoControls.playNext' => 'Play Next',
'videoControls.playButton' => 'Play',
'videoControls.pauseButton' => 'Pause',
'videoControls.playbackPaused' => 'Paused',
'videoControls.playbackResumed' => 'Playing',
'videoControls.showPlaybackControls' => 'Show playback controls',
'videoControls.hidePlaybackControls' => 'Hide playback controls',
'videoControls.seekBackwardButton' => ({required Object seconds}) => 'Seek backward ${seconds} seconds',
@@ -5709,10 +5717,10 @@ extension on Translations {
'videoControls.chapters' => 'Chapters',
'videoControls.noChaptersAvailable' => 'No chapters available',
'videoControls.queue' => 'Queue',
'videoControls.noQueueItems' => 'No items in queue',
'videoControls.searchSubtitles' => 'Search Subtitles',
_ => null,
} ?? switch (path) {
'videoControls.noQueueItems' => 'No items in queue',
'videoControls.searchSubtitles' => 'Search Subtitles',
'videoControls.language' => 'Language',
'videoControls.noSubtitlesFound' => 'No subtitles found',
'videoControls.subtitleDownloaded' => 'Subtitle downloaded',
@@ -6223,10 +6231,10 @@ extension on Translations {
'watchTogether.joinWatchSession' => 'Join Watch Session',
'watchTogether.enterCodeHint' => 'Enter 5-character code',
'watchTogether.pasteFromClipboard' => 'Paste from clipboard',
'watchTogether.pleaseEnterCode' => 'Please enter a session code',
'watchTogether.codeMustBe5Chars' => 'Session code must be 5 characters',
_ => null,
} ?? switch (path) {
'watchTogether.pleaseEnterCode' => 'Please enter a session code',
'watchTogether.codeMustBe5Chars' => 'Session code must be 5 characters',
'watchTogether.joinInstructions' => 'Enter the host\'s session code to join.',
'watchTogether.failedToCreate' => 'Failed to create session',
'watchTogether.failedToJoin' => 'Failed to join session',
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Spela nästa",
"playButton": "Spela",
"pauseButton": "Pausa",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Visa uppspelningskontroller",
"hidePlaybackControls": "Dölj uppspelningskontroller",
"seekBackwardButton": "Spola bakåt ${seconds} sekunder",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Sonrakini Oynat",
"playButton": "Oynat",
"pauseButton": "Duraklat",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Oynatma kontrollerini göster",
"hidePlaybackControls": "Oynatma kontrollerini gizle",
"seekBackwardButton": "${seconds} saniye geri sar",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "Keyingisini ijro etish",
"playButton": "Ijro etish",
"pauseButton": "Pauza",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "Boshqaruv tugmalarini koʻrsatish",
"hidePlaybackControls": "Boshqaruv tugmalarini yashirish",
"seekBackwardButton": "${seconds} soniya orqaga oʻtkazish",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "播放下一集",
"playButton": "播放",
"pauseButton": "暫停",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "顯示播放控制項",
"hidePlaybackControls": "隱藏播放控制項",
"seekBackwardButton": "後退 ${seconds} 秒",
+2
View File
@@ -493,6 +493,8 @@
"playNext": "播放下一集",
"playButton": "播放",
"pauseButton": "暂停",
"playbackPaused": "",
"playbackResumed": "",
"showPlaybackControls": "显示播放控制项",
"hidePlaybackControls": "隐藏播放控制项",
"seekBackwardButton": "快退 ${seconds} 秒",
+1 -1
View File
@@ -308,7 +308,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
onSubtitleTrackChanged: _onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged,
onSeekRequested: _seekPlayback,
onPlayPauseRequested: () => _playOrPauseWithPlaybackIntent(player!),
onPlayPauseRequested: _handleControlsTransport,
onSeekCompleted: _notifyWatchTogetherSeek,
onBack: _handleBackButton,
onReachedEnd: ({skipAutoPlayCountdown = false}) =>
@@ -479,6 +479,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
unawaited(_seekBackForRewind(currentPlayer));
unawaited(_playWithPlaybackIntent(currentPlayer));
_wasPlayingBeforeInactive = false;
_announceTransportCommand(willPlay: true);
_updateMediaControlsPlaybackState();
},
onPause: () {
@@ -489,6 +490,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
return;
}
unawaited(_pauseWithPlaybackIntent(currentPlayer));
_announceTransportCommand(willPlay: false);
_updateMediaControlsPlaybackState();
},
onTogglePlayPause: () {
@@ -496,10 +498,12 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
if (currentPlayer == null) return;
if (currentPlayer.state.isActive) {
unawaited(_pauseWithPlaybackIntent(currentPlayer));
_announceTransportCommand(willPlay: false);
} else {
unawaited(_seekBackForRewind(currentPlayer));
unawaited(_playWithPlaybackIntent(currentPlayer));
_wasPlayingBeforeInactive = false;
_announceTransportCommand(willPlay: true);
}
_updateMediaControlsPlaybackState();
},
+56 -21
View File
@@ -1705,13 +1705,30 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
'Apple TV remote play/pause received source=${action.source}'
'${action.detail == null ? '' : ' detail=${action.detail}'}',
);
await _toggleRemotePlayPause(source: 'Apple TV remote');
await _remoteTransport(TransportCommand.toggle, source: 'Apple TV remote');
}
/// Toggle play/pause on behalf of a hardware remote (Apple TV bridge or a
/// hardware media key). Mirrors the controls path: rewind-on-resume, then
/// play/pause with playback intent.
Future<void> _toggleRemotePlayPause({required String source}) async {
/// Announce an *accepted user transport command* with a centred transient disc.
///
/// Deliberately not driven from `player.streams.playing` or the
/// `_*WithPlaybackIntent` helpers: those also fire for the sleep timer,
/// lifecycle and audio-session changes, frame-rate re-opens, still-watching
/// prompts, route exit and episode reloads — none of which are user
/// commands. Each accepted command site opts in explicitly instead (#1676).
void _announceTransportCommand({required bool willPlay}) {
if (!mounted) return;
// Visible chrome already renders the play/pause state.
if (_chromeController.controlsVisible) return;
_toastController.showTransport(
willPlay ? Symbols.play_arrow_rounded : Symbols.pause_rounded,
willPlay ? t.videoControls.playbackResumed : t.videoControls.playbackPaused,
);
}
/// Apply a transport command on behalf of a hardware remote (Apple TV bridge
/// or a hardware media key). Mirrors the controls path: rewind-on-resume,
/// then play/pause with playback intent, then announce.
Future<void> _remoteTransport(TransportCommand command, {required String source}) async {
if (!mounted || ModalRoute.of(context)?.isCurrent != true) return;
final currentPlayer = player;
@@ -1725,23 +1742,43 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
return;
}
// Rewind-on-resume follows the resolved intent, never the current state: a
// directed pause on an already-paused video must not jump backwards.
final resumes = switch (command) {
TransportCommand.play => !currentPlayer.state.playing,
TransportCommand.pause => false,
TransportCommand.toggle => !currentPlayer.state.playing,
};
try {
if (!currentPlayer.state.playing) {
if (resumes) {
await _seekBackForRewind(currentPlayer);
if (!mounted || player != currentPlayer) return;
}
await _playOrPauseWithPlaybackIntent(currentPlayer);
await switch (command) {
TransportCommand.play => _playWithPlaybackIntent(currentPlayer),
TransportCommand.pause => _pauseWithPlaybackIntent(currentPlayer),
TransportCommand.toggle => _playOrPauseWithPlaybackIntent(currentPlayer),
};
_announceTransportCommand(willPlay: _playbackIntentShouldPlay);
} catch (e, st) {
appLogger.w('$source play/pause failed', error: e, stackTrace: st);
}
}
/// Hardware media play/pause keys (Android TV remotes). Deliberately not
/// space/configured hotkeys — text fields must still receive those.
static bool _isHardwarePlayPauseKey(LogicalKeyboardKey key) =>
key == LogicalKeyboardKey.mediaPlayPause ||
key == LogicalKeyboardKey.mediaPlay ||
key == LogicalKeyboardKey.mediaPause;
/// Transport requested from the player controls (keyboard hotkey, companion
/// remote, on-screen button, click-to-toggle, D-pad Select). Authorization
/// and rewind-on-resume already ran in the controls layer.
Future<void> _handleControlsTransport(TransportCommand command) async {
final currentPlayer = player;
if (currentPlayer == null) return;
await switch (command) {
TransportCommand.play => _playWithPlaybackIntent(currentPlayer),
TransportCommand.pause => _pauseWithPlaybackIntent(currentPlayer),
TransportCommand.toggle => _playOrPauseWithPlaybackIntent(currentPlayer),
};
_announceTransportCommand(willPlay: _playbackIntentShouldPlay);
}
String? _lastLogError;
bool _sawServer500 = false;
@@ -1923,20 +1960,18 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
() => _handleScreenPlayerNavigation(navigationKey),
);
}
// Hardware media play/pause must act even when focus rests on this
// Hardware media transport must act even when focus rests on this
// node or a sibling overlay — otherwise the key only reveals the
// chrome and leaks to the (possibly stale/suspended) Android
// MediaSession (#1375). Gated to TV-style nav: on desktop the global
// HardwareKeyboard handler already acts (handlers don't stop focus
// dispatch), and Apple TV delivers play/pause via its native bridge.
if (_videoPlayerNavigationEnabled &&
!PlatformDetector.isAppleTV() &&
_isHardwarePlayPauseKey(event.logicalKey)) {
// The chrome deliberately stays down; _remoteTransport announces the
// accepted command with a centred transient disc instead (#1676).
final transportCommand = classifyTransportKey(event.logicalKey);
if (_videoPlayerNavigationEnabled && !PlatformDetector.isAppleTV() && transportCommand != null) {
if (event is KeyDownEvent) {
unawaited(_toggleRemotePlayPause(source: 'Hardware media key'));
if (node.hasPrimaryFocus) {
_chromeController.show(focusTarget: PlayerChromeFocusTarget.playPause);
}
unawaited(_remoteTransport(transportCommand, source: 'Hardware media key'));
}
return KeyEventResult.handled; // consume down, repeat, and up
}
@@ -206,6 +206,12 @@ class KeyboardShortcutsService extends ChangeNotifier {
VoidCallback? onToggleMute,
ValueChanged<int>? onLiveSeekBy,
Future<void> Function(Duration position)? onSeekRequested,
/// Takes over relative seeking entirely when supplied, so the caller can
/// coalesce a burst of presses and report the accepted offset. Without it
/// each press rebases off `player.state.position`, which a slow backend
/// has not applied yet.
ValueChanged<int>? onSeekBy,
}) {
final isRepeat = event is KeyRepeatEvent;
if (event is! KeyDownEvent && !isRepeat) return KeyEventResult.ignored;
@@ -278,6 +284,10 @@ class KeyboardShortcutsService extends ChangeNotifier {
}
void performSeek(int offsetSeconds) {
if (onSeekBy != null) {
onSeekBy(offsetSeconds);
return;
}
// Relative live-TV skip: route through the parent accumulator, which
// coalesces a rapid burst into one transcode re-open (#1253).
if (onLiveSeekBy != null) {
@@ -16,6 +16,10 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
key == LogicalKeyboardKey.arrowRight;
}
bool _isHorizontalKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight;
}
bool _isSelectKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.enter ||
@@ -23,27 +27,28 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
key == LogicalKeyboardKey.gameButtonA;
}
/// Determine if the key event should toggle play/pause based on configured hotkeys.
bool _isPlayPauseKey(KeyEvent event) {
final logicalKey = event.logicalKey;
final physicalKey = event.physicalKey;
/// Resolve the transport intent for a key event, or null when the key is not
/// a transport key. Hardware `mediaPlay`/`mediaPause` stay *directed*; the
/// configured hotkey is always a toggle.
TransportCommand? _transportCommandFor(KeyEvent event) {
// Always accept hardware media transport keys (Android TV remotes)
final hardware = classifyTransportKey(event.logicalKey);
if (hardware != null) return hardware;
// Always accept hardware media play/pause keys (Android TV remotes)
if (logicalKey == LogicalKeyboardKey.mediaPlayPause ||
logicalKey == LogicalKeyboardKey.mediaPlay ||
logicalKey == LogicalKeyboardKey.mediaPause) {
return true;
}
final physicalKey = event.physicalKey;
// When the shortcuts service is available, respect the configured play/pause hotkey
if (_keyboardService != null) {
final hotkey = _keyboardService!.hotkeys['play_pause'];
if (hotkey == null) return false;
return hotkey.key == physicalKey;
if (hotkey == null) return null;
return hotkey.key == physicalKey ? TransportCommand.toggle : null;
}
// Fallback to defaults while the service is loading
return physicalKey == PhysicalKeyboardKey.space || physicalKey == PhysicalKeyboardKey.mediaPlayPause;
if (physicalKey == PhysicalKeyboardKey.space || physicalKey == PhysicalKeyboardKey.mediaPlayPause) {
return TransportCommand.toggle;
}
return null;
}
bool _isMediaSeekKey(LogicalKeyboardKey key) {
@@ -57,8 +62,8 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
return key == LogicalKeyboardKey.mediaTrackNext || key == LogicalKeyboardKey.mediaTrackPrevious;
}
bool _isPlayPauseActivation(KeyEvent event) {
return event is KeyDownEvent && _isPlayPauseKey(event);
TransportCommand? _playPauseActivation(KeyEvent event) {
return event is KeyDownEvent ? _transportCommandFor(event) : null;
}
void _activateHiddenControlsPrimaryAction() {
@@ -70,8 +75,11 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
_activateSkipMarker();
return;
}
_playOrPause();
// Raise the chrome *before* toggling: Select is the deliberate "show me the
// controls" affordance, and the visible chrome suppresses the transient
// transport disc that would otherwise flash underneath it.
_showControlsWithFocus();
unawaited(_playOrPause());
}
KeyEventResult _handleLocalPlayerNavigationKeyEvent(KeyEvent event, PlayerNavigationKey navigationKey) {
@@ -126,6 +134,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
onToggleMute: widget.volumeController.toggleMute,
onLiveSeekBy: widget.onLiveSeekBy,
onSeekRequested: widget.onSeekRequested,
onSeekBy: _keyboardSeekBy,
);
}
@@ -158,10 +167,12 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
if (isMobile) return false;
// Handle play/pause globally - works regardless of focus
if (_isPlayPauseActivation(event)) {
_playOrPause();
_showControlsWithFocus(requestFocus: false);
// Handle play/pause globally - works regardless of focus. The screen
// announces the accepted command with a transient disc, so the chrome
// stays down and subtitles stay readable (#1676).
final globalCommand = _playPauseActivation(event);
if (globalCommand != null) {
unawaited(_playOrPause(command: globalCommand));
return true; // Event handled, stop propagation
}
@@ -187,6 +198,18 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
}
if (navigationKey != PlayerNavigationKey.none) return KeyEventResult.ignored;
// Releasing a key ends its seek burst, before the KeyUp is consumed below.
// Two independent reasons to fire:
// - a released hidden-chrome arrow must reset the acceleration tier even
// when nothing is pending, because live TV (and a zero-duration item)
// seeks straight through onLiveSeekBy without touching the accumulator;
// - any key holding a pending target commits it now, so rebound shortcuts
// and Shift+arrow large seeks land promptly rather than on the debounce.
if (event is KeyUpEvent &&
((!_showControls && _isHorizontalKey(event.logicalKey)) || _hiddenSeek.pendingPosition != null)) {
_flushHiddenDirectionalSeek();
}
// Only handle KeyDown and KeyRepeat events.
// Consume KeyUp events for navigation keys to prevent leaking to previous routes.
// Let non-navigation keys (volume, etc.) pass through to the OS.
@@ -201,16 +224,15 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
}
final key = event.logicalKey;
final isPlayPauseKey = _isPlayPauseKey(event);
final transportCommand = _transportCommandFor(event);
// Always consume play/pause keys to prevent propagation to background routes.
// On TV/mobile, handle play/pause here; on desktop, the global handler does it.
if (isPlayPauseKey) {
if (_videoPlayerNavigationEnabled || isMobile) {
if (_isPlayPauseActivation(event)) {
_playOrPause();
_showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled);
}
// Always consume transport keys to prevent propagation to background routes.
// On TV/mobile, handle them here; on desktop, the global handler does it.
// The chrome deliberately stays down — the screen announces the accepted
// command with a centred transient disc instead (#1676).
if (transportCommand != null) {
if ((_videoPlayerNavigationEnabled || isMobile) && event is KeyDownEvent) {
unawaited(_playOrPause(command: transportCommand));
}
return KeyEventResult.handled;
}
@@ -220,9 +242,8 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
if (event is KeyDownEvent && _isMediaSeekKey(key)) {
if (widget.canControl) {
final isForward = key == LogicalKeyboardKey.mediaFastForward || key == LogicalKeyboardKey.mediaSkipForward;
unawaited(_seekToChapter(forward: isForward));
_seekToChapterWithFeedback(forward: isForward);
}
_showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled);
return KeyEventResult.handled;
}
@@ -230,9 +251,8 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
// Uses same behavior as seek keys: chapter navigation or time-based seek.
if (event is KeyDownEvent && _isMediaTrackKey(key)) {
if (widget.canControl) {
unawaited(_seekToChapter(forward: key == LogicalKeyboardKey.mediaTrackNext));
_seekToChapterWithFeedback(forward: key == LogicalKeyboardKey.mediaTrackNext);
}
_showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled);
return KeyEventResult.handled;
}
@@ -244,21 +264,17 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
return handleOneShotSelect(event, _activateHiddenControlsPrimaryAction);
}
// On desktop/TV, show controls on directional input.
// LEFT/RIGHT focuses timeline for seeking, UP/DOWN focuses play/pause.
// On desktop/TV, directional input drives the player without the chrome.
// LEFT/RIGHT seeks in place with a transient badge; UP/DOWN is the
// deliberate "show me the controls" gesture.
if (!isMobile && _isDirectionalKey(key) && (_videoPlayerNavigationEnabled || PlatformDetector.isTV())) {
if (!_showControls) {
final isHorizontal = key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight;
if (isHorizontal) {
_showControlsWithTimelineFocus();
// A repeat may arrive before the post-frame focus handoff reaches
// the timeline. Consume it here without adding another seek step;
// once focused, the timeline owns intentional held-key repeats.
if (shouldStartHiddenDirectionalSeek(event) && widget.canControl) {
final forward = key == LogicalKeyboardKey.arrowRight;
unawaited(_seekByTime(forward: forward));
if (_isHorizontalKey(key)) {
if (shouldStartHiddenDirectionalSeek(event)) {
_hiddenDirectionalSeek(forward: key == LogicalKeyboardKey.arrowRight, isRepeat: event is KeyRepeatEvent);
}
} else {
_flushHiddenDirectionalSeek();
_showControlsWithFocus();
}
return KeyEventResult.handled;
@@ -25,20 +25,128 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
await _seekByOffset(delta);
}
Future<void> _seekToChapter({required bool forward}) async {
if (_chapters.isEmpty) {
// No chapters - seek by configured amount
final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall);
await _seekByOffset(delta);
/// Relative seek reported through the transient skip badge instead of the
/// scrub bar, so the picture and its subtitles stay uncovered (#1676).
///
/// Steps are coalesced into one absolute seek pinned to the pending target,
/// so a burst of presses cannot rebase off a position a slow backend has not
/// applied yet — without that the badge would report a total the player
/// never actually seeks.
void _seekByWithFeedback(Duration delta) {
if (!widget.canControl || delta == Duration.zero) return;
final forward = !delta.isNegative;
// Live TV: relative epoch-based skips go through the parent accumulator —
// an absolute target is meaningless against a moving live edge (#1253).
if (widget.isLive && widget.onLiveSeekBy != null) {
final stepSeconds = (delta.inMilliseconds.abs() / 1000).round().clamp(1, 300);
widget.onLiveSeekBy!(forward ? stepSeconds : -stepSeconds);
_registerSkipFeedback(isForward: forward, seconds: stepSeconds);
return;
}
final targetIndex = MediaChapter.seekTargetIndex(widget.player.state.position, _chapters, forward: forward);
if (targetIndex != null) {
await _seekToPosition(_chapters[targetIndex].startTime);
} else if (!forward) {
await _seekToPosition(Duration.zero);
if (widget.player.state.duration.inMilliseconds <= 0) return;
_hiddenSeek.seekBy(delta);
_registerSkipFeedback(isForward: forward, seconds: (delta.inMilliseconds.abs() / 1000).round());
}
/// Seek requested by a configured keyboard shortcut (the default Left/Right
/// and Shift+Left/Right bindings, plus any rebinding of them). Desktop never
/// reaches the D-pad path below, so this is its route to the same badge.
void _keyboardSeekBy(int offsetSeconds) => _seekByWithFeedback(Duration(seconds: offsetSeconds));
/// Directional D-pad seek with the chrome hidden. Mirrors the focused
/// timeline's held-key behaviour — progressive acceleration plus one
/// coalesced seek — without raising the timeline.
void _hiddenDirectionalSeek({required bool forward, required bool isRepeat}) {
if (!widget.canControl) return;
if (_hiddenSeekForward != forward) {
_hiddenSeekForward = forward;
_hiddenSeekRepeatCount = 0;
}
if (isRepeat) _hiddenSeekRepeatCount++;
final multiplier = isRepeat ? steppedSeekMultiplier(_hiddenSeekRepeatCount) : 1.0;
final stepMs = (_seekTimeSmall * 1000 * multiplier).clamp(500, 120_000).toInt();
_seekByWithFeedback(Duration(milliseconds: forward ? stepMs : -stepMs));
}
/// Commit the pending coalesced seek — the key was released, or the chrome
/// took over. A no-op when nothing is pending.
void _flushHiddenDirectionalSeek() {
_hiddenSeekForward = null;
_hiddenSeekRepeatCount = 0;
_hiddenSeek.flush();
}
/// Tolerance for "already at the start", so a previous-chapter press at the
/// very beginning is recognised as a no-op rather than a rewind to zero.
static const Duration _startOfMediaTolerance = Duration(milliseconds: 500);
/// What an adjacent-chapter seek would do from the current position, without
/// performing it. Resolving separately lets a caller show feedback on key
/// down rather than after a potentially slow transcode re-open.
///
/// A null [target] with chapters present means there is nowhere to go — past
/// the last chapter going forward, or already at the start going back — so
/// callers must neither seek nor announce.
({bool usedChapters, MediaChapter? chapter, Duration? target}) _resolveChapterSeek({required bool forward}) {
if (_chapters.isEmpty) return (usedChapters: false, chapter: null, target: null);
final position = widget.player.state.position;
final targetIndex = MediaChapter.seekTargetIndex(position, _chapters, forward: forward);
if (targetIndex != null) {
final chapter = _chapters[targetIndex];
return (usedChapters: true, chapter: chapter, target: chapter.startTime);
}
if (!forward && position > _startOfMediaTolerance) {
return (usedChapters: true, chapter: null, target: Duration.zero);
}
return (usedChapters: true, chapter: null, target: null);
}
Future<void> _seekToChapter({required bool forward}) {
return _applyChapterSeek(_resolveChapterSeek(forward: forward), forward: forward);
}
Future<void> _applyChapterSeek(
({bool usedChapters, MediaChapter? chapter, Duration? target}) resolved, {
required bool forward,
}) async {
if (!resolved.usedChapters) {
// No chapters - seek by configured amount
await _seekByOffset(Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall));
return;
}
final target = resolved.target;
if (target != null) await _seekToPosition(target);
}
/// Chapter-aware seek driven by a remote's transport keys. Shows a transient
/// badge instead of raising the chrome (#1676).
void _seekToChapterWithFeedback({required bool forward}) {
final resolved = _resolveChapterSeek(forward: forward);
if (!resolved.usedChapters) {
// No chapters: take the same coalesced path as every other badged seek.
// Going through _applyChapterSeek here would rebase each press off
// player.state.position, so a burst would report a total it never
// commits.
_seekByWithFeedback(Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall));
return;
}
if (resolved.target != null) {
// Only announce a jump that actually happens.
final title = resolved.chapter?.title?.trim();
widget.toastController.show(
forward ? Symbols.skip_next_rounded : Symbols.skip_previous_rounded,
title != null && title.isNotEmpty
? title
: (forward ? t.videoControls.nextChapterButton : t.videoControls.previousChapterButton),
);
}
unawaited(_applyChapterSeek(resolved, forward: forward));
}
Future<void> _seekToPosition(Duration position, {bool notifyCompletion = true}) async {
@@ -73,14 +181,31 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
return seekFuture;
}
Future<void> _playOrPause() async {
Future<void> _playOrPause({TransportCommand command = TransportCommand.toggle}) async {
if (!widget.canControl) return;
if (!widget.player.state.playing && _rewindOnResume > 0) {
// Rewind-on-resume keys off the *resolved* intent, not the current state:
// a directed pause on an already-paused video must leave the position
// untouched instead of jumping backwards.
final willPlay = switch (command) {
TransportCommand.play => true,
TransportCommand.pause => false,
TransportCommand.toggle => !widget.player.state.playing,
};
if (willPlay && !widget.player.state.playing && _rewindOnResume > 0) {
final target = widget.player.state.position - Duration(seconds: _rewindOnResume);
final clamped = clampSeekPosition(widget.player, target);
await (widget.onSeekRequested ?? widget.player.seek)(clamped);
}
await (widget.onPlayPauseRequested ?? widget.player.playOrPause)();
final requested = widget.onPlayPauseRequested;
if (requested != null) {
await requested(command);
return;
}
await switch (command) {
TransportCommand.play => widget.player.play(),
TransportCommand.pause => widget.player.pause(),
TransportCommand.toggle => widget.player.playOrPause(),
};
}
/// Throttled seek for timeline slider - executes immediately then throttles to 200ms.
@@ -442,11 +567,11 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
_singleTapTimer?.cancel();
_singleTapTimer = null;
// While the skip pill is visible, every tap in the same-direction zone
// While the skip readout is visible, every tap in the same-direction zone
// stacks another skip immediately — repeat skips cost one tap, not a
// fresh double-tap. A tap in the opposite zone falls through to pairing.
if (_showDoubleTapFeedback && _lastDoubleTapWasForward == isForward) {
_handleStackingSkip(isForward: isForward);
_handleDoubleTapSkip(isForward: isForward);
return;
}
@@ -480,31 +605,36 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
return renderObject is RenderBox ? renderObject.size : Size.zero;
}
/// Handle stacking skip - add to accumulated skip when feedback is active.
void _handleStackingSkip({required bool isForward}) {
if (!widget.canControl) return;
_accumulatedSkipSeconds += _seekTimeSmall;
/// Accumulate skip feedback. Consecutive skips in the same direction stack
/// into one running total; a direction flip restarts the count.
void _registerSkipFeedback({required bool isForward, required int seconds}) {
final stacking = _showDoubleTapFeedback && _lastDoubleTapWasForward == isForward;
_accumulatedSkipSeconds = stacking ? _accumulatedSkipSeconds + seconds : seconds;
_showSkipFeedback(isForward: isForward);
final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall);
unawaited(_seekByOffset(delta));
}
/// Handle a skip-zone double tap (and every stacked tap that follows it).
void _handleDoubleTapSkip({required bool isForward}) {
if (!widget.canControl) return;
_accumulatedSkipSeconds = _seekTimeSmall;
_showSkipFeedback(isForward: isForward);
_registerSkipFeedback(isForward: isForward, seconds: _seekTimeSmall);
final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall);
unawaited(_seekByOffset(delta));
}
/// How long the skip badge stays at full opacity. 1200 ms gives time to read
/// the value and keep skipping; Maestro builds hold it far longer because
/// accessibility-tree queries on physical devices routinely outlast the
/// production timeout — the same reason the chrome hide delay is extended.
Duration get _skipFeedbackDuration => const bool.fromEnvironment('PLEZY_MAESTRO_E2E')
? const Duration(seconds: 30)
: const Duration(milliseconds: 1200);
/// Show animated visual feedback for skip gesture
void _showSkipFeedback({required bool isForward}) {
// Cancel BOTH timers: a skip landing during the fade-out window must not
// leave the old hide timer pending, or it kills the fresh pill and zeroes
// leave the old hide timer pending, or it kills the fresh readout and zeroes
// the accumulated count mid-display.
_feedbackTimer?.cancel();
_feedbackHideTimer?.cancel();
@@ -518,8 +648,7 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
// Capture duration before timer to avoid context access in callback
final slowDuration = tokens(context).slow;
// Fade out after delay (1200ms gives time to see value and continue tapping)
_feedbackTimer = Timer(const Duration(milliseconds: 1200), () {
_feedbackTimer = Timer(_skipFeedbackDuration, () {
if (mounted) {
_setControlsState(() {
_doubleTapFeedbackOpacity = 0.0;
@@ -218,16 +218,6 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
}
}
/// Show controls and focus timeline on LEFT/RIGHT input (TV/desktop)
void _showControlsWithTimelineFocus() {
widget.chromeController.show();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_desktopControlsKey.currentState?.requestTimelineFocus();
});
}
/// Hide controls when navigating up from timeline (keyboard mode)
/// If skip marker button or Play Next dialog is visible, focus it instead of hiding controls
void _hideControlsFromKeyboard() {
@@ -262,6 +252,10 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
});
_reclaimFocusAfterControlsHide();
} else if (visibilityChanged) {
// The timeline is about to take over held-key seeking; commit whatever
// the hidden-chrome burst accumulated so it can't rebase from a stale
// position once the timeline's own accumulator starts.
_flushHiddenDirectionalSeek();
_setControlsState(() {
_controlsMounted = true;
_controlsOpaque = false;
+63 -13
View File
@@ -26,6 +26,7 @@ import 'package:flutter/services.dart'
KeyEvent,
KeyDownEvent,
KeyUpEvent,
KeyRepeatEvent,
HardwareKeyboard;
import '../../services/fullscreen_state_manager.dart';
import '../../services/macos_window_service.dart';
@@ -43,6 +44,7 @@ import '../../focus/dpad_navigator.dart';
import '../../database/app_database.dart';
import '../../media/media_backend.dart';
import '../../media/media_item.dart';
import '../../media/stepped_seek.dart';
import '../../models/livetv_capture_buffer.dart';
import '../../providers/multi_server_provider.dart';
import '../../media/media_source_info.dart';
@@ -67,6 +69,7 @@ import 'icons.dart';
import 'player_chrome_controller.dart';
import 'playback_extras_loader.dart';
import 'widgets/player_toast_indicator.dart';
import 'widgets/transport_feedback_indicator.dart';
import '../../utils/app_logger.dart';
import '../../i18n/strings.g.dart';
import '../../focus/input_mode_tracker.dart';
@@ -391,8 +394,25 @@ bool shouldSkipDuplicateTimelineSeek({required Duration? lastDispatchedSeek, req
return lastDispatchedSeek == finalSeek;
}
/// A user transport intent. `play`/`pause` are *directed* — a remote with
/// dedicated buttons must not flip the state it explicitly asked for.
enum TransportCommand { play, pause, toggle }
/// Maps hardware media transport keys to their intent. Returns null for keys
/// that are not transport keys (including the configured play/pause hotkey,
/// which callers resolve to [TransportCommand.toggle] themselves).
TransportCommand? classifyTransportKey(LogicalKeyboardKey key) {
if (key == LogicalKeyboardKey.mediaPlay) return TransportCommand.play;
if (key == LogicalKeyboardKey.mediaPause) return TransportCommand.pause;
if (key == LogicalKeyboardKey.mediaPlayPause) return TransportCommand.toggle;
return null;
}
/// Directional seeking with the chrome hidden owns the whole key burst —
/// repeats accelerate in place rather than escalating to the timeline — so
/// both the initial press and its repeats perform a step.
@visibleForTesting
bool shouldStartHiddenDirectionalSeek(KeyEvent event) => event is KeyDownEvent;
bool shouldStartHiddenDirectionalSeek(KeyEvent event) => event.isActionable;
typedef PlaybackSourceChangeCallback =
Future<PlaybackSourceChangeOutcome> Function({
@@ -444,9 +464,10 @@ class PlexVideoControls extends StatefulWidget {
/// playback state around the native player seek.
final Future<void> Function(Duration position)? onSeekRequested;
/// Called for app-level play/pause requests so the owning screen can track
/// user playback intent separately from transient buffering state.
final Future<void> Function()? onPlayPauseRequested;
/// Called for app-level transport requests so the owning screen can track
/// user playback intent separately from transient buffering state, and
/// announce the accepted command.
final Future<void> Function(TransportCommand command)? onPlayPauseRequested;
/// Called when a seek operation completes (for Watch Together sync)
final Function(Duration position)? onSeekCompleted;
@@ -522,12 +543,19 @@ class PlexVideoControls extends StatefulWidget {
/// Toast controller for VLC-style in-player notifications (rate changes, backend switch).
final PlayerToastController toastController;
/// Seeds the chapter list so widget tests can exercise chapter-dependent
/// behaviour without a media-server client. Production always loads through
/// [VideoControlsPlaybackExtrasLoader].
@visibleForTesting
final List<MediaChapter>? initialChapters;
const PlexVideoControls({
super.key,
required this.player,
required this.volumeController,
required this.metadata,
required this.toastController,
this.initialChapters,
this.onNext,
this.onPrevious,
this.availableVersions = const [],
@@ -600,8 +628,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
// item can start while a stale one is still in flight (and the stale
// response is discarded).
String? _extrasLoadKey;
List<MediaChapter> _chapters = [];
bool _chaptersLoaded = false;
late List<MediaChapter> _chapters = widget.initialChapters ?? [];
late bool _chaptersLoaded = widget.initialChapters != null;
bool _isFullscreen = false;
bool _isAlwaysOnTop = false;
late final FocusNode _focusNode;
@@ -634,7 +662,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
// Custom tap detection state (more reliable than Flutter's onDoubleTap)
DateTime? _lastSkipTapTime;
bool _lastSkipTapWasForward = true;
Timer? _feedbackHideTimer; // Removes the skip pill after its fade-out completes
Timer? _feedbackHideTimer; // Removes the skip readout after its fade-out completes
Timer? _singleTapTimer; // Timer for delayed single-tap action (toggle controls)
final TwoFingerDoubleTapTracker _twoFingerDoubleTapTracker = TwoFingerDoubleTapTracker();
final MobileEdgeAdjustmentTracker _edgeAdjustmentTracker = MobileEdgeAdjustmentTracker();
@@ -663,6 +691,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
late final Throttle _seekThrottle;
Duration? _lastDispatchedTimelineSeek;
Future<void>? _lastDispatchedTimelineSeekFuture;
// Directional key seeking while the chrome is hidden (#1676). Owns the whole
// key burst — repeats accelerate in place rather than escalating to the
// timeline — and coalesces it into one absolute seek, like the timeline does.
late final DebouncedSeekAccumulator _hiddenSeek;
bool? _hiddenSeekForward;
int _hiddenSeekRepeatCount = 0;
// Current marker state
MediaMarker? _currentMarker;
List<MediaMarker> _markers = [];
@@ -701,7 +735,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
StreamSubscription<double>? _rateSubscription;
double? _lastReportedRate;
// Suppression window used when long-press ends so the rate-restore emission
// doesn't flash a second pill as the rate snaps back.
// doesn't flash a second notice as the rate snaps back.
DateTime? _suppressRateToastUntil;
// PiP support
@@ -725,6 +759,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
leading: true,
trailing: true,
);
_hiddenSeek = DebouncedSeekAccumulator(
currentPosition: () => widget.player.state.position,
duration: () => widget.player.state.duration,
seek: (target) => unawaited(_seekToPosition(target)),
);
// Side effects: rotation lock + focus on nav-enable. Both fire immediately
// so init wiring (orientation, focus) lives in one place.
bindEffect<bool>(SettingsService.rotationLocked, _applyRotationLock);
@@ -847,6 +886,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
_skipButtonDismissTimer?.cancel();
_singleTapTimer?.cancel();
_seekThrottle.cancel();
_hiddenSeek.dispose();
_edgeAdjustmentTracker.cancel();
_edgeAdjustmentIndicator.dispose();
_pipService.isPipActive.removeListener(_onEdgeAdjustmentPipChanged);
@@ -1132,8 +1172,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
),
),
),
// Visual feedback overlay for double-tap
if (isMobile && _showDoubleTapFeedback)
// Transient skip badge: mobile double-tap and keyboard/remote
// seeking both use it so neither has to raise the full chrome.
if (_showDoubleTapFeedback)
Positioned.fill(
child: IgnorePointer(
child: AnimatedOpacity(
@@ -1148,7 +1189,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
),
// Speed indicator overlay for long-press 2x
if (_showSpeedIndicator) Positioned.fill(child: IgnorePointer(child: _buildSpeedIndicator())),
// Stream-driven VLC-style pill (rate changes, backend-switch notifications)
// Stream-driven transient feedback: an icon-only disc centred
// in the frame for accepted transport commands, a textual pill
// at the top for rate changes and other notices.
Positioned.fill(
child: IgnorePointer(
child: ListenableBuilder(
@@ -1156,14 +1199,21 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
builder: (context, _) {
final toast = widget.toastController.current;
if (toast == null) return const SizedBox.shrink();
return AnimatedSwitcher(
return switch (toast.kind) {
PlayerToastKind.transport => TransportFeedbackIndicator(
icon: toast.icon,
text: toast.text,
pulse: toast.pulse,
),
PlayerToastKind.notice => AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: PlayerToastIndicator(
key: ValueKey('${toast.icon.codePoint}:${toast.text}'),
icon: toast.icon,
text: toast.text,
),
);
),
};
},
),
),
@@ -1,40 +1,160 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../i18n/strings.g.dart';
import '../../../utils/formatters.dart';
import '../../../utils/platform_detector.dart';
import '../../app_icon.dart';
class DoubleTapFeedback extends StatelessWidget {
/// Label for the skip feedback. Plain `Ns` stays readable up to a minute; beyond
/// that (reachable by held D-pad seeking, which accelerates) a raw second
/// count is unreadable, so fall back to the M:SS timestamp form.
@visibleForTesting
String formatSkipFeedbackLabel(int seconds) {
if (seconds < 60) return '$seconds${t.settings.secondsShort}';
return formatDurationTimestamp(Duration(seconds: seconds));
}
/// Transient seek readout at the side of the frame the seek travels toward: the
/// amount, and a single chevron on the same line drifting that way.
///
/// Deliberately unbacked — no scrim, no puck. Anything large enough to read as a
/// surface also covers picture and subtitles, which is the complaint this
/// feedback exists to answer. Legibility comes from shadows instead.
///
/// Only the chevron moves. The amount is what the viewer reads, so it stays put.
class DoubleTapFeedback extends StatefulWidget {
final bool isForward;
final int seconds;
const DoubleTapFeedback({super.key, required this.isForward, required this.seconds});
/// Inset from the anchored edge. TVs overscan roughly 5% of each edge, so
/// derive it from the viewport rather than assuming 1080p logical geometry — a
/// TV reporting 960dp at 2x would otherwise get double the intended inset.
/// Clamped so the readout never sits tighter than the touch layout, never
/// drifts toward centre on an ultra-wide viewport, and always leaves itself
/// room on a narrow one.
static double _horizontalInset(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
final overscan = PlatformDetector.isTV() ? (width * 0.05).clamp(60.0, 160.0) : 60.0;
return overscan.clamp(0.0, math.max(0.0, (width - _minReadoutWidth) / 2));
}
/// Chevron plus a few characters of label at the largest step.
static const double _minReadoutWidth = 200;
/// Type scale. A TV is read from across the room, so it needs a bigger step
/// than a handset at arm's length even though the two report a similar
/// logical width — 960dp at 2x versus roughly 900dp in landscape.
static double _labelSize(BuildContext context) => PlatformDetector.isTV() ? 34 : 26;
static double _chevronSize(BuildContext context) => PlatformDetector.isTV() ? 46 : 36;
/// How far the chevron drifts either side of centre, in logical pixels. Scaled
/// with the glyph so the motion stays proportional.
static double _driftDistance(BuildContext context) => _chevronSize(context) * 0.22;
static const Duration _driftPeriod = Duration(milliseconds: 1100);
@override
Widget build(BuildContext context) {
return Align(
alignment: isForward ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 60),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.6), shape: BoxShape.circle),
child: Column(
mainAxisSize: .min,
children: [
AppIcon(
isForward ? Symbols.forward_media_rounded : Symbols.replay_rounded,
State<DoubleTapFeedback> createState() => _DoubleTapFeedbackState();
}
class _DoubleTapFeedbackState extends State<DoubleTapFeedback> with SingleTickerProviderStateMixin {
/// The chevron drifts the way the seek goes, looping free for as long as the
/// readout is up so a held key reads as continuous travel.
///
/// Deliberately never restarted per press: key repeats arrive every few tens
/// of milliseconds, far faster than the cycle, so restarting would pin the
/// chevron at the start of its nudge for the whole burst. No per-press kick is
/// needed anyway - a same-direction press changes the amount, and a direction
/// flip flips the chevron and the side it sits on.
late final AnimationController _drift = AnimationController(duration: DoubleTapFeedback._driftPeriod, vsync: this)
..repeat();
@override
void dispose() {
_drift.dispose();
super.dispose();
}
/// The chevron never disappears; it only brightens as it travels.
static const double _minChevronOpacity = 0.7;
/// Share of the cycle spent travelling outward, the rest returning.
static const double _outwardFraction = 0.7;
static const List<Shadow> _legibility = [Shadow(color: Colors.black87, blurRadius: 6)];
Widget _buildChevron(BuildContext context) {
return AnimatedBuilder(
animation: _drift,
builder: (context, child) {
// Most of the cycle is the outward stroke; the return is brief, so the
// eye reads travel in the seek direction rather than a symmetric wobble.
// Both ends rest at zero, so the wrap needs no fade to hide a snap - the
// chevron is a persistent cue, never a blinking one.
final phase = _drift.value;
final travel = phase < _outwardFraction
? Curves.easeOut.transform(phase / _outwardFraction)
: 1 - Curves.easeInOut.transform((phase - _outwardFraction) / (1 - _outwardFraction));
final dx = travel * DoubleTapFeedback._driftDistance(context) * (widget.isForward ? 1 : -1);
return Transform.translate(
offset: Offset(dx, 0),
child: Opacity(opacity: _minChevronOpacity + (1 - _minChevronOpacity) * travel, child: child),
);
},
child: AppIcon(
widget.isForward ? Symbols.chevron_right_rounded : Symbols.chevron_left_rounded,
fill: 1,
color: Colors.white,
size: 32,
size: DoubleTapFeedback._chevronSize(context),
shadows: _legibility,
),
const SizedBox(height: 4),
);
}
@override
Widget build(BuildContext context) {
final isForward = widget.isForward;
// Own semantics node with a spoken label: the visible "10s"/"2:55" is a
// glance affordance, while assistive tech gets the direction and amount.
// Without the container the text merges into the full-screen playback
// control behind it and never reaches the user.
return Semantics(
container: true,
liveRegion: true,
excludeSemantics: true,
label: isForward
? t.videoControls.seekForwardButton(seconds: widget.seconds)
: t.videoControls.seekBackwardButton(seconds: widget.seconds),
child: Align(
alignment: isForward ? Alignment.centerRight : Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: DoubleTapFeedback._horizontalInset(context)),
child: Row(
mainAxisSize: .min,
children: [
// Chevron leads on the side the seek travels toward.
if (!isForward) ...[_buildChevron(context), const SizedBox(width: 6)],
Text(
'$seconds${t.settings.secondsShort}',
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .bold),
formatSkipFeedbackLabel(widget.seconds),
style: TextStyle(
color: Colors.white,
fontSize: DoubleTapFeedback._labelSize(context),
fontWeight: .bold,
shadows: _legibility,
),
),
if (isForward) ...[const SizedBox(width: 6), _buildChevron(context)],
],
),
),
),
);
}
}
@@ -3,6 +3,8 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'transport_feedback_indicator.dart';
/// VLC-style dark pill shown at top-center of the video player.
/// Used for rate changes and other transient in-player notifications.
class PlayerToastIndicator extends StatelessWidget {
@@ -13,7 +15,16 @@ class PlayerToastIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Align(
// Own semantics node: without it the pill's text merges into whatever
// full-screen control sits behind it (the "show playback controls" tap
// target), corrupting that button's name and hiding the status. liveRegion
// makes assistive tech announce the transition.
return Semantics(
container: true,
liveRegion: true,
excludeSemantics: true,
label: text,
child: Align(
alignment: .topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.8),
@@ -41,23 +52,63 @@ class PlayerToastIndicator extends StatelessWidget {
),
),
),
),
);
}
}
/// How a transient in-player notification presents itself.
enum PlayerToastKind {
/// Textual dark pill at the top of the frame: rate changes, chapter titles,
/// backend switches, errors.
notice,
/// Icon-only disc at the centre of the frame confirming an accepted
/// play/pause command, in the shape viewers know from YouTube.
transport,
}
/// Owns the currently-displayed toast + auto-hide timer.
/// Created per video-player session; disposed with the screen.
class PlayerToastController extends ChangeNotifier {
({IconData icon, String text})? _current;
({IconData icon, String text, PlayerToastKind kind, int pulse})? _current;
Timer? _timer;
int _pulse = 0;
({IconData icon, String text})? get current => _current;
({IconData icon, String text, PlayerToastKind kind, int pulse})? get current => _current;
void show(IconData icon, String text, {Duration duration = const Duration(milliseconds: 1200)}) {
/// Maestro builds hold every pill far longer: accessibility-tree queries on
/// physical devices routinely outlast the production timeout, the same
/// reason the chrome hide delay is extended for E2E.
static const Duration _maestroMinimumDuration = Duration(seconds: 30);
/// Confirms an accepted play/pause command. [text] is not drawn - the disc is
/// icon-only - but it remains the semantics label so assistive tech and the
/// E2E accessibility tree still read "Paused"/"Playing".
///
/// Lifetime comes from the disc itself, which fades itself back out, so the
/// widget is never unmounted mid-exit.
void showTransport(IconData icon, String text) {
show(icon, text, kind: PlayerToastKind.transport, duration: TransportFeedbackIndicator.totalDuration);
}
void show(
IconData icon,
String text, {
Duration duration = const Duration(milliseconds: 1200),
PlayerToastKind kind = PlayerToastKind.notice,
}) {
_timer?.cancel();
_current = (icon: icon, text: text);
// Every accepted command carries a fresh pulse. Two identical commands in a
// row (an explicit pause while already paused, say) produce an identical
// icon/text pair, so without this the animated child would be reused and
// its one-shot pop would never replay.
_current = (icon: icon, text: text, kind: kind, pulse: ++_pulse);
notifyListeners();
_timer = Timer(duration, () {
final effective = const bool.fromEnvironment('PLEZY_MAESTRO_E2E') && duration < _maestroMinimumDuration
? _maestroMinimumDuration
: duration;
_timer = Timer(effective, () {
_current = null;
_timer = null;
notifyListeners();
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import '../../app_icon.dart';
/// Centre-screen confirmation of an accepted play/pause command, in the shape
/// viewers know from YouTube: a translucent disc that grows and fades in, holds
/// briefly at rest, then leaves the way it arrived.
///
/// Deliberately icon-only and centred. Subtitles are horizontally centred and
/// sit in the top or bottom band of the frame - a textual pill at the top
/// overlapped ASS `\an8` placement (song lyrics, sign translations), which is
/// exactly the readability complaint this feedback exists to avoid.
class TransportFeedbackIndicator extends StatefulWidget {
const TransportFeedbackIndicator({super.key, required this.icon, required this.text, required this.pulse});
final IconData icon;
/// Not drawn. Carried for assistive tech and the E2E accessibility tree.
final String text;
/// Monotonic per accepted command. Two identical commands in a row reuse this
/// State, so the pop replays off a pulse change rather than off icon/text.
final int pulse;
/// Grow and fade in, hold long enough to read, then run the same motion in
/// reverse. Symmetric on purpose: the disc leaves the way it arrived.
static const Duration _enter = Duration(milliseconds: 150);
static const Duration _hold = Duration(milliseconds: 500);
static const Duration totalDuration = Duration(milliseconds: 800);
/// The glyph nearly fills the disc — it is a state cue, not a button, so the
/// surface only needs to be big enough to keep the icon legible over bright
/// picture rather than to look tappable.
@visibleForTesting
static const double diameter = 72;
@visibleForTesting
static const double iconSize = 44;
@override
State<TransportFeedbackIndicator> createState() => _TransportFeedbackIndicatorState();
}
class _TransportFeedbackIndicatorState extends State<TransportFeedbackIndicator> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
duration: TransportFeedbackIndicator.totalDuration,
vsync: this,
);
static final double _enterWeight = TransportFeedbackIndicator._enter.inMilliseconds.toDouble();
static final double _holdWeight = TransportFeedbackIndicator._hold.inMilliseconds.toDouble();
static final double _exitWeight =
TransportFeedbackIndicator.totalDuration.inMilliseconds - _enterWeight - _holdWeight;
late final Animation<double> _scale = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(begin: 0.8, end: 1.0).chain(CurveTween(curve: Curves.easeOut)),
weight: _enterWeight,
),
TweenSequenceItem(tween: ConstantTween(1.0), weight: _holdWeight),
TweenSequenceItem(
tween: Tween(begin: 1.0, end: 0.8).chain(CurveTween(curve: Curves.easeIn)),
weight: _exitWeight,
),
]).animate(_controller);
late final Animation<double> _opacity = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(begin: 0.0, end: 1.0).chain(CurveTween(curve: Curves.easeOut)),
weight: _enterWeight,
),
TweenSequenceItem(tween: ConstantTween(1.0), weight: _holdWeight),
TweenSequenceItem(
tween: Tween(begin: 1.0, end: 0.0).chain(CurveTween(curve: Curves.easeIn)),
weight: _exitWeight,
),
]).animate(_controller);
@override
void initState() {
super.initState();
_controller.forward();
}
@override
void didUpdateWidget(TransportFeedbackIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.pulse != widget.pulse) _controller.forward(from: 0);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Own semantics node: without it the label merges into the full-screen
// "show playback controls" target behind it, corrupting that button's name
// and hiding the status. liveRegion announces the transition.
return Semantics(
container: true,
liveRegion: true,
excludeSemantics: true,
label: widget.text,
child: Center(
child: FadeTransition(
opacity: _opacity,
child: ScaleTransition(
scale: _scale,
child: Container(
width: TransportFeedbackIndicator.diameter,
height: TransportFeedbackIndicator.diameter,
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.55), shape: BoxShape.circle),
child: Center(
child: AppIcon(widget.icon, fill: 1, color: Colors.white, size: TransportFeedbackIndicator.iconSize),
),
),
),
),
),
);
}
}
+7 -2
View File
@@ -1497,7 +1497,7 @@ void main() {
});
group('shouldStartHiddenDirectionalSeek', () {
test('accepts only the initial key-down event', () {
test('accepts the initial press and its repeats, so a held key keeps seeking in place', () {
expect(shouldStartHiddenDirectionalSeek(_keyDown(LogicalKeyboardKey.arrowRight)), isTrue);
expect(
shouldStartHiddenDirectionalSeek(
@@ -1507,9 +1507,14 @@ void main() {
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',
);
expect(shouldStartHiddenDirectionalSeek(_keyUp(LogicalKeyboardKey.arrowRight)), isFalse);
});
});
@@ -0,0 +1,924 @@
import 'dart:math' as math;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:provider/provider.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/mpv/mpv.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/utils/platform_detector.dart';
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
import 'package:plezy/widgets/video_controls/player_chrome_controller.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:plezy/widgets/video_controls/video_controls.dart';
import 'package:plezy/widgets/video_controls/widgets/double_tap_feedback.dart';
import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/widgets/video_controls/widgets/transport_feedback_indicator.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/theme.dart';
/// Regression coverage for #1676: remote/keyboard seeking and pausing must
/// drive playback through a transient badge instead of raising the full player
/// chrome, which covers the subtitles the user is trying to read.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('transient player feedback', () {
late _RecordingPlayer player;
late PlayerChromeController chrome;
late PlayerToastController toast;
late VideoVolumeController volume;
late PlaybackStateProvider playbackState;
late WatchTogetherProvider watchTogether;
late AppDatabase database;
late List<TransportCommand> transportCommands;
setUp(() async {
LocaleSettings.setLocaleSync(AppLocale.en);
await initializeDateFormatting('en');
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.seekTimeSmall, 10);
await settings.write(SettingsService.rewindOnResume, 5);
// Android TV: PlatformDetector.isTV() drives both the directional-seek
// branch and the videoPlayerNavigationEnabled default.
TvDetectionService.debugSetAppleTVOverride(true);
PlatformDetector.debugSetIsDesktopOSOverride(false);
database = AppDatabase.forTesting(NativeDatabase.memory());
player = _RecordingPlayer();
chrome = PlayerChromeController();
toast = PlayerToastController();
volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100);
playbackState = PlaybackStateProvider();
watchTogether = WatchTogetherProvider();
});
tearDown(() async {
TvDetectionService.debugSetAppleTVOverride(null);
PlatformDetector.debugSetIsDesktopOSOverride(null);
volume.dispose();
playbackState.dispose();
watchTogether.dispose();
chrome.dispose();
toast.dispose();
await database.close();
});
Future<void> pumpControls(
WidgetTester tester, {
List<MediaChapter>? chapters,
bool wireTransportCallback = false,
bool isLive = false,
ValueChanged<int>? onLiveSeekBy,
}) async {
transportCommands = [];
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<AppDatabase>.value(value: database),
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playbackState),
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogether),
],
child: MaterialApp(
theme: ThemeData(platform: TargetPlatform.android, extensions: const [testMonoTokens]),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: PlexVideoControls(
player: player,
volumeController: volume,
metadata: testMediaItem(id: 'transient-feedback'),
toastController: toast,
chromeController: chrome,
initialChapters: chapters,
canNavigateMediaItems: false,
isLive: isLive,
onLiveSeekBy: onLiveSeekBy,
onPlayPauseRequested: wireTransportCallback
? (command) async {
transportCommands.add(command);
await switch (command) {
TransportCommand.play => player.play(),
TransportCommand.pause => player.pause(),
TransportCommand.toggle => player.playOrPause(),
};
}
: null,
),
),
),
),
),
);
await tester.pump();
// Every case below starts from hidden chrome — the state the issue is about.
chrome.hide();
chrome.markControlsHidden();
await tester.pump();
expect(chrome.controlsVisible, isFalse);
}
Future<void> settleFeedback(WidgetTester tester) async {
chrome.cancelAutoHide();
toast.hide();
await tester.pumpWidget(const SizedBox.shrink());
}
testWidgets('a right-arrow tap seeks without raising the chrome', (tester) async {
await pumpControls(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(chrome.controlsVisible, isFalse, reason: 'seeking must not cover the picture');
expect(find.byType(DoubleTapFeedback), findsOneWidget);
expect(find.text('10s'), findsOneWidget);
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(chrome.controlsVisible, isFalse);
expect(player.seeks, [const Duration(minutes: 10, seconds: 10)]);
await settleFeedback(tester);
});
testWidgets('repeated taps in one direction stack into a running total', (tester) async {
await pumpControls(tester);
for (var i = 0; i < 3; i++) {
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
}
expect(find.text('30s'), findsOneWidget);
expect(chrome.controlsVisible, isFalse);
await settleFeedback(tester);
});
testWidgets('flipping direction restarts the badge count', (tester) async {
await pumpControls(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(find.text('10s'), findsOneWidget);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(find.text('10s'), findsOneWidget, reason: 'the reverse burst is counted on its own');
expect(chrome.controlsVisible, isFalse);
await settleFeedback(tester);
});
testWidgets('a held arrow accelerates and commits exactly one seek on release', (tester) async {
await pumpControls(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
for (var i = 0; i < 8; i++) {
await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
}
expect(chrome.controlsVisible, isFalse, reason: 'a held seek must not escalate to the scrub bar');
expect(player.seeks, isEmpty, reason: 'the burst is coalesced, not dispatched per repeat');
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(player.seeks, hasLength(1));
// 1 press at 10s + 5 repeats at 1.5x + 3 repeats at 3x = 10 + 75 + 90.
expect(player.seeks.single, const Duration(minutes: 10) + const Duration(seconds: 175));
await settleFeedback(tester);
});
testWidgets('up-arrow still raises the chrome as the deliberate escape hatch', (tester) async {
await pumpControls(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(chrome.controlsVisible, isTrue);
await settleFeedback(tester);
});
testWidgets('a media fast-forward key skips with a badge and no chrome', (tester) async {
await pumpControls(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaFastForward);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.mediaFastForward);
await tester.pump();
expect(chrome.controlsVisible, isFalse);
expect(find.text('10s'), findsOneWidget);
expect(player.seeks, [const Duration(minutes: 10, seconds: 10)]);
await settleFeedback(tester);
});
testWidgets('a rapid media-key burst commits the total the badge reports', (tester) async {
// No chapters, so the transport key falls back to a timed skip. A slow
// backend must not let the badge climb past what actually gets seeked.
player.freezePositionOnSeek = true;
await pumpControls(tester);
for (var i = 0; i < 3; i++) {
await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaFastForward);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.mediaFastForward);
await tester.pump();
}
expect(find.text('30s'), findsOneWidget);
expect(player.seeks, [
const Duration(minutes: 10, seconds: 10),
const Duration(minutes: 10, seconds: 20),
const Duration(minutes: 10, seconds: 30),
]);
expect(chrome.controlsVisible, isFalse);
await settleFeedback(tester);
});
testWidgets('a media fast-forward key announces the chapter it lands on', (tester) async {
await pumpControls(
tester,
chapters: [
MediaChapter(id: 1, startTimeOffset: 0, endTimeOffset: 900000, title: 'Cold Open'),
MediaChapter(id: 2, startTimeOffset: 900000, endTimeOffset: 1800000, title: 'The Heist'),
],
);
await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaFastForward);
await tester.pump();
expect(chrome.controlsVisible, isFalse);
expect(find.byType(DoubleTapFeedback), findsNothing, reason: 'a chapter jump is not an N-second skip');
expect(find.byType(PlayerToastIndicator), findsOneWidget);
expect(find.text('The Heist'), findsOneWidget);
await settleFeedback(tester);
});
testWidgets('a chapter seek past the last chapter announces nothing', (tester) async {
// Position 10:00 sits inside the final chapter, so there is nowhere
// forward to jump; the badge must not claim a jump that never happens.
await pumpControls(
tester,
chapters: [
MediaChapter(id: 1, startTimeOffset: 0, endTimeOffset: 300000, title: 'Cold Open'),
MediaChapter(id: 2, startTimeOffset: 300000, endTimeOffset: 2700000, title: 'The Heist'),
],
);
await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaFastForward);
await tester.pump();
expect(find.byType(PlayerToastIndicator), findsNothing);
expect(find.byType(DoubleTapFeedback), findsNothing);
expect(player.seeks, isEmpty);
expect(chrome.controlsVisible, isFalse);
await settleFeedback(tester);
});
testWidgets('a backward chapter seek at the very start announces nothing', (tester) async {
await pumpControls(
tester,
chapters: [
MediaChapter(id: 1, startTimeOffset: 0, endTimeOffset: 900000, title: 'Cold Open'),
MediaChapter(id: 2, startTimeOffset: 900000, endTimeOffset: 1800000, title: 'The Heist'),
],
);
player.setPosition(Duration.zero);
await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaRewind);
await tester.pump();
expect(find.byType(PlayerToastIndicator), findsNothing);
expect(player.seeks, isEmpty, reason: 'already at the start — nothing to rewind to');
expect(chrome.controlsVisible, isFalse);
await settleFeedback(tester);
});
testWidgets('a live hold resets its acceleration tier when the key is released', (tester) async {
// Live seeks bypass the accumulator entirely, so nothing is ever pending;
// the release must still reset the tier or the next hold in the same
// direction resumes mid-acceleration.
final liveOffsets = <int>[];
await pumpControls(tester, isLive: true, onLiveSeekBy: liveOffsets.add);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
for (var i = 0; i < 6; i++) {
await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
}
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(liveOffsets, [10, 15, 15, 15, 15, 15, 30], reason: 'the first hold climbs through the tiers');
liveOffsets.clear();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(liveOffsets, [10, 15], reason: 'a fresh hold restarts at the slowest tier');
expect(chrome.controlsVisible, isFalse);
await settleFeedback(tester);
});
testWidgets('a directed pause on an already-paused video neither resumes nor rewinds', (tester) async {
player.setPlaying(false);
await pumpControls(tester, wireTransportCallback: true);
await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaPause);
await tester.pump();
expect(transportCommands, [TransportCommand.pause]);
expect(player.playOrPauseCalls, 0, reason: 'a dedicated pause button must never toggle');
expect(player.pauseCalls, 1);
expect(
player.seeks,
isEmpty,
reason: 'rewind-on-resume must follow the resolved intent, not the current paused state',
);
expect(player.state.position, const Duration(minutes: 10));
expect(chrome.controlsVisible, isFalse);
await settleFeedback(tester);
});
testWidgets('a directed play on a paused video rewinds then resumes', (tester) async {
player.setPlaying(false);
await pumpControls(tester, wireTransportCallback: true);
await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaPlay);
await tester.pump();
expect(transportCommands, [TransportCommand.play]);
expect(player.seeks, [const Duration(minutes: 9, seconds: 55)]);
expect(player.playCalls, 1);
expect(player.playOrPauseCalls, 0);
expect(chrome.controlsVisible, isFalse);
await settleFeedback(tester);
});
testWidgets('the combined play/pause key toggles without raising the chrome', (tester) async {
await pumpControls(tester, wireTransportCallback: true);
await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaPlayPause);
await tester.pump();
expect(transportCommands, [TransportCommand.toggle]);
expect(player.playOrPauseCalls, 1);
expect(chrome.controlsVisible, isFalse, reason: 'pausing must not cover the subtitles either');
await settleFeedback(tester);
});
testWidgets('select raises the chrome before toggling so no badge flashes under it', (tester) async {
await pumpControls(tester, wireTransportCallback: true);
await tester.sendKeyDownEvent(LogicalKeyboardKey.select);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.select);
await tester.pump();
expect(chrome.controlsVisible, isTrue);
expect(transportCommands, [TransportCommand.toggle]);
await settleFeedback(tester);
});
/// Scale of the disc's one-shot pop. Reading it back is how "the animation
/// replayed" becomes observable rather than assumed.
double discScale(WidgetTester tester) => tester
.widget<ScaleTransition>(
find.descendant(of: find.byType(TransportFeedbackIndicator), matching: find.byType(ScaleTransition)),
)
.scale
.value;
testWidgets('an accepted transport command shows a centred disc, not the top pill', (tester) async {
await pumpControls(tester);
toast.showTransport(Symbols.pause_rounded, 'Paused');
await tester.pump();
await tester.pump(const Duration(milliseconds: 520));
expect(find.byType(TransportFeedbackIndicator), findsOneWidget);
expect(
find.byType(PlayerToastIndicator),
findsNothing,
reason: 'a top pill would land on ASS \\an8 subtitles - the readability complaint itself',
);
// Centred in the frame, clear of both subtitle bands.
final disc = tester.getRect(find.byType(TransportFeedbackIndicator));
final surface = tester.getRect(find.byType(PlexVideoControls));
expect(disc.center.dx, moreOrLessEquals(surface.center.dx, epsilon: 1));
expect(disc.center.dy, moreOrLessEquals(surface.center.dy, epsilon: 1));
// Still announced, so assistive tech and the E2E tree keep reading it.
expect(find.bySemanticsLabel('Paused'), findsOneWidget);
// The glyph nearly fills the disc. It is a state cue, not a button, so a
// wide ring of padding would just cover more picture for no gain. Measured
// at the held scale, where the transform is identity.
final circle = tester.getRect(
find.descendant(of: find.byType(TransportFeedbackIndicator), matching: find.byType(DecoratedBox)),
);
final glyph = tester.getRect(
find.descendant(of: find.byType(TransportFeedbackIndicator), matching: find.byType(AppIcon)),
);
expect(circle.width, moreOrLessEquals(circle.height, epsilon: 0.5), reason: 'a circle, not an oval');
expect(
glyph.width / circle.width,
greaterThan(0.55),
reason: 'the icon should nearly fill the disc rather than float in padding',
);
await settleFeedback(tester);
});
testWidgets('a repeated identical transport command replays the pop', (tester) async {
await pumpControls(tester);
toast.showTransport(Symbols.pause_rounded, 'Paused');
await tester.pump();
await tester.pump(const Duration(milliseconds: 520));
final settled = discScale(tester);
// Same icon and same label: without a per-command pulse this reuses the
// State and the pop would never run again.
toast.showTransport(Symbols.pause_rounded, 'Paused');
await tester.pump();
expect(
discScale(tester),
lessThan(settled),
reason: 'an identical repeat must restart the pop, not sit at its finished scale',
);
await settleFeedback(tester);
});
testWidgets('the disc leaves the way it arrived', (tester) async {
await pumpControls(tester);
double discOpacity() => tester
.widget<FadeTransition>(
find.descendant(of: find.byType(TransportFeedbackIndicator), matching: find.byType(FadeTransition)),
)
.opacity
.value;
toast.showTransport(Symbols.pause_rounded, 'Paused');
await tester.pump();
// Grows and fades in from 0.8/0.
expect(discScale(tester), lessThan(1.0));
expect(discOpacity(), lessThan(1.0));
// Holds fully visible at rest, long enough to read.
await tester.pump(const Duration(milliseconds: 300));
expect(discScale(tester), moreOrLessEquals(1.0, epsilon: 0.01));
expect(discOpacity(), moreOrLessEquals(1.0, epsilon: 0.01));
await tester.pump(const Duration(milliseconds: 300));
expect(discScale(tester), moreOrLessEquals(1.0, epsilon: 0.01), reason: 'still held at 600ms');
// Then runs the same motion backwards rather than expanding away.
await tester.pump(const Duration(milliseconds: 120));
expect(discScale(tester), lessThan(1.0), reason: 'shrinks back toward 0.8, never past 1.0');
expect(discOpacity(), lessThan(1.0));
await settleFeedback(tester);
});
testWidgets('the seek readout sits unbacked at the edge it seeks toward', (tester) async {
await pumpControls(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
final feedback = find.byType(DoubleTapFeedback);
// Nothing is painted behind it. A backdrop large enough to read as a
// surface is exactly what covers the picture and subtitles.
expect(find.descendant(of: feedback, matching: find.byType(ClipPath)), findsNothing);
expect(find.descendant(of: feedback, matching: find.byType(ColoredBox)), findsNothing);
expect(find.descendant(of: feedback, matching: find.byType(DecoratedBox)), findsNothing);
// Amount and exactly one chevron, on one line, near the seek-side edge.
final chevron = find.descendant(of: feedback, matching: find.byType(AppIcon));
expect(chevron, findsOneWidget);
final label = tester.getRect(find.text('10s'));
final arrow = tester.getRect(chevron);
expect(arrow.left, greaterThan(label.left), reason: 'the chevron leads in the travel direction');
expect(
arrow.center.dy,
moreOrLessEquals(label.center.dy, epsilon: 2),
reason: 'chevron and amount share one line',
);
final surface = tester.getRect(find.byType(PlexVideoControls));
expect(arrow.right, lessThan(surface.right), reason: 'inside the overscan-safe inset');
expect(label.left, greaterThan(surface.center.dx), reason: 'anchored to the right half, not centred');
expect(label.center.dy, moreOrLessEquals(surface.center.dy, epsilon: 2));
await settleFeedback(tester);
});
/// Opacity of the drifting chevron. Sampling it is how "still animating"
/// becomes observable.
double chevronOpacity(WidgetTester tester) => tester
.widget<Opacity>(find.descendant(of: find.byType(DoubleTapFeedback), matching: find.byType(Opacity)))
.opacity;
double chevronDx(WidgetTester tester) => tester
.widget<Transform>(find.descendant(of: find.byType(DoubleTapFeedback), matching: find.byType(Transform)))
.transform
.getTranslation()
.x;
testWidgets('each stacked press shows its new total', (tester) async {
await pumpControls(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('10s'), findsOneWidget);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
await tester.pump(const Duration(milliseconds: 60));
// The amount is the per-press feedback here; no extra kick is needed.
expect(find.text('20s'), findsOneWidget);
expect(find.text('10s'), findsNothing);
await settleFeedback(tester);
});
testWidgets('the chevron stays visible and travels one way through a held burst', (tester) async {
// It is a persistent cue, not a blinking one: it must never fade out
// entirely, and it must only ever displace toward the seek direction.
await pumpControls(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
var peak = 0.0;
var floor = 1.0;
var mostBackward = 0.0;
var peakDx = 0.0;
final samples = <double>[];
final positions = <double>{};
for (var i = 0; i < 20; i++) {
await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowRight);
await tester.pump(const Duration(milliseconds: 60));
final o = chevronOpacity(tester);
peak = math.max(peak, o);
floor = math.min(floor, o);
final dx = chevronDx(tester);
mostBackward = math.min(mostBackward, dx);
peakDx = math.max(peakDx, dx);
samples.add(dx);
positions.add(double.parse(dx.toStringAsFixed(2)));
}
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(floor, greaterThan(0.6), reason: 'the chevron must never blink out mid-burst');
expect(peak, greaterThan(0.9), reason: 'and must reach full strength as it travels');
expect(mostBackward, greaterThanOrEqualTo(-0.01), reason: 'a forward seek never crosses behind its origin');
expect(positions.length, greaterThan(3), reason: 'it keeps moving rather than sitting still');
// The outward stroke dominates. Mean displacement over a cycle is exactly
// half the amplitude for a symmetric wobble, and higher when the chevron
// dwells at the far end and returns briefly.
final mean = samples.reduce((a, b) => a + b) / samples.length;
expect(
mean / peakDx,
greaterThan(0.55),
reason: 'travel should read as directional, not as an even back-and-forth',
);
await settleFeedback(tester);
});
testWidgets('the seek readout fits a narrow portrait viewport', (tester) async {
// Rotation is unlockable, so portrait playback is reachable, and the inset
// is derived from viewport width. Pump the readout at the real viewport
// size rather than through the harness, whose surface is pinned landscape.
const narrow = Size(400, 800);
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(size: narrow),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox.fromSize(
key: const ValueKey('viewport'),
size: narrow,
child: const Stack(children: [Positioned.fill(child: DoubleTapFeedback(isForward: true, seconds: 10))]),
),
),
),
),
);
await tester.pump(const Duration(milliseconds: 300));
expect(tester.takeException(), isNull, reason: 'must not overflow at 400px wide');
final viewport = tester.getRect(find.byKey(const ValueKey('viewport')));
final row = tester.getRect(find.byType(Row));
expect(row.left, greaterThanOrEqualTo(viewport.left), reason: 'stays on screen');
expect(row.right, lessThanOrEqualTo(viewport.right));
expect(row.width, lessThan(viewport.width), reason: 'a readout, not a full-width band');
});
});
// Desktop keyboard seeking never reaches the D-pad branch above: with
// videoPlayerNavigationEnabled false and no TV, the configured Left/Right and
// Shift+Left/Right shortcuts fall through to KeyboardShortcutsService.
group('desktop keyboard seeking', () {
late _RecordingPlayer player;
late PlayerChromeController chrome;
late PlayerToastController toast;
late VideoVolumeController volume;
late PlaybackStateProvider playbackState;
late WatchTogetherProvider watchTogether;
late AppDatabase database;
setUp(() async {
LocaleSettings.setLocaleSync(AppLocale.en);
await initializeDateFormatting('en');
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.seekTimeSmall, 10);
await settings.write(SettingsService.seekTimeLarge, 30);
await settings.write(SettingsService.videoPlayerNavigationEnabled, false);
TvDetectionService.debugSetAppleTVOverride(false);
PlatformDetector.debugSetIsDesktopOSOverride(true);
database = AppDatabase.forTesting(NativeDatabase.memory());
player = _RecordingPlayer();
chrome = PlayerChromeController();
toast = PlayerToastController();
volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100);
playbackState = PlaybackStateProvider();
watchTogether = WatchTogetherProvider();
});
tearDown(() async {
TvDetectionService.debugSetAppleTVOverride(null);
PlatformDetector.debugSetIsDesktopOSOverride(null);
volume.dispose();
playbackState.dispose();
watchTogether.dispose();
chrome.dispose();
toast.dispose();
await database.close();
});
Future<void> pumpDesktopControls(WidgetTester tester) async {
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<AppDatabase>.value(value: database),
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: 1280,
height: 720,
child: PlexVideoControls(
player: player,
volumeController: volume,
metadata: testMediaItem(id: 'desktop-keyboard-seek'),
toastController: toast,
chromeController: chrome,
canNavigateMediaItems: false,
),
),
),
),
),
);
// The shortcuts service loads asynchronously; without it the arrow keys
// are consumed before ever reaching a seek.
await tester.pumpAndSettle();
chrome.hide();
chrome.markControlsHidden();
await tester.pump();
expect(chrome.controlsVisible, isFalse);
}
Future<void> settleFeedback(WidgetTester tester) async {
chrome.cancelAutoHide();
toast.hide();
await tester.pumpWidget(const SizedBox.shrink());
}
Future<void> pressKey(WidgetTester tester, LogicalKeyboardKey key) async {
await tester.sendKeyDownEvent(key);
await tester.pump();
await tester.sendKeyUpEvent(key);
await tester.pump();
}
testWidgets('the configured seek shortcut shows the badge and leaves the chrome down', (tester) async {
await pumpDesktopControls(tester);
await pressKey(tester, LogicalKeyboardKey.arrowRight);
expect(chrome.controlsVisible, isFalse, reason: 'keyboard seeking must not cover the subtitles either');
expect(find.byType(DoubleTapFeedback), findsOneWidget);
expect(find.text('10s'), findsOneWidget);
expect(player.seeks, [const Duration(minutes: 10, seconds: 10)]);
await settleFeedback(tester);
});
testWidgets('the backward shortcut reports its own direction', (tester) async {
await pumpDesktopControls(tester);
await pressKey(tester, LogicalKeyboardKey.arrowLeft);
expect(find.text('10s'), findsOneWidget);
expect(player.seeks, [const Duration(minutes: 9, seconds: 50)]);
expect(chrome.controlsVisible, isFalse);
await settleFeedback(tester);
});
testWidgets('the large seek shortcut reports the large amount', (tester) async {
await pumpDesktopControls(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await pressKey(tester, LogicalKeyboardKey.arrowRight);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.pump();
expect(find.text('30s'), findsOneWidget);
expect(player.seeks, [const Duration(minutes: 10, seconds: 30)]);
expect(chrome.controlsVisible, isFalse);
await settleFeedback(tester);
});
testWidgets('a rapid burst commits the total the badge reports, even when seeks lag', (tester) async {
// A slow backend has not moved the position when the next press lands.
// Rebasing off player.state.position would peg every request near +10s
// while the badge climbed to 30s.
player.freezePositionOnSeek = true;
await pumpDesktopControls(tester);
for (var i = 0; i < 3; i++) {
await pressKey(tester, LogicalKeyboardKey.arrowRight);
}
expect(find.text('30s'), findsOneWidget);
expect(player.seeks, [
const Duration(minutes: 10, seconds: 10),
const Duration(minutes: 10, seconds: 20),
const Duration(minutes: 10, seconds: 30),
]);
expect(
player.seeks.last,
const Duration(minutes: 10) + const Duration(seconds: 30),
reason: 'the committed target must equal the badge total',
);
expect(chrome.controlsVisible, isFalse);
await settleFeedback(tester);
});
});
group('formatSkipFeedbackLabel', () {
setUp(() => LocaleSettings.setLocaleSync(AppLocale.en));
test('keeps a bare second count below a minute', () {
expect(formatSkipFeedbackLabel(10), '10s');
expect(formatSkipFeedbackLabel(59), '59s');
});
test('switches to a timestamp once a held seek passes a minute', () {
expect(formatSkipFeedbackLabel(60), '1:00');
expect(formatSkipFeedbackLabel(175), '2:55');
});
});
}
/// Minimal [Player] that records transport calls and keeps a settable
/// playing/position state so intent-dependent behaviour can be asserted.
class _RecordingPlayer implements Player {
final List<Duration> seeks = [];
int playCalls = 0;
int pauseCalls = 0;
int playOrPauseCalls = 0;
/// Simulates a backend that has not applied the seek yet, so a caller that
/// rebases off [state] would compute a stale target.
bool freezePositionOnSeek = false;
bool _playing = true;
Duration _position = const Duration(minutes: 10);
void setPlaying(bool value) => _playing = value;
void setPosition(Duration value) => _position = value;
@override
String get playerType => 'mpv';
@override
PlayerState get state =>
PlayerState(playing: _playing, position: _position, duration: const Duration(minutes: 45), seekable: true);
@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> seek(Duration position) async {
seeks.add(position);
if (!freezePositionOnSeek) _position = position;
}
@override
Future<void> play() async {
playCalls++;
_playing = true;
}
@override
Future<void> pause() async {
pauseCalls++;
_playing = false;
}
@override
Future<void> playOrPause() async {
playOrPauseCalls++;
_playing = !_playing;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}