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:
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();
|
||||
|
||||
Reference in new issue
Block a user