From d9bc7d6b8627d95365bc470b1cfd364ca9a3b5be Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 24 Dec 2025 08:20:57 +0100 Subject: [PATCH] feat(mobile): declutter controls and add stacking skips close #189 and #190 --- lib/screens/video_player_screen.dart | 4 +- .../video_controls/mobile_video_controls.dart | 24 +---- .../video_controls/video_controls.dart | 102 +++++++++++++++--- 3 files changed, 94 insertions(+), 36 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index d7156dab..527dc7b7 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -665,6 +665,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin try { PlaybackInitializationResult result; + Map? plexHeaders; if (widget.isOffline) { // Offline mode: get video path from downloads without requiring server @@ -672,6 +673,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin } else { // Online mode: use server-specific client final client = _getClientForMetadata(context); + plexHeaders = client.config.headers; final playbackService = PlaybackInitializationService(client: client, database: PlexApiCache.instance.database); result = await playbackService.getPlaybackData( metadata: widget.metadata, @@ -689,7 +691,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin final resumePosition = widget.metadata.viewOffset != null ? Duration(milliseconds: widget.metadata.viewOffset!) : null; - await player!.open(Media(result.videoUrl!, start: resumePosition)); + await player!.open(Media(result.videoUrl!, start: resumePosition, headers: plexHeaders)); // Attach player to Watch Together session for sync (if in session) if (mounted && !widget.isOffline) { diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 1689b631..7d18a47a 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -136,18 +136,8 @@ class MobileVideoControls extends StatelessWidget { iconSize: 48, onPressed: onPrevious!, ), - const SizedBox(width: 16), + const SizedBox(width: 24), ], - _buildCircularButton( - semanticLabel: t.videoControls.seekBackwardButton(seconds: seekTimeSmall), - icon: getReplayIcon(seekTimeSmall), - iconSize: 48, - onPressed: () { - final newPosition = seekWithClamping(player, Duration(seconds: -seekTimeSmall)); - onSeekCompleted?.call(newPosition); - }, - ), - const SizedBox(width: 48), _buildCircularButton( semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton, icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, @@ -162,19 +152,9 @@ class MobileVideoControls extends StatelessWidget { } }, ), - const SizedBox(width: 48), - _buildCircularButton( - semanticLabel: t.videoControls.seekForwardButton(seconds: seekTimeSmall), - icon: getForwardIcon(seekTimeSmall), - iconSize: 48, - onPressed: () { - final newPosition = seekWithClamping(player, Duration(seconds: seekTimeSmall)); - onSeekCompleted?.call(newPosition); - }, - ), // Next episode button (hidden when unavailable) if (onNext != null) ...[ - const SizedBox(width: 16), + const SizedBox(width: 24), _buildCircularButton( semanticLabel: t.videoControls.nextButton, icon: Symbols.skip_next_rounded, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 0f6eb15f..6e0f3a1c 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -152,6 +152,11 @@ class _PlexVideoControlsState extends State with WindowListen double _doubleTapFeedbackOpacity = 0.0; bool _lastDoubleTapWasForward = true; Timer? _feedbackTimer; + int _accumulatedSkipSeconds = 0; // Stacking skip: total skip during active feedback + // Custom tap detection state (more reliable than Flutter's onDoubleTap) + DateTime? _lastSkipTapTime; + bool _lastSkipTapWasForward = true; + DateTime? _lastSkipActionTime; // Debounce: prevents double-tap counting as 2 skips // Seek throttle late final Throttle _seekThrottle; // Current marker state @@ -797,11 +802,70 @@ class _PlexVideoControlsState extends State with WindowListen widget.onSeekCompleted?.call(position); } + /// Handle tap in skip zone with custom double-tap detection + void _handleTapInSkipZone({required bool isForward}) { + final now = DateTime.now(); + + // 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) { + return; + } + + // Check if this qualifies as a double-tap (within 400ms of last tap, same side) + final isDoubleTap = + _lastSkipTapTime != null && + now.difference(_lastSkipTapTime!).inMilliseconds < 400 && + _lastSkipTapWasForward == isForward; + + // Skip ONLY on detected double-tap (no single-tap-to-add behavior) + if (isDoubleTap) { + _lastSkipTapTime = null; // Reset to prevent triple-tap chaining + + if (_showDoubleTapFeedback && _lastDoubleTapWasForward == isForward) { + // Stacking skip - add to accumulated + _handleStackingSkip(isForward: isForward); + } else { + // First double-tap - initiate skip + _handleDoubleTapSkip(isForward: isForward); + } + } else { + // First tap - record timestamp and wait for possible second tap + _lastSkipTapTime = now; + _lastSkipTapWasForward = isForward; + } + } + + /// Handle stacking skip - add to accumulated skip when feedback is active + void _handleStackingSkip({required bool isForward}) { + if (!widget.canControl) return; + + // Add to accumulated skip + _accumulatedSkipSeconds += _seekTimeSmall; + + // Calculate and perform seek + final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall); + final newPosition = seekWithClamping(widget.player, delta); + + // Notify Watch Together + widget.onSeekCompleted?.call(newPosition); + + // Refresh feedback (extends timer, updates display) + _showSkipFeedback(isForward: isForward); + + // Record skip time for debounce + _lastSkipActionTime = DateTime.now(); + } + /// Handle double-tap skip forward or backward void _handleDoubleTapSkip({required bool isForward}) { // Ignore if user cannot control playback if (!widget.canControl) return; + // Reset accumulated skip for new gesture + _accumulatedSkipSeconds = _seekTimeSmall; + // Calculate the new position (clamped to valid range) final currentPosition = widget.player.state.position; final duration = widget.player.state.duration; @@ -817,6 +881,9 @@ class _PlexVideoControlsState extends State with WindowListen // Show visual feedback _showSkipFeedback(isForward: isForward); + + // Record skip time for debounce + _lastSkipActionTime = DateTime.now(); } /// Show animated visual feedback for skip gesture @@ -832,8 +899,8 @@ class _PlexVideoControlsState extends State with WindowListen // Capture duration before timer to avoid context access in callback final slowDuration = tokens(context).slow; - // Fade out after delay - _feedbackTimer = Timer(const Duration(milliseconds: 500), () { + // Fade out after delay (1200ms gives time to see value and continue tapping) + _feedbackTimer = Timer(const Duration(milliseconds: 1200), () { if (mounted) { setState(() { _doubleTapFeedbackOpacity = 0.0; @@ -843,6 +910,7 @@ class _PlexVideoControlsState extends State with WindowListen if (mounted) { setState(() { _showDoubleTapFeedback = false; + _accumulatedSkipSeconds = 0; // Reset when feedback hides }); } }); @@ -858,11 +926,21 @@ class _PlexVideoControlsState extends State with WindowListen margin: const EdgeInsets.symmetric(horizontal: 60), padding: const EdgeInsets.all(20), decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.6), shape: BoxShape.circle), - child: AppIcon( - _lastDoubleTapWasForward ? getForwardIcon(_seekTimeSmall) : getReplayIcon(_seekTimeSmall), - fill: 1, - color: Colors.white, - size: 48, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon( + _lastDoubleTapWasForward ? Symbols.fast_forward_rounded : Symbols.fast_rewind_rounded, + fill: 1, + color: Colors.white, + size: 32, + ), + const SizedBox(height: 4), + Text( + '$_accumulatedSkipSeconds${t.settings.secondsShort}', + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), + ), + ], ), ), ); @@ -1168,28 +1246,26 @@ class _PlexVideoControlsState extends State with WindowListen return Stack( children: [ - // Left zone - skip backward + // Left zone - skip backward (custom double-tap detection) Positioned( left: 0, top: topExclude, bottom: bottomExclude, width: leftZoneWidth, child: GestureDetector( - onTap: _toggleControls, - onDoubleTap: () => _handleDoubleTapSkip(isForward: false), + onTap: () => _handleTapInSkipZone(isForward: false), behavior: HitTestBehavior.translucent, child: Container(color: Colors.transparent), ), ), - // Right zone - skip forward + // Right zone - skip forward (custom double-tap detection) Positioned( right: 0, top: topExclude, bottom: bottomExclude, width: leftZoneWidth, child: GestureDetector( - onTap: _toggleControls, - onDoubleTap: () => _handleDoubleTapSkip(isForward: true), + onTap: () => _handleTapInSkipZone(isForward: true), behavior: HitTestBehavior.translucent, child: Container(color: Colors.transparent), ),