From c53018b53b443eb8048e38e17bc37fd0e38a85bc Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 2 Jan 2026 01:01:24 +0100 Subject: [PATCH] fix: hide controls in PiP mode --- lib/main.dart | 5 +- lib/screens/video_player_screen.dart | 281 ++++---- lib/services/pip_service.dart | 37 +- lib/services/video_pip_manager.dart | 19 +- .../video_controls/video_controls.dart | 675 +++++++++--------- 5 files changed, 522 insertions(+), 495 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index c5c691a6..79ea1e19 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,6 +29,7 @@ import 'services/data_aggregation_service.dart'; import 'services/in_app_review_service.dart'; import 'services/server_registry.dart'; import 'services/download_manager_service.dart'; +import 'services/pip_service.dart'; import 'services/download_storage_service.dart'; import 'services/plex_api_cache.dart'; import 'database/app_database.dart'; @@ -59,9 +60,11 @@ void main() async { futures.add(windowManager.ensureInitialized()); } - // Initialize TV detection for Android + // Initialize TV detection and PiP service for Android if (Platform.isAndroid) { futures.add(TvDetectionService.getInstance().then((_) {})); + // Initialize PiP service to listen for PiP state changes + PipService(); } // Configure macOS window with custom titlebar (depends on window manager) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 3a21d0e0..4ab8a374 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -30,6 +30,7 @@ import '../services/settings_service.dart'; import '../services/track_selection_service.dart'; import '../services/video_filter_manager.dart'; import '../services/video_pip_manager.dart'; +import '../services/pip_service.dart'; import '../providers/user_profile_provider.dart'; import '../utils/app_logger.dart'; import '../utils/orientation_helper.dart'; @@ -727,9 +728,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _videoFilterManager!.updateVideoFilter(); // PIP Manager - _videoPIPManager = VideoPIPManager( - player: player! - ); + _videoPIPManager = VideoPIPManager(player: player!); } // Add external subtitles to the player @@ -1558,142 +1557,156 @@ class VideoPlayerScreenState extends State with WidgetsBindin }, ), ), - // Netflix-style auto-play overlay - if (_showPlayNextDialog && _nextEpisode != null) - Positioned( - right: 24, - bottom: 100, - child: Container( - width: 320, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Consumer( - builder: (context, playbackState, child) { - final isShuffleActive = playbackState.isShuffleActive; - return Row( - children: [ - Text( - 'Next Episode', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - if (isShuffleActive) ...[ - const SizedBox(width: 4), - AppIcon( - Symbols.shuffle_rounded, - fill: 1, - size: 12, - color: Colors.white.withValues(alpha: 0.7), - ), - ], - ], - ); - }, - ), - const SizedBox(height: 4), - if (_nextEpisode!.parentIndex != null && _nextEpisode!.index != null) - Text( - 'S${_nextEpisode!.parentIndex} E${_nextEpisode!.index} · ${_nextEpisode!.title}', - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ) - else - Text( - _nextEpisode!.title, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: _cancelAutoPlay, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Text(t.dialog.cancel), - ), - ), - const SizedBox(width: 8), - Expanded( - child: FilledButton( - onPressed: _playNext, - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, + // Netflix-style auto-play overlay (hidden in PiP mode) + ValueListenableBuilder( + valueListenable: PipService().isPipActive, + builder: (context, isInPip, child) { + if (isInPip || !_showPlayNextDialog || _nextEpisode == null) { + return const SizedBox.shrink(); + } + return Positioned( + right: 24, + bottom: 100, + child: Container( + width: 320, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('$_autoPlayCountdown'), - const SizedBox(width: 4), - const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18), + Consumer( + builder: (context, playbackState, child) { + final isShuffleActive = playbackState.isShuffleActive; + return Row( + children: [ + Text( + 'Next Episode', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + if (isShuffleActive) ...[ + const SizedBox(width: 4), + AppIcon( + Symbols.shuffle_rounded, + fill: 1, + size: 12, + color: Colors.white.withValues(alpha: 0.7), + ), + ], + ], + ); + }, + ), + const SizedBox(height: 4), + if (_nextEpisode!.parentIndex != null && _nextEpisode!.index != null) + Text( + 'S${_nextEpisode!.parentIndex} E${_nextEpisode!.index} · ${_nextEpisode!.title}', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ) + else + Text( + _nextEpisode!.title, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), ], ), ), - ), - ], - ), - ], - ), - ), - ), - // Buffering indicator (also shows during initial load, but not when exiting) - ValueListenableBuilder( - valueListenable: _isBuffering, - builder: (context, isBuffering, child) { - return ValueListenableBuilder( - valueListenable: _hasFirstFrame, - builder: (context, hasFrame, child) { - // Don't show spinner when exiting (just black overlay) - // Show spinner when (buffering OR loading) AND NOT exiting - if ((!isBuffering && hasFrame) || _isExiting.value) return const SizedBox.shrink(); - return Positioned.fill( - child: Center( - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.5), - shape: BoxShape.circle, - ), - child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 3), + ], ), - ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _cancelAutoPlay, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Text(t.dialog.cancel), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + onPressed: _playNext, + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('$_autoPlayCountdown'), + const SizedBox(width: 4), + const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ), + // Buffering indicator (also shows during initial load, but not when exiting) + // Hidden in PiP mode + ValueListenableBuilder( + valueListenable: PipService().isPipActive, + builder: (context, isInPip, child) { + if (isInPip) return const SizedBox.shrink(); + return ValueListenableBuilder( + valueListenable: _isBuffering, + builder: (context, isBuffering, child) { + return ValueListenableBuilder( + valueListenable: _hasFirstFrame, + builder: (context, hasFrame, child) { + // Don't show spinner when exiting (just black overlay) + // Show spinner when (buffering OR loading) AND NOT exiting + if ((!isBuffering && hasFrame) || _isExiting.value) return const SizedBox.shrink(); + return Positioned.fill( + child: Center( + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 3), + ), + ), + ); + }, ); }, ); diff --git a/lib/services/pip_service.dart b/lib/services/pip_service.dart index 88b16328..bb02ed35 100644 --- a/lib/services/pip_service.dart +++ b/lib/services/pip_service.dart @@ -1,20 +1,35 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; class PipService { - static const MethodChannel _channel = - MethodChannel('app.plezy/pip'); + static const MethodChannel _channel = MethodChannel('app.plezy/pip'); + + // Singleton instance + static final PipService _instance = PipService._internal(); + factory PipService() => _instance; + + PipService._internal() { + // Listen for callbacks from native Android + _channel.setMethodCallHandler(_handleMethodCall); + } + + /// ValueNotifier for PiP state - widgets can listen to this + final ValueNotifier isPipActive = ValueNotifier(false); + + Future _handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'onPipChanged': + final isInPip = call.arguments as bool; + isPipActive.value = isInPip; + break; + } + } static Future isSupported() async { return await _channel.invokeMethod('isSupported') ?? false; } - static Future enter({ - int? width, - int? height, - }) async { - await _channel.invokeMethod('enter', { - 'width': width, - 'height': height, - }); + static Future enter({int? width, int? height}) async { + await _channel.invokeMethod('enter', {'width': width, 'height': height}); } -} \ No newline at end of file +} diff --git a/lib/services/video_pip_manager.dart b/lib/services/video_pip_manager.dart index 6bcb7a27..3746ded7 100644 --- a/lib/services/video_pip_manager.dart +++ b/lib/services/video_pip_manager.dart @@ -1,28 +1,25 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../mpv/mpv.dart'; import '../services/pip_service.dart'; /// Manages video Picture-in-Picture mode - class VideoPIPManager { final Player player; - bool isPIPactive = false; - VideoPIPManager({required this.player}); - + Size? _playerSize; Size? get playerSize => _playerSize; + /// Access PiP state from the service + ValueNotifier get isPipActive => PipService().isPipActive; + /// Toggle native PiP Future togglePIP() async { final supported = await PipService.isSupported(); if (!supported) return; - - await PipService.enter( - width: _playerSize?.width.toInt(), - height: _playerSize?.height.toInt(), - ); - + + await PipService.enter(width: _playerSize?.width.toInt(), height: _playerSize?.height.toInt()); } -} \ No newline at end of file +} diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 406bcdd7..f0423a13 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -193,6 +193,7 @@ class _PlexVideoControlsState extends State with WindowListen // PiP support bool _isPipSupported = false; + final PipService _pipService = PipService(); @override void initState() { @@ -845,8 +846,7 @@ class _PlexVideoControlsState extends State with WindowListen // Debounce: ignore taps within 200ms of last skip action // This prevents double-taps from counting as two separate skips - if (_lastSkipActionTime != null && - now.difference(_lastSkipActionTime!).inMilliseconds < 200) { + if (_lastSkipActionTime != null && now.difference(_lastSkipActionTime!).inMilliseconds < 200) { return; } @@ -1068,20 +1068,11 @@ class _PlexVideoControlsState extends State with WindowListen child: Row( mainAxisSize: MainAxisSize.min, children: [ - AppIcon( - Symbols.fast_forward_rounded, - fill: 1, - color: Colors.white, - size: 16, - ), + AppIcon(Symbols.fast_forward_rounded, fill: 1, color: Colors.white, size: 16), const SizedBox(width: 4), const Text( '2x', - style: TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.bold, - ), + style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), ), ], ), @@ -1249,347 +1240,355 @@ class _PlexVideoControlsState extends State with WindowListen // Use desktop controls for desktop platforms AND Android TV final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV(); - return Focus( - focusNode: _focusNode, - autofocus: true, - onKeyEvent: (node, event) { - // Only handle KeyDown and KeyRepeat events - if (!event.isActionable) { - return KeyEventResult.ignored; - } - - // Reset hide timer on any keyboard/controller input when controls are visible - if (_showControls) { - _restartHideTimerIfPlaying(); - } - - final key = event.logicalKey; - final isPlayPauseKey = _isPlayPauseKey(event); - - // Always consume play/pause keys to prevent propagation to background routes - // On TV/mobile, handle play/pause here; on desktop, the global handler does it - if (isPlayPauseKey) { - if (_videoPlayerNavigationEnabled || isMobile) { - if (_isPlayPauseActivation(event)) { - widget.player.playOrPause(); - _showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled); + // Hide ALL controls when in PiP mode (Android only) + return ValueListenableBuilder( + valueListenable: _pipService.isPipActive, + builder: (context, isInPip, _) { + if (isInPip) return const SizedBox.shrink(); + return Focus( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: (node, event) { + // Only handle KeyDown and KeyRepeat events + if (!event.isActionable) { + return KeyEventResult.ignored; } - } - return KeyEventResult.handled; - } - // Handle Back/Escape: show controls if hidden, navigate back if visible - if (_isBackKey(key)) { - if (!_showControls) { - _showControlsWithFocus(); - return KeyEventResult.handled; - } - // Controls visible - navigate back - Navigator.of(context).pop(true); - return KeyEventResult.handled; - } + // Reset hide timer on any keyboard/controller input when controls are visible + if (_showControls) { + _restartHideTimerIfPlaying(); + } - // Handle Select/Enter when controls are hidden: pause and show controls - // Only intercept if this Focus node itself has primary focus (not a descendant) - if (_isSelectKey(key) && !_showControls && _focusNode.hasPrimaryFocus) { - widget.player.playOrPause(); - _showControlsWithFocus(); - return KeyEventResult.handled; - } + final key = event.logicalKey; + final isPlayPauseKey = _isPlayPauseKey(event); - // On desktop, show controls and focus play/pause on directional input - // Only handle navigation if video player navigation is enabled - if (!isMobile && _isDirectionalKey(key) && _videoPlayerNavigationEnabled) { - // If controls are hidden, show them and focus play/pause - if (!_showControls) { - _showControlsWithFocus(); - return KeyEventResult.handled; - } - // If controls are shown, let the event propagate to the focused control - // The DesktopVideoControls will handle navigation - return KeyEventResult.ignored; - } + // Always consume play/pause keys to prevent propagation to background routes + // On TV/mobile, handle play/pause here; on desktop, the global handler does it + if (isPlayPauseKey) { + if (_videoPlayerNavigationEnabled || isMobile) { + if (_isPlayPauseActivation(event)) { + widget.player.playOrPause(); + _showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled); + } + } + return KeyEventResult.handled; + } - // Pass other events to the keyboard shortcuts service - if (_keyboardService == null) return KeyEventResult.ignored; + // Handle Back/Escape: show controls if hidden, navigate back if visible + if (_isBackKey(key)) { + if (!_showControls) { + _showControlsWithFocus(); + return KeyEventResult.handled; + } + // Controls visible - navigate back + Navigator.of(context).pop(true); + return KeyEventResult.handled; + } - final result = _keyboardService!.handleVideoPlayerKeyEvent( - event, - widget.player, - _toggleFullscreen, - _toggleSubtitles, - _nextAudioTrack, - _nextSubtitleTrack, - _nextChapter, - _previousChapter, - onBack: widget.onBack ?? () => Navigator.of(context).pop(true), - ); - return result; - }, - child: Listener( - behavior: HitTestBehavior.translucent, - onPointerHover: (_) => _showControlsFromPointerActivity(), - child: MouseRegion( - cursor: _showControls ? SystemMouseCursors.basic : SystemMouseCursors.none, - onHover: (_) => _showControlsFromPointerActivity(), - child: Stack( - children: [ - // Invisible tap detector that always covers the full area - // Also handles long-press for 2x speed - Positioned.fill( - child: GestureDetector( - onTap: _toggleControls, - onLongPressStart: (_) => _handleLongPressStart(), - onLongPressEnd: (_) => _handleLongPressEnd(), - onLongPressCancel: _handleLongPressCancel, - behavior: HitTestBehavior.opaque, - child: Container(color: Colors.transparent), - ), - ), - // Middle area double-tap detector for fullscreen (desktop only) - // Only covers the clear video area (20% to 80% vertically) - if (!isMobile) - Positioned( - top: 0, - left: 0, - right: 0, - bottom: 0, - child: LayoutBuilder( - builder: (context, constraints) { - final height = constraints.maxHeight; - final topExclude = height * 0.20; // Top 20% - final bottomExclude = height * 0.20; // Bottom 20% + // Handle Select/Enter when controls are hidden: pause and show controls + // Only intercept if this Focus node itself has primary focus (not a descendant) + if (_isSelectKey(key) && !_showControls && _focusNode.hasPrimaryFocus) { + widget.player.playOrPause(); + _showControlsWithFocus(); + return KeyEventResult.handled; + } - return Stack( - children: [ - Positioned( - top: topExclude, - left: 0, - right: 0, - bottom: bottomExclude, - child: GestureDetector( - onTap: _toggleControls, - onDoubleTap: _toggleFullscreen, - behavior: HitTestBehavior.translucent, - child: Container(color: Colors.transparent), - ), - ), - ], - ); - }, + // On desktop, show controls and focus play/pause on directional input + // Only handle navigation if video player navigation is enabled + if (!isMobile && _isDirectionalKey(key) && _videoPlayerNavigationEnabled) { + // If controls are hidden, show them and focus play/pause + if (!_showControls) { + _showControlsWithFocus(); + return KeyEventResult.handled; + } + // If controls are shown, let the event propagate to the focused control + // The DesktopVideoControls will handle navigation + return KeyEventResult.ignored; + } + + // Pass other events to the keyboard shortcuts service + if (_keyboardService == null) return KeyEventResult.ignored; + + final result = _keyboardService!.handleVideoPlayerKeyEvent( + event, + widget.player, + _toggleFullscreen, + _toggleSubtitles, + _nextAudioTrack, + _nextSubtitleTrack, + _nextChapter, + _previousChapter, + onBack: widget.onBack ?? () => Navigator.of(context).pop(true), + ); + return result; + }, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerHover: (_) => _showControlsFromPointerActivity(), + child: MouseRegion( + cursor: _showControls ? SystemMouseCursors.basic : SystemMouseCursors.none, + onHover: (_) => _showControlsFromPointerActivity(), + child: Stack( + children: [ + // Invisible tap detector that always covers the full area + // Also handles long-press for 2x speed + Positioned.fill( + child: GestureDetector( + onTap: _toggleControls, + onLongPressStart: (_) => _handleLongPressStart(), + onLongPressEnd: (_) => _handleLongPressEnd(), + onLongPressCancel: _handleLongPressCancel, + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), + ), ), - ), - // Mobile double-tap zones for skip forward/backward - if (isMobile) - Positioned.fill( - child: LayoutBuilder( - builder: (context, constraints) { - final height = constraints.maxHeight; - final width = constraints.maxWidth; - final topExclude = height * 0.15; // Exclude top 15% (top bar) - final bottomExclude = height * 0.15; // Exclude bottom 15% (seek slider) - final leftZoneWidth = width * 0.35; // Left 35% + // Middle area double-tap detector for fullscreen (desktop only) + // Only covers the clear video area (20% to 80% vertically) + if (!isMobile) + Positioned( + top: 0, + left: 0, + right: 0, + bottom: 0, + child: LayoutBuilder( + builder: (context, constraints) { + final height = constraints.maxHeight; + final topExclude = height * 0.20; // Top 20% + final bottomExclude = height * 0.20; // Bottom 20% - return Stack( - children: [ - // Left zone - skip backward (custom double-tap detection) - Positioned( - left: 0, - top: topExclude, - bottom: bottomExclude, - width: leftZoneWidth, - child: GestureDetector( - onTap: () => _handleTapInSkipZone(isForward: false), - onLongPressStart: (_) => _handleLongPressStart(), - onLongPressEnd: (_) => _handleLongPressEnd(), - onLongPressCancel: _handleLongPressCancel, - behavior: HitTestBehavior.opaque, - child: Container(color: Colors.transparent), - ), - ), - // Right zone - skip forward (custom double-tap detection) - Positioned( - right: 0, - top: topExclude, - bottom: bottomExclude, - width: leftZoneWidth, - child: GestureDetector( - onTap: () => _handleTapInSkipZone(isForward: true), - onLongPressStart: (_) => _handleLongPressStart(), - onLongPressEnd: (_) => _handleLongPressEnd(), - onLongPressCancel: _handleLongPressCancel, - behavior: HitTestBehavior.opaque, - child: Container(color: Colors.transparent), - ), - ), - ], - ); - }, - ), - ), - // Custom controls overlay - use AnimatedOpacity to keep widget tree alive - // On Linux, use Offstage after fade completes to fully hide - // Positioned AFTER double-tap zones so controls receive taps first - Positioned.fill( - child: Offstage( - offstage: Platform.isLinux && _controlsFullyHidden, - child: IgnorePointer( - ignoring: !_showControls, - child: FocusScope( - // Prevent focus from entering controls when hidden - canRequestFocus: _showControls, - child: AnimatedOpacity( - opacity: _showControls ? 1.0 : 0.0, - duration: const Duration(milliseconds: 200), - child: LayoutBuilder( - builder: (context, constraints) { - return GestureDetector( - onTapUp: (details) => _handleControlsOverlayTap(details, constraints), - onLongPressStart: (_) => _handleLongPressStart(), - onLongPressEnd: (_) => _handleLongPressEnd(), - onLongPressCancel: _handleLongPressCancel, - behavior: HitTestBehavior.deferToChild, - child: ValueListenableBuilder( - valueListenable: widget.hasFirstFrame ?? ValueNotifier(true), - builder: (context, hasFrame, child) { - return Container( - decoration: BoxDecoration( - // Use solid black when loading, gradient when loaded - color: hasFrame ? null : Colors.black, - 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], - ) - : null, - ), - child: child, - ); - }, - child: isMobile - ? Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: (_) => _restartHideTimerIfPlaying(), - child: MobileVideoControls( - player: widget.player, - metadata: widget.metadata, - chapters: _chapters, - chaptersLoaded: _chaptersLoaded, - seekTimeSmall: _seekTimeSmall, - trackChapterControls: _buildTrackChapterControlsWidget(), - onSeek: _throttledSeek, - onSeekEnd: _finalizeSeek, - onSeekCompleted: widget.onSeekCompleted, - onPlayPause: () {}, // Not used, handled internally - onCancelAutoHide: () => _hideTimer?.cancel(), - onStartAutoHide: _startHideTimer, - onBack: widget.onBack, - onNext: widget.onNext, - onPrevious: widget.onPrevious, - canControl: widget.canControl, - hasFirstFrame: widget.hasFirstFrame, - ), - ) - : Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: (_) => _restartHideTimerIfPlaying(), - child: DesktopVideoControls( - key: _desktopControlsKey, - player: widget.player, - metadata: widget.metadata, - onNext: widget.onNext, - onPrevious: widget.onPrevious, - chapters: _chapters, - chaptersLoaded: _chaptersLoaded, - seekTimeSmall: _seekTimeSmall, - onSeekToPreviousChapter: _seekToPreviousChapter, - onSeekToNextChapter: _seekToNextChapter, - onSeek: _throttledSeek, - onSeekEnd: _finalizeSeek, - getReplayIcon: getReplayIcon, - getForwardIcon: getForwardIcon, - onFocusActivity: _restartHideTimerIfPlaying, - onHideControls: _hideControlsFromKeyboard, - // Track chapter controls data - availableVersions: widget.availableVersions, - selectedMediaIndex: widget.selectedMediaIndex, - boxFitMode: widget.boxFitMode, - audioSyncOffset: _audioSyncOffset, - subtitleSyncOffset: _subtitleSyncOffset, - isFullscreen: _isFullscreen, - isAlwaysOnTop: _isAlwaysOnTop, - onTogglePIPMode: (_isPipSupported && Platform.isAndroid) ? widget.onTogglePIPMode : null, - onCycleBoxFitMode: widget.onCycleBoxFitMode, - onToggleFullscreen: _toggleFullscreen, - onToggleAlwaysOnTop: _toggleAlwaysOnTop, - onSwitchVersion: _switchMediaVersion, - onAudioTrackChanged: widget.onAudioTrackChanged, - onSubtitleTrackChanged: widget.onSubtitleTrackChanged, - onLoadSeekTimes: () async { - if (mounted) { - await _loadSeekTimes(); - } - }, - onCancelAutoHide: () => _hideTimer?.cancel(), - onStartAutoHide: _startHideTimer, - serverId: widget.metadata.serverId ?? '', - onBack: widget.onBack, - canControl: widget.canControl, - hasFirstFrame: widget.hasFirstFrame, - ), - ), + return Stack( + children: [ + Positioned( + top: topExclude, + left: 0, + right: 0, + bottom: bottomExclude, + child: GestureDetector( + onTap: _toggleControls, + onDoubleTap: _toggleFullscreen, + behavior: HitTestBehavior.translucent, + child: Container(color: Colors.transparent), + ), ), - ); - }, + ], + ); + }, + ), + ), + // Mobile double-tap zones for skip forward/backward + if (isMobile) + Positioned.fill( + child: LayoutBuilder( + builder: (context, constraints) { + final height = constraints.maxHeight; + final width = constraints.maxWidth; + final topExclude = height * 0.15; // Exclude top 15% (top bar) + final bottomExclude = height * 0.15; // Exclude bottom 15% (seek slider) + final leftZoneWidth = width * 0.35; // Left 35% + + return Stack( + children: [ + // Left zone - skip backward (custom double-tap detection) + Positioned( + left: 0, + top: topExclude, + bottom: bottomExclude, + width: leftZoneWidth, + child: GestureDetector( + onTap: () => _handleTapInSkipZone(isForward: false), + onLongPressStart: (_) => _handleLongPressStart(), + onLongPressEnd: (_) => _handleLongPressEnd(), + onLongPressCancel: _handleLongPressCancel, + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), + ), + ), + // Right zone - skip forward (custom double-tap detection) + Positioned( + right: 0, + top: topExclude, + bottom: bottomExclude, + width: leftZoneWidth, + child: GestureDetector( + onTap: () => _handleTapInSkipZone(isForward: true), + onLongPressStart: (_) => _handleLongPressStart(), + onLongPressEnd: (_) => _handleLongPressEnd(), + onLongPressCancel: _handleLongPressCancel, + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), + ), + ), + ], + ); + }, + ), + ), + // Custom controls overlay - use AnimatedOpacity to keep widget tree alive + // On Linux, use Offstage after fade completes to fully hide + // Positioned AFTER double-tap zones so controls receive taps first + Positioned.fill( + child: Offstage( + offstage: Platform.isLinux && _controlsFullyHidden, + child: IgnorePointer( + ignoring: !_showControls, + child: FocusScope( + // Prevent focus from entering controls when hidden + canRequestFocus: _showControls, + child: AnimatedOpacity( + opacity: _showControls ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: LayoutBuilder( + builder: (context, constraints) { + return GestureDetector( + onTapUp: (details) => _handleControlsOverlayTap(details, constraints), + onLongPressStart: (_) => _handleLongPressStart(), + onLongPressEnd: (_) => _handleLongPressEnd(), + onLongPressCancel: _handleLongPressCancel, + behavior: HitTestBehavior.deferToChild, + child: ValueListenableBuilder( + valueListenable: widget.hasFirstFrame ?? ValueNotifier(true), + builder: (context, hasFrame, child) { + return Container( + decoration: BoxDecoration( + // Use solid black when loading, gradient when loaded + color: hasFrame ? null : Colors.black, + 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], + ) + : null, + ), + child: child, + ); + }, + child: isMobile + ? Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => _restartHideTimerIfPlaying(), + child: MobileVideoControls( + player: widget.player, + metadata: widget.metadata, + chapters: _chapters, + chaptersLoaded: _chaptersLoaded, + seekTimeSmall: _seekTimeSmall, + trackChapterControls: _buildTrackChapterControlsWidget(), + onSeek: _throttledSeek, + onSeekEnd: _finalizeSeek, + onSeekCompleted: widget.onSeekCompleted, + onPlayPause: () {}, // Not used, handled internally + onCancelAutoHide: () => _hideTimer?.cancel(), + onStartAutoHide: _startHideTimer, + onBack: widget.onBack, + onNext: widget.onNext, + onPrevious: widget.onPrevious, + canControl: widget.canControl, + hasFirstFrame: widget.hasFirstFrame, + ), + ) + : Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => _restartHideTimerIfPlaying(), + child: DesktopVideoControls( + key: _desktopControlsKey, + player: widget.player, + metadata: widget.metadata, + onNext: widget.onNext, + onPrevious: widget.onPrevious, + chapters: _chapters, + chaptersLoaded: _chaptersLoaded, + seekTimeSmall: _seekTimeSmall, + onSeekToPreviousChapter: _seekToPreviousChapter, + onSeekToNextChapter: _seekToNextChapter, + onSeek: _throttledSeek, + onSeekEnd: _finalizeSeek, + getReplayIcon: getReplayIcon, + getForwardIcon: getForwardIcon, + onFocusActivity: _restartHideTimerIfPlaying, + onHideControls: _hideControlsFromKeyboard, + // Track chapter controls data + availableVersions: widget.availableVersions, + selectedMediaIndex: widget.selectedMediaIndex, + boxFitMode: widget.boxFitMode, + audioSyncOffset: _audioSyncOffset, + subtitleSyncOffset: _subtitleSyncOffset, + isFullscreen: _isFullscreen, + isAlwaysOnTop: _isAlwaysOnTop, + onTogglePIPMode: (_isPipSupported && Platform.isAndroid) + ? widget.onTogglePIPMode + : null, + onCycleBoxFitMode: widget.onCycleBoxFitMode, + onToggleFullscreen: _toggleFullscreen, + onToggleAlwaysOnTop: _toggleAlwaysOnTop, + onSwitchVersion: _switchMediaVersion, + onAudioTrackChanged: widget.onAudioTrackChanged, + onSubtitleTrackChanged: widget.onSubtitleTrackChanged, + onLoadSeekTimes: () async { + if (mounted) { + await _loadSeekTimes(); + } + }, + onCancelAutoHide: () => _hideTimer?.cancel(), + onStartAutoHide: _startHideTimer, + serverId: widget.metadata.serverId ?? '', + onBack: widget.onBack, + canControl: widget.canControl, + hasFirstFrame: widget.hasFirstFrame, + ), + ), + ), + ); + }, + ), + ), ), ), ), ), - ), - ), - // Visual feedback overlay for double-tap - if (isMobile && _showDoubleTapFeedback) - Positioned.fill( - child: IgnorePointer( - child: AnimatedOpacity( - opacity: _doubleTapFeedbackOpacity, - duration: tokens(context).slow, - child: _buildDoubleTapFeedback(), + // Visual feedback overlay for double-tap + if (isMobile && _showDoubleTapFeedback) + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + opacity: _doubleTapFeedbackOpacity, + duration: tokens(context).slow, + child: _buildDoubleTapFeedback(), + ), + ), ), - ), - ), - // Speed indicator overlay for long-press 2x - if (_showSpeedIndicator) - Positioned.fill( - child: IgnorePointer( - child: _buildSpeedIndicator(), - ), - ), - // Skip intro/credits button - if (_currentMarker != null) - Positioned( - right: 24, - bottom: isMobile ? 80 : 115, - child: AnimatedOpacity(opacity: 1.0, duration: tokens(context).slow, child: _buildSkipMarkerButton()), - ), - // Performance overlay (top-left) - if (_showPerformanceOverlay) - Positioned( - top: isMobile ? 60 : 16, - left: 16, - child: IgnorePointer(child: PlayerPerformanceOverlay(player: widget.player)), - ), - ], + // Speed indicator overlay for long-press 2x + if (_showSpeedIndicator) Positioned.fill(child: IgnorePointer(child: _buildSpeedIndicator())), + // Skip intro/credits button + if (_currentMarker != null) + Positioned( + right: 24, + bottom: isMobile ? 80 : 115, + child: AnimatedOpacity( + opacity: 1.0, + duration: tokens(context).slow, + child: _buildSkipMarkerButton(), + ), + ), + // Performance overlay (top-left) + if (_showPerformanceOverlay) + Positioned( + top: isMobile ? 60 : 16, + left: 16, + child: IgnorePointer(child: PlayerPerformanceOverlay(player: widget.player)), + ), + ], + ), + ), ), - ), - ), + ); + }, ); }