Chapters on the scrubber, Skip OP/ED, audio/subtitle menus

- PlaybackController exposes chapters, segments (Jellyfin
  MediaSegments for account holders), tracks, position/duration.
- Own seek bar with chapter diamonds and chapter title on hover,
  seeking through the sync user-seek path.
- Skip OP / Skip ED pills from Intro/Outro segments or chapter names
  (ED skips to its end only); Skip 1:30 fallback for >20 min videos
  without OP info; next-episode pill now starts at the ED.
- PgUp/PgDn chapters, Shift+Right +1:30; Audio and Subtitles menus
  with count badges via the existing track path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 02:56:41 +03:00
1 parent ae4edc29e8
commit bc98c5c665
21 files changed
+2084 -257

No files matched your search

@@ -0,0 +1,272 @@
import 'dart:convert';
import 'package:sync_relay_core/core.dart' show JellyfinMediaSegment, JellyfinSegmentType, JellyfinStreamType;
import '../jellyfin/track_prefs.dart' show MpvTrack;
// Chapters, segments and tracks of the open media as plain values, plus
// the skip rules built on them (OP / ED / the 1:30 fallback, chapter
// steps). No Flutter, no player: `PlaybackController` feeds its signals
// through these and any controls UI reads them.
/// One mpv chapter. [end] is the next chapter's start, or the duration for
/// the last one ([start] while the duration is unknown).
class MediaChapter {
final int index;
final String title;
final Duration start;
final Duration end;
const MediaChapter({required this.index, required this.title, required this.start, required this.end});
@override
bool operator ==(Object other) =>
other is MediaChapter &&
other.index == index &&
other.title == title &&
other.start == start &&
other.end == end;
@override
int get hashCode => Object.hash(index, title, start, end);
@override
String toString() => 'MediaChapter($index "$title" $start-$end)';
}
/// A marked range of the item from Jellyfin (MediaSegments): intro,
/// outro, recap, preview or commercial.
class MediaSegment {
final JellyfinSegmentType kind;
final Duration start;
final Duration end;
const MediaSegment({required this.kind, required this.start, required this.end});
factory MediaSegment.fromJellyfin(JellyfinMediaSegment s) =>
MediaSegment(kind: s.type, start: s.start, end: s.end);
@override
String toString() => 'MediaSegment(${kind.name} $start-$end)';
}
/// Where a skip range came from.
enum SkipSource { segment, chapter }
/// An opening or ending: skipping it seeks to [end].
class SkipRange {
final Duration start;
final Duration end;
final SkipSource source;
/// The chapter title when [source] is a chapter.
final String? title;
const SkipRange(this.start, this.end, this.source, {this.title});
@override
bool operator ==(Object other) =>
other is SkipRange &&
other.start == start &&
other.end == end &&
other.source == source &&
other.title == title;
@override
int get hashCode => Object.hash(start, end, source, title);
@override
String toString() => 'SkipRange(${source.name} $start-$end${title == null ? '' : ' "$title"'})';
}
Duration _secs(num s) => Duration(microseconds: (s * 1e6).round());
/// mpv `chapter-list` as JSON (`[{"title": "OP", "time": 30.0}, ...]`).
/// Ends come from the next start, the last from [duration].
List<MediaChapter> parseChapterList(String json, Duration duration) {
final Object? raw;
try {
raw = jsonDecode(json);
} on FormatException {
return const [];
}
if (raw is! List) return const [];
final starts = <(String, Duration)>[
for (final c in raw)
if (c is Map && c['time'] is num && (c['time'] as num).isFinite)
((c['title'] is String ? c['title'] as String : '').trim(), _secs(c['time'] as num)),
]..sort((a, b) => a.$2.compareTo(b.$2));
return chaptersFromStarts(starts, duration);
}
/// Chapters from (title, start) pairs in start order.
List<MediaChapter> chaptersFromStarts(List<(String, Duration)> starts, Duration duration) => [
for (var i = 0; i < starts.length; i++)
MediaChapter(
index: i,
title: starts[i].$1,
start: starts[i].$2,
end: i + 1 < starts.length
? starts[i + 1].$2
: (duration > starts[i].$2 ? duration : starts[i].$2),
),
];
/// mpv `track-list` as JSON: the audio and subtitle tracks (video and
/// unknown types left out), with mpv's selection.
List<MpvTrack> parseTrackList(String json) {
final Object? raw;
try {
raw = jsonDecode(json);
} on FormatException {
return const [];
}
if (raw is! List) return const [];
String? s(Object? v) => v is String && v.isNotEmpty ? v : null;
return [
for (final t in raw)
if (t is Map && t['id'] is int && (t['type'] == 'audio' || t['type'] == 'sub'))
MpvTrack(
id: t['id'] as int,
type: t['type'] == 'audio' ? JellyfinStreamType.audio : JellyfinStreamType.subtitle,
language: s(t['lang']),
title: s(t['title']),
codec: s(t['codec']),
channels: (t['demux-channel-count'] ?? t['audio-channels']) is int
? (t['demux-channel-count'] ?? t['audio-channels']) as int
: null,
isDefault: t['default'] == true,
isForced: t['forced'] == true,
isHearingImpaired: t['hearing-impaired'] == true,
isExternal: t['external'] == true,
selected: t['selected'] == true,
),
];
}
final _openingTitle = RegExp(r'\b(op|opening|intro)\d*\b', caseSensitive: false);
final _endingTitle = RegExp(r'\b(ed|ending|credits|outro)\d*\b', caseSensitive: false);
/// "OP", "OP1", "Opening", "Intro" (whole words, any case).
bool isOpeningTitle(String title) => _openingTitle.hasMatch(title);
/// "ED", "ED2", "Ending", "Credits", "Outro" (whole words, any case).
bool isEndingTitle(String title) => _endingTitle.hasMatch(title);
SkipRange? _range(List<MediaSegment> segments, List<MediaChapter> chapters,
JellyfinSegmentType kind, bool Function(String) titleMatches) {
for (final s in segments) {
if (s.kind == kind && s.end > s.start) return SkipRange(s.start, s.end, SkipSource.segment);
}
for (final c in chapters) {
if (c.end > c.start && titleMatches(c.title)) {
return SkipRange(c.start, c.end, SkipSource.chapter, title: c.title);
}
}
return null;
}
/// The opening: the first Jellyfin Intro segment, else the first chapter
/// titled like an opening ([isOpeningTitle]).
SkipRange? openingRange(List<MediaSegment> segments, List<MediaChapter> chapters) =>
_range(segments, chapters, JellyfinSegmentType.intro, isOpeningTitle);
/// The ending: the first Jellyfin Outro segment, else the first chapter
/// titled like an ending ([isEndingTitle]).
SkipRange? endingRange(List<MediaSegment> segments, List<MediaChapter> chapters) =>
_range(segments, chapters, JellyfinSegmentType.outro, isEndingTitle);
/// Index of the chapter playing at [position], or null (before the first
/// chapter, or none).
int? chapterIndexAt(List<MediaChapter> chapters, Duration position) {
int? at;
for (final c in chapters) {
if (c.start <= position) at = c.index;
}
return at;
}
/// mpv's `chapter-seek-threshold`: going back within this much of a
/// chapter's start goes to the chapter before it.
const chapterBackThreshold = Duration(seconds: 5);
/// Target of a chapter step ([delta] +1 = PgDn, -1 = PgUp), as mpv: back
/// goes to the current chapter's start, or the previous one within
/// [chapterBackThreshold] of it; forward to the next chapter's start.
/// Null when there is nowhere to go.
Duration? chapterStepTarget(List<MediaChapter> chapters, Duration position, int delta) {
if (chapters.isEmpty || delta == 0) return null;
final at = chapterIndexAt(chapters, position);
if (delta > 0) {
final next = (at ?? -1) + delta;
return next < chapters.length ? chapters[next].start : null;
}
if (at == null) return null;
var target = at + delta + 1;
if (position - chapters[at].start < chapterBackThreshold) target--;
return target >= 0 ? chapters[target].start : Duration.zero;
}
/// What the skip pill offers.
enum SkipKind {
/// "Skip OP": to the opening's end.
opening,
/// "Skip ED": to the ending's end, nothing further.
ending,
/// "Skip 1:30": no opening known, +[fallbackSkip] from where it plays.
fixed,
}
class SkipOffer {
final SkipKind kind;
/// Seek target (for [SkipKind.fixed] as of the position it was computed
/// at; the press itself skips relative to the moment it lands).
final Duration target;
const SkipOffer(this.kind, this.target);
@override
bool operator ==(Object other) => other is SkipOffer && other.kind == kind && other.target == target;
@override
int get hashCode => Object.hash(kind, target);
@override
String toString() => 'SkipOffer(${kind.name} -> $target)';
}
/// The fixed skip (pill without an opening, Shift+Right, the controls'
/// "1:30" button).
const fallbackSkip = Duration(seconds: 90);
/// The fallback pill needs media longer than this (an episode, not a clip).
const fallbackMinDuration = Duration(minutes: 20);
/// ... and shows during this first part only.
const fallbackWindow = Duration(minutes: 6);
/// No pill in the last second of a range: the skip would do nothing.
const _skipTail = Duration(seconds: 1);
/// The skip pill at [position]: inside the opening "Skip OP" (to its end),
/// inside the ending "Skip ED" (to its end only: what follows, preview or
/// more episode, is the viewer's call). Without any opening info on media
/// longer than [fallbackMinDuration], "Skip 1:30" during the first
/// [fallbackWindow]. Null otherwise.
SkipOffer? skipOfferAt({
required Duration position,
required Duration duration,
required SkipRange? opening,
required SkipRange? ending,
}) {
bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail;
if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end);
if (ending != null && inside(ending)) return SkipOffer(SkipKind.ending, ending.end);
if (opening == null && duration > fallbackMinDuration && position < fallbackWindow) {
final to = position + fallbackSkip;
return SkipOffer(SkipKind.fixed, to < duration ? to : duration);
}
return null;
}
/// "1:05", "1:02:03".
String formatClock(Duration d) {
final neg = d.isNegative;
final t = d.abs();
String two(int n) => n.toString().padLeft(2, '0');
final h = t.inHours, m = t.inMinutes % 60, s = t.inSeconds % 60;
final body = h > 0 ? '$h:${two(m)}:${two(s)}' : '$m:${two(s)}';
return neg ? '-$body' : body;
}
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
@@ -8,13 +9,13 @@ import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../jellyfin/track_prefs.dart' show MpvTrack;
import '../shaders/shader_assets.dart';
import '../shaders/shader_presets.dart';
import '../state/settings.dart';
import '../sync/state_diff.dart';
import '../sync/user_seek.dart';
import 'frame_watchdog.dart';
import 'media_marks.dart';
/// Owns one mpv [Player] (and its [VideoController]) per session: created
/// by [init] on join, released by [dispose] on leave, swapped by
@@ -38,6 +39,8 @@ class PlaybackController {
bool _probing = false;
final FrameWatchdog _watchdog = FrameWatchdog();
StreamSubscription<PlayerLog>? _logSub;
final List<StreamSubscription<Duration>> _timeSubs = [];
String _chapterJson = '';
// `glsl-shaders` as last set on the current player ('' = none).
String _shaders = '';
Future<void> _shaderOp = Future.value();
@@ -71,6 +74,44 @@ class PlaybackController {
final renderMode = signal(RenderMode.unknown);
static final lastRenderMode = signal(RenderMode.unknown);
// ------------------------------------------------- media marks (signals)
//
// What the open file has, for any controls UI (the session's seek bar,
// chapter / track menus and skip pills read these; nothing here knows
// about widgets). Reset when the player goes away; [segments] is owned
// by the coordinator (it survives Reload video, the item is the same).
/// mpv `chapter-list`: title and start; end = next start, or the
/// duration for the last one. Empty without chapters.
final chapters = signal<List<MediaChapter>>(const []);
/// Jellyfin MediaSegments of the current item (Intro / Outro / Recap /
/// Preview / Commercial). Only viewers with their own account fetch
/// them (relay viewers cannot call Jellyfin): empty otherwise.
final segments = signal<List<MediaSegment>>(const []);
/// Audio and subtitle tracks from mpv's `track-list` (id, language,
/// title, codec, channels, default / forced flags, [MpvTrack.selected]).
final tracks = signal<List<MpvTrack>>(const []);
/// Playback position, in 250 ms steps (enough for pills and menus; a
/// seek bar should listen to `player.stream.position`).
final position = signal(Duration.zero);
/// Duration of the open file (zero until known).
final duration = signal(Duration.zero);
/// The opening ([openingRange]: Intro segment, else an OP-titled
/// chapter) and the ending ([endingRange]: Outro, else ED-titled).
late final Computed<SkipRange?> opening =
computed(() => openingRange(segments.value, chapters.value));
late final Computed<SkipRange?> ending =
computed(() => endingRange(segments.value, chapters.value));
/// Index into [chapters] of the chapter playing now.
late final Computed<int?> currentChapter =
computed(() => chapterIndexAt(chapters.value, position.value));
/// Pinned mpv properties whose read-back differs from what was set
/// (mpv ignores invalid values silently), as `name: wanted -> got`.
final List<String> pinMismatches = [];
@@ -116,6 +157,7 @@ class PlaybackController {
renderMode.value = RenderMode.unknown;
shaderNotice.value = null;
_logSub = _player!.stream.log.listen(_onLog);
_watchMarks(_player!);
await _pinBufferProps();
await applyShaders();
_watchdog.reset();
@@ -364,6 +406,9 @@ class PlaybackController {
Future<void> openUrl(String url, {Map<String, String>? headers}) async {
if (!_ready) return;
try {
_chapterJson = '';
chapters.value = const [];
tracks.value = const [];
// An empty map, not null: media_kit caches headers per URI.
await player.open(Media(url, httpHeaders: headers ?? const {}), play: false);
} catch (e, st) {
@@ -372,28 +417,58 @@ class PlaybackController {
}
}
/// The audio / subtitle tracks of the open file (mpv's track-list).
List<MpvTrack> get tracks {
final p = _player;
if (p == null) return const [];
MpvTrack? of(dynamic t, JellyfinStreamType type) {
final id = int.tryParse('${t.id}');
if (id == null) return null;
return MpvTrack(
id: id,
type: type,
language: t.language as String?,
title: t.title as String?,
codec: t.codec as String?,
channels: (t.channelscount ?? t.audiochannels) as int?,
isDefault: t.isDefault == true,
);
}
// Position / duration from media_kit's streams; chapter-list and
// track-list observed in mpv (read back as JSON).
void _watchMarks(SyncPlayer p) {
_timeSubs.add(p.stream.position.listen((d) {
final ms = d.inMilliseconds;
position.value = Duration(milliseconds: ms - ms % 250);
}));
_timeSubs.add(p.stream.duration.listen((d) {
duration.value = d;
// The last chapter ends at the duration.
if (_chapterJson.isNotEmpty) chapters.value = parseChapterList(_chapterJson, d);
}));
() async {
try {
final dynamic platform = p.platform;
await platform?.observeProperty('chapter-list', (String v) async {
if (!identical(p, _player)) return;
_chapterJson = v;
chapters.value = parseChapterList(v, duration.peek());
});
await platform?.observeProperty('track-list', (String v) async {
if (!identical(p, _player)) return;
_setTracks(parseTrackList(v));
});
} catch (e) {
debugPrint('observe chapters / tracks failed: $e');
}
}();
}
return [
for (final t in p.state.tracks.audio) ?of(t, JellyfinStreamType.audio),
for (final t in p.state.tracks.subtitle) ?of(t, JellyfinStreamType.subtitle),
];
void _setTracks(List<MpvTrack> list) {
if (!listEquals(tracks.peek(), list)) tracks.value = list;
}
/// Reads `track-list` now (after a selection: [tracks] then shows it at
/// once instead of on mpv's next change notice).
Future<void> _refreshTracks() async {
final p = _player;
if (p == null) return;
try {
final dynamic platform = p.platform;
final v = '${await platform?.getProperty('track-list')}';
if (identical(p, _player)) _setTracks(parseTrackList(v));
} catch (_) {}
}
void _clearMarks() {
_chapterJson = '';
chapters.value = const [];
tracks.value = const [];
position.value = Duration.zero;
duration.value = Duration.zero;
}
/// Waits (bounded) until the open file's tracks are listed and mpv has
@@ -402,12 +477,12 @@ class PlaybackController {
Future<List<MpvTrack>> waitForTracks({Duration timeout = const Duration(seconds: 5)}) async {
final end = DateTime.now().add(timeout);
while (_player != null && DateTime.now().isBefore(end)) {
final t = tracks;
final t = tracks.peek();
final hasAudio = t.any((x) => x.type == JellyfinStreamType.audio);
if (t.isNotEmpty && (!hasAudio || (await currentTrackIds()).aid != null)) return t;
await Future.delayed(const Duration(milliseconds: 100));
}
return tracks;
return tracks.peek();
}
/// mpv's selected audio and subtitle track ids (null = none / off).
@@ -440,6 +515,7 @@ class PlaybackController {
} catch (e) {
debugPrint('select tracks failed: $e');
}
await _refreshTracks();
}
double get positionSecs {
@@ -495,9 +571,14 @@ class PlaybackController {
_watchTimer = null;
await _logSub?.cancel();
_logSub = null;
for (final sub in _timeSubs) {
await sub.cancel();
}
_timeSubs.clear();
_player = null;
_video = null;
alive.value = false;
_clearMarks();
await _settleUi();
try {
await p.dispose();