diff --git a/lib/media/media_source_info.dart b/lib/media/media_source_info.dart index d709336c..b59b61c5 100644 --- a/lib/media/media_source_info.dart +++ b/lib/media/media_source_info.dart @@ -238,6 +238,30 @@ class MediaChapter { } return null; } + + /// Find the chapter targeted by next/previous traversal. Previous traversal + /// restarts the current chapter only after [previousRestartThreshold]; before + /// that it selects an earlier chapter. + static int? seekTargetIndex( + Duration position, + List chapters, { + required bool forward, + Duration previousRestartThreshold = const Duration(seconds: 3), + }) { + final positionMs = position.inMilliseconds; + if (forward) { + for (int i = 0; i < chapters.length; i++) { + if ((chapters[i].startTimeOffset ?? 0) > positionMs) return i; + } + return null; + } + + final thresholdMs = previousRestartThreshold.inMilliseconds; + for (int i = chapters.length - 1; i >= 0; i--) { + if (positionMs > (chapters[i].startTimeOffset ?? 0) + thresholdMs) return i; + } + return null; + } } class MediaMarker { diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 8ef7636b..273fef4f 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -1054,28 +1054,14 @@ class DesktopVideoControlsState extends State { /// Returns the label of the next chapter the user would seek to, or null. String? _getNextChapterLabel(Duration position) { - if (widget.chapters.isEmpty) return null; - final currentPositionMs = position.inMilliseconds; - for (final chapter in widget.chapters) { - final chapterStart = chapter.startTimeOffset ?? 0; - if (chapterStart > currentPositionMs) { - return chapter.label; - } - } - return null; + final index = MediaChapter.seekTargetIndex(position, widget.chapters, forward: true); + return index == null ? null : widget.chapters[index].label; } /// Returns the label of the previous chapter the user would seek to, or null. String? _getPreviousChapterLabel(Duration position) { - if (widget.chapters.isEmpty) return null; - final currentPositionMs = position.inMilliseconds; - for (int i = widget.chapters.length - 1; i >= 0; i--) { - final chapterStart = widget.chapters[i].startTimeOffset ?? 0; - if (currentPositionMs > chapterStart + 3000) { - return widget.chapters[i].label; - } - } - return null; + final index = MediaChapter.seekTargetIndex(position, widget.chapters, forward: false); + return index == null ? null : widget.chapters[index].label; } Widget _buildFocusableButton({ diff --git a/lib/widgets/video_controls/parts/playback_input.dart b/lib/widgets/video_controls/parts/playback_input.dart index 34e84b4c..b3f08a69 100644 --- a/lib/widgets/video_controls/parts/playback_input.dart +++ b/lib/widgets/video_controls/parts/playback_input.dart @@ -31,25 +31,10 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { return; } - final currentPositionMs = widget.player.state.position.inMilliseconds; - - if (forward) { - for (final chapter in _chapters) { - final chapterStart = chapter.startTimeOffset ?? 0; - if (chapterStart > currentPositionMs) { - await _seekToPosition(Duration(milliseconds: chapterStart)); - return; - } - } - } else { - for (int i = _chapters.length - 1; i >= 0; i--) { - final chapterStart = _chapters[i].startTimeOffset ?? 0; - if (currentPositionMs > chapterStart + 3000) { - // If more than 3 seconds into chapter, go to start of current chapter - await _seekToPosition(Duration(milliseconds: chapterStart)); - return; - } - } + final targetIndex = MediaChapter.seekTargetIndex(widget.player.state.position, _chapters, forward: forward); + if (targetIndex != null) { + await _seekToPosition(_chapters[targetIndex].startTime); + } else if (!forward) { await _seekToPosition(Duration.zero); } } diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index cecfbe2a..5d94228f 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -110,7 +110,7 @@ class ContentStripState extends State { /// Request focus on the current chapter or queue item (called by parent when strip appears). void requestInitialFocus() { if (_activeTab == _StripTab.chapters && _chapterFocusNodes.isNotEmpty) { - final currentIndex = _getCurrentChapterIndex(); + final currentIndex = MediaChapter.indexAtPosition(widget.player.state.position, widget.chapters); final idx = (currentIndex ?? 0).clamp(0, _chapterFocusNodes.length - 1); _chapterFocusNodes[idx].requestFocus(); _scrollToFocusedNode(_chapterFocusNodes[idx]); @@ -122,21 +122,6 @@ class ContentStripState extends State { } } - int? _getCurrentChapterIndex() { - final currentPositionMs = widget.player.state.position.inMilliseconds; - for (int i = 0; i < widget.chapters.length; i++) { - final chapter = widget.chapters[i]; - final startMs = chapter.startTimeOffset ?? 0; - final endMs = - chapter.endTimeOffset ?? - (i < widget.chapters.length - 1 ? widget.chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt()); - if (currentPositionMs >= startMs && currentPositionMs < endMs) { - return i; - } - } - return null; - } - Future _handleChapterTap(Duration position) async { final clamped = clampSeekPosition(widget.player, position); await (widget.onSeekRequested ?? widget.player.seek)(clamped); @@ -226,7 +211,8 @@ class ContentStripState extends State { }); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _chapterFocusNodes.isNotEmpty) { - final idx = (_getCurrentChapterIndex() ?? 0).clamp(0, _chapterFocusNodes.length - 1); + final currentIndex = MediaChapter.indexAtPosition(widget.player.state.position, widget.chapters); + final idx = (currentIndex ?? 0).clamp(0, _chapterFocusNodes.length - 1); _chapterFocusNodes[idx].requestFocus(); _scrollToFocusedNode(_chapterFocusNodes[idx]); } diff --git a/test/media/media_source_info_test.dart b/test/media/media_source_info_test.dart index d4764f56..d23d2dd4 100644 --- a/test/media/media_source_info_test.dart +++ b/test/media/media_source_info_test.dart @@ -3,6 +3,40 @@ import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/utils/track_label_builder.dart'; void main() { + group('MediaChapter traversal', () { + final chapters = [ + MediaChapter(id: 1, startTimeOffset: 0, title: 'One'), + MediaChapter(id: 2, startTimeOffset: 10000, title: 'Two'), + MediaChapter(id: 3, startTimeOffset: 20000, title: 'Three'), + ]; + + test('forward traversal uses the first strictly later chapter', () { + expect(MediaChapter.seekTargetIndex(const Duration(milliseconds: 9999), chapters, forward: true), 1); + expect(MediaChapter.seekTargetIndex(const Duration(milliseconds: 10000), chapters, forward: true), 2); + expect(MediaChapter.seekTargetIndex(const Duration(milliseconds: 20000), chapters, forward: true), isNull); + }); + + test('previous traversal preserves the strict three-second restart threshold', () { + expect(MediaChapter.seekTargetIndex(const Duration(milliseconds: 13000), chapters, forward: false), 0); + expect(MediaChapter.seekTargetIndex(const Duration(milliseconds: 13001), chapters, forward: false), 1); + expect(MediaChapter.seekTargetIndex(const Duration(milliseconds: 3000), chapters, forward: false), isNull); + }); + + test('handles empty chapters and null starts', () { + expect(MediaChapter.seekTargetIndex(Duration.zero, const [], forward: true), isNull); + final missingStart = [MediaChapter(id: 1), MediaChapter(id: 2, startTimeOffset: 5000)]; + expect(MediaChapter.seekTargetIndex(Duration.zero, missingStart, forward: true), 1); + expect(MediaChapter.seekTargetIndex(const Duration(milliseconds: 3001), missingStart, forward: false), 0); + }); + + test('indexAtPosition uses start-inclusive and end-exclusive ranges', () { + expect(MediaChapter.indexAtPosition(Duration.zero, chapters), 0); + expect(MediaChapter.indexAtPosition(const Duration(milliseconds: 9999), chapters), 0); + expect(MediaChapter.indexAtPosition(const Duration(milliseconds: 10000), chapters), 1); + expect(MediaChapter.indexAtPosition(const Duration(hours: 1), chapters), 2); + }); + }); + group('MediaSubtitleTrack label', () { test('language leads; a bare "Forced" title folds into the suffix', () { final track = MediaSubtitleTrack(