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
@@ -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;
+66 -16
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(
duration: const Duration(milliseconds: 150),
child: PlayerToastIndicator(
key: ValueKey('${toast.icon.codePoint}:${toast.text}'),
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,38 +1,158 @@
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
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: DoubleTapFeedback._chevronSize(context),
shadows: _legibility,
),
);
}
@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,
fill: 1,
color: Colors.white,
size: 32,
),
const SizedBox(height: 4),
Text(
'$seconds${t.settings.secondsShort}',
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .bold),
),
],
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(
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,31 +15,41 @@ class PlayerToastIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Align(
alignment: .topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.8),
child: Container(
margin: const EdgeInsets.only(top: 20),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: Row(
mainAxisSize: .min,
children: [
AppIcon(icon, fill: 1, color: Colors.white, size: 16),
const SizedBox(width: 4),
Flexible(
child: Text(
text,
maxLines: 1,
overflow: .ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .bold),
// 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),
child: Container(
margin: const EdgeInsets.only(top: 20),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: Row(
mainAxisSize: .min,
children: [
AppIcon(icon, fill: 1, color: Colors.white, size: 16),
const SizedBox(width: 4),
Flexible(
child: Text(
text,
maxLines: 1,
overflow: .ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .bold),
),
),
),
],
],
),
),
),
),
@@ -45,19 +57,58 @@ 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),
),
),
),
),
),
);
}
}