Files
plezy/lib/widgets/video_controls/video_controls.dart
T
edde746 9d51a040c3 fix(player): keep the remote on the player surface after a window switch (#1797)
Returning to the desktop window with the chrome still up left arrow keys
navigating the OSD instead of seeking: the first press seeked and silently
moved focus onto Play/Pause, and every press after that walked the buttons.

A window blur drops Flutter's primary focus to the root scope, so the player
screen's reclaim parks it on its own node. The only handoff back down to the
controls was the chrome visible->hidden transition, so with the OSD up nothing
reclaimed it -- hence the reported workarounds of letting the controls hide, or
moving the pointer off the player and back. Pointer exit normally hides the
chrome and masks this, which is why it only shows when the pointer stays over
the player while another window takes focus.

Hand the surface back on window re-activation, next to the existing hide-path
claim, and rename the helper since it is no longer hidden-chrome specific. The
claim runs synchronously because a platform callback is not guaranteed to be
followed by a frame; the screen's reclaim re-tests hasFocus when it runs, so the
two no longer compete.

Also gate the screen's self-heal so a directional key no longer pulls focus into
the OSD when "Video Player Navigation" is off -- Tab and select keep their path
in, which the ungated return value would otherwise consume with nowhere to go.
2026-08-05 15:24:57 +02:00

1381 lines
60 KiB
Dart

import 'dart:async' show StreamSubscription, Timer, unawaited;
import 'dart:io' show Platform;
import '../../media/ids.dart';
import 'package:flutter/gestures.dart'
show
PointerCancelEvent,
PointerDeviceKind,
PointerDownEvent,
PointerMoveEvent,
PointerScrollEvent,
PointerSignalEvent,
PointerUpEvent,
kDoubleTapTimeout;
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:rate_limiter/rate_limiter.dart';
import 'package:flutter/services.dart'
show
SystemChrome,
DeviceOrientation,
LogicalKeyboardKey,
PhysicalKeyboardKey,
KeyEvent,
KeyDownEvent,
KeyUpEvent,
KeyRepeatEvent,
HardwareKeyboard;
import '../../services/fullscreen_state_manager.dart';
import '../../services/macos_window_service.dart';
import '../../services/pip_service.dart';
import '../../services/playback_initialization_types.dart';
import '../../services/playback_subtitle_resolver.dart';
import 'package:window_manager/window_manager.dart';
import '../../mixins/settings_effect_mixin.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../mpv/mpv.dart';
import '../overlay_sheet.dart';
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';
import '../../models/transcode_quality_preset.dart';
import '../../media/media_version.dart';
import '../../screens/video_player_screen.dart';
import '../../focus/key_event_utils.dart';
import '../../services/keyboard_shortcuts_service.dart';
import '../../services/device_adjustment_service.dart';
import '../../services/scrub_preview_source.dart';
import '../../services/settings_service.dart';
import '../../services/video_volume_controller.dart';
import '../../utils/codec_utils.dart';
import '../../utils/formatters.dart';
import '../../utils/platform_detector.dart';
import '../../utils/player_utils.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/snackbar_helper.dart';
import '../../utils/latest_async_write.dart';
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';
import 'models/track_controls_state.dart';
import 'widgets/double_tap_feedback.dart';
import 'helpers/mobile_edge_adjustment_tracker.dart';
import 'helpers/two_finger_tap_tracker.dart';
import 'widgets/linux_keep_alive.dart';
import 'widgets/mobile_edge_adjustment_indicator.dart';
import 'widgets/mobile_skip_zones.dart';
import 'widgets/skip_marker_button.dart';
import 'widgets/track_chapter_controls.dart';
import 'widgets/performance_overlay/performance_overlay.dart';
import '../rasterized_gradient.dart';
import 'mobile_video_controls.dart';
import 'desktop_video_controls.dart';
import 'package:provider/provider.dart';
import '../../models/shader_preset.dart';
import '../../providers/playback_state_provider.dart';
import '../../providers/shader_provider.dart';
import '../../services/shader_service.dart';
part 'parts/key_events.dart';
part 'parts/markers.dart';
part 'parts/navigation.dart';
part 'parts/playback_extras.dart';
part 'parts/playback_input.dart';
part 'parts/track_controls.dart';
part 'parts/visibility.dart';
/// Subtitle tracks offered in the player's "source" subtitle list.
///
/// Direct play exposes embedded tracks in the native player and can attach
/// arbitrary sidecars itself. A transcode can only deliver external sidecars
/// or embedded codecs that the server can convert/burn into the rendition.
List<MediaSubtitleTrack> selectableSourceSubtitleTracks(
List<MediaSubtitleTrack> tracks, {
required bool isTranscoding,
required Set<int> sidecarSourceIds,
required bool supportsEmbeddedTranscodeSelection,
}) {
if (!isTranscoding) {
return tracks
.where((track) {
final requiresSidecar =
track.isExternalFile || (!track.usesExternalDelivery && track.key != null && track.key!.isNotEmpty);
return !requiresSidecar || sidecarSourceIds.contains(track.id);
})
.toList(growable: false);
}
return tracks
.where(
(track) =>
sidecarSourceIds.contains(track.id) ||
(supportsEmbeddedTranscodeSelection && CodecUtils.isTranscodableSubtitleCodec(track.codec)),
)
.toList(growable: false);
}
@visibleForTesting
MediaSubtitleTrack? findNewExternalSubtitleTrack(List<MediaSubtitleTrack> tracks, Set<int> existingSourceIds) {
for (final track in tracks) {
if (track.isExternal && !existingSourceIds.contains(track.id)) return track;
}
return null;
}
@visibleForTesting
SubtitleDownloadApplyOutcome subtitleDownloadApplyOutcomeFor(PlaybackSourceChangeOutcome outcome) {
return switch (outcome) {
PlaybackSourceChangeOutcome.applied ||
PlaybackSourceChangeOutcome.unchanged => SubtitleDownloadApplyOutcome.applied,
PlaybackSourceChangeOutcome.busy => SubtitleDownloadApplyOutcome.busy,
PlaybackSourceChangeOutcome.superseded => SubtitleDownloadApplyOutcome.superseded,
PlaybackSourceChangeOutcome.unavailable => SubtitleDownloadApplyOutcome.unavailable,
PlaybackSourceChangeOutcome.failed => SubtitleDownloadApplyOutcome.failed,
};
}
@visibleForTesting
ShaderPreset resolveShaderTogglePreset({
required ShaderPreset currentPreset,
required ShaderPreset savedPreset,
required List<ShaderPreset> allPresets,
}) {
if (currentPreset.isEnabled) return ShaderPreset.none;
if (savedPreset.isEnabled) return savedPreset;
return allPresets.firstWhere((p) => p.isEnabled, orElse: () => ShaderPreset.nvscalerDefault);
}
@visibleForTesting
({
List<MediaVersion> availableVersions,
bool serverSupportsTranscoding,
bool isTranscoding,
List<MediaAudioTrack> sourceAudioTracks,
int? selectedAudioStreamId,
List<MediaSubtitleTrack> sourceSubtitleTracks,
PlaybackSourceSubtitleChoice? selectedSubtitleChoice,
bool canSwitch,
})
effectiveVersionQualityControls({
required bool isOfflinePlayback,
required List<MediaVersion> availableVersions,
required bool serverSupportsTranscoding,
required bool isTranscoding,
required List<MediaAudioTrack> sourceAudioTracks,
required int? selectedAudioStreamId,
required List<MediaSubtitleTrack> sourceSubtitleTracks,
required PlaybackSourceSubtitleChoice? selectedSubtitleChoice,
}) {
if (isOfflinePlayback) {
return (
availableVersions: const <MediaVersion>[],
serverSupportsTranscoding: false,
isTranscoding: false,
sourceAudioTracks: const <MediaAudioTrack>[],
selectedAudioStreamId: null,
sourceSubtitleTracks: const <MediaSubtitleTrack>[],
selectedSubtitleChoice: null,
canSwitch: false,
);
}
return (
availableVersions: availableVersions,
serverSupportsTranscoding: serverSupportsTranscoding,
isTranscoding: isTranscoding,
sourceAudioTracks: sourceAudioTracks,
selectedAudioStreamId: selectedAudioStreamId,
sourceSubtitleTracks: sourceSubtitleTracks,
selectedSubtitleChoice: selectedSubtitleChoice,
canSwitch: true,
);
}
@visibleForTesting
bool shouldShowSkipMarkerButton({
required bool hasFirstFrame,
required bool hasMarker,
required bool hasPlayNextPrompt,
required bool skipButtonDismissed,
required bool controlsVisible,
}) {
return hasFirstFrame && hasMarker && !hasPlayNextPrompt && (!skipButtonDismissed || controlsVisible);
}
enum PlayerNavigationKey { none, physicalEscape, back, home }
enum PlayerBackDisposition { closeContentStrip, exitFullscreenIfActive, hideControls, exitPlayer }
bool shouldPhysicalEscapeExitFullscreen({
required bool isMacOS,
required bool videoPlayerNavigationEnabled,
required bool playerEnteredFullscreen,
}) {
return !isMacOS && !videoPlayerNavigationEnabled && playerEnteredFullscreen;
}
/// Coordinates the player-level stages shared by keyboard, controller, and
/// companion navigation after descendants have handled local overlays.
class PlayerNavigationCoordinator {
final PlayerChromeController chromeController;
final bool Function() isPromptOpen;
final VoidCallback dismissPrompt;
final bool Function() isChromePresented;
final Future<bool> Function() exitFullscreenIfActive;
final bool Function() _physicalEscapeExitsFullscreen;
final VoidCallback exitPlayer;
final VoidCallback navigateHome;
final bool Function() isActive;
bool _handlingPhysicalEscape = false;
PlayerNavigationCoordinator({
required this.chromeController,
required this.isPromptOpen,
required this.dismissPrompt,
required this.isChromePresented,
required this.exitFullscreenIfActive,
bool Function()? physicalEscapeExitsFullscreen,
required this.exitPlayer,
required this.navigateHome,
bool Function()? isActive,
}) : _physicalEscapeExitsFullscreen = physicalEscapeExitsFullscreen ?? _alwaysTrue,
isActive = isActive ?? _alwaysActive;
static bool _alwaysActive() => true;
static bool _alwaysTrue() => true;
void handle(PlayerNavigationKey navigationKey) {
if (navigationKey == PlayerNavigationKey.home) {
navigateHome();
return;
}
if (isPromptOpen()) {
dismissPrompt();
return;
}
final disposition = resolvePlayerBackDisposition(
navigationKey: navigationKey,
contentStripVisible: chromeController.contentStripVisible,
controlsVisible: isChromePresented(),
physicalEscapeExitsFullscreen: _physicalEscapeExitsFullscreen(),
);
_applyDisposition(disposition);
}
void _applyDisposition(PlayerBackDisposition disposition) {
switch (disposition) {
case PlayerBackDisposition.closeContentStrip:
chromeController.setContentStripVisible(false);
return;
case PlayerBackDisposition.exitFullscreenIfActive:
unawaited(_handlePhysicalEscape());
return;
case PlayerBackDisposition.hideControls:
chromeController.hide();
return;
case PlayerBackDisposition.exitPlayer:
exitPlayer();
return;
}
}
Future<void> _handlePhysicalEscape() async {
if (_handlingPhysicalEscape) return;
_handlingPhysicalEscape = true;
final chromeWasPresented = isChromePresented();
try {
if (await exitFullscreenIfActive()) return;
if (!isActive()) return;
final disposition = resolvePlayerBackDisposition(
navigationKey: PlayerNavigationKey.back,
contentStripVisible: chromeController.contentStripVisible,
controlsVisible: chromeWasPresented || isChromePresented(),
);
_applyDisposition(disposition);
} finally {
_handlingPhysicalEscape = false;
}
}
}
// ignore: unused-code
PlayerBackDisposition resolvePlayerBackDisposition({
required PlayerNavigationKey navigationKey,
required bool contentStripVisible,
required bool controlsVisible,
bool physicalEscapeExitsFullscreen = true,
}) {
assert(navigationKey == PlayerNavigationKey.physicalEscape || navigationKey == PlayerNavigationKey.back);
if (contentStripVisible) return PlayerBackDisposition.closeContentStrip;
if (navigationKey == PlayerNavigationKey.physicalEscape && physicalEscapeExitsFullscreen) {
return PlayerBackDisposition.exitFullscreenIfActive;
}
return controlsVisible ? PlayerBackDisposition.hideControls : PlayerBackDisposition.exitPlayer;
}
/// Maps a key event to the player-level navigation stage it should drive.
///
/// [textEditingActive] defaults to [isTextEditingFocused]; inject it in tests.
/// Bare Backspace and Home double as player navigation *and* as caret editing
/// keys, so a focused text editor takes them back — otherwise typing in a
/// player sheet (subtitle search) walks the back pipeline out of the player
/// instead of correcting a character (#1741). Only physical-keyboard presses
/// are surrendered: a synthesized dpad/gamepad/companion press has no caret,
/// and [LogicalKeyboardKey.browserHome] is a dedicated navigation key with no
/// editing role at all.
PlayerNavigationKey classifyPlayerNavigationKey(
KeyEvent event, {
required bool isAppleTV,
bool? hasModifiers,
bool? textEditingActive,
}) {
final key = event.logicalKey;
if (key == LogicalKeyboardKey.escape) {
return event.isPhysicalKeyboardEvent && !isAppleTV ? PlayerNavigationKey.physicalEscape : PlayerNavigationKey.back;
}
if (key.isBackKey) return PlayerNavigationKey.back;
final modifiersPressed =
hasModifiers ??
(HardwareKeyboard.instance.isShiftPressed ||
HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isAltPressed ||
HardwareKeyboard.instance.isMetaPressed);
// Resolved lazily: only the two editing keys below pay for the focus lookup.
bool textEditorOwnsKey() => event.isPhysicalKeyboardEvent && (textEditingActive ?? isTextEditingFocused());
if (key == LogicalKeyboardKey.backspace && event.isPhysicalKeyboardEvent && !modifiersPressed) {
return textEditorOwnsKey() ? PlayerNavigationKey.none : PlayerNavigationKey.back;
}
if ((key == LogicalKeyboardKey.home || key == LogicalKeyboardKey.browserHome) && !modifiersPressed) {
if (key == LogicalKeyboardKey.home && textEditorOwnsKey()) return PlayerNavigationKey.none;
return PlayerNavigationKey.home;
}
return PlayerNavigationKey.none;
}
/// Gives the player route ownership of navigation that arrives while its
/// loading/error body is still establishing focus. The matching key-up still
/// performs the action through the normal [Focus.onKeyEvent] path.
bool primePlayerNavigationFocusForEvent(
KeyEvent event, {
required FocusNode focusNode,
required bool playerReady,
required bool isCurrentRoute,
required bool isAppleTV,
}) {
if (!isCurrentRoute || playerReady || event is! KeyDownEvent) return false;
if (classifyPlayerNavigationKey(event, isAppleTV: isAppleTV) == PlayerNavigationKey.none) {
return false;
}
focusNode.requestFocus();
return true;
}
KeyEventResult handlePlayerNavigationKeyAction(
KeyEvent event,
PlayerNavigationKey navigationKey,
VoidCallback onAction,
) {
if (navigationKey == PlayerNavigationKey.none) return KeyEventResult.ignored;
// macOS may also translate Backspace / Escape / browser Back into a route
// pop on key-down. Suppress that parallel path before the player performs
// its single staged action on key-up.
if (navigationKey != PlayerNavigationKey.home && event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
}
if (event.logicalKey.isBackKey) return handleBackKeyAction(event, onAction);
if (event is KeyUpEvent) {
if (navigationKey != PlayerNavigationKey.home) {
BackKeyCoordinator.markHandled();
}
onAction();
}
return KeyEventResult.handled;
}
@visibleForTesting
bool shouldSkipDuplicateTimelineSeek({required Duration? lastDispatchedSeek, required Duration finalSeek}) {
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.isActionable;
typedef PlaybackSourceChangeCallback =
Future<PlaybackSourceChangeOutcome> Function({
int? newMediaIndex,
TranscodeQualityPreset? newPreset,
int? newAudioStreamId,
PlaybackSourceSubtitleChoice? newSubtitleChoice,
});
enum PlaybackSourceChangeOutcome { applied, unchanged, busy, unavailable, superseded, failed }
typedef _EdgeAdjustmentIndicatorState = ({bool visible, MobileEdgeAdjustmentSide? side, double value});
class PlexVideoControls extends StatefulWidget {
final Player player;
final VideoVolumeController volumeController;
final MediaItem metadata;
final VoidCallback? onNext;
final VoidCallback? onPrevious;
final List<MediaVersion> availableVersions;
final int selectedMediaIndex;
final TranscodeQualityPreset selectedQualityPreset;
final bool serverSupportsTranscoding;
final bool isTranscoding;
final bool isOfflinePlayback;
final List<MediaAudioTrack> sourceAudioTracks;
final int? selectedAudioStreamId;
final List<MediaSubtitleTrack> sourceSubtitleTracks;
final PlaybackSourceSubtitleChoice? selectedSubtitleChoice;
final int? selectedSecondarySubtitleStreamId;
final List<PlaybackSubtitleSidecar> sourceSubtitleSidecars;
final int? sourcePartId;
final PlaybackSourceChangeCallback? onPlaybackSourceChanged;
final int boxFitMode;
final double videoZoomScale;
final VoidCallback? onTogglePIPMode;
final VoidCallback? onCycleBoxFitMode;
final ValueChanged<double>? onVideoZoomChanged;
final VoidCallback? onZoomIn;
final VoidCallback? onZoomOut;
final VoidCallback? onResetVideoZoom;
final VoidCallback? onCycleAudioTrack;
final VoidCallback? onCycleSubtitleTrack;
final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
/// Called for app-level seek requests so the owning screen can coordinate
/// playback state around the native player seek.
final Future<void> Function(Duration position)? onSeekRequested;
/// 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;
/// Called when back button is pressed (for Watch Together session leave confirmation)
final VoidCallback? onBack;
/// Called when the video has effectively reached the end (e.g. credits extend
/// to EOF and can't be seeked past). Parent should route this into its normal
/// completion flow so the auto-play-next setting is honored.
final void Function({required bool skipAutoPlayCountdown})? onReachedEnd;
/// Whether the user can control playback (false in host-only mode for non-host).
final bool canControl;
/// Whether the user may choose another queue item or episode. Watch
/// Together guests never own this capability, even in anyone-control mode.
final bool canNavigateMediaItems;
/// Notifier for whether first video frame has rendered (shows loading state when false).
final ValueNotifier<bool>? hasFirstFrame;
/// Optional focus node for Play Next dialog button (for TV navigation from timeline)
final FocusNode? playNextFocusNode;
/// Shared controller for player chrome visibility, auto-hide, and layout state.
final PlayerChromeController chromeController;
/// Optional shader service for MPV shader control
final ShaderService? shaderService;
/// Called when shader preset changes
final VoidCallback? onShaderChanged;
/// Optional callback that returns thumbnail image bytes for a given timestamp.
final ScrubFrame? Function(Duration time)? thumbnailDataBuilder;
/// Whether this is a live TV stream (disables seek, progress, etc.)
final bool isLive;
/// Channel name for live TV display
final String? liveChannelName;
/// Capture buffer for live TV time-shift (null = no time-shift support)
final CaptureBuffer? captureBuffer;
/// Whether playback is at the live edge
final bool isAtLiveEdge;
/// Epoch seconds corresponding to player position 0 (for live TV)
final double streamStartEpoch;
/// Current playback position as absolute epoch seconds (for live TV)
final int? currentPositionEpoch;
/// Seek callback for live TV time-shift (absolute epoch seconds; scrubber)
final ValueChanged<int>? onLiveSeek;
/// Relative live-TV skip callback (delta seconds). The owning screen
/// accumulates rapid presses and debounces the transcode re-open, so skip
/// buttons/dpad/remote keys must use this rather than `onLiveSeek` (#1253).
final ValueChanged<int>? onLiveSeekBy;
/// Jump to live edge callback
final VoidCallback? onJumpToLive;
/// Whether ambient lighting is enabled (passed to settings sheet)
final bool isAmbientLightingEnabled;
/// Called to toggle ambient lighting (passed to settings sheet)
final VoidCallback? onToggleAmbientLighting;
/// 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 [],
this.selectedMediaIndex = 0,
this.selectedQualityPreset = TranscodeQualityPreset.original,
this.serverSupportsTranscoding = false,
this.isTranscoding = false,
this.isOfflinePlayback = false,
this.sourceAudioTracks = const [],
this.selectedAudioStreamId,
this.sourceSubtitleTracks = const [],
this.selectedSubtitleChoice,
this.selectedSecondarySubtitleStreamId,
this.sourceSubtitleSidecars = const <PlaybackSubtitleSidecar>[],
this.sourcePartId,
this.onPlaybackSourceChanged,
this.boxFitMode = 0,
this.videoZoomScale = 1.0,
this.onTogglePIPMode,
this.onCycleBoxFitMode,
this.onVideoZoomChanged,
this.onZoomIn,
this.onZoomOut,
this.onResetVideoZoom,
this.onCycleAudioTrack,
this.onCycleSubtitleTrack,
this.onAudioTrackChanged,
this.onSubtitleTrackChanged,
this.onSecondarySubtitleTrackChanged,
this.onSeekRequested,
this.onPlayPauseRequested,
this.onSeekCompleted,
this.onBack,
this.onReachedEnd,
this.canControl = true,
required this.canNavigateMediaItems,
this.hasFirstFrame,
this.playNextFocusNode,
required this.chromeController,
this.shaderService,
this.onShaderChanged,
this.thumbnailDataBuilder,
this.isLive = false,
this.liveChannelName,
this.captureBuffer,
this.isAtLiveEdge = true,
this.streamStartEpoch = 0,
this.currentPositionEpoch,
this.onLiveSeek,
this.onLiveSeekBy,
this.onJumpToLive,
this.isAmbientLightingEnabled = false,
this.onToggleAmbientLighting,
});
@override
State<PlexVideoControls> createState() => _PlexVideoControlsState();
}
class _PlexVideoControlsState extends State<PlexVideoControls>
with WindowListener, SettingsEffectMixin, MountedSetStateMixin {
bool get _showControls => widget.chromeController.controlsVisible;
bool get _hasRenderedFirstFrame => widget.hasFirstFrame?.value ?? true;
late bool _lastControlsVisible;
late bool _controlsMounted;
late bool _controlsOpaque;
bool _isLoadingExtras = false;
// Item key the in-flight extras load belongs to, so a load for a swapped
// item can start while a stale one is still in flight (and the stale
// response is discarded).
String? _extrasLoadKey;
late List<MediaChapter> _chapters = widget.initialChapters ?? [];
late bool _chaptersLoaded = widget.initialChapters != null;
bool _isFullscreen = false;
bool _isAlwaysOnTop = false;
late final FocusNode _focusNode;
KeyboardShortcutsService? _keyboardService;
// Live settings — read through the service so a change anywhere in the app
// reflects here without a manual reload. UI rebuilds are wired via
// [bindRebuild] in [initState]; side effects (rotation, sync) via [bindEffect].
SettingsService get _settings => SettingsService.instance;
int get _seekTimeSmall => _settings.read(SettingsService.seekTimeSmall);
int get _rewindOnResume => _settings.read(SettingsService.rewindOnResume);
int get _audioSyncOffset => _settings.read(SettingsService.audioSyncOffset);
int get _subtitleSyncOffset => _settings.read(SettingsService.subtitleSyncOffset);
bool get _isRotationLocked => _settings.read(SettingsService.rotationLocked);
bool _isScreenLocked = false; // Touch lock during playback
bool _showLockIcon = false; // Whether to show the lock overlay icon
Timer? _lockIconTimer;
bool get _clickVideoTogglesPlayback => _settings.read(SettingsService.clickVideoTogglesPlayback);
bool get _showChapterMarkersOnTimeline => _settings.read(SettingsService.showChapterMarkersOnTimeline);
int _trafficLightVisibilityGeneration = 0;
// GlobalKey to access DesktopVideoControls state for focus management
final GlobalKey<DesktopVideoControlsState> _desktopControlsKey = GlobalKey<DesktopVideoControlsState>();
// Double-tap feedback state
bool _showDoubleTapFeedback = false;
double _doubleTapFeedbackOpacity = 0.0;
bool _lastDoubleTapWasForward = true;
Timer? _feedbackTimer;
int _accumulatedSkipSeconds = 0; // Stacking skip: total skip during active feedback
// Desktop double-click detection (more reliable than Flutter's onDoubleTap).
// The mobile skip zones do not use this; they pair off _singleTapTimer.
DateTime? _lastSkipTapTime;
// Direction of the skip-zone tap _singleTapTimer is currently counting down.
bool _lastSkipTapWasForward = true;
Timer? _feedbackHideTimer; // Removes the skip readout after its fade-out completes
// Deferred lone-tap action for the skip zones, and the pairing window itself:
// while it is active the tap that started it can still become a double tap.
Timer? _singleTapTimer;
final TwoFingerTapTracker _twoFingerTapTracker = TwoFingerTapTracker();
final MobileEdgeAdjustmentTracker _edgeAdjustmentTracker = MobileEdgeAdjustmentTracker();
final DeviceAdjustmentService _deviceAdjustmentService = DeviceAdjustmentService.instance;
DateTime? _suppressTouchTapUntil;
final ValueNotifier<_EdgeAdjustmentIndicatorState> _edgeAdjustmentIndicator = ValueNotifier((
visible: false,
side: null,
value: 0.0,
));
double? _edgeAdjustmentStartValue;
bool _edgeAdjustmentWasActive = false;
MobileEdgeAdjustmentSide? _pendingEdgeAdjustmentSide;
double _pendingEdgeAdjustmentDelta = 0.0;
int? _pendingEdgeAdjustmentGeneration;
Future<double?>? _edgeAdjustmentBaselineFuture;
MobileEdgeAdjustmentSide? _edgeAdjustmentBaselineSide;
int _edgeAdjustmentBaselineGeneration = 0;
double? _lastKnownBrightness;
double? _lastKnownMediaVolume;
DateTime? _lastEdgeAdjustmentWriteAt;
double? _lastEdgeAdjustmentWriteValue;
Timer? _edgeAdjustmentIndicatorHideTimer;
Timer? _edgeAdjustmentIndicatorClearTimer;
// Seek throttle
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 = [];
bool _markersLoaded = false;
// Playback state subscription for auto-hide timer
StreamSubscription<bool>? _playingSubscription;
// Completed subscription to show controls when video ends
StreamSubscription<bool>? _completedSubscription;
// Position subscription for marker tracking
StreamSubscription<Duration>? _positionSubscription;
// Auto-skip state
bool get _autoSkipIntro => _settings.read(SettingsService.autoSkipIntro);
bool get _autoSkipCredits => _settings.read(SettingsService.autoSkipCredits);
int get _autoSkipDelay => _settings.read(SettingsService.autoSkipDelay);
Timer? _autoSkipTimer;
double _autoSkipProgress = 0.0;
// Skip button dismiss state
bool _skipButtonDismissed = false;
Timer? _skipButtonDismissTimer;
// Video player navigation (use arrow keys to navigate controls)
bool get _videoPlayerNavigationEnabled => _settings.read(SettingsService.videoPlayerNavigationEnabled);
// Performance overlay
bool get _showPerformanceOverlay => _settings.read(SettingsService.showPerformanceOverlay);
bool get _autoHidePerformanceOverlay => _settings.read(SettingsService.autoHidePerformanceOverlay);
// Long-press 2x speed state
bool _isLongPressing = false;
// Subtitle visibility toggle state
bool _subtitlesVisible = true;
bool _confirmedSubtitlesVisible = true;
int _subtitleVisibilityWriteGeneration = 0;
// Skip marker button focus node (for TV D-pad navigation)
late final FocusNode _skipMarkerFocusNode;
final ValueNotifier<bool> _fallbackHasFirstFrame = ValueNotifier<bool>(true);
double? _rateBeforeLongPress;
bool _showSpeedIndicator = false;
StreamSubscription<double>? _rateSubscription;
double? _lastReportedRate;
// Suppression window used when long-press ends so the rate-restore emission
// doesn't flash a second notice as the rate snaps back.
DateTime? _suppressRateToastUntil;
// PiP support
bool _isPipSupported = false;
final PipService _pipService = PipService();
AppLifecycleListener? _edgeAdjustmentLifecycleListener;
@override
void initState() {
super.initState();
_lastControlsVisible = widget.chromeController.controlsVisible;
_controlsMounted = _lastControlsVisible;
_controlsOpaque = _lastControlsVisible;
_focusNode = FocusNode();
_skipMarkerFocusNode = FocusNode(debugLabel: 'SkipMarkerButton');
_seekThrottle = throttle(
(Duration pos) {
unawaited(_seekToTimelinePosition(pos));
},
const Duration(milliseconds: 200),
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);
bindEffect<bool>(SettingsService.videoPlayerNavigationEnabled, (enabled) {
_configureChromeController();
if (enabled && _showControls) _focusPlayPauseIfKeyboardMode();
}, fireImmediately: false);
// Rebuild on any setting that affects build output (seek labels, skip
// logic, perf overlay visibility, click-toggles, etc.).
bindRebuild([
SettingsService.seekTimeSmall,
SettingsService.rewindOnResume,
SettingsService.audioSyncOffset,
SettingsService.subtitleSyncOffset,
SettingsService.rotationLocked,
SettingsService.autoSkipIntro,
SettingsService.autoSkipCredits,
SettingsService.autoSkipDelay,
SettingsService.videoPlayerNavigationEnabled,
SettingsService.showPerformanceOverlay,
SettingsService.autoHidePerformanceOverlay,
SettingsService.clickVideoTogglesPlayback,
SettingsService.showChapterMarkersOnTimeline,
]);
widget.chromeController.addListener(_onChromeChanged);
_configureChromeController();
widget.chromeController.setPlaying(widget.player.state.playing);
_initKeyboardService();
_listenToPosition();
_listenToPlayingState();
_listenToCompleted();
_checkPipSupport();
_deviceAdjustmentService.onResume = _refreshDeviceAdjustmentValues;
_deviceAdjustmentService.setRestoreSuppressed(_pipService.isPipActive.value);
_pipService.isPipActive.addListener(_onEdgeAdjustmentPipChanged);
_edgeAdjustmentLifecycleListener = AppLifecycleListener(
onResume: _refreshDeviceAdjustmentValues,
onShow: _refreshDeviceAdjustmentValues,
onHide: _cancelEdgeAdjustmentGesture,
onPause: _cancelEdgeAdjustmentGesture,
);
// Add window listener for tracking fullscreen state (for button icon)
if (PlatformDetector.isDesktopOS()) {
if (Platform.isMacOS) {
_isFullscreen = FullscreenStateManager().isFullscreen;
FullscreenStateManager().addListener(_onFullscreenStateChanged);
}
windowManager.addListener(this);
_initAlwaysOnTopState();
}
// Register global key handler for focus-independent shortcuts (desktop only)
HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent);
// Listen for first frame to start auto-hide timer
widget.hasFirstFrame?.addListener(_onFirstFrameReady);
// Defer context-dependent initialization to after first build
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// Subscribe to rate stream *after* first frame so the initial
// setRate(defaultSpeed) emission during player startup is missed.
_lastReportedRate = widget.player.state.rate;
_rateSubscription = widget.player.streams.rate.listen(_onRateChanged);
_loadPlaybackExtras();
_focusPlayPauseIfKeyboardMode();
// A route that opened with no chrome never ran the hide transition that
// normally hands focus down here, and this Focus autofocuses too late to
// win it: the screen node claimed it during the loading phase.
if (!widget.chromeController.controlsVisible) _claimPlayerSurfaceFocus();
if (PlatformDetector.isMobile(context) && !PlatformDetector.isTV()) {
_refreshDeviceAdjustmentValues();
}
});
}
void _setControlsState(VoidCallback fn) => setStateIfMounted(fn);
void _configureChromeController() {
widget.chromeController.configure(hideDelay: _hideDelay, hasFirstFrame: widget.hasFirstFrame?.value ?? true);
}
@override
void didUpdateWidget(PlexVideoControls oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.player != widget.player) {
++_subtitleVisibilityWriteGeneration;
}
if (oldWidget.chromeController != widget.chromeController) {
oldWidget.chromeController.removeListener(_onChromeChanged);
_lastControlsVisible = widget.chromeController.controlsVisible;
_controlsMounted = _lastControlsVisible;
_controlsOpaque = _lastControlsVisible;
widget.chromeController.addListener(_onChromeChanged);
}
// The same controls instance survives in-place episode swaps — re-key
// the per-item chapters/markers/skip state when the item changes.
// (Quality/version switches keep the same item, so no refetch churn.)
if (oldWidget.metadata.globalKey != widget.metadata.globalKey) {
_setControlsState(() {
_chapters = [];
_chaptersLoaded = false;
_markers = [];
_markersLoaded = false;
});
_clearCurrentMarker();
_loadPlaybackExtras();
}
_configureChromeController();
}
@override
void dispose() {
++_subtitleVisibilityWriteGeneration;
HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent);
widget.chromeController.removeListener(_onChromeChanged);
widget.hasFirstFrame?.removeListener(_onFirstFrameReady);
_feedbackTimer?.cancel();
_feedbackHideTimer?.cancel();
_lockIconTimer?.cancel();
_edgeAdjustmentIndicatorHideTimer?.cancel();
_edgeAdjustmentIndicatorClearTimer?.cancel();
_edgeAdjustmentLifecycleListener?.dispose();
_edgeAdjustmentLifecycleListener = null;
_autoSkipTimer?.cancel();
_skipButtonDismissTimer?.cancel();
_singleTapTimer?.cancel();
_seekThrottle.cancel();
_hiddenSeek.dispose();
_edgeAdjustmentTracker.cancel();
_edgeAdjustmentIndicator.dispose();
_pipService.isPipActive.removeListener(_onEdgeAdjustmentPipChanged);
_deviceAdjustmentService.onResume = null;
_deviceAdjustmentService.setRestoreSuppressed(false);
unawaited(_deviceAdjustmentService.restoreBrightness());
// A player exit mid-scrub must not leak the hold into the route teardown.
widget.chromeController.release(PlayerChromeHold.scrub, notify: false, restartAutoHide: false);
_playingSubscription?.cancel();
_completedSubscription?.cancel();
_positionSubscription?.cancel();
_rateSubscription?.cancel();
_focusNode.dispose();
_skipMarkerFocusNode.dispose();
_fallbackHasFirstFrame.dispose();
// Restore original rate if long-press was active when disposed
if (_isLongPressing && _rateBeforeLongPress != null) {
widget.player.setRate(_rateBeforeLongPress!);
}
// Remove window listener and reset always-on-top if it was enabled
if (PlatformDetector.isDesktopOS()) {
windowManager.removeListener(this);
if (_isAlwaysOnTop) {
windowManager.setAlwaysOnTop(false);
}
}
if (Platform.isMacOS) {
FullscreenStateManager().removeListener(_onFullscreenStateChanged);
_trafficLightVisibilityGeneration++;
unawaited(MacOSWindowService.setTrafficLightsVisible(true));
}
super.dispose();
}
void _onFullscreenStateChanged() {
final isFullscreen = FullscreenStateManager().isFullscreen;
if (!mounted || _isFullscreen == isFullscreen) return;
setState(() {
_isFullscreen = isFullscreen;
});
_updateTrafficLightVisibility();
}
void _onEdgeAdjustmentPipChanged() {
final isInPip = _pipService.isPipActive.value;
_deviceAdjustmentService.setRestoreSuppressed(isInPip);
if (isInPip) {
widget.chromeController.hold(PlayerChromeHold.pip);
_cancelEdgeAdjustmentGesture();
} else {
widget.chromeController.release(PlayerChromeHold.pip);
}
}
@override
void onWindowEnterFullScreen() {
if (mounted) {
setState(() {
_isFullscreen = true;
});
}
}
@override
void onWindowLeaveFullScreen() {
if (mounted) {
setState(() {
_isFullscreen = false;
});
}
}
@override
void onWindowMaximize() {
// On macOS, maximize is the same as fullscreen (green button)
if (mounted && Platform.isMacOS) {
setState(() {
_isFullscreen = true;
});
}
}
@override
void onWindowUnmaximize() {
// On macOS, unmaximize means exiting fullscreen
if (mounted && Platform.isMacOS) {
setState(() {
_isFullscreen = false;
});
}
}
/// Re-activating the window drops Flutter's primary focus to the root scope,
/// and the enclosing player screen reclaims it onto its own node. Nothing
/// hands it back down while the chrome stays visible — the hide transition is
/// the only other handoff — so the next arrow key reaches the screen's
/// self-heal and jumps focus into the OSD (#1797). Take the surface back
/// unless a control below already owns it.
@override
void onWindowFocus() {
// Claim now rather than post-frame: this arrives on a platform callback,
// which is not guaranteed to be followed by a frame. The screen's own
// reclaim re-tests `hasFocus` when it runs, so once the surface holds the
// remote the two no longer compete.
if (!mounted || _focusNode.hasFocus) return;
// A route pushed above the player still leaves these controls mounted;
// re-activating the window must not pull the remote off the top route.
if (ModalRoute.of(context)?.isCurrent != true) return;
_claimPlayerSurfaceFocus();
}
@override
// ignore: no-empty-block - required by WindowListener interface
void onWindowResize() {}
@override
Widget build(BuildContext context) {
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
// Hide ALL controls when in PiP mode (except macOS where main window stays visible)
return ValueListenableBuilder<bool>(
valueListenable: _pipService.isPipActive,
builder: (context, isInPip, _) {
if (isInPip && !Platform.isMacOS) return const SizedBox.shrink();
return Focus(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: (node, event) => _handleControlsKeyEvent(event, isMobile),
child: Listener(
behavior: HitTestBehavior.translucent,
// Any pointer input cancels an in-progress auto-skip countdown. This
// root Listener observes every pointer event over the player without
// consuming it, so it's the single cancel point for clicks, touches,
// mouse moves, scroll, and trackpad. Keys are handled the same way in
// _handleGlobalKeyEvent.
onPointerDown: (event) {
_cancelAutoSkipFromUserInteraction();
if (isMobile) _handleTouchPointerDown(event);
},
onPointerHover: (_) => _cancelAutoSkipFromUserInteraction(),
onPointerMove: isMobile ? _handleTouchPointerMove : null,
onPointerUp: isMobile ? _handleTouchPointerUp : null,
onPointerCancel: isMobile ? _handleTouchPointerCancel : null,
onPointerSignal: _handlePointerSignal,
onPointerPanZoomStart: (_) => _cancelAutoSkipFromUserInteraction(),
child: MouseRegion(
onHover: (_) => _showControlsFromPointerActivity(),
child: Stack(
children: [
// Keep-alive for Linux's idle GTK frame clock; inert on every
// other platform (the widget owns the platform decision).
// Windows must NOT tick here: forced repaints during playback
// perturb VRR scanout in fullscreen (#1707).
const Positioned(top: 0, left: 0, child: LinuxKeepAlive()),
// Also handles long-press for 2x speed.
Positioned.fill(
child: Semantics(
button: true,
label: _showControls
? t.videoControls.hidePlaybackControls
: t.videoControls.showPlaybackControls,
onTap: _toggleControlsFromSemantics,
child: GestureDetector(
excludeFromSemantics: true,
onTap: _handleOuterTap,
onLongPressStart: (_) => _handleLongPressStart(),
onLongPressEnd: (_) => _handleLongPressEnd(),
onLongPressCancel: _handleLongPressCancel,
behavior: HitTestBehavior.opaque,
child: const ColoredBox(color: Colors.transparent),
),
),
),
// Mobile double-tap zones for skip forward/backward
if (isMobile)
MobileSkipZones(
onTapInSkipZone: (isForward) => _handleTapInSkipZone(isForward: isForward),
onLongPressStart: (_) => _handleLongPressStart(),
onLongPressEnd: (_) => _handleLongPressEnd(),
onLongPressCancel: _handleLongPressCancel,
),
// Custom controls overlay
// Positioned AFTER double-tap zones so controls receive taps first
Positioned.fill(
child: !_controlsMounted
? const SizedBox.shrink()
: IgnorePointer(
ignoring: !_showControls,
child: FocusScope(
// Prevent focus from entering controls when hidden
canRequestFocus: _showControls,
child: AnimatedOpacity(
opacity: _controlsOpaque ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
onEnd: () {
if (!_showControls) {
widget.chromeController.markControlsHidden();
if (_controlsMounted) {
setState(() => _controlsMounted = false);
}
}
},
child: Builder(
builder: (context) {
return GestureDetector(
onTapUp: (details) => _handleControlsOverlayTap(details, _sizeOf(context)),
onLongPressStart: (_) => _handleLongPressStart(),
onLongPressEnd: (_) => _handleLongPressEnd(),
onLongPressCancel: _handleLongPressCancel,
behavior: HitTestBehavior.deferToChild,
child: ValueListenableBuilder<bool>(
valueListenable: widget.hasFirstFrame ?? _fallbackHasFirstFrame,
builder: (context, hasFrame, child) {
// Solid black while loading, scrim once frames flow.
// Both states share one widget type: hasFrame flips
// on every in-place episode switch / live-TV zap, and
// a runtimeType change here would re-inflate the whole
// controls subtree and drop its state.
return RasterizedGradient(
gradient: hasFrame
? LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.7),
Colors.transparent,
Colors.transparent,
Colors.black.withValues(alpha: 0.7),
],
stops: const [0.0, 0.2, 0.8, 1.0],
)
: const LinearGradient(colors: [Colors.black, Colors.black]),
child: child,
);
},
child: isMobile
? Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) {
if (!widget.chromeController.contentStripVisible) {
_restartHideTimerForCurrentPlaybackState();
}
},
child: Builder(
builder: (context) {
final playbackState = context.watch<PlaybackStateProvider>();
final canShowQueue =
playbackState.isQueueActive && widget.canNavigateMediaItems;
final hasStripContent = _chapters.isNotEmpty || canShowQueue;
return MobileVideoControls(
player: widget.player,
metadata: widget.metadata,
chapters: _chapters,
chaptersLoaded: _chaptersLoaded,
showChapterMarkersOnTimeline: _showChapterMarkersOnTimeline,
seekTimeSmall: _seekTimeSmall,
trackChapterControls: _buildTrackChapterControlsWidget(
hideChaptersAndQueue: hasStripContent,
),
onSeek: _throttledSeek,
onSeekEnd: _finalizeSeek,
onScrubStart: _holdTimelineScrub,
onScrubEnd: _releaseTimelineScrub,
onSeekRequested: widget.onSeekRequested,
onSeekCompleted: widget.onSeekCompleted,
onPlayPause: () => unawaited(_playOrPause()),
onCancelAutoHide: widget.chromeController.cancelAutoHide,
onStartAutoHide: widget.chromeController.startAutoHide,
onBack: widget.onBack,
onNext: widget.onNext,
onPrevious: widget.onPrevious,
canControl: widget.canControl,
hasFirstFrame: widget.hasFirstFrame,
thumbnailDataBuilder: widget.thumbnailDataBuilder,
isLive: widget.isLive,
liveChannelName: widget.liveChannelName,
captureBuffer: widget.captureBuffer,
isAtLiveEdge: widget.isAtLiveEdge,
streamStartEpoch: widget.streamStartEpoch,
onLiveSeek: widget.onLiveSeek,
serverId: widget.metadata.serverId,
showQueueTab: canShowQueue,
onQueueItemSelected: canShowQueue ? _onQueueItemSelected : null,
chromeController: widget.chromeController,
onStripVisibilityChanged: (visible) {
if (visible) {
widget.chromeController.setContentStripVisible(true);
} else {
widget.chromeController.setContentStripVisible(false);
}
},
isInEdgeAdjustmentZone: _isGlobalPositionInEdgeAdjustmentZone,
);
},
),
)
: _buildDesktopControlsListener(),
),
);
},
),
),
),
),
),
// 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(
opacity: _doubleTapFeedbackOpacity,
duration: tokens(context).slow,
child: DoubleTapFeedback(
isForward: _lastDoubleTapWasForward,
seconds: _accumulatedSkipSeconds,
),
),
),
),
// Speed indicator overlay for long-press 2x
if (_showSpeedIndicator) Positioned.fill(child: IgnorePointer(child: _buildSpeedIndicator())),
// 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(
listenable: widget.toastController,
builder: (context, _) {
final toast = widget.toastController.current;
if (toast == null) return const SizedBox.shrink();
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,
),
),
};
},
),
),
),
if (isMobile)
Positioned.fill(
child: IgnorePointer(
child: ValueListenableBuilder<_EdgeAdjustmentIndicatorState>(
valueListenable: _edgeAdjustmentIndicator,
builder: (context, indicator, _) {
final side = indicator.side;
if (side == null) {
return const SizedBox.shrink();
}
return AnimatedOpacity(
opacity: indicator.visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 160),
child: RepaintBoundary(
child: MobileEdgeAdjustmentIndicator(
key: ValueKey(side),
side: side,
value: indicator.value,
),
),
);
},
),
),
),
// Skip intro/credits button (auto-dismisses after 7s, then only shows with controls)
if (_isSkipMarkerButtonVisible)
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
right: 24,
bottom: () {
if (!_showControls) return 24.0;
if (widget.chromeController.contentStripVisible) {
return 180.0;
}
return isMobile ? 80.0 : 115.0;
}(),
child: AnimatedOpacity(
opacity: 1.0,
duration: tokens(context).slow,
child: _buildSkipMarkerButton(),
),
),
if (_showPerformanceOverlay)
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
top: _showControls ? (isMobile ? 100.0 : 60.0) : 16.0,
left: 16,
right: 16,
// Clear the bottom controls (same clearance as the skip button) so the
// overlay can shrink to fit instead of being clipped by the screen edge.
bottom: !_showControls ? 16.0 : (isMobile ? 80.0 : 115.0),
// Align loosens the tight constraints from the fully-anchored Positioned;
// without it the card would stretch to fill the whole region.
child: Align(
alignment: Alignment.topLeft,
child: AnimatedOpacity(
opacity: (!_autoHidePerformanceOverlay || _showControls) ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: IgnorePointer(child: PlayerPerformanceOverlay(player: widget.player)),
),
),
),
if (_isScreenLocked)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
setState(() => _showLockIcon = true);
_startLockIconHideTimer();
},
onLongPress: _unlockScreen,
child: AnimatedOpacity(
opacity: _showLockIcon ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: const BorderRadius.all(Radius.circular(28)),
),
child: Row(
mainAxisSize: .min,
children: [
const AppIcon(Symbols.lock_rounded, fill: 1, color: Colors.white, size: 20),
const SizedBox(width: 8),
Text(
t.videoControls.longPressToUnlock,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w500),
),
],
),
),
),
),
),
),
],
),
),
),
);
},
);
}
}