Files
plezy/lib/widgets/video_controls/parts/navigation.dart
T
edde746 e3703892b3 fix(player): keep a keyboard Enter out of focus navigation
Pressing Enter over the player put the whole app into keyboard mode and
dropped focus onto Play/Pause, even with Video Player Navigation off. Two
independent paths did it. InputModeTracker promoted on any key satisfying
isNavigationKey, a set that unioned activation, dismissal and the menu key
with the arrows and consulted no setting at all; separately the surface's
Select handler always asked the chrome for focus. Escape had the same effect,
which on desktop reads as the mouse cursor vanishing mid-playback.

Both now ask one predicate. eventRequestsFocusNavigation decides whether the
app switches to keyboard mode and whether a key may hand focus to the chrome,
so the two cannot disagree and focus can never land on a control while focus
chrome is still suppressed. Activation and dismissal act on what already has
focus, so they answer no; Tab, the menu key, a remote's OK or BACK, and an
arrow that will really traverse answer yes. The one input the predicate cannot
read off the event, whether the focused feature owns arrow keys, rides on the
node as DirectionalShortcutFocusNode instead of on a subtree, so every sheet,
prompt and OSD button stays an ordinary traversal target with nothing to
re-enable.

playerDirectionalNavigationEnabled and videoPlayerNavigationPreference replace
five hand-copied pref-or-isTV expressions and a screen-level cache that
disagreed with the live getter after a toggle. Services whose input is
synthesized past HardwareKeyboard announce themselves through
InputModeTracker.reportNonPointerInput rather than two static callbacks and
three copies of a highlight-strategy write. That registration is now
identity-guarded: the bootstrap-to-app tree swap disposed the outgoing tracker
after the incoming one initialised and cleared both callbacks, so gamepad and
companion remote input had stopped switching to keyboard mode entirely.

Falling out of the same rule: a companion heartbeat no longer flips an idle
desktop host into keyboard mode, analog-stick drift promotes only past the
deadzone that actually navigates, Enter keeps toggling playback once the
chrome is up, Tab both reaches and traverses the OSD, and the player surface
claims the remote from mount rather than only when the chrome starts hidden,
so the first key on a desktop route is a playback shortcut instead of the
screen node's chrome-raising self-heal.

isNavigationKey becomes isReservedControlKey, since its real meaning is a
shell key rather than a text character and the old name is what invited the
conflation. The unreachable PlayerChromeFocusTarget.timeline goes with it.
2026-08-07 13:23:53 +02:00

167 lines
7.2 KiB
Dart

