Files
plezy/lib/widgets/video_controls/parts/visibility.dart
T
edde746 bbed260169 fix(player): start the TV player with its chrome down
A television raised the whole OSD and timebar on every playback start. The
chrome controller is born visible, and its auto-hide clock cannot arm until the
first frame lands, so the controls did not merely appear early: they appeared
exactly when the picture did, and then sat over the opening five seconds of
every movie and episode. The timeline is gated behind the first frame, so the
bar materialised on top of the video rather than over the loading spinner,
which is what makes it read as a pop-up rather than as chrome that was already
there.

The route now opens with no chrome on TV. Nothing is lost: the loading spinner
and buffering overlay are their own overlays, the screen focus node owns back,
and the first D-pad press raises the controls the way it already does after
every auto-hide. Pointer and touch platforms keep the chrome, where the
viewer's hand is on the surface and the title and back affordance belong over
the spinner.

Initial presentation now follows initial visibility. They were separate:
seeding only visibility would leave the route claiming its chrome was still
presented, so PlayerNavigationCoordinator would read back as "hide the chrome",
hide() would no-op against chrome that was never up, and the press would be
swallowed instead of leaving the player.

Controls that mount with the chrome already down now claim focus themselves.
Focus normally reaches them through the hide transition, and their own
autofocus cannot win it back because the screen node took it during the loading
phase. Left alone, the screen node kept primary focus and its self-heal raised
the entire OSD on the first D-pad press, which put the chrome straight back
over the picture and bypassed the transient seek and transport indicators.

Both player spinners now carry a label. They were bare progress indicators, so
a screen reader announced nothing at all while the picture was coming up, and
the TV Maestro flows had no way left to tell a loading player from a playing
one once the Pause button stopped appearing on its own.

The two TV flows are repaired to match. They waited on that button, and now
wait for the labelled spinner to clear, which cannot happen before the media is
opened. 05 additionally reaches Search by D-pad rather than a percentage
coordinate, because a tap flips InputModeTracker to pointer mode and collapses
the rail it is aiming at, and it gates on the play-next prompt's own Cancel
action: "Next Episode" is also the credits skip button, so the old assertion
could pass without the prompt ever opening.

close #1765
2026-08-02 11:45:27 +02:00

313 lines
10 KiB
Dart

