refactor(player): centralize chapter traversal
This commit is contained in:
@@ -238,6 +238,30 @@ class MediaChapter {
|
|||||||
}
|
}
|
||||||
return null;
|
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<MediaChapter> 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 {
|
class MediaMarker {
|
||||||
|
|||||||
@@ -1054,28 +1054,14 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
|||||||
|
|
||||||
/// Returns the label of the next chapter the user would seek to, or null.
|
/// Returns the label of the next chapter the user would seek to, or null.
|
||||||
String? _getNextChapterLabel(Duration position) {
|
String? _getNextChapterLabel(Duration position) {
|
||||||
if (widget.chapters.isEmpty) return null;
|
final index = MediaChapter.seekTargetIndex(position, widget.chapters, forward: true);
|
||||||
final currentPositionMs = position.inMilliseconds;
|
return index == null ? null : widget.chapters[index].label;
|
||||||
for (final chapter in widget.chapters) {
|
|
||||||
final chapterStart = chapter.startTimeOffset ?? 0;
|
|
||||||
if (chapterStart > currentPositionMs) {
|
|
||||||
return chapter.label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the label of the previous chapter the user would seek to, or null.
|
/// Returns the label of the previous chapter the user would seek to, or null.
|
||||||
String? _getPreviousChapterLabel(Duration position) {
|
String? _getPreviousChapterLabel(Duration position) {
|
||||||
if (widget.chapters.isEmpty) return null;
|
final index = MediaChapter.seekTargetIndex(position, widget.chapters, forward: false);
|
||||||
final currentPositionMs = position.inMilliseconds;
|
return index == null ? null : widget.chapters[index].label;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFocusableButton({
|
Widget _buildFocusableButton({
|
||||||
|
|||||||
@@ -31,25 +31,10 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final currentPositionMs = widget.player.state.position.inMilliseconds;
|
final targetIndex = MediaChapter.seekTargetIndex(widget.player.state.position, _chapters, forward: forward);
|
||||||
|
if (targetIndex != null) {
|
||||||
if (forward) {
|
await _seekToPosition(_chapters[targetIndex].startTime);
|
||||||
for (final chapter in _chapters) {
|
} else if (!forward) {
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await _seekToPosition(Duration.zero);
|
await _seekToPosition(Duration.zero);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class ContentStripState extends State<ContentStrip> {
|
|||||||
/// Request focus on the current chapter or queue item (called by parent when strip appears).
|
/// Request focus on the current chapter or queue item (called by parent when strip appears).
|
||||||
void requestInitialFocus() {
|
void requestInitialFocus() {
|
||||||
if (_activeTab == _StripTab.chapters && _chapterFocusNodes.isNotEmpty) {
|
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);
|
final idx = (currentIndex ?? 0).clamp(0, _chapterFocusNodes.length - 1);
|
||||||
_chapterFocusNodes[idx].requestFocus();
|
_chapterFocusNodes[idx].requestFocus();
|
||||||
_scrollToFocusedNode(_chapterFocusNodes[idx]);
|
_scrollToFocusedNode(_chapterFocusNodes[idx]);
|
||||||
@@ -122,21 +122,6 @@ class ContentStripState extends State<ContentStrip> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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<void> _handleChapterTap(Duration position) async {
|
Future<void> _handleChapterTap(Duration position) async {
|
||||||
final clamped = clampSeekPosition(widget.player, position);
|
final clamped = clampSeekPosition(widget.player, position);
|
||||||
await (widget.onSeekRequested ?? widget.player.seek)(clamped);
|
await (widget.onSeekRequested ?? widget.player.seek)(clamped);
|
||||||
@@ -226,7 +211,8 @@ class ContentStripState extends State<ContentStrip> {
|
|||||||
});
|
});
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted && _chapterFocusNodes.isNotEmpty) {
|
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();
|
_chapterFocusNodes[idx].requestFocus();
|
||||||
_scrollToFocusedNode(_chapterFocusNodes[idx]);
|
_scrollToFocusedNode(_chapterFocusNodes[idx]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,40 @@ import 'package:plezy/media/media_source_info.dart';
|
|||||||
import 'package:plezy/utils/track_label_builder.dart';
|
import 'package:plezy/utils/track_label_builder.dart';
|
||||||
|
|
||||||
void main() {
|
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', () {
|
group('MediaSubtitleTrack label', () {
|
||||||
test('language leads; a bare "Forced" title folds into the suffix', () {
|
test('language leads; a bare "Forced" title folds into the suffix', () {
|
||||||
final track = MediaSubtitleTrack(
|
final track = MediaSubtitleTrack(
|
||||||
|
|||||||
Reference in New Issue
Block a user