From f3fbdad4dc3b8600fecf2a3fa368865603acd73c Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 6 Feb 2026 04:54:27 +0100 Subject: [PATCH] feat: separate chapter & skip buttons close #381 --- .../desktop_video_controls.dart | 66 +++++++++++++------ .../video_controls/video_controls.dart | 8 +++ 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 0ed686cf..298286c2 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -34,6 +34,8 @@ class DesktopVideoControls extends StatefulWidget { final int seekTimeSmall; final VoidCallback onSeekToPreviousChapter; final VoidCallback onSeekToNextChapter; + final VoidCallback? onSeekBackward; + final VoidCallback? onSeekForward; final ValueChanged onSeek; final ValueChanged onSeekEnd; final IconData Function(int) getReplayIcon; @@ -89,6 +91,8 @@ class DesktopVideoControls extends StatefulWidget { required this.seekTimeSmall, required this.onSeekToPreviousChapter, required this.onSeekToNextChapter, + this.onSeekBackward, + this.onSeekForward, required this.onSeek, required this.onSeekEnd, required this.getReplayIcon, @@ -129,7 +133,9 @@ class DesktopVideoControlsState extends State { // Focus nodes for playback control buttons late final FocusNode _prevItemFocusNode; late final FocusNode _prevChapterFocusNode; + late final FocusNode _skipBackFocusNode; late final FocusNode _playPauseFocusNode; + late final FocusNode _skipForwardFocusNode; late final FocusNode _nextChapterFocusNode; late final FocusNode _nextItemFocusNode; late final FocusNode _timelineFocusNode; @@ -152,7 +158,9 @@ class DesktopVideoControlsState extends State { super.initState(); _prevItemFocusNode = FocusNode(debugLabel: 'PrevItem'); _prevChapterFocusNode = FocusNode(debugLabel: 'PrevChapter'); + _skipBackFocusNode = FocusNode(debugLabel: 'SkipBack'); _playPauseFocusNode = FocusNode(debugLabel: 'PlayPause'); + _skipForwardFocusNode = FocusNode(debugLabel: 'SkipForward'); _nextChapterFocusNode = FocusNode(debugLabel: 'NextChapter'); _nextItemFocusNode = FocusNode(debugLabel: 'NextItem'); _timelineFocusNode = FocusNode(debugLabel: 'Timeline'); @@ -164,7 +172,9 @@ class DesktopVideoControlsState extends State { _buttonFocusNodes = [ _prevItemFocusNode, _prevChapterFocusNode, + _skipBackFocusNode, _playPauseFocusNode, + _skipForwardFocusNode, _nextChapterFocusNode, _nextItemFocusNode, ]; @@ -174,7 +184,9 @@ class DesktopVideoControlsState extends State { void dispose() { _prevItemFocusNode.dispose(); _prevChapterFocusNode.dispose(); + _skipBackFocusNode.dispose(); _playPauseFocusNode.dispose(); + _skipForwardFocusNode.dispose(); _nextChapterFocusNode.dispose(); _nextItemFocusNode.dispose(); _timelineFocusNode.dispose(); @@ -440,19 +452,27 @@ class DesktopVideoControlsState extends State { semanticLabel: t.videoControls.previousButton, ), ), - // Previous chapter (or skip backward if no chapters) + // Previous chapter Opacity( opacity: widget.canControl ? 1.0 : 0.5, child: _buildFocusableButton( focusNode: _prevChapterFocusNode, index: 1, - icon: widget.chapters.isEmpty - ? widget.getReplayIcon(widget.seekTimeSmall) - : Symbols.fast_rewind_rounded, - onPressed: widget.canControl ? widget.onSeekToPreviousChapter : null, - semanticLabel: widget.chapters.isEmpty - ? t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall) - : t.videoControls.previousChapterButton, + icon: Symbols.fast_rewind_rounded, + color: widget.chapters.isNotEmpty && widget.canControl ? Colors.white : Colors.white54, + onPressed: widget.canControl && widget.chapters.isNotEmpty ? widget.onSeekToPreviousChapter : null, + semanticLabel: t.videoControls.previousChapterButton, + ), + ), + // Skip backward + Opacity( + opacity: widget.canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _skipBackFocusNode, + index: 2, + icon: widget.getReplayIcon(widget.seekTimeSmall), + onPressed: widget.canControl ? widget.onSeekBackward : null, + semanticLabel: t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall), ), ), // Play/Pause @@ -463,7 +483,7 @@ class DesktopVideoControlsState extends State { builder: (context, isPlaying) { return _buildFocusableButton( focusNode: _playPauseFocusNode, - index: 2, + index: 3, icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, iconSize: 32, onPressed: widget.canControl @@ -480,19 +500,27 @@ class DesktopVideoControlsState extends State { }, ), ), - // Next chapter (or skip forward if no chapters) + // Skip forward + Opacity( + opacity: widget.canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _skipForwardFocusNode, + index: 4, + icon: widget.getForwardIcon(widget.seekTimeSmall), + onPressed: widget.canControl ? widget.onSeekForward : null, + semanticLabel: t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall), + ), + ), + // Next chapter Opacity( opacity: widget.canControl ? 1.0 : 0.5, child: _buildFocusableButton( focusNode: _nextChapterFocusNode, - index: 3, - icon: widget.chapters.isEmpty - ? widget.getForwardIcon(widget.seekTimeSmall) - : Symbols.fast_forward_rounded, - onPressed: widget.canControl ? widget.onSeekToNextChapter : null, - semanticLabel: widget.chapters.isEmpty - ? t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall) - : t.videoControls.nextChapterButton, + index: 5, + icon: Symbols.fast_forward_rounded, + color: widget.chapters.isNotEmpty && widget.canControl ? Colors.white : Colors.white54, + onPressed: widget.canControl && widget.chapters.isNotEmpty ? widget.onSeekToNextChapter : null, + semanticLabel: t.videoControls.nextChapterButton, ), ), // Next item @@ -500,7 +528,7 @@ class DesktopVideoControlsState extends State { opacity: widget.canControl ? 1.0 : 0.5, child: _buildFocusableButton( focusNode: _nextItemFocusNode, - index: 4, + index: 6, icon: Symbols.skip_next_rounded, color: widget.onNext != null && widget.canControl ? Colors.white : Colors.white54, onPressed: widget.canControl ? widget.onNext : null, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 3fbe5685..8ee3f85e 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -826,6 +826,12 @@ class _PlexVideoControlsState extends State with WindowListen void _seekToNextChapter() => _seekToChapter(forward: true); + void _seekByTime({required bool forward}) { + final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall); + final newPosition = seekWithClamping(widget.player, delta); + widget.onSeekCompleted?.call(newPosition); + } + void _seekToChapter({required bool forward}) { if (_chapters.isEmpty) { // No chapters - seek by configured amount @@ -1687,6 +1693,8 @@ class _PlexVideoControlsState extends State with WindowListen seekTimeSmall: _seekTimeSmall, onSeekToPreviousChapter: _seekToPreviousChapter, onSeekToNextChapter: _seekToNextChapter, + onSeekBackward: () => _seekByTime(forward: false), + onSeekForward: () => _seekByTime(forward: true), onSeek: _throttledSeek, onSeekEnd: _finalizeSeek, getReplayIcon: getReplayIcon,