fix(player): drop chapter-derived intro markers longer than three minutes

A movie whose first chapter is titled "Opening Credits" or "Introduction"
got a Skip Intro button that skipped the whole chapter — several minutes
of picture. Detected intros are short; only Plezy's own chapter-title
fallback produced these. Cap chapter-derived intros at three minutes.
Server-supplied markers and credits chapters are unaffected.

close #2235
This commit is contained in:
edde746
2026-09-03 18:34:44 +02:00
parent be8fa15733
commit 8b1ac156f3
2 changed files with 93 additions and 0 deletions
+12
View File
@@ -376,11 +376,22 @@ class PlaybackExtras {
return RegExp(source, caseSensitive: false);
}
/// Longest chapter that may become a chapter-derived intro marker.
///
/// Detected intros (Plex, Intro Skipper) fall well inside two minutes; a
/// movie's first chapter titled "Opening Credits" or "Introduction" runs
/// five to ten minutes of actual picture, and skipping it skips the film
/// (#2235). Applies only to markers minted here from chapter titles, never
/// to server-supplied markers, and never to credits, which are legitimately
/// long on movies.
static const maxChapterIntroDuration = Duration(minutes: 3);
/// Returns [PlaybackExtras] using real markers when available, filling any
/// missing marker types from chapter titles matching intro/credits patterns.
/// [forceChapterFallback] prefers chapter-derived markers for any type they
/// provide. When real markers exist, reclassifies markers with unknown types
/// against the patterns so non-standard type strings get recognized.
/// Chapter-derived intros longer than [maxChapterIntroDuration] are dropped.
factory PlaybackExtras.withChapterFallback({
required List<MediaChapter> chapters,
required List<MediaMarker> markers,
@@ -411,6 +422,7 @@ class PlaybackExtras {
final end = ch.endTimeOffset ?? (i + 1 < chapters.length ? chapters[i + 1].startTimeOffset : null);
if (end == null) continue;
if (type == 'intro' && end - start > maxChapterIntroDuration.inMilliseconds) continue;
synthetic.add(MediaMarker(id: ch.id, type: type, startTimeOffset: start, endTimeOffset: end));
}
+81
View File
@@ -169,4 +169,85 @@ void main() {
expect(extras.markers.single.startTimeOffset, 0);
});
});
group('PlaybackExtras.withChapterFallback intro duration cap', () {
const cap = PlaybackExtras.maxChapterIntroDuration;
const movieEnd = 7200000;
test('a movie-length "Opening Credits" chapter does not become an intro', () {
final extras = PlaybackExtras.withChapterFallback(
chapters: [
MediaChapter(id: 1, startTimeOffset: 0, endTimeOffset: 480000, title: 'Opening Credits'),
MediaChapter(id: 2, startTimeOffset: 480000, endTimeOffset: movieEnd, title: 'Chapter 2'),
],
markers: [],
);
expect(extras.markers, isEmpty);
});
test('a chapter exactly at the cap still qualifies; one millisecond over does not', () {
final atCap = PlaybackExtras.withChapterFallback(
chapters: [
MediaChapter(id: 1, startTimeOffset: 1000, endTimeOffset: 1000 + cap.inMilliseconds, title: 'Intro'),
],
markers: [],
);
final overCap = PlaybackExtras.withChapterFallback(
chapters: [
MediaChapter(id: 1, startTimeOffset: 1000, endTimeOffset: 1001 + cap.inMilliseconds, title: 'Intro'),
],
markers: [],
);
expect(atCap.markers.map((m) => m.type), ['intro']);
expect(overCap.markers, isEmpty);
});
test('the cap uses the next chapter start when the chapter has no end', () {
final extras = PlaybackExtras.withChapterFallback(
chapters: [
MediaChapter(id: 1, startTimeOffset: 0, title: 'Opening'),
MediaChapter(id: 2, startTimeOffset: 600000, title: 'Chapter 2'),
],
markers: [],
);
expect(extras.markers, isEmpty);
});
test('long credits chapters are still derived', () {
final extras = PlaybackExtras.withChapterFallback(
chapters: [
MediaChapter(id: 1, startTimeOffset: 0, endTimeOffset: 6600000, title: 'Chapter 1'),
MediaChapter(id: 2, startTimeOffset: 6600000, endTimeOffset: movieEnd, title: 'End Credits'),
],
markers: [],
);
expect(extras.markers.map((m) => m.type), ['credits']);
expect(extras.markers.single.endTimeOffset, movieEnd);
});
test('server-supplied intro markers are never capped', () {
final extras = PlaybackExtras.withChapterFallback(
chapters: [],
markers: [MediaMarker(id: 1, type: 'intro', startTimeOffset: 0, endTimeOffset: 600000)],
);
expect(extras.markers.map((m) => m.type), ['intro']);
expect(extras.markers.single.endTimeOffset, 600000);
});
test('a dropped chapter intro leaves native markers intact under forced fallback', () {
final extras = PlaybackExtras.withChapterFallback(
chapters: [MediaChapter(id: 1, startTimeOffset: 0, endTimeOffset: 480000, title: 'Opening Credits')],
markers: [MediaMarker(id: 9, type: 'credits', startTimeOffset: 6600000, endTimeOffset: movieEnd)],
forceChapterFallback: true,
);
expect(extras.markers.map((m) => m.type), ['credits']);
expect(extras.markers.single.id, 9);
});
});
}