397 lines
13 KiB
Dart
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();
|
|
}
|
|
}
|
|
}
|