part of '../video_controls.dart';
extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
Widget _buildDesktopControlsListener() {
final playbackState = context.watch<PlaybackStateProvider>();
final trackControlsState = _buildTrackControlsState(
playbackState: playbackState,
onToggleAlwaysOnTop: Platform.isMacOS ? null : _toggleAlwaysOnTop,
);
final useDpad = playerDirectionalNavigationEnabled();
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) => _restartHideTimerForCurrentPlaybackState(),
child: DesktopVideoControls(
key: _desktopControlsKey,
player: widget.player,
volumeController: widget.volumeController,
metadata: widget.metadata,
onNext: _abandoningBurst(widget.onNext),
onPrevious: _abandoningBurst(widget.onPrevious),
onPlayPause: () => unawaited(_playOrPause()),
chapters: _chapters,
chaptersLoaded: _chaptersLoaded,
showChapterMarkersOnTimeline: _showChapterMarkersOnTimeline,
seekTimeSmall: _seekTimeSmall,
onSeekToPreviousChapter: _seekToPreviousChapter,
onSeekToNextChapter: _seekToNextChapter,
onSeekBackward: () => unawaited(_seekByTime(forward: false)),
onSeekForward: () => unawaited(_seekByTime(forward: true)),
onSeek: _throttledSeek,
onSeekEnd: _finalizeSeek,
onScrubStart: _holdTimelineScrub,
onScrubEnd: _releaseTimelineScrub,
onSeekRequested: widget.onSeekRequested,
getReplayIcon: getReplayIcon,
getForwardIcon: getForwardIcon,
onFocusActivity: _restartHideTimerForCurrentPlaybackState,
onHideControls: _hideControlsFromKeyboard,
trackControlsState: trackControlsState,
onBack: widget.onBack,
hasFirstFrame: widget.hasFirstFrame,
thumbnailDataBuilder: widget.thumbnailDataBuilder,
liveChannelName: widget.liveChannelName,
captureBuffer: widget.captureBuffer,
isAtLiveEdge: widget.isAtLiveEdge,
streamStartEpoch: widget.streamStartEpoch,
currentPositionEpoch: widget.currentPositionEpoch,
onLiveSeek: _liveSeekAbandoningBurst(widget.onLiveSeek),
onLiveSeekBy: widget.onLiveSeekBy,
onJumpToLive: _abandoningBurst(widget.onJumpToLive),
useDpadNavigation: useDpad,
serverId: widget.metadata.serverId,
showQueueTab: playbackState.isQueueActive && widget.canNavigateMediaItems,
onQueueItemSelected: playbackState.isQueueActive && widget.canNavigateMediaItems ? _onQueueItemSelected : null,
onCancelAutoHide: widget.chromeController.cancelAutoHide,
onStartAutoHide: _startHideTimer,
onSeekCompleted: widget.onSeekCompleted,
onContentStripVisibilityChanged: (visible) {
widget.chromeController.setContentStripVisible(visible);
},
chromeController: widget.chromeController,
),
);
}
void _onQueueItemSelected(MediaItem item) {
// Same contract as next/previous: the switch is asynchronous, so a burst
// still armed here would debounce into a seek on the outgoing item.
_hiddenSeek.cancel();
_desktopControlsKey.currentState?.abandonPendingSeek();
_dismissSkipFeedback();
final videoPlayerState = context.findAncestorStateOfType<VideoPlayerScreenState>();
videoPlayerState?.navigateToQueueItem(item);
}
Future<SubtitleDownloadApplyOutcome> _onSubtitleDownloaded({
required String serverId,
required String ratingKey,
}) async {
if (!mounted) return SubtitleDownloadApplyOutcome.unavailable;
// Plex-only: the OpenSubtitles polling flow uses [getVideoPlaybackData]
// and the Plex token. Jellyfin has no analogue and the entry point
// (`subtitleSearchSupported`) is already gated on backend, but guard
// here too in case a future caller wires the same handler elsewhere.
if (widget.metadata.backend != MediaBackend.plex) return SubtitleDownloadApplyOutcome.unavailable;
if (widget.metadata.serverId != serverId || widget.metadata.id != ratingKey) {
return SubtitleDownloadApplyOutcome.superseded;
}
final switchSource = widget.onPlaybackSourceChanged;
if (switchSource == null) return SubtitleDownloadApplyOutcome.unavailable;
final itemKey = widget.metadata.globalKey;
bool targetIsCurrent() =>
mounted &&
widget.metadata.globalKey == itemKey &&
widget.metadata.serverId == serverId &&
widget.metadata.id == ratingKey;
try {
final client = context.getPlexClientForServer(ServerId(serverId));
// Plex's OpenSubtitles download is asynchronous: the PUT returns immediately
// but the new stream entry shows up in metadata seconds later. Poll until it
// appears. Up to 15s matches what Plex-web tolerates before giving up.
// Snapshot the authoritative source IDs so we can identify the new
// download without asking mpv to synchronously open its remote URL.
final existingSourceIds = widget.sourceSubtitleTracks.map((track) => track.id).toSet();
final deadline = DateTime.now().add(const Duration(seconds: 15));
MediaSubtitleTrack? newTrack;
while (mounted && DateTime.now().isBefore(deadline)) {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return SubtitleDownloadApplyOutcome.superseded;
try {
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
final data = await client.getVideoPlaybackData(ratingKey);
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
if (data.mediaInfo == null) continue;
newTrack = findNewExternalSubtitleTrack(data.mediaInfo!.subtitleTracks, existingSourceIds);
if (newTrack != null) break;
} catch (e) {
appLogger.w('Subtitle download poll iteration failed', error: e);
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
}
}
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
if (newTrack == null) return SubtitleDownloadApplyOutcome.timedOut;
final outcome = await switchSource(newSubtitleChoice: PlaybackSourceSubtitleChoice.source(newTrack.id));
return subtitleDownloadApplyOutcomeFor(outcome);
} catch (e) {
appLogger.w('Failed to refresh subtitles after download', error: e);
return SubtitleDownloadApplyOutcome.failed;
}
}
/// Request a version, quality preset, audio stream, or source subtitle reload.
/// The owning player screen decides how to apply it so controls do not own
/// player lifecycle/navigation policy.
Future<void> _switchVersionAndQuality({
int? newMediaIndex,
TranscodeQualityPreset? newPreset,
int? newAudioStreamId,
PlaybackSourceSubtitleChoice? newSubtitleChoice,
}) async {
final onPlaybackSourceChanged = widget.onPlaybackSourceChanged;
if (onPlaybackSourceChanged == null) return;
try {
await onPlaybackSourceChanged(
newMediaIndex: newMediaIndex,
newPreset: newPreset,
newAudioStreamId: newAudioStreamId,
newSubtitleChoice: newSubtitleChoice,
);
} catch (e) {
if (mounted) {
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
}
}
}