part of '../video_controls.dart';
extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
/// Called when hasFirstFrame changes - start auto-hide timer when first frame is ready
void _onFirstFrameReady() {
final hasFrame = widget.hasFirstFrame?.value ?? true;
widget.chromeController.setHasFirstFrame(hasFrame);
if (hasFrame) {
// Retry with network-first if initial cache-first returned empty
if (_chapters.isEmpty && _markers.isEmpty) {
_loadPlaybackExtras(forceRefresh: true);
}
_syncCurrentMarkerForCurrentPosition();
} else {
_clearCurrentMarker();
}
}
/// Focus play/pause button if we're in keyboard navigation mode (desktop/TV only)
void _focusPlayPauseIfKeyboardMode() {
if (!mounted) return;
if (!_videoPlayerNavigationEnabled) return;
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
if (!isMobile && InputModeTracker.isKeyboardMode(context)) {
_desktopControlsKey.currentState?.requestPlayPauseFocus();
}
}
/// Listen to playback state changes to manage auto-hide timer
void _listenToPlayingState() {
_playingSubscription = widget.player.streams.playing.listen((isPlaying) {
widget.chromeController.setPlaying(isPlaying);
});
}
/// Listen to completed stream to show controls when video ends
void _listenToCompleted() {
_completedSubscription = widget.player.streams.completed.listen((completed) {
if (completed && mounted) {
if (_isLongPressing) {
_handleLongPressCancel();
}
widget.chromeController.show(restartAutoHide: false);
widget.chromeController.cancelAutoHide();
}
});
}
/// Controls hide delay: 5s on mobile/TV/keyboard-nav, 3s on desktop with mouse.
/// Maestro builds extend the delay because accessibility-tree queries can take
/// longer than the production timeout on physical devices.
Duration get _hideDelay {
if (const bool.fromEnvironment('PLEZY_MAESTRO_E2E')) {
return const Duration(seconds: 30);
}
final isMobile = (Platform.isIOS || Platform.isAndroid) && !PlatformDetector.isTV();
if (isMobile || PlatformDetector.isTV() || _videoPlayerNavigationEnabled) {
return const Duration(seconds: 5);
}
return const Duration(seconds: 3);
}
/// Shared hide logic: hides controls, notifies parent, updates traffic lights, restores focus.
void _hideControls() {
if (!mounted) return;
widget.chromeController.hide();
}
void _startHideTimer() => widget.chromeController.startAutoHide();
/// Restart the hide timer on user interaction for the current playback state.
void _restartHideTimerForCurrentPlaybackState() => widget.chromeController.restartAutoHideForCurrentPlaybackState();
void _handlePointerSignal(PointerSignalEvent event) {
if (event is! PointerScrollEvent) return;
_cancelAutoSkipFromUserInteraction();
widget.volumeController.adjust(-event.scrollDelta.dy / 20);
_showControlsFromPointerActivity();
}
/// Show controls in response to pointer activity (mouse/trackpad movement).
void _showControlsFromPointerActivity() {
widget.chromeController.recordPointerActivity();
}
void _toggleControls() {
widget.chromeController.toggle();
}
void _toggleControlsFromSemantics() {
if (_showControls) {
widget.chromeController.hide();
return;
}
widget.chromeController.show(restartAutoHide: false);
widget.chromeController.cancelAutoHide();
}
/// Apply preferred orientations for the given lock state. Wired to
/// [SettingsService.rotationLocked] via [bindEffect] so any change — from
/// this toggle or from the settings screen — fires the same SystemChrome call.
void _applyRotationLock(bool locked) {
if (PlatformDetector.isAutomotive()) return;
unawaited(
SystemChrome.setPreferredOrientations(
locked ? const [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight] : DeviceOrientation.values,
),
);
}
void _toggleRotationLock() {
unawaited(_settings.write(SettingsService.rotationLocked, !_isRotationLocked));
}
void _toggleScreenLock() {
final locking = !_isScreenLocked;
_setControlsState(() {
_isScreenLocked = locking;
if (locking) {
_showLockIcon = true;
}
});
if (locking) {
_cancelEdgeAdjustmentGesture();
widget.chromeController.hide(ignoreHolds: true);
_startLockIconHideTimer();
}
}
void _startLockIconHideTimer() {
_lockIconTimer?.cancel();
_lockIconTimer = Timer(const Duration(seconds: 3), () {
if (mounted) _setControlsState(() => _showLockIcon = false);
});
}
void _unlockScreen() {
_setControlsState(() {
_isScreenLocked = false;
_showLockIcon = false;
});
_lockIconTimer?.cancel();
widget.chromeController.show();
}
void _updateTrafficLightVisibility() async {
final generation = ++_trafficLightVisibilityGeneration;
// When maximized or fullscreen, always keep traffic lights visible so the
// user can reach them without the controls-hide-on-mouse-leave race.
// In normal windowed mode, toggle with controls as before.
final isMaximizedOrFullscreen = await windowManager.isMaximized() || await MacOSWindowService.isFullscreen();
if (!mounted || generation != _trafficLightVisibilityGeneration) return;
final visible = isMaximizedOrFullscreen || _showControls;
await MacOSWindowService.setTrafficLightsVisible(visible);
}
Future<void> _checkPipSupport() async {
if (!PlatformDetector.supportsPictureInPicture()) {
return;
}
try {
final supported = await PipService.isSupported();
if (mounted) {
_setControlsState(() {
_isPipSupported = supported;
});
}
} catch (e) {
return;
}
}
Future<void> _toggleFullscreen() async {
if (!PlatformDetector.isDesktopOS()) return;
await FullscreenStateManager().toggleFullscreen();
}
/// Initialize always-on-top state from window manager (desktop only)
Future<void> _initAlwaysOnTopState() async {
final isOnTop = await windowManager.isAlwaysOnTop();
if (mounted && isOnTop != _isAlwaysOnTop) {
_setControlsState(() {
_isAlwaysOnTop = isOnTop;
});
}
}
/// Toggle always-on-top window mode (desktop only)
Future<void> _toggleAlwaysOnTop() async {
if (!PlatformDetector.isDesktopOS()) return;
final newValue = !_isAlwaysOnTop;
await windowManager.setAlwaysOnTop(newValue);
if (!mounted) return;
_setControlsState(() {
_isAlwaysOnTop = newValue;
});
}
/// Show controls and optionally focus play/pause on keyboard input (desktop only)
void _showControlsWithFocus({bool requestFocus = true}) {
widget.chromeController.show();
if (requestFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_desktopControlsKey.currentState?.requestPlayPauseFocus();
});
} else {
// When not requesting focus on play/pause, ensure main focus node keeps focus
// This prevents focus from being lost when controls become visible
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_focusNode.hasFocus) {
_focusNode.requestFocus();
}
});
}
}
/// 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() {
if (widget.playNextFocusNode != null) {
widget.playNextFocusNode!.requestFocus();
return;
}
if (_currentMarker != null) {
_skipMarkerFocusNode.requestFocus();
return;
}
if (_showControls) {
_hideControls();
}
}
void _onChromeChanged() {
if (!mounted) return;
final controlsVisible = widget.chromeController.controlsVisible;
final visibilityChanged = controlsVisible != _lastControlsVisible;
final focusTarget = widget.chromeController.takeFocusTarget();
_lastControlsVisible = controlsVisible;
if (visibilityChanged && !controlsVisible) {
_desktopControlsKey.currentState?.hideContentStrip();
_cancelSkipButtonDismissTimer();
_setControlsState(() {
_controlsOpaque = false;
if (_currentMarker != null) _skipButtonDismissed = true;
});
_claimHiddenChromeFocus();
} 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;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_showControls || !_controlsMounted) return;
_setControlsState(() => _controlsOpaque = true);
});
} else if (controlsVisible && !_controlsMounted) {
_setControlsState(() {
_controlsMounted = true;
_controlsOpaque = true;
});
}
if (visibilityChanged && Platform.isMacOS) {
_updateTrafficLightVisibility();
}
if (focusTarget != null) {
_requestFocusTarget(focusTarget);
}
}
/// Park focus on the player surface so the hidden-chrome key layer owns the
/// remote. Without this the screen node keeps primary focus and its
/// self-heal raises the whole chrome on the first actionable key, which is
/// what the transient seek and transport indicators exist to avoid.
void _claimHiddenChromeFocus() {
final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false;
if (sheetOpen) return;
_focusNode.requestFocus();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_focusNode.hasPrimaryFocus) {
_focusNode.requestFocus();
}
});
}
void _requestFocusTarget(PlayerChromeFocusTarget target) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !widget.chromeController.controlsVisible) return;
// Never steal focus from an open sheet (same rule as
// _claimHiddenChromeFocus).
if (OverlaySheetController.maybeOf(context)?.isOpen ?? false) return;
switch (target) {
case PlayerChromeFocusTarget.playPause:
_desktopControlsKey.currentState?.requestPlayPauseFocus();
case PlayerChromeFocusTarget.timeline:
_desktopControlsKey.currentState?.requestTimelineFocus();
}
});
}
}