Files
plezy/lib/widgets/video_controls/parts/visibility.dart
T
2026-05-11 02:47:15 +02:00

397 lines
13 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() {
if (widget.hasFirstFrame?.value == true) {
_startHideTimer();
// Retry with network-first if initial cache-first returned empty
if (_chapters.isEmpty && _markers.isEmpty) {
_loadPlaybackExtras(forceRefresh: true);
}
}
}
/// Called when controlsVisible is set externally (e.g. screen-level focus recovery
/// after controls auto-hide ejects focus on Android TV).
void _onControlsVisibleExternal() {
if (widget.controlsVisible?.value == true && !_showControls && mounted) {
_showControlsWithFocus();
}
}
/// 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) {
if (isPlaying && _showControls) {
_startHideTimer();
} else if (!isPlaying && _showControls) {
_startPausedHideTimer();
}
});
}
/// 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();
}
_setControlsState(() {
_showControls = true;
});
// Notify parent of visibility change (for popup positioning)
widget.controlsVisible?.value = true;
_hideTimer?.cancel();
}
});
}
/// Controls hide delay: 5s on mobile/TV/keyboard-nav, 3s on desktop with mouse.
Duration get _hideDelay {
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 || !_showControls || _forceShowControls) return;
_setControlsState(() {
_showControls = false;
_isContentStripVisible = false;
// Dismiss skip button with controls — after this it only re-appears with controls
if (_currentMarker != null) {
_skipButtonDismissed = true;
}
});
_desktopControlsKey.currentState?.hideContentStrip();
_cancelSkipButtonDismissTimer();
widget.controlsVisible?.value = false;
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
// Reclaim focus so the global key handler stays active for TV dpad,
// but skip if an overlay sheet owns focus — stealing it would break
// sheet navigation (e.g. the compact sync bar).
final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false;
if (!sheetOpen) {
// Always request primary focus on _focusNode — not just when hasFocus is
// false. hasFocus is true when a descendant (e.g. play/pause) has focus,
// but we need _focusNode itself to hold primary focus so its onKeyEvent
// fires for the next d-pad press (otherwise focus escapes to the screen-
// level self-heal handler which shows controls with play/pause focus).
_focusNode.requestFocus();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_focusNode.hasPrimaryFocus) {
_focusNode.requestFocus();
}
});
}
}
void _startHideTimer() {
_hideTimer?.cancel();
// Don't auto-hide while loading first frame (user needs to see spinner and back button)
final hasFrame = widget.hasFirstFrame?.value ?? true;
if (!hasFrame) return;
if (_forceShowControls) return;
// Only auto-hide while playing; keep controls visible while paused.
if (widget.player.state.playing) {
_hideTimer = Timer(_hideDelay, () {
// Also check hasFirstFrame in callback (in case it changed)
final stillLoading = !(widget.hasFirstFrame?.value ?? true);
if (mounted && widget.player.state.playing && !stillLoading) {
_hideControls();
}
});
}
}
/// Auto-hide controls after pause (does not check playing state in callback).
void _startPausedHideTimer() {
_hideTimer?.cancel();
if (_forceShowControls) return;
_hideTimer = Timer(_hideDelay, () {
_hideControls();
});
}
/// Restart the hide timer on user interaction (if video is playing)
void _restartHideTimerIfPlaying() {
if (widget.player.state.playing) {
_startHideTimer();
}
}
/// Hide controls immediately when the mouse leaves the player area (desktop only).
void _hideControlsFromPointerExit() {
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
if (isMobile) return;
_hideTimer?.cancel();
_hideControls();
}
void _handlePointerSignal(PointerSignalEvent event) {
if (event is PointerScrollEvent && _keyboardService != null) {
final delta = event.scrollDelta.dy;
final volume = widget.player.state.volume;
final maxVol = _keyboardService!.maxVolume.toDouble();
final newVolume = (volume - delta / 20).clamp(0.0, maxVol);
widget.player.setVolume(newVolume);
unawaited(SettingsService.getInstance().then((s) => s.write(SettingsService.volume, newVolume)));
_showControlsFromPointerActivity();
}
}
/// Show controls in response to pointer activity (mouse/trackpad movement).
void _showControlsFromPointerActivity() {
final nowMs = _pointerActivityStopwatch.elapsedMilliseconds;
final shouldThrottle = _showControls && nowMs - _lastPointerActivityMs < 120;
if (shouldThrottle) return;
_lastPointerActivityMs = nowMs;
if (!_showControls) {
_setControlsState(() {
_showControls = true;
});
// Notify parent of visibility change (for popup positioning)
widget.controlsVisible?.value = true;
// On macOS, keep window controls in sync with the overlay
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
}
// Keep the overlay visible while the user is moving the pointer
_restartHideTimerIfPlaying();
// Cancel auto-skip when user moves pointer over the player
_cancelAutoSkipTimer();
}
void _toggleControls() {
if (_showControls) {
_hideControls();
} else {
_setControlsState(() {
_showControls = true;
});
widget.controlsVisible?.value = true;
_startHideTimer();
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
}
// Cancel auto-skip on any tap
_cancelAutoSkipTimer();
}
/// 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) {
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) {
_hideControls();
_startLockIconHideTimer();
}
}
void _startLockIconHideTimer() {
_lockIconTimer?.cancel();
_lockIconTimer = Timer(const Duration(seconds: 3), () {
if (mounted) _setControlsState(() => _showLockIcon = false);
});
}
void _unlockScreen() {
_setControlsState(() {
_isScreenLocked = false;
_showLockIcon = false;
_showControls = true;
});
_lockIconTimer?.cancel();
widget.controlsVisible?.value = true;
_startHideTimer();
}
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 || _forceShowControls ? true : _showControls;
await MacOSWindowService.setTrafficLightsVisible(visible);
}
Future<void> _checkPipSupport() async {
if (!Platform.isAndroid && !Platform.isIOS && !Platform.isMacOS) {
return;
}
try {
final supported = await PipService.isSupported();
if (mounted) {
_setControlsState(() {
_isPipSupported = supported;
});
}
} catch (e) {
return;
}
}
/// macOS PiP changed — force controls visible while PiP is active
void _onMacPipChanged() {
if (!mounted) return;
final inPip = _pipService.isPipActive.value;
_setControlsState(() => _forceShowControls = inPip);
if (inPip) {
_hideTimer?.cancel();
widget.controlsVisible?.value = true;
} else {
_startHideTimer();
}
}
Future<void> _toggleFullscreen() async {
if (!PlatformDetector.isMobile(context)) {
await FullscreenStateManager().toggleFullscreen();
}
}
/// Exit fullscreen if the window is actually fullscreen (async check).
/// Used by ESC handler on Windows/Linux to avoid relying on _isFullscreen flag.
Future<void> _exitFullscreenIfNeeded() async {
if (await windowManager.isFullScreen()) {
await FullscreenStateManager().exitFullscreen();
}
}
/// 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.isMobile(context)) {
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}) {
if (!_showControls) {
_setControlsState(() {
_showControls = true;
});
// Notify parent of visibility change (for popup positioning)
widget.controlsVisible?.value = true;
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
}
_startHideTimer();
if (requestFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_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();
}
});
}
}
/// Show controls and focus timeline on LEFT/RIGHT input (TV/desktop)
void _showControlsWithTimelineFocus() {
if (!_showControls) {
_setControlsState(() {
_showControls = true;
});
// Notify parent of visibility change (for popup positioning)
widget.controlsVisible?.value = true;
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
}
_startHideTimer();
WidgetsBinding.instance.addPostFrameCallback((_) {
_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() {
if (widget.playNextFocusNode != null) {
widget.playNextFocusNode!.requestFocus();
return;
}
if (_currentMarker != null) {
_skipMarkerFocusNode.requestFocus();
return;
}
if (_showControls) {
_hideControls();
}
}
}