diff --git a/README.md b/README.md index dd415ce..4e5abfb 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,27 @@ updates, playing the next Jellyfin episode automatically, video shaders Integrations links Jellyfin servers (below). About shows the version. Keys in a session: Space = ready / unready, Tab = side panel (people, log, -chat, change video), Left/Right = seek 10 s, F = fullscreen, Esc = close -panel or ask to leave, Ctrl+R = reload video, `<` / `>` or Shift+P / +chat, change video), Left/Right = seek 10 s, Shift+Right = skip 1:30, +PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc = +close panel or ask to leave, Ctrl+R = reload video, `<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode, Ctrl+1..6 = Anime4K mode A, B, -C, A+A, B+B, C+A, Ctrl+0 = shaders off. +C, A+A, B+B, C+A, Ctrl+0 = shaders off. Every seek, skip and chapter jump +moves everyone. + +Controls: the seek bar marks chapter starts with a diamond; hovering it +shows the time and the chapter title. The chapters button lists them +(current one checked; click to jump). "1:30" skips ahead 1:30. Audio and +Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3 +2.0 · title"; subtitles have Off. + +Skipping: during the opening a "Skip OP" button floats in the corner (it +jumps to the end of the opening), during the ending "Skip ED" (to the end +of the ending only; what comes after is yours to skip). The opening and +ending come from Jellyfin's intro / outro segments when your Jellyfin has +them (needs a segment plugin, e.g. Intro Skipper), else from chapter names +(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening, on +anything longer than 20 minutes, "Skip 1:30" floats there during the first +6 minutes. ## Ports @@ -145,15 +162,16 @@ Watching: a button to link one; it plays as soon as you are signed in. Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`, -Shift+P / Shift+N) put up the previous or next episode for everyone. In -the last two minutes a "Next episode" button floats in the corner. When an +Shift+P / Shift+N) put up the previous or next episode for everyone. From +the ending (when it is known, see Skipping above; else in the last two +minutes) a "Next episode" button floats in the corner. When an episode ends, a card shows what is next with Play next; with "Play the next episode automatically" (Settings, on by default) it counts down 10 s first, and Cancel stops it on your screen (seeking back stops it for everyone). Anyone with an account can put the next one up; friends watching via the host cannot. -Audio and subtitles: the subtitles button in the controls picks tracks. +Audio and subtitles: the Audio and Subtitles buttons in the controls pick tracks. Your choice is remembered per series (and as a default) and the closest match (language, then title, then codec and channels) is selected on the next episode. This is per person; nobody else is affected. diff --git a/apps/desktop/lib/src/jellyfin/session_media.dart b/apps/desktop/lib/src/jellyfin/session_media.dart index 5c10ffa..a7f0d05 100644 --- a/apps/desktop/lib/src/jellyfin/session_media.dart +++ b/apps/desktop/lib/src/jellyfin/session_media.dart @@ -314,10 +314,14 @@ Future<({EpisodeLink? previous, EpisodeLink? next})> findAdjacentEpisodes(OwnPla } } -/// When the floating "Next episode" pill appears: the last two minutes of -/// [runtime] (all of it for shorter media). One place to switch it to the -/// item's outro / credits segment later. -Duration nextPromptStart(JellyfinItem? item, Duration runtime) { +/// When the floating "Next episode" pill appears: at [endingStart] (the +/// ED: Jellyfin Outro segment or ED-titled chapter, see `endingRange`) +/// when one is known inside [runtime], else the last two minutes of +/// [runtime] (all of it for shorter media). +Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) { + if (endingStart != null && endingStart > Duration.zero && endingStart < runtime) { + return endingStart; + } const lead = Duration(minutes: 2); return runtime > lead ? runtime - lead : Duration.zero; } diff --git a/apps/desktop/lib/src/jellyfin/track_prefs.dart b/apps/desktop/lib/src/jellyfin/track_prefs.dart index 6ef15e3..04654e7 100644 --- a/apps/desktop/lib/src/jellyfin/track_prefs.dart +++ b/apps/desktop/lib/src/jellyfin/track_prefs.dart @@ -12,7 +12,8 @@ import 'accounts.dart'; const trackPrefsBoxName = 'track_prefs'; const _keyGlobal = 'global'; -/// One mpv track as media_kit lists it ([id] is mpv's `aid` / `sid`). +/// One audio or subtitle track of the open file as mpv's `track-list` +/// has it ([id] is mpv's `aid` / `sid`; [selected] is mpv's choice). class MpvTrack { final int id; final JellyfinStreamType type; @@ -21,6 +22,10 @@ class MpvTrack { final String? codec; final int? channels; final bool isDefault; + final bool isForced; + final bool isHearingImpaired; + final bool isExternal; + final bool selected; const MpvTrack({ required this.id, required this.type, @@ -29,6 +34,10 @@ class MpvTrack { this.codec, this.channels, this.isDefault = false, + this.isForced = false, + this.isHearingImpaired = false, + this.isExternal = false, + this.selected = false, }); /// As a stream for [matchTrack]; its index is the mpv id. @@ -39,17 +48,93 @@ class MpvTrack { title: title, codec: codec, channels: channels, - isDefault: isDefault); + isDefault: isDefault, + isForced: isForced, + isHearingImpaired: isHearingImpaired); - /// Menu text: "English (eac3 2ch)", "Track 2". - String get label { - final name = title ?? language ?? 'Track $id'; - final extra = [?codec, if (channels != null) '${channels}ch'].join(' '); - final lang = title != null && language != null ? ' [$language]' : ''; - return extra.isEmpty ? '$name$lang' : '$name$lang ($extra)'; - } + /// Menu text: "Japanese · E-AC3 2.0 · Commentary", "Track 2 · ASS". + String get label => [ + languageName(language) ?? 'Track $id', + [?codecName(codec), ?channelLayoutName(channels)].join(' '), + if (title != null && title != languageName(language)) title!, + if (isForced) 'Forced', + if (isHearingImpaired) 'SDH', + ].where((p) => p.isNotEmpty).join(' · '); + + @override + bool operator ==(Object other) => + other is MpvTrack && + other.id == id && + other.type == type && + other.language == language && + other.title == title && + other.codec == codec && + other.channels == channels && + other.isDefault == isDefault && + other.isForced == isForced && + other.isHearingImpaired == isHearingImpaired && + other.isExternal == isExternal && + other.selected == selected; + + @override + int get hashCode => Object.hash(id, type, language, title, codec, channels, isDefault, isForced, + isHearingImpaired, isExternal, selected); + + @override + String toString() => 'MpvTrack(${type.name} $id ${selected ? '*' : ''}$label)'; } +const _languages = { + 'jpn': 'Japanese', 'ja': 'Japanese', 'eng': 'English', 'en': 'English', + 'ger': 'German', 'deu': 'German', 'de': 'German', 'fre': 'French', 'fra': 'French', + 'fr': 'French', 'spa': 'Spanish', 'es': 'Spanish', 'ita': 'Italian', 'it': 'Italian', + 'por': 'Portuguese', 'pt': 'Portuguese', 'rus': 'Russian', 'ru': 'Russian', + 'chi': 'Chinese', 'zho': 'Chinese', 'zh': 'Chinese', 'kor': 'Korean', 'ko': 'Korean', + 'ara': 'Arabic', 'ar': 'Arabic', 'pol': 'Polish', 'pl': 'Polish', 'dut': 'Dutch', + 'nld': 'Dutch', 'nl': 'Dutch', 'swe': 'Swedish', 'sv': 'Swedish', 'fin': 'Finnish', + 'fi': 'Finnish', 'nor': 'Norwegian', 'no': 'Norwegian', 'dan': 'Danish', 'da': 'Danish', + 'tur': 'Turkish', 'tr': 'Turkish', 'hin': 'Hindi', 'hi': 'Hindi', 'tha': 'Thai', + 'th': 'Thai', 'vie': 'Vietnamese', 'vi': 'Vietnamese', 'ind': 'Indonesian', 'id': 'Indonesian', + 'may': 'Malay', 'msa': 'Malay', 'ms': 'Malay', 'heb': 'Hebrew', 'he': 'Hebrew', + 'gre': 'Greek', 'ell': 'Greek', 'el': 'Greek', 'cze': 'Czech', 'ces': 'Czech', 'cs': 'Czech', + 'hun': 'Hungarian', 'hu': 'Hungarian', 'ukr': 'Ukrainian', 'uk': 'Ukrainian', + 'rum': 'Romanian', 'ron': 'Romanian', 'ro': 'Romanian', 'und': 'Undetermined', +}; + +/// "jpn" -> "Japanese"; other codes upper-cased, null for none. +String? languageName(String? code) { + if (code == null || code.isEmpty) return null; + return _languages[code.toLowerCase()] ?? code.toUpperCase(); +} + +const _codecs = { + 'eac3': 'E-AC3', 'ac3': 'AC3', 'aac': 'AAC', 'flac': 'FLAC', 'opus': 'Opus', + 'vorbis': 'Vorbis', 'mp3': 'MP3', 'truehd': 'TrueHD', 'dts': 'DTS', 'mlp': 'MLP', + 'alac': 'ALAC', 'ass': 'ASS', 'ssa': 'SSA', 'subrip': 'SRT', 'srt': 'SRT', + 'webvtt': 'VTT', 'mov_text': 'TX3G', 'hdmv_pgs_subtitle': 'PGS', 'pgssub': 'PGS', + 'dvd_subtitle': 'VobSub', 'dvdsub': 'VobSub', 'dvb_subtitle': 'DVB', +}; + +/// mpv / Jellyfin codec name for menus: "eac3" -> "E-AC3", "subrip" -> "SRT". +String? codecName(String? codec) { + if (codec == null || codec.isEmpty) return null; + final c = codec.toLowerCase(); + if (c.startsWith('pcm')) return 'PCM'; + return _codecs[c] ?? codec.toUpperCase(); +} + +/// Channel count as a layout: 2 -> "2.0", 6 -> "5.1", 8 -> "7.1". +String? channelLayoutName(int? channels) => switch (channels) { + null || <= 0 => null, + 1 => '1.0', + 2 => '2.0', + 3 => '2.1', + 6 => '5.1', + 7 => '6.1', + 8 => '7.1', + final n => '${n}ch', + }; + /// A viewer's audio/subtitle choice as traits (indices shift between /// files). [subtitlesOff] wins over [subtitle]. class TrackChoice { diff --git a/apps/desktop/lib/src/playback/media_marks.dart b/apps/desktop/lib/src/playback/media_marks.dart new file mode 100644 index 0000000..6e8ec9b --- /dev/null +++ b/apps/desktop/lib/src/playback/media_marks.dart @@ -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 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 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 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 segments, List 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 segments, List 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 segments, List 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 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 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; +} diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart index 9469ea7..c2fd45f 100644 --- a/apps/desktop/lib/src/playback/playback_controller.dart +++ b/apps/desktop/lib/src/playback/playback_controller.dart @@ -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? _logSub; + final List> _timeSubs = []; + String _chapterJson = ''; // `glsl-shaders` as last set on the current player ('' = none). String _shaders = ''; Future _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>(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>(const []); + + /// Audio and subtitle tracks from mpv's `track-list` (id, language, + /// title, codec, channels, default / forced flags, [MpvTrack.selected]). + final tracks = signal>(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 opening = + computed(() => openingRange(segments.value, chapters.value)); + late final Computed ending = + computed(() => endingRange(segments.value, chapters.value)); + + /// Index into [chapters] of the chapter playing now. + late final Computed 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 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 openUrl(String url, {Map? 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 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 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 _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> 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(); diff --git a/apps/desktop/lib/src/screens/home/settings_page.dart b/apps/desktop/lib/src/screens/home/settings_page.dart index fd5a133..36be894 100644 --- a/apps/desktop/lib/src/screens/home/settings_page.dart +++ b/apps/desktop/lib/src/screens/home/settings_page.dart @@ -14,10 +14,13 @@ import 'update_indicator.dart'; const sessionShortcuts = [ ('Space', 'Toggle ready'), ('← / →', 'Seek back / forward 10 s'), + ('Shift+→', 'Skip 1:30 (for everyone)'), + ('PgUp / PgDn', 'Previous / next chapter'), ('F', 'Toggle fullscreen'), ('Tab', 'Open / close the people & chat panel'), ('Ctrl+R', 'Reload the video'), ('> or Shift+N', 'Put up the next Jellyfin episode'), + ('< or Shift+P', 'Put up the previous Jellyfin episode'), ('Ctrl+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'), ('Ctrl+0', 'Shaders off'), ('Esc', 'Close panel or fullscreen, else ask to leave'), diff --git a/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart index 3656933..04717d3 100644 --- a/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart +++ b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:sync_relay_core/core.dart' show JellyfinStreamType; import '../../jellyfin/session_media.dart'; -import '../../jellyfin/track_prefs.dart'; import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs; +import 'player_controls.dart' show FloatingPill; // Jellyfin pieces of the session screen. Plain widgets fed by the // coordinator's signals, so tests can pump them without the player. @@ -37,65 +36,21 @@ class EpisodeSkipButton extends StatelessWidget { } } -/// Floating "Next episode ›" near the end of an episode: faint at rest, -/// lit on hover. Lives outside the auto-hiding controls, so it shows in -/// fullscreen and with the controls hidden. -class NextEpisodePill extends StatefulWidget { +/// Floating "Next episode ›" from the ED (or the last two minutes): +/// faint at rest, lit on hover. Lives outside the auto-hiding controls, so +/// it shows in fullscreen and with the controls hidden. +class NextEpisodePill extends StatelessWidget { final EpisodeLink episode; final VoidCallback onPressed; const NextEpisodePill({super.key, required this.episode, required this.onPressed}); @override - State createState() => _NextEpisodePillState(); -} - -class _NextEpisodePillState extends State { - bool _hover = false; - - @override - Widget build(BuildContext context) { - final primary = Theme.of(context).colorScheme.primary; - return MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _hover = true), - onExit: (_) => setState(() => _hover = false), - child: Tooltip( - message: 'Next: ${widget.episode.label}', - child: AnimatedScale( - duration: const Duration(milliseconds: 150), - scale: _hover ? 1.06 : 1, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: _hover ? 1 : 0.45, - child: GestureDetector( - key: const ValueKey('next-episode-pill'), - onTap: widget.onPressed, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.75), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: _hover ? primary : Colors.white24), - boxShadow: _hover - ? [BoxShadow(color: primary.withValues(alpha: 0.45), blurRadius: 16)] - : const [], - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('Next episode', style: TextStyle(color: Colors.white, fontSize: 13)), - SizedBox(width: 4), - Icon(Icons.chevron_right, color: Colors.white, size: 18), - ], - ), - ), - ), - ), - ), - ), - ); - } + Widget build(BuildContext context) => FloatingPill( + label: 'Next episode', + tooltip: 'Next: ${episode.label}', + tapKey: const ValueKey('next-episode-pill'), + onPressed: onPressed, + ); } /// At the end of an episode: "Next: S1E7 title" with Play now, and while @@ -231,98 +186,3 @@ class ViaHostBadge extends StatelessWidget { ); } } - -/// A track pick from [TracksControlButton]: audio id, subtitle id, or off. -typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff}); - -/// Audio / subtitle menu for the controls bar. The choice is remembered -/// for the next Jellyfin item (per viewer). -class TracksControlButton extends StatelessWidget { - final FocusNode? focusNode; - final List Function() tracks; - final Future<({int? aid, int? sid})> Function() current; - final void Function(TrackPick pick) onPick; - final ValueChanged? onMenuOpen; - const TracksControlButton({ - super.key, - this.focusNode, - required this.tracks, - required this.current, - required this.onPick, - this.onMenuOpen, - }); - - @override - Widget build(BuildContext context) { - return Tooltip( - message: 'Audio and subtitles', - child: IconButton( - key: const ValueKey('tracks-button'), - focusNode: focusNode, - color: Colors.white, - iconSize: 20, - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.subtitles_outlined), - onPressed: () => _open(context), - ), - ); - } - - Future _open(BuildContext context) async { - final list = tracks(); - final button = context.findRenderObject() as RenderBox; - final overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox; - final position = RelativeRect.fromRect( - Rect.fromPoints( - button.localToGlobal(Offset.zero, ancestor: overlay), - button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay), - ), - Offset.zero & overlay.size, - ); - final navigator = Navigator.of(context); - final now = await current(); - if (!navigator.mounted) return; - final audio = list.where((t) => t.type == JellyfinStreamType.audio).toList(); - final subs = list.where((t) => t.type == JellyfinStreamType.subtitle).toList(); - PopupMenuItem header(String text) => PopupMenuItem( - enabled: false, - height: 28, - child: Text(text, style: const TextStyle(fontSize: 12, color: Colors.grey))); - onMenuOpen?.call(true); - final pick = await showMenu( - context: navigator.context, - position: position, - popUpAnimationStyle: AnimationStyle.noAnimation, - constraints: const BoxConstraints(minWidth: 280, maxWidth: 420), - items: [ - header('Audio'), - if (audio.isEmpty) - const PopupMenuItem(enabled: false, height: 32, child: Text('None')), - for (final t in audio) - CheckedPopupMenuItem( - value: (aid: t.id, sid: null, subtitlesOff: false), - checked: now.aid == t.id, - height: 36, - child: Text(t.label, maxLines: 1, overflow: TextOverflow.ellipsis), - ), - const PopupMenuDivider(), - header('Subtitles'), - CheckedPopupMenuItem( - value: (aid: null, sid: null, subtitlesOff: true), - checked: now.sid == null, - height: 36, - child: const Text('Off'), - ), - for (final t in subs) - CheckedPopupMenuItem( - value: (aid: null, sid: t.id, subtitlesOff: false), - checked: now.sid == t.id, - height: 36, - child: Text(t.label, maxLines: 1, overflow: TextOverflow.ellipsis), - ), - ], - ); - onMenuOpen?.call(false); - if (pick != null) onPick(pick); - } -} diff --git a/apps/desktop/lib/src/screens/session/player_controls.dart b/apps/desktop/lib/src/screens/session/player_controls.dart new file mode 100644 index 0000000..4a84ba0 --- /dev/null +++ b/apps/desktop/lib/src/screens/session/player_controls.dart @@ -0,0 +1,624 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:sync_relay_core/core.dart' show JellyfinStreamType; + +import '../../jellyfin/track_prefs.dart' show MpvTrack; +import '../../playback/media_marks.dart'; + +// Session control-bar pieces built on the playback signals (chapters, +// tracks, skip offers): the seek bar with chapter marks, the chapter and +// track menus, the 1:30 skip button and the floating skip pill. Plain +// widgets fed by values and callbacks, so tests pump them without a player. + +/// Keeps media_kit's desktop controls shown, as a mouse hover would. +/// While a seek bar drag runs no hover events arrive, and the controls +/// would hide (and unmount the bar) mid-drag. A guarded dynamic call on +/// the private controls state; nothing happens outside those controls. +void keepVideoControlsAwake(BuildContext context) { + context.visitAncestorElements((e) { + if (e is StatefulElement && + e.state.runtimeType.toString() == '_MaterialDesktopVideoControlsState') { + try { + (e.state as dynamic).onHover(); + } catch (_) {} + return false; + } + return true; + }); +} + +/// The seek bar: played / buffered track, a diamond at each chapter start +/// (not the one at 0), and on hover the time with the chapter title above +/// it. Like media_kit's bar it reports a seek on every drag move and on +/// release ([onSeek]); the session routes those through the user-seek path +/// (debounced, everyone follows). +class ChapterSeekBar extends StatefulWidget { + final Duration position; + final Duration duration; + final Duration buffer; + final List chapters; + final ValueChanged onSeek; + /// Any pointer activity on the bar (keeps the controls up). + final VoidCallback? onActivity; + const ChapterSeekBar({ + super.key, + required this.position, + required this.duration, + this.buffer = Duration.zero, + this.chapters = const [], + required this.onSeek, + this.onActivity, + }); + + static const height = 28.0; + + @override + State createState() => _ChapterSeekBarState(); +} + +class _ChapterSeekBarState extends State { + double? _hoverX; + bool _dragging = false; + double _dragFraction = 0; + + double _fraction(Duration d) { + final total = widget.duration.inMicroseconds; + if (total <= 0) return 0; + return (d.inMicroseconds / total).clamp(0.0, 1.0); + } + + Duration _at(double fraction) => widget.duration * fraction.clamp(0.0, 1.0); + + void _seekTo(double x, double width) { + if (widget.duration <= Duration.zero || width <= 0) return; + _dragFraction = (x / width).clamp(0.0, 1.0); + widget.onSeek(_at(_dragFraction)); + } + + @override + Widget build(BuildContext context) { + final primary = Theme.of(context).colorScheme.primary; + return LayoutBuilder(builder: (context, constraints) { + final width = constraints.maxWidth; + final active = _hoverX != null || _dragging; + final barHeight = active ? 5.0 : 3.0; + final played = _dragging ? _dragFraction : _fraction(widget.position); + const mid = ChapterSeekBar.height / 2; + final hoverX = _hoverX; + final hoverAt = hoverX == null || width <= 0 ? null : _at(hoverX / width); + final hoverChapter = hoverAt == null ? null : chapterIndexAt(widget.chapters, hoverAt); + final hoverTitle = hoverChapter == null ? '' : widget.chapters[hoverChapter].title; + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (e) => setState(() => _hoverX = e.localPosition.dx.clamp(0, width)), + onHover: (e) { + setState(() => _hoverX = e.localPosition.dx.clamp(0, width)); + widget.onActivity?.call(); + }, + onExit: (_) => setState(() => _hoverX = null), + child: Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (e) { + widget.onActivity?.call(); + setState(() { + _dragging = true; + _dragFraction = width <= 0 ? 0 : (e.localPosition.dx / width).clamp(0.0, 1.0); + }); + }, + onPointerMove: (e) { + widget.onActivity?.call(); + setState(() => _hoverX = e.localPosition.dx.clamp(0, width)); + _seekTo(e.localPosition.dx, width); + }, + onPointerUp: (e) { + _seekTo(e.localPosition.dx, width); + setState(() => _dragging = false); + widget.onActivity?.call(); + }, + onPointerCancel: (_) => setState(() => _dragging = false), + child: SizedBox( + key: const ValueKey('seek-bar'), + width: width, + height: ChapterSeekBar.height, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + right: 0, + top: mid - barHeight / 2, + height: barHeight, + child: Container(color: Colors.white24), + ), + Positioned( + left: 0, + top: mid - barHeight / 2, + height: barHeight, + width: width * _fraction(widget.buffer), + child: Container(color: Colors.white24), + ), + if (hoverX != null && !_dragging) + Positioned( + left: 0, + top: mid - barHeight / 2, + height: barHeight, + width: hoverX, + child: Container(color: Colors.white24), + ), + Positioned( + left: 0, + top: mid - barHeight / 2, + height: barHeight, + width: width * played, + child: Container(color: primary), + ), + for (final c in widget.chapters) + if (c.start > Duration.zero && widget.duration > Duration.zero) + Positioned( + key: ValueKey('chapter-mark-${c.index}'), + left: width * _fraction(c.start) - 4, + top: mid - 4, + width: 8, + height: 8, + child: IgnorePointer( + child: Transform.rotate( + angle: math.pi / 4, + child: Container( + margin: const EdgeInsets.all(1), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.black54, width: 0.8), + ), + ), + ), + ), + ), + if (active) + Positioned( + left: width * played - 6, + top: mid - 6, + width: 12, + height: 12, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration(color: primary, shape: BoxShape.circle), + ), + ), + ), + if (hoverAt != null) + Positioned( + // Kept clear of the window edges (the tip is centred). + left: hoverX!.clamp(math.min(60.0, width / 2), math.max(width - 60, width / 2)), + bottom: ChapterSeekBar.height - 2, + child: IgnorePointer( + child: FractionalTranslation( + translation: const Offset(-0.5, 0), + child: _HoverTip(title: hoverTitle, time: formatClock(hoverAt)), + ), + ), + ), + ], + ), + ), + ), + ); + }); + } +} + +class _HoverTip extends StatelessWidget { + final String title; + final String time; + const _HoverTip({required this.title, required this.time}); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(maxWidth: 320), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (title.isNotEmpty) + Text(title, + key: const ValueKey('seek-hover-chapter'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)), + Text(time, + key: const ValueKey('seek-hover-time'), + style: const TextStyle(color: Colors.white70, fontSize: 11)), + ], + ), + ); + } +} + +/// [ChapterSeekBar] on a live player: position, duration and buffer from +/// its streams. Seeks go to `player.seek`, which the session's +/// `SyncPlayer` turns into user seeks. +class PlayerSeekBar extends StatefulWidget { + final Player player; + final List chapters; + const PlayerSeekBar({super.key, required this.player, required this.chapters}); + + @override + State createState() => _PlayerSeekBarState(); +} + +class _PlayerSeekBarState extends State { + final List> _subs = []; + late Duration _position, _duration, _buffer; + + @override + void initState() { + super.initState(); + _listen(); + } + + @override + void didUpdateWidget(PlayerSeekBar old) { + super.didUpdateWidget(old); + if (!identical(old.player, widget.player)) { + _cancel(); + _listen(); + } + } + + void _listen() { + final p = widget.player; + _position = p.state.position; + _duration = p.state.duration; + _buffer = p.state.buffer; + void set(VoidCallback f) { + if (mounted) setState(f); + } + + _subs.addAll([ + p.stream.position.listen((d) => set(() => _position = d)), + p.stream.duration.listen((d) => set(() => _duration = d)), + p.stream.buffer.listen((d) => set(() => _buffer = d)), + ]); + } + + void _cancel() { + for (final s in _subs) { + s.cancel(); + } + _subs.clear(); + } + + @override + void dispose() { + _cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ChapterSeekBar( + position: _position, + duration: _duration, + buffer: _buffer, + chapters: widget.chapters, + onSeek: (d) => widget.player.seek(d), + onActivity: () => keepVideoControlsAwake(context), + ); + } +} + +RelativeRect _menuPosition(BuildContext context) { + final button = context.findRenderObject() as RenderBox; + final overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox; + return RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(Offset.zero, ancestor: overlay), + button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay), + ), + Offset.zero & overlay.size, + ); +} + +/// Control-bar icon with a small count in the corner. +class _BadgedIcon extends StatelessWidget { + final IconData icon; + final int count; + const _BadgedIcon(this.icon, this.count); + + @override + Widget build(BuildContext context) { + return Stack( + clipBehavior: Clip.none, + children: [ + Icon(icon), + if (count > 0) + Positioned( + right: -7, + top: -5, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0.5), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(999), + ), + child: Text('$count', + style: const TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)), + ), + ), + ], + ); + } +} + +/// Chapter list for the controls: current chapter checked, a pick seeks +/// to its start. Nothing when the file has no chapters. +class ChaptersControlButton extends StatelessWidget { + final List chapters; + final int? current; + final ValueChanged onPick; + final FocusNode? focusNode; + final ValueChanged? onMenuOpen; + const ChaptersControlButton({ + super.key, + required this.chapters, + required this.current, + required this.onPick, + this.focusNode, + this.onMenuOpen, + }); + + @override + Widget build(BuildContext context) { + if (chapters.isEmpty) return const SizedBox.shrink(); + return Tooltip( + message: 'Chapters (PgUp / PgDn)', + child: IconButton( + key: const ValueKey('chapters-button'), + focusNode: focusNode, + color: Colors.white, + iconSize: 20, + visualDensity: VisualDensity.compact, + icon: _BadgedIcon(Icons.format_list_bulleted, chapters.length), + onPressed: () => _open(context), + ), + ); + } + + Future _open(BuildContext context) async { + final position = _menuPosition(context); + onMenuOpen?.call(true); + final pick = await showMenu( + context: context, + position: position, + popUpAnimationStyle: AnimationStyle.noAnimation, + constraints: const BoxConstraints(minWidth: 240, maxWidth: 420), + items: [ + for (final c in chapters) + CheckedPopupMenuItem( + value: c, + checked: c.index == current, + height: 36, + child: Row( + children: [ + Expanded( + child: Text(c.title.isEmpty ? 'Chapter ${c.index + 1}' : c.title, + maxLines: 1, overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: 12), + Text(formatClock(c.start), style: const TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), + ), + ], + ); + onMenuOpen?.call(false); + if (pick != null) onPick(pick); + } +} + +/// A track pick from [TrackMenuButton]: audio id, subtitle id, or off. +typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff}); + +/// Audio or subtitle menu for the controls bar (the count of tracks on the +/// icon): "Japanese · E-AC3 2.0 · title", mpv's selection checked, +/// subtitles with Off. The choice is remembered for the next Jellyfin item +/// (per viewer). +class TrackMenuButton extends StatelessWidget { + final JellyfinStreamType type; + /// All tracks; only those of [type] are listed. + final List tracks; + final void Function(TrackPick pick) onPick; + final FocusNode? focusNode; + final ValueChanged? onMenuOpen; + const TrackMenuButton({ + super.key, + required this.type, + required this.tracks, + required this.onPick, + this.focusNode, + this.onMenuOpen, + }); + + bool get _audio => type == JellyfinStreamType.audio; + + @override + Widget build(BuildContext context) { + final list = tracks.where((t) => t.type == type).toList(); + return Tooltip( + message: _audio ? 'Audio' : 'Subtitles', + child: IconButton( + key: ValueKey(_audio ? 'audio-button' : 'subtitles-button'), + focusNode: focusNode, + color: Colors.white, + iconSize: 20, + visualDensity: VisualDensity.compact, + icon: _BadgedIcon(_audio ? Icons.audiotrack_outlined : Icons.subtitles_outlined, list.length), + onPressed: () => _open(context, list), + ), + ); + } + + Future _open(BuildContext context, List list) async { + final position = _menuPosition(context); + onMenuOpen?.call(true); + final pick = await showMenu( + context: context, + position: position, + popUpAnimationStyle: AnimationStyle.noAnimation, + constraints: const BoxConstraints(minWidth: 280, maxWidth: 460), + items: [ + if (!_audio) + CheckedPopupMenuItem( + value: (aid: null, sid: null, subtitlesOff: true), + checked: !list.any((t) => t.selected), + height: 36, + child: const Text('Off'), + ), + if (_audio && list.isEmpty) + const PopupMenuItem(enabled: false, height: 32, child: Text('None')), + for (final t in list) + CheckedPopupMenuItem( + value: _audio + ? (aid: t.id, sid: null, subtitlesOff: false) + : (aid: null, sid: t.id, subtitlesOff: false), + checked: t.selected, + height: 36, + child: Text(t.label, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + ); + onMenuOpen?.call(false); + if (pick != null) onPick(pick); + } +} + +/// "1:30" in the controls: skip forward [fallbackSkip] (Shift+Right). +class SkipForwardButton extends StatelessWidget { + final VoidCallback onPressed; + final FocusNode? focusNode; + const SkipForwardButton({super.key, required this.onPressed, this.focusNode}); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: 'Skip 1:30 for everyone (Shift+Right)', + child: TextButton.icon( + key: const ValueKey('skip-forward-button'), + focusNode: focusNode, + style: TextButton.styleFrom( + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: onPressed, + icon: const Icon(Icons.fast_forward_rounded, size: 16), + label: const Text('1:30', style: TextStyle(fontSize: 12)), + ), + ); + } +} + +/// Floating pill over the video (Next episode, Skip OP / ED / 1:30): +/// faint at rest, lit on hover. Lives outside the auto-hiding controls, so +/// it shows in fullscreen and with the controls hidden. +class FloatingPill extends StatefulWidget { + final String label; + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + /// Key of the tap target (tests find the pill by it). + final Key? tapKey; + const FloatingPill({ + super.key, + required this.label, + required this.tooltip, + required this.onPressed, + this.icon = Icons.chevron_right, + this.tapKey, + }); + + @override + State createState() => _FloatingPillState(); +} + +class _FloatingPillState extends State { + bool _hover = false; + + @override + Widget build(BuildContext context) { + final primary = Theme.of(context).colorScheme.primary; + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: Tooltip( + message: widget.tooltip, + child: AnimatedScale( + duration: const Duration(milliseconds: 150), + scale: _hover ? 1.06 : 1, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: _hover ? 1 : 0.45, + child: GestureDetector( + key: widget.tapKey, + onTap: widget.onPressed, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: _hover ? primary : Colors.white24), + boxShadow: _hover + ? [BoxShadow(color: primary.withValues(alpha: 0.45), blurRadius: 16)] + : const [], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(widget.label, style: const TextStyle(color: Colors.white, fontSize: 13)), + const SizedBox(width: 4), + Icon(widget.icon, color: Colors.white, size: 18), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +/// "Skip OP" / "Skip ED" / "Skip 1:30" for a [SkipOffer]. +class SkipPill extends StatelessWidget { + final SkipOffer offer; + final VoidCallback onPressed; + const SkipPill({super.key, required this.offer, required this.onPressed}); + + static String labelFor(SkipKind kind) => switch (kind) { + SkipKind.opening => 'Skip OP', + SkipKind.ending => 'Skip ED', + SkipKind.fixed => 'Skip 1:30', + }; + + @override + Widget build(BuildContext context) { + return FloatingPill( + label: labelFor(offer.kind), + tooltip: offer.kind == SkipKind.fixed + ? 'Skip 1:30 for everyone (Shift+Right)' + : 'Skip to ${formatClock(offer.target)} for everyone', + icon: Icons.fast_forward_rounded, + tapKey: const ValueKey('skip-pill'), + onPressed: onPressed, + ); + } +} diff --git a/apps/desktop/lib/src/screens/session_screen.dart b/apps/desktop/lib/src/screens/session_screen.dart index 59e6a10..58713dc 100644 --- a/apps/desktop/lib/src/screens/session_screen.dart +++ b/apps/desktop/lib/src/screens/session_screen.dart @@ -4,6 +4,8 @@ import 'package:media_kit_video/media_kit_video.dart'; import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart'; +import '../playback/media_marks.dart' show SkipOffer; + import '../jellyfin/browse_scope.dart' show BrowseStart; import '../jellyfin/session_media.dart'; import '../session/health.dart'; @@ -22,6 +24,7 @@ import '../widgets/window_drag_layer.dart'; import 'home/integrations_page.dart'; import 'home/media_picker.dart'; import 'session/jellyfin_widgets.dart'; +import 'session/player_controls.dart'; import 'session/swap_panel.dart'; class SessionScreen extends StatefulWidget { @@ -257,6 +260,17 @@ class _SessionScreenState extends State { coord.playPreviousEpisode(); return true; } + // mpv's chapter keys. + if (event.logicalKey == LogicalKeyboardKey.pageDown || + event.logicalKey == LogicalKeyboardKey.pageUp) { + _lastKeyAt = now; + return coord.stepChapter(event.logicalKey == LogicalKeyboardKey.pageDown ? 1 : -1); + } + if (shift && event.logicalKey == LogicalKeyboardKey.arrowRight) { + _lastKeyAt = now; + coord.skipForward(); + return true; + } if (event.logicalKey == LogicalKeyboardKey.tab) { _lastKeyAt = now; _sheetOpen.value = !_sheetOpen.peek(); @@ -320,56 +334,51 @@ class _SessionScreenState extends State { visibleOnMount: true, hideMouseOnControlsRemoval: true, controlsHoverDuration: const Duration(seconds: 3), + // Our seek bar (chapter marks, hover title) sits in the bottom bar, + // which grows by its height; the top bar keeps its row at the top. + displaySeekBar: false, + buttonBarHeight: _barHeight + ChapterSeekBar.height, topButtonBar: [ - const Spacer(), - MaterialDesktopCustomButton( - icon: const Icon(Icons.group), - onPressed: () { - _sheetOpen.value = true; - coord.markInput(); - }, - ), - SignalBuilder( - builder: (context) => WindowButtons(focusNode: _noFocus, fullscreen: _isFullscreen.value, onToggleFullscreen: _toggleFullscreen), + Expanded( + child: Align( + alignment: Alignment.topCenter, + child: SizedBox( + height: _barHeight, + child: Row( + children: [ + const Spacer(), + MaterialDesktopCustomButton( + icon: const Icon(Icons.group), + onPressed: () { + _sheetOpen.value = true; + coord.markInput(); + }, + ), + SignalBuilder( + builder: (context) => WindowButtons(focusNode: _noFocus, fullscreen: _isFullscreen.value, onToggleFullscreen: _toggleFullscreen), + ), + ], + ), + ), + ), ), ], bottomButtonBar: [ - SignalBuilder( - builder: (context) => EpisodeSkipButton( - focusNode: _noFocus, - episode: coord.previousEpisode.value, - next: false, - onPressed: coord.playPreviousEpisode, + Expanded( + child: Column( + children: [ + SignalBuilder( + builder: (context) => PlayerSeekBar( + player: coord.playback.player, + chapters: coord.playback.chapters.value, + ), + ), + Expanded( + child: Row(children: _bottomButtons(coord)), + ), + ], ), ), - SignalBuilder( - builder: (context) => EpisodeSkipButton( - focusNode: _noFocus, - episode: coord.nextEpisode.value, - next: true, - onPressed: coord.playNextEpisode, - ), - ), - const MaterialDesktopVolumeButton(), - _ReadyControlButton(focusNode: _noFocus), - _MarkAllReadyControlButton(focusNode: _noFocus), - _ReloadVideoControlButton(focusNode: _noFocus, onPressed: _reloadVideo), - const MaterialDesktopPositionIndicator(), - const Spacer(), - TracksControlButton( - focusNode: _noFocus, - tracks: () => coord.playback.tracks, - current: coord.playback.currentTrackIds, - onPick: (p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff), - onMenuOpen: (open) => _shaderMenuOpen = open, - ), - ShaderControlButton( - focusNode: _noFocus, - renderMode: coord.playback.renderMode, - onPick: _pickShader, - onMenuOpen: (open) => _shaderMenuOpen = open, - ), - const MaterialDesktopFullscreenButton(), ], ); return FocusTraversalGroup( @@ -415,6 +424,73 @@ class _SessionScreenState extends State { ); } + static const _barHeight = 56.0; + + List _bottomButtons(SessionCoordinator coord) { + final playback = coord.playback; + void menuOpen(bool open) => _shaderMenuOpen = open; + void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff); + return [ + SignalBuilder( + builder: (context) => EpisodeSkipButton( + focusNode: _noFocus, + episode: coord.previousEpisode.value, + next: false, + onPressed: coord.playPreviousEpisode, + ), + ), + SignalBuilder( + builder: (context) => EpisodeSkipButton( + focusNode: _noFocus, + episode: coord.nextEpisode.value, + next: true, + onPressed: coord.playNextEpisode, + ), + ), + const MaterialDesktopVolumeButton(), + _ReadyControlButton(focusNode: _noFocus), + _MarkAllReadyControlButton(focusNode: _noFocus), + _ReloadVideoControlButton(focusNode: _noFocus, onPressed: _reloadVideo), + const MaterialDesktopPositionIndicator(), + const Spacer(), + SkipForwardButton(focusNode: _noFocus, onPressed: coord.skipForward), + SignalBuilder( + builder: (context) => ChaptersControlButton( + focusNode: _noFocus, + chapters: playback.chapters.value, + current: playback.currentChapter.value, + onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6), + onMenuOpen: menuOpen, + ), + ), + SignalBuilder( + builder: (context) => TrackMenuButton( + focusNode: _noFocus, + type: JellyfinStreamType.audio, + tracks: playback.tracks.value, + onPick: pickTrack, + onMenuOpen: menuOpen, + ), + ), + SignalBuilder( + builder: (context) => TrackMenuButton( + focusNode: _noFocus, + type: JellyfinStreamType.subtitle, + tracks: playback.tracks.value, + onPick: pickTrack, + onMenuOpen: menuOpen, + ), + ), + ShaderControlButton( + focusNode: _noFocus, + renderMode: playback.renderMode, + onPick: _pickShader, + onMenuOpen: menuOpen, + ), + const MaterialDesktopFullscreenButton(), + ]; + } + Widget _sessionControls(VideoState state) { return Stack( children: [ @@ -575,8 +651,9 @@ class _SessionScreenState extends State { } /// Jellyfin: the blocked card, the end-of-episode card (countdown) and - /// the "Next episode" pill. Outside the auto-hiding controls, so they - /// also show in fullscreen. + /// the "Next episode" pill; for any media the skip pill (Skip OP / ED / + /// 1:30) above it. Outside the auto-hiding controls, so they also show + /// in fullscreen. Widget _jellyfinOverlay() { final coord = SessionState.instance.coordinator; return Positioned.fill( @@ -587,6 +664,8 @@ class _SessionScreenState extends State { final countdown = coord.autoAdvance.value; final finished = coord.serverState.value?.transport == 'finished'; final pill = coord.nextPromptVisible.value; + final SkipOffer? skip = coord.skipOffer.value; + final showNext = next != null && pill; return Stack( children: [ if (blocked != null) @@ -600,7 +679,7 @@ class _SessionScreenState extends State { if (next != null && (finished || countdown != null)) Positioned( right: 24, - bottom: 110, + bottom: 138, child: EndOfEpisodeCard( next: next, countdown: countdown, @@ -608,11 +687,18 @@ class _SessionScreenState extends State { onCancel: coord.cancelAutoAdvance, ), ) - else if (next != null && pill) + else if (showNext || skip != null) Positioned( right: 24, - bottom: 96, - child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode), + bottom: 124, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 10, + children: [ + if (skip != null) SkipPill(offer: skip, onPressed: () => coord.skip(skip)), + if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode), + ], + ), ), ], ); diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 9dd5a1b..e5d0219 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -8,6 +8,7 @@ import '../jellyfin/accounts.dart'; import '../jellyfin/progress.dart'; import '../jellyfin/session_media.dart'; import '../jellyfin/track_prefs.dart'; +import '../playback/media_marks.dart'; import '../playback/playback_controller.dart'; import '../screens/home/picked_media.dart'; import '../sfx/sfx.dart'; @@ -136,6 +137,21 @@ class SessionCoordinator { /// End-of-episode countdown; null when none runs here. final autoAdvance = signal(null); + /// The skip pill for the current position ([skipOfferAt]: Skip OP, + /// Skip ED, or Skip 1:30 without opening info); null while nothing + /// is open or at `finished`. Press it with [skip]. + late final Computed skipOffer = computed(() { + final r = serverState.value; + final duration = playback.duration.value; + if (r == null || r.transport == 'finished' || duration <= Duration.zero) return null; + return skipOfferAt( + position: playback.position.value, + duration: duration, + opening: playback.opening.value, + ending: playback.ending.value, + ); + }); + late final Computed selfMember = computed(() { final r = serverState.value; if (r == null || sync.memberId.isEmpty) return null; @@ -767,6 +783,33 @@ class SessionCoordinator { _userSeek(target < 0 ? 0 : target); } + /// A skip pill: Skip OP / Skip ED seek to the range's end, Skip 1:30 + /// is [skipForward]. Through the user-seek path, so everyone follows. + void skip(SkipOffer offer) { + if (offer.kind == SkipKind.fixed) { + skipForward(); + } else { + seekTo(offer.target.inMicroseconds / 1e6); + } + } + + /// +[fallbackSkip] (1:30): the controls' button, Shift+Right and the + /// fallback pill. Relative to a seek in flight, like the arrow keys. + void skipForward() => nudge(fallbackSkip.inMicroseconds / 1e6); + + /// PgDn (+1) / PgUp (-1): next / previous chapter as mpv steps them + /// ([chapterStepTarget]). False when there is no chapter to go to. + bool stepChapter(int delta) { + final base = _seekGate.target; + final at = base == null + ? playback.position.peek() + : Duration(microseconds: (base * 1e6).round()); + final target = chapterStepTarget(playback.chapters.peek(), at, delta); + if (target == null) return false; + seekTo(target.inMicroseconds / 1e6); + return true; + } + void sendChat(String text) { // Enter in the chat field can fire both onSubmitted and the focused // send button's onPressed; the first send clears the field, so the @@ -863,6 +906,7 @@ class SessionCoordinator { previousEpisode.value = null; nextEpisode.value = null; nextPromptVisible.value = false; + playback.segments.value = const []; _advanceTimer?.cancel(); _advanceTimer = null; _advanceDoneFor = -1; @@ -988,6 +1032,7 @@ class SessionCoordinator { }, ); unawaited(_findEpisodes(plan, gen)); + unawaited(_loadSegments(plan, ref, gen)); // Playing carries the tracks: wait (bounded) until the file is in. await playback.waitForTracks(); if (gen != _jfGen) return; @@ -1009,6 +1054,18 @@ class SessionCoordinator { if (r != null) _jellyfinTransport(r); } + /// The item's MediaSegments (own account only; a server without the + /// endpoint or without a segment provider gives none). + Future _loadSegments(OwnPlay plan, JellyfinRef ref, int gen) async { + try { + final list = await plan.client.mediaSegments(ref.itemId).timeout(const Duration(seconds: 15)); + if (gen != _jfGen) return; + playback.segments.value = [for (final s in list) MediaSegment.fromJellyfin(s)]; + } catch (e) { + debugPrint('media segments for ${ref.itemId} failed: $e'); + } + } + Future _applyTrackChoice(JellyfinPlan plan, int gen) async { final own = plan is OwnPlay ? plan : null; final choice = await trackPrefs.load(seriesId: own?.item?.seriesId); @@ -1028,7 +1085,7 @@ class SessionCoordinator { final plan = jellyfinPlan.peek(); final own = plan is OwnPlay ? plan : null; await trackPrefs.save( - TrackChoice.of(aid: ids.aid, sid: ids.sid, source: own?.source, tracks: playback.tracks), + TrackChoice.of(aid: ids.aid, sid: ids.sid, source: own?.source, tracks: playback.tracks.peek()), seriesId: own?.item?.seriesId); final snap = await _snapshot(); if (snap != null) _reporter?.update(snap, force: true); @@ -1047,7 +1104,8 @@ class SessionCoordinator { var show = false; if (next != null && duration > 0 && autoAdvance.peek() == null && r.transport != 'finished') { final item = (jellyfinPlan.peek() as OwnPlay?)?.item; - final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round())); + final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()), + endingStart: playback.ending.peek()?.start); show = snap.position * 1000 >= at.inMilliseconds; } nextPromptVisible.value = show; diff --git a/apps/desktop/test/chapters_mpv_e2e_test.dart b/apps/desktop/test/chapters_mpv_e2e_test.dart new file mode 100644 index 0000000..fb4573c --- /dev/null +++ b/apps/desktop/test/chapters_mpv_e2e_test.dart @@ -0,0 +1,144 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:sync_relay_server/server.dart'; +import 'package:watch_together/src/playback/media_marks.dart'; +import 'package:watch_together/src/session/session_coordinator.dart'; +import 'package:watch_together/src/sync/state_diff.dart'; + +final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path; + +/// 23 min mkv with chapters OP 0:30-2:00, Part A, ED 20:00-21:30, Part B, +/// two audio tracks (jpn, eng "Dub") and an English srt subtitle. +Future _media() async { + final path = '$_tmp/chapters_23m.mkv'; + if (await File(path).exists()) return path; + final meta = File('$_tmp/chapters_meta.txt'); + String chapter(int start, int end, String title) => + '[CHAPTER]\nTIMEBASE=1/1000\nSTART=${start * 1000}\nEND=${end * 1000}\ntitle=$title\n'; + await meta.writeAsString(';FFMETADATA1\n' + '${chapter(30, 120, 'OP')}${chapter(120, 1200, 'Part A')}' + '${chapter(1200, 1290, 'ED')}${chapter(1290, 1380, 'Part B')}'); + final srt = File('$_tmp/chapters_subs.srt'); + await srt.writeAsString('1\n00:00:01,000 --> 00:00:05,000\nHello\n'); + final r = await Process.run('ffmpeg', [ + '-y', '-v', 'error', + '-f', 'lavfi', '-i', 'testsrc2=size=160x120:rate=10', + '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', + '-f', 'lavfi', '-i', 'sine=frequency=660:sample_rate=48000', + '-i', srt.path, '-i', meta.path, + '-map', '0:v', '-map', '1:a', '-map', '2:a', '-map', '3:s', + '-map_metadata', '4', '-map_chapters', '4', + '-metadata:s:a:0', 'language=jpn', + '-metadata:s:a:1', 'language=eng', '-metadata:s:a:1', 'title=Dub', + '-metadata:s:s:0', 'language=eng', + '-t', '1380', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '10', + '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '32k', '-c:s', 'srt', path, + ]); + if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}'); + return path; +} + +Future _poll( + FutureOr Function() cond, + String what, { + Duration timeout = const Duration(seconds: 30), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + if (await cond()) return; + await Future.delayed(const Duration(milliseconds: 100)); + } + fail('timed out waiting for $what'); +} + +void main() { + test( + 'chapters and tracks from mpv; Skip OP and PgDn go through the server', + timeout: const Timeout(Duration(minutes: 5)), + () async { + MediaKit.ensureInitialized(); + final media = await _media(); + final server = SyncServer(AppConfig(port: 0, secret: 'ch', name: 'ch', mediaPath: media)); + await server.start(); + final a = SessionCoordinator(); + final b = SessionCoordinator(); + try { + for (final (c, name) in [(a, 'a'), (b, 'b')]) { + await c.connect(ConnectParams( + host: '127.0.0.1', port: server.boundPort, secret: 'ch', name: name)); + await c.playback.player.setVolume(0); + } + await _poll(() => a.serverState.peek()?.members.length == 2, 'both in'); + + // chapters signal: titles, starts, ends (last = duration). + await _poll(() => a.playback.chapters.peek().length == 4 && a.playback.duration.peek() > Duration.zero, + 'chapters'); + final ch = a.playback.chapters.peek(); + // ignore: avoid_print + print('chapters: $ch'); + expect(ch.map((c) => c.title), ['OP', 'Part A', 'ED', 'Part B']); + expect(ch.map((c) => c.start.inSeconds), [30, 120, 1200, 1290]); + expect(ch[2].end, ch[3].start); + expect(ch[3].end.inSeconds, closeTo(1380, 1)); + expect(a.playback.opening.peek(), + SkipRange(const Duration(seconds: 30), ch[1].start, SkipSource.chapter, title: 'OP')); + expect(a.playback.ending.peek()!.start, const Duration(minutes: 20)); + expect(a.playback.segments.peek(), isEmpty, reason: 'not a Jellyfin item'); + + // tracks signal: two audio, one subtitle; mpv's choice marked. + await _poll(() => a.playback.tracks.peek().length == 3, 'tracks'); + final tracks = a.playback.tracks.peek(); + // ignore: avoid_print + print('tracks: $tracks'); + final audio = tracks.where((t) => t.type == JellyfinStreamType.audio).toList(); + expect(audio.map((t) => t.label), ['Japanese · AAC 1.0', 'English · AAC 1.0 · Dub']); + expect(audio.first.selected, isTrue); + await a.selectTrack(aid: 2); + expect(a.playback.tracks.peek().where((t) => t.selected && t.type == JellyfinStreamType.audio).single.id, 2); + + a.toggleReady(); + b.toggleReady(); + await _poll(() => a.playback.player.state.playing && b.playback.player.state.playing, 'both playing'); + + // Into the OP: the pill offers Skip OP to its end. + a.seekTo(40); + await _poll(() { + final r = a.serverState.peek(); + return r != null && r.seekBy == a.sync.memberId && (r.position - 40).abs() < 2; + }, 'server at 0:40'); + await _poll(() => a.skipOffer.peek()?.kind == SkipKind.opening, 'Skip OP offered'); + expect(a.skipOffer.peek()!.target, const Duration(minutes: 2)); + int seeks(SessionCoordinator c) => c.feedLog.peek().where((f) => f.action == 'seek').length; + await _poll(() => seeks(b) >= 1 && seeks(b) == seeks(a), 'b saw the seek to 0:40'); + final seeksBefore = seeks(b); + a.skip(a.skipOffer.peek()!); + await _poll(() { + final r = a.serverState.peek(); + return r != null && + r.seekBy == a.sync.memberId && + serverPositionAt(r, a.sync.serverNow()) >= 120 && + serverPositionAt(r, a.sync.serverNow()) < 125; + }, 'server at the OP end'); + await _poll(() => b.playback.positionSecs >= 119.5 && b.playback.positionSecs < 126, 'b follows'); + await _poll(() => seeks(b) == seeksBefore + 1, 'b saw the Skip OP seek'); + await Future.delayed(const Duration(seconds: 1)); + expect(seeks(b), seeksBefore + 1, reason: 'Skip OP is one seek'); + await _poll(() => a.skipOffer.peek() == null, 'pill gone after the OP'); + + // PgDn: next chapter (ED at 20:00), for everyone. + expect(a.stepChapter(1), isTrue); + await _poll(() => b.playback.positionSecs >= 1199.5 && b.playback.positionSecs < 1206, 'b at the ED'); + await _poll(() => a.skipOffer.peek()?.kind == SkipKind.ending, 'Skip ED offered'); + expect(a.skipOffer.peek()!.target, const Duration(minutes: 21, seconds: 30)); + } finally { + await a.dispose(); + await b.dispose(); + await server.stop(); + } + }, + ); +} diff --git a/apps/desktop/test/jellyfin_e2e_test.dart b/apps/desktop/test/jellyfin_e2e_test.dart index 1eb34c8..034bc1d 100644 --- a/apps/desktop/test/jellyfin_e2e_test.dart +++ b/apps/desktop/test/jellyfin_e2e_test.dart @@ -9,6 +9,7 @@ import 'package:sync_relay_server/server.dart'; import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:watch_together/src/jellyfin/session_media.dart'; import 'package:watch_together/src/jellyfin/track_prefs.dart'; +import 'package:watch_together/src/playback/media_marks.dart'; import 'package:watch_together/src/session/session_coordinator.dart'; // Two account holders and a guest watch Jellyfin episodes together over a @@ -134,6 +135,15 @@ class _FakeJellyfin { if (path == '/Shows/$_series/Episodes') { return json({'Items': [_item(_ep1), _item(_ep2)], 'TotalRecordCount': 2}); } + if (path == '/MediaSegments/$_ep1') { + return json({ + 'Items': [ + {'Id': 'seg1', 'ItemId': _ep1, 'Type': 'Intro', 'StartTicks': 20000000, 'EndTicks': 60000000}, + ], + 'TotalRecordCount': 1, + 'StartIndex': 0, + }); + } if (path.startsWith('/Sessions/Playing')) { res.statusCode = 204; return res.close(); @@ -260,6 +270,13 @@ void main() { }, 'jf access in the member list'); expect(a.nextEpisode.peek()?.item.id, _ep2); expect(b.nextEpisode.peek(), isNull, reason: 'guests cannot resolve the next one'); + // MediaSegments with an account; the guest (relay) has none. + await _poll(() => a.playback.segments.peek().isNotEmpty, 'segments'); + expect(a.playback.opening.peek()?.source, SkipSource.segment); + expect(a.playback.opening.peek()?.end, const Duration(seconds: 6)); + expect(b.playback.segments.peek(), isEmpty); + expect(jelly.calls.where((c) => c.path.startsWith('/MediaSegments') && _FakeJellyfin.authFields(c.auth)['Token'] == 'CTOKEN'), + isNotEmpty); for (final x in all) { x.toggleReady(); diff --git a/apps/desktop/test/media_marks_test.dart b/apps/desktop/test/media_marks_test.dart new file mode 100644 index 0000000..9aaafb4 --- /dev/null +++ b/apps/desktop/test/media_marks_test.dart @@ -0,0 +1,197 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:watch_together/src/jellyfin/session_media.dart'; +import 'package:watch_together/src/jellyfin/track_prefs.dart'; +import 'package:watch_together/src/playback/media_marks.dart'; + +Duration s(num secs) => Duration(milliseconds: (secs * 1000).round()); +Duration m(int min, [int sec = 0]) => Duration(minutes: min, seconds: sec); + +/// OP 0:30-2:00, Part A, ED 20:00-21:30, Part B; 24:00 long. +final chapters = chaptersFromStarts([ + ('Prologue', Duration.zero), + ('OP', m(0, 30)), + ('Part A', m(2)), + ('ED', m(20)), + ('Part B', m(21, 30)), +], m(24)); + +void main() { + group('chapter-list', () { + test('parses mpv JSON, ends from the next start and the duration', () { + final c = parseChapterList( + '[{"title":"OP","time":30.0},{"title":"Part A","time":120.5},{"time":1200}]', m(24)); + expect(c, [ + MediaChapter(index: 0, title: 'OP', start: s(30), end: s(120.5)), + MediaChapter(index: 1, title: 'Part A', start: s(120.5), end: m(20)), + MediaChapter(index: 2, title: '', start: m(20), end: m(24)), + ]); + }); + + test('unknown duration: the last chapter ends where it starts', () { + final c = parseChapterList('[{"title":"A","time":0},{"title":"B","time":10}]', Duration.zero); + expect(c.last.end, s(10)); + }); + + test('garbage gives none', () { + expect(parseChapterList('', m(1)), isEmpty); + expect(parseChapterList('{"a":1}', m(1)), isEmpty); + expect(parseChapterList('[{"title":"x"}]', m(1)), isEmpty); + }); + }); + + group('track-list', () { + test('audio and subtitles with flags and selection; video left out', () { + final t = parseTrackList('''[ + {"id":1,"type":"video","codec":"h264","selected":true}, + {"id":1,"type":"audio","lang":"jpn","codec":"eac3","demux-channel-count":2,"default":true,"selected":true}, + {"id":2,"type":"audio","lang":"eng","title":"Dub","codec":"aac","demux-channel-count":6}, + {"id":1,"type":"sub","lang":"eng","title":"Signs","codec":"ass","forced":true}, + {"id":2,"type":"sub","codec":"subrip","external":true,"selected":true} + ]'''); + expect(t.length, 4); + expect(t[0], const MpvTrack( + id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, + isDefault: true, selected: true)); + expect(t[2].isForced, isTrue); + expect(t[3].isExternal && t[3].selected, isTrue); + expect(t.map((x) => x.label), [ + 'Japanese · E-AC3 2.0', + 'English · AAC 5.1 · Dub', + 'English · ASS · Signs · Forced', + 'Track 2 · SRT', + ]); + }); + }); + + group('opening / ending', () { + test('from Jellyfin segments first', () { + final seg = [ + MediaSegment(kind: JellyfinSegmentType.recap, start: Duration.zero, end: s(20)), + MediaSegment(kind: JellyfinSegmentType.intro, start: s(40), end: s(130)), + MediaSegment(kind: JellyfinSegmentType.outro, start: m(21), end: m(22, 30)), + ]; + expect(openingRange(seg, chapters), SkipRange(s(40), s(130), SkipSource.segment)); + expect(endingRange(seg, chapters), SkipRange(m(21), m(22, 30), SkipSource.segment)); + }); + + test('else from chapter titles', () { + expect(openingRange(const [], chapters), SkipRange(s(30), m(2), SkipSource.chapter, title: 'OP')); + expect(endingRange(const [], chapters), SkipRange(m(20), m(21, 30), SkipSource.chapter, title: 'ED')); + }); + + test('an empty segment range falls back to chapters', () { + final seg = [MediaSegment(kind: JellyfinSegmentType.intro, start: s(10), end: s(10))]; + expect(openingRange(seg, chapters)!.source, SkipSource.chapter); + }); + + test('title variants: whole words, any case, numbered', () { + for (final t in ['OP', 'op', 'OP1', 'OP 2', 'Opening', 'Opening Song', 'Intro', 'INTRO']) { + expect(isOpeningTitle(t), isTrue, reason: t); + } + for (final t in ['Opportunity', 'Operation', 'Introduction', 'Part A', 'Prologue', 'Stop', '']) { + expect(isOpeningTitle(t), isFalse, reason: t); + } + for (final t in ['ED', 'ed', 'ED2', 'Ending', 'Ending Theme', 'Credits', 'End Credits', 'Outro']) { + expect(isEndingTitle(t), isTrue, reason: t); + } + for (final t in ['Edge', 'Edited', 'Endings?', 'Part B', 'Preview', 'Red']) { + expect(isEndingTitle(t), isFalse, reason: t); + } + }); + + test('none: no segments and no matching chapter titles', () { + final plain = chaptersFromStarts([('Part A', Duration.zero), ('Part B', m(10))], m(24)); + expect(openingRange(const [], plain), isNull); + expect(endingRange(const [], plain), isNull); + expect(openingRange(const [], const []), isNull); + }); + }); + + group('skip offers', () { + final op = SkipRange(s(30), m(2), SkipSource.chapter); + final ed = SkipRange(m(20), m(21, 30), SkipSource.chapter); + SkipOffer? at(Duration p, {SkipRange? opening, SkipRange? ending, Duration? duration}) => skipOfferAt( + position: p, duration: duration ?? m(24), opening: opening, ending: ending); + + test('Skip OP inside the opening, to its end', () { + expect(at(s(29), opening: op, ending: ed), isNull); + expect(at(s(30), opening: op, ending: ed), SkipOffer(SkipKind.opening, m(2))); + expect(at(s(118), opening: op, ending: ed), SkipOffer(SkipKind.opening, m(2))); + // Last second: nothing left to skip. + expect(at(s(119.5), opening: op, ending: ed), isNull); + expect(at(m(5), opening: op, ending: ed), isNull); + }); + + test('Skip ED to the ending end only', () { + expect(at(m(20), opening: op, ending: ed), SkipOffer(SkipKind.ending, m(21, 30))); + expect(at(m(21), opening: op, ending: ed), SkipOffer(SkipKind.ending, m(21, 30))); + expect(at(m(21, 30), opening: op, ending: ed), isNull); + // Known ending without an opening: the fallback applies early on. + expect(at(m(20, 10), ending: ed), SkipOffer(SkipKind.ending, m(21, 30))); + }); + + test('1:30 fallback: no opening, over 20 min, first 6 min only', () { + expect(at(Duration.zero), SkipOffer(SkipKind.fixed, s(90))); + expect(at(m(1)), SkipOffer(SkipKind.fixed, m(2, 30))); + expect(at(m(5, 59)), SkipOffer(SkipKind.fixed, m(7, 29))); + expect(at(m(6)), isNull); + expect(at(m(1), duration: m(20)), isNull, reason: 'exactly 20 min is not over'); + expect(at(m(1), duration: m(12)), isNull); + expect(at(m(1), ending: ed), SkipOffer(SkipKind.fixed, m(2, 30))); + // An opening known (but not playing) means no fallback. + expect(at(m(3), opening: op), isNull); + }); + }); + + group('next episode prompt', () { + test('at the ED start when known, else the last two minutes', () { + expect(nextPromptStart(null, m(24), endingStart: m(20)), m(20)); + expect(nextPromptStart(null, m(24)), m(22)); + expect(nextPromptStart(null, m(24), endingStart: null), m(22)); + // Nonsense ending starts fall back. + expect(nextPromptStart(null, m(24), endingStart: m(25)), m(22)); + expect(nextPromptStart(null, m(24), endingStart: Duration.zero), m(22)); + expect(nextPromptStart(null, s(90)), Duration.zero); + }); + }); + + group('chapter steps (PgUp / PgDn)', () { + test('current chapter and forward steps', () { + expect(chapterIndexAt(chapters, s(45)), 1); + expect(chapterIndexAt(chapters, m(23)), 4); + expect(chapterStepTarget(chapters, s(45), 1), m(2)); + expect(chapterStepTarget(chapters, m(21), 1), m(21, 30)); + expect(chapterStepTarget(chapters, m(22), 1), isNull, reason: 'no chapter after the last'); + }); + + test('back: current start, or the previous one near a start (mpv)', () { + expect(chapterStepTarget(chapters, m(10), -1), m(2)); + expect(chapterStepTarget(chapters, m(2, 3), -1), s(30)); + expect(chapterStepTarget(chapters, s(2), -1), Duration.zero); + expect(chapterStepTarget(const [], m(1), 1), isNull); + }); + + test('before the first chapter forward goes to it', () { + final late = chaptersFromStarts([('A', m(1)), ('B', m(5))], m(10)); + expect(chapterIndexAt(late, s(10)), isNull); + expect(chapterStepTarget(late, s(10), 1), m(1)); + expect(chapterStepTarget(late, s(10), -1), isNull); + }); + }); + + test('clock format', () { + expect(formatClock(s(65)), '1:05'); + expect(formatClock(const Duration(hours: 1, minutes: 2, seconds: 3)), '1:02:03'); + }); + + test('menu labels: language names, codecs, layouts', () { + expect(languageName('jpn'), 'Japanese'); + expect(languageName('xyz'), 'XYZ'); + expect(languageName(null), isNull); + expect(codecName('hdmv_pgs_subtitle'), 'PGS'); + expect(codecName('pcm_s16le'), 'PCM'); + expect(channelLayoutName(8), '7.1'); + expect(channelLayoutName(4), '4ch'); + }); +} diff --git a/apps/desktop/test/player_controls_test.dart b/apps/desktop/test/player_controls_test.dart new file mode 100644 index 0000000..47f4032 --- /dev/null +++ b/apps/desktop/test/player_controls_test.dart @@ -0,0 +1,198 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart' show JellyfinStreamType; +import 'package:watch_together/src/jellyfin/track_prefs.dart'; +import 'package:watch_together/src/playback/media_marks.dart'; +import 'package:watch_together/src/screens/session/player_controls.dart'; + +// The control-bar pieces alone (never the session screen or a `Video`). + +Duration m(int min, [int sec = 0]) => Duration(minutes: min, seconds: sec); + +final chapters = chaptersFromStarts([ + ('Prologue', Duration.zero), + ('OP', m(0, 30)), + ('Part A', m(2)), + ('ED', m(20)), + ('Part B', m(21, 30)), +], m(24)); + +Finder item(String text) => + find.ancestor(of: find.text(text), matching: find.byType(CheckedPopupMenuItem)); + +Widget host(Widget child, {double width = 960}) => MaterialApp( + home: Scaffold( + backgroundColor: Colors.black, + body: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 40), + child: SizedBox(width: width, child: child), + ), + ), + ), + ); + +void main() { + group('seek bar', () { + testWidgets('a mark at each chapter start but the first, placed by time', (tester) async { + await tester.pumpWidget(host(ChapterSeekBar( + position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {}))); + expect(find.byKey(const ValueKey('chapter-mark-0')), findsNothing, reason: 'starts at 0'); + final bar = tester.getRect(find.byKey(const ValueKey('seek-bar'))); + for (final c in chapters.skip(1)) { + final mark = tester.getCenter(find.byKey(ValueKey('chapter-mark-${c.index}'))); + final x = bar.left + bar.width * c.start.inSeconds / m(24).inSeconds; + expect(mark.dx, moreOrLessEquals(x, epsilon: 0.5), reason: c.title); + expect(mark.dy, moreOrLessEquals(bar.center.dy, epsilon: 0.5)); + } + }); + + testWidgets('no marks without a duration or chapters', (tester) async { + await tester.pumpWidget(host(ChapterSeekBar( + position: Duration.zero, duration: Duration.zero, chapters: chapters, onSeek: (_) {}))); + expect(find.byKey(const ValueKey('chapter-mark-1')), findsNothing); + await tester.pumpWidget(host(ChapterSeekBar(position: Duration.zero, duration: m(24), onSeek: (_) {}))); + expect(find.byKey(const ValueKey('chapter-mark-1')), findsNothing); + }); + + testWidgets('hover shows the chapter title above the time', (tester) async { + await tester.pumpWidget(host(ChapterSeekBar( + position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {}))); + final bar = tester.getRect(find.byKey(const ValueKey('seek-bar'))); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + addTearDown(mouse.removePointer); + // 20:45 of 24:00: inside "ED". + await mouse.moveTo(Offset(bar.left + bar.width * (20.75 / 24), bar.center.dy)); + await tester.pump(); + final title = find.byKey(const ValueKey('seek-hover-chapter')); + final time = find.byKey(const ValueKey('seek-hover-time')); + expect(tester.widget(title).data, 'ED'); + expect(tester.widget(time).data, '20:45'); + expect(tester.getCenter(title).dy, lessThan(tester.getCenter(time).dy)); + expect(tester.getRect(time).bottom, lessThanOrEqualTo(bar.top + 2), reason: 'above the bar'); + // 10:00: Part A. + await mouse.moveTo(Offset(bar.left + bar.width * (10 / 24), bar.center.dy)); + await tester.pump(); + expect(tester.widget(title).data, 'Part A'); + await mouse.moveTo(Offset(bar.left, bar.top - 200)); + await tester.pump(); + expect(time, findsNothing); + }); + + testWidgets('no chapters: the hover shows only the time', (tester) async { + await tester.pumpWidget(host(ChapterSeekBar(position: m(1), duration: m(24), onSeek: (_) {}))); + final bar = tester.getRect(find.byKey(const ValueKey('seek-bar'))); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + addTearDown(mouse.removePointer); + await mouse.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy)); + await tester.pump(); + expect(find.byKey(const ValueKey('seek-hover-chapter')), findsNothing); + expect(tester.widget(find.byKey(const ValueKey('seek-hover-time'))).data, '12:00'); + }); + + testWidgets('drag seeks on every move and on release', (tester) async { + final seeks = []; + await tester.pumpWidget(host(ChapterSeekBar( + position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add))); + final bar = tester.getRect(find.byKey(const ValueKey('seek-bar'))); + final g = await tester.startGesture(Offset(bar.left + bar.width / 4, bar.center.dy), + kind: PointerDeviceKind.mouse); + expect(seeks, isEmpty, reason: 'press alone does not seek'); + await g.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy)); + await g.moveTo(Offset(bar.left + bar.width * 3 / 4, bar.center.dy)); + await g.up(); + expect(seeks.length, 3); + expect(seeks[0], m(12)); + expect(seeks.last, m(18)); + }); + }); + + group('skip pill', () { + testWidgets('labels per kind; faint, lit on hover; click skips', (tester) async { + var pressed = 0; + for (final (kind, label) in [ + (SkipKind.opening, 'Skip OP'), + (SkipKind.ending, 'Skip ED'), + (SkipKind.fixed, 'Skip 1:30'), + ]) { + await tester.pumpWidget(host(Align(child: SkipPill(offer: SkipOffer(kind, m(2)), onPressed: () => pressed++)))); + expect(find.text(label), findsOneWidget); + } + double opacity() => tester.widget( + find.descendant(of: find.byType(SkipPill), matching: find.byType(AnimatedOpacity))).opacity; + expect(opacity(), lessThan(0.5)); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + addTearDown(mouse.removePointer); + await mouse.moveTo(tester.getCenter(find.byKey(const ValueKey('skip-pill')))); + await tester.pumpAndSettle(); + expect(opacity(), 1); + await tester.tap(find.byKey(const ValueKey('skip-pill'))); + expect(pressed, 1); + }); + }); + + group('menus', () { + const tracks = [ + MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true), + MpvTrack(id: 2, type: JellyfinStreamType.audio, language: 'eng', codec: 'aac', channels: 6, title: 'Dub'), + MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'), + ]; + + testWidgets('audio menu: count badge, labels, current checked, pick', (tester) async { + TrackPick? got; + await tester.pumpWidget(host(Row(children: [ + TrackMenuButton(type: JellyfinStreamType.audio, tracks: tracks, onPick: (p) => got = p), + TrackMenuButton(type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (p) => got = p), + ]))); + expect(find.descendant(of: find.byKey(const ValueKey('audio-button')), matching: find.text('2')), + findsOneWidget); + expect(find.descendant(of: find.byKey(const ValueKey('subtitles-button')), matching: find.text('1')), + findsOneWidget); + await tester.tap(find.byKey(const ValueKey('audio-button'))); + await tester.pumpAndSettle(); + final items = tester.widgetList>(find.byType(CheckedPopupMenuItem)); + expect(items.map((i) => i.checked), [true, false]); + expect(find.text('Japanese · E-AC3 2.0'), findsOneWidget); + await tester.tap(item('English · AAC 5.1 · Dub')); + await tester.pumpAndSettle(); + expect(got, (aid: 2, sid: null, subtitlesOff: false)); + }); + + testWidgets('subtitle menu has Off, checked when none is selected', (tester) async { + TrackPick? got; + await tester.pumpWidget(host(TrackMenuButton( + type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (p) => got = p))); + await tester.tap(find.byKey(const ValueKey('subtitles-button'))); + await tester.pumpAndSettle(); + final items = tester.widgetList>(find.byType(CheckedPopupMenuItem)); + expect(items.map((i) => i.checked), [true, false]); + await tester.tap(item('Off')); + await tester.pumpAndSettle(); + expect(got, (aid: null, sid: null, subtitlesOff: true)); + }); + + testWidgets('chapter menu: current checked, a pick seeks to its start', (tester) async { + MediaChapter? got; + await tester.pumpWidget(host(ChaptersControlButton(chapters: chapters, current: 2, onPick: (c) => got = c))); + await tester.tap(find.byKey(const ValueKey('chapters-button'))); + await tester.pumpAndSettle(); + final items = tester.widgetList>( + find.byType(CheckedPopupMenuItem)); + expect(items.map((i) => i.checked), [false, false, true, false, false]); + expect(find.text('20:00'), findsOneWidget); + await tester.tap(item('ED')); + await tester.pumpAndSettle(); + expect(got!.start, m(20)); + }); + + testWidgets('no chapters, no chapter button', (tester) async { + await tester.pumpWidget(host(ChaptersControlButton(chapters: const [], current: null, onPick: (_) {}))); + expect(find.byKey(const ValueKey('chapters-button')), findsNothing); + }); + }); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9b611d6..7a03d47 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -106,8 +106,9 @@ Loosely modeled on Syncplay, with a different readiness design. over 5 s for more than 2 s seeks, ahead by more than 1.5 s sets rate 0.95 until level, otherwise nothing. - **User seeks**: the player is a `SyncPlayer` whose public `seek` is the - user's: media_kit's seek bar (one `seek` per pointer move while dragging), - the arrow keys and `seekTo` all become explicit events. The player moves at + user's: the session's seek bar (`ChapterSeekBar`, one `seek` per pointer + move while dragging and one on release, like media_kit's), the arrow + keys, skips, chapter jumps and `seekTo` all become explicit events. The player moves at once (scrub preview); `UserSeekGate` sends one `seek` intent with a fresh `seq` after 300 ms without another request (`seekDebounceMs`). While the user is seeking, and until the echo of the last sent seq arrives (3 s at @@ -259,6 +260,38 @@ freeze reloads the video with a toast. It fires once until the picture moves again for 10 s, so it cannot loop. Seeks, pause, buffering and a hidden window disarm it. +Chapters, segments, tracks (`PlaybackController` signals, values from +`playback/media_marks.dart`, no widgets; any controls UI reads them): +- `chapters`: mpv `chapter-list` (observed through the same dynamic + `observeProperty` and read as JSON): `MediaChapter` index, title, start; + end = next start, the last one's = `duration`. +- `segments`: the Jellyfin item's MediaSegments (`GET /MediaSegments/{id}`, + 10.10+: Intro, Outro, Recap, Preview, Commercial), fetched by the + coordinator with `OwnPlay` only; relay and blocked viewers cannot call + Jellyfin and use chapters alone. Empty without a segment provider plugin + (the case on the owner's 10.11 server). +- `tracks`: audio and subtitle `MpvTrack`s from mpv `track-list` (id, + language, title, codec, channels, default / forced / hearing-impaired / + external, `selected`), re-read after every selection. +- `position` (250 ms steps), `duration`; computed `opening`, `ending` + (`SkipRange`) and `currentChapter`. + +Skip rules (pure, `media_marks.dart`): +- Opening = first Intro segment, else the first chapter whose title has a + whole word OP / Opening / Intro (any case, digits allowed: "OP1"). + Ending = first Outro segment, else ED / Ending / Credits / Outro. +- `skipOfferAt` (the coordinator's `skipOffer`, null at `finished`): inside + the opening (up to its last second) "Skip OP" to its end; inside the + ending "Skip ED" to its end only (no guessing about previews: the viewer + decides). Without any opening and with media over 20 min, "Skip 1:30" + during the first 6 min. +- +1:30 (`skipForward`: the pill, the controls' "1:30" button, + Shift+Right) is a `nudge`, so repeated presses add up. +- Chapter steps (PgUp / PgDn, `chapterStepTarget`): forward to the next + start; back to the current start, or the previous one within 5 s of it + (mpv's `chapter-seek-threshold`). +- Every skip and chapter jump is a user seek (debounced, everyone follows). + Never unmount GPU-textured widgets (avatar images) on a transport-gated branch: teardown races mpv's frame upload and the window goes black. Keep them mounted and skip painting (`Visibility` with `maintainState`). @@ -272,8 +305,8 @@ behind a `JellyfinHttp` interface (tests inject a fake). Plans: - `JellyfinClient`: one server + one user. Auth (Quick Connect, password, `/Users/Me` check, logout), browsing (views, Resume, NextUp, Latest, paged `/Items`, search, seasons, episodes), image URLs (tag + maxWidth, no token - needed), PlaybackInfo, stream URLs, Playing / Progress / Stopped reports, - played / unplayed / resume position. + needed), PlaybackInfo, stream URLs, MediaSegments (a 404 = none), + Playing / Progress / Stopped reports, played / unplayed / resume position. - `normalizeServerUrl`, `parseJellyfinLink`: pasted addresses and item links -> `(baseUrl, itemId)`; accounts are keyed by `ServerId` (`/System/Info/Public`). @@ -360,9 +393,10 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, - **Episodes**: with `OwnPlay` on an episode, `adjacentEpisodes` (one `/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the previous and next episode. The controls show previous / next buttons - (also `<` `>`, Shift+P / Shift+N); from `nextPromptStart` (last 2 min of - the runtime; the single place to switch to an outro segment later) a - floating "Next episode" pill shows outside the auto-hiding controls. Any + (also `<` `>`, Shift+P / Shift+N); from `nextPromptStart` (the ending's + start when one is known, see "Skip rules", else the last 2 min of the + runtime) a floating "Next episode" pill shows outside the auto-hiding + controls. Auto-advance still waits for `finished`. Any of them swaps to that episode's ref with the clicker's account. Guests (relay, blocked) see none of it. - **Auto-advance** (Settings, on by default): at `finished` with a known @@ -375,8 +409,8 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, wire change. Cancel is local: it stops the countdown on that screen, and the swapper's Cancel stops the swap; a seek by anyone ends `finished` and with it every countdown. -- **Tracks**: the controls' subtitles button lists mpv's audio and subtitle - tracks. A pick is applied, reported, and saved as a `TrackChoice` +- **Tracks**: the controls' Audio and Subtitles buttons list + `PlaybackController.tracks`. A pick is applied, reported, and saved as a `TrackChoice` (language, title, codec, channels, forced/HI: from the Jellyfin streams with `OwnPlay`, else from mpv's track list) per series and as the global fallback in the `track_prefs` Hive box. On the next Jellyfin item it is @@ -450,6 +484,17 @@ Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client): it: the swap panel opens on the series of the episode playing, its season selected and the episode highlighted. +Session controls: media_kit's `MaterialDesktopVideoControls` with our +bars (`screens/session_screen.dart`, pieces in +`screens/session/player_controls.dart`). Its seek bar is off; ours +(`ChapterSeekBar`: chapter diamonds, hover time with the chapter title) +sits in the bottom bar, which is taller by the bar's height (the top bar +keeps its row at the top). A drag sends no hover events, so the bar keeps +the controls up through a guarded dynamic `onHover()` on media_kit's +private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED / +1:30, Next episode) and cards live outside the auto-hiding controls, so +they show in fullscreen (whose route renders only the video and controls). + Window chrome is frameless (`window_manager`): every screen brings its own drag area and min/max/close buttons (Home's top bar; the session uses `widgets/window_chrome_bar.dart`; both use `window_buttons.dart`); the session drag layer is `window_drag_layer.dart`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 10f3360..2471235 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -49,8 +49,6 @@ in a session"): - Queue: put several library items (a season, a pick list) up as a playlist that advances like a swap (shares `queue-add` / `queue-skip` with the backlog's Playlist item). -- The "Next episode" prompt from the item's Outro / credits segment instead - of the last two minutes (`nextPromptStart`). - Auto-advance cancel for everyone: today Cancel is per screen (the designated swapper's Cancel stops the swap; a seek stops all). A shared cancel needs a wire message. diff --git a/packages/core/lib/src/jellyfin/client.dart b/packages/core/lib/src/jellyfin/client.dart index 3eac53b..f1ec499 100644 --- a/packages/core/lib/src/jellyfin/client.dart +++ b/packages/core/lib/src/jellyfin/client.dart @@ -499,6 +499,27 @@ class JellyfinClient { Map get authHeaders => {'Authorization': clientInfo.authorizationHeader(accessToken)}; + /// `GET /MediaSegments/{itemId}` (Jellyfin 10.10+): the item's intro, + /// outro, recap, preview and commercial ranges, in start order. Empty when + /// no segment provider has marked it, and on servers without the + /// endpoint (404). + Future> mediaSegments(String itemId) async { + final Object? j; + try { + j = await _json('GET', '/MediaSegments/$itemId'); + } on JellyfinException catch (e) { + if (e.statusCode == 404) return const []; + rethrow; + } + final items = _map(j)['Items']; + final list = [ + if (items is List) + for (final e in items) + if (e is Map) JellyfinMediaSegment.fromJson(e.cast()) + ]..sort((a, b) => a.startTicks.compareTo(b.startTicks)); + return list; + } + // ------------------------------------------------------------ progress /// `POST /Sessions/Playing`: playback started (Jellyfin shows the session diff --git a/packages/core/lib/src/jellyfin/models.dart b/packages/core/lib/src/jellyfin/models.dart index c1a7133..7f72c91 100644 --- a/packages/core/lib/src/jellyfin/models.dart +++ b/packages/core/lib/src/jellyfin/models.dart @@ -555,3 +555,54 @@ class JellyfinPlaybackInfo { return mediaSources.isEmpty ? null : mediaSources.first; } } + +/// Kind of a [JellyfinMediaSegment] (Jellyfin `MediaSegmentType`). +enum JellyfinSegmentType { + unknown, + commercial, + preview, + recap, + outro, + intro; + + static JellyfinSegmentType parse(String? v) => switch (v) { + 'Commercial' => commercial, + 'Preview' => preview, + 'Recap' => recap, + 'Outro' => outro, + 'Intro' => intro, + _ => unknown, + }; +} + +/// A marked range of an item (`GET /MediaSegments/{itemId}`, Jellyfin +/// 10.10+): intro, outro, recap, preview or commercial. Filled by segment +/// provider plugins; without one the list is empty. +class JellyfinMediaSegment { + final String id; + final String itemId; + final JellyfinSegmentType type; + final int startTicks; + final int endTicks; + const JellyfinMediaSegment({ + required this.id, + required this.itemId, + required this.type, + required this.startTicks, + required this.endTicks, + }); + + Duration get start => ticksToDuration(startTicks); + Duration get end => ticksToDuration(endTicks); + + factory JellyfinMediaSegment.fromJson(JellyfinJson j) => JellyfinMediaSegment( + id: _str(j['Id']) ?? '', + itemId: _str(j['ItemId']) ?? '', + type: JellyfinSegmentType.parse(_str(j['Type'])), + startTicks: _int(j['StartTicks']) ?? 0, + endTicks: _int(j['EndTicks']) ?? 0, + ); + + @override + String toString() => 'JellyfinMediaSegment(${type.name} $start-$end)'; +} diff --git a/packages/core/test/fixtures/jellyfin/media_segments.json b/packages/core/test/fixtures/jellyfin/media_segments.json new file mode 100644 index 0000000..71a22d5 --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/media_segments.json @@ -0,0 +1,27 @@ +{ + "Items": [ + { + "Id": "5d1f0a7c2b3e4f60a1b2c3d4e5f60718", + "ItemId": "b18826be994070a79f9b52bc0f66f052", + "Type": "Outro", + "StartTicks": 12900000000, + "EndTicks": 13800000000 + }, + { + "Id": "0c9a8b7d6e5f40312a3b4c5d6e7f8091", + "ItemId": "b18826be994070a79f9b52bc0f66f052", + "Type": "Intro", + "StartTicks": 300000000, + "EndTicks": 1200000000 + }, + { + "Id": "1a2b3c4d5e6f47089a0b1c2d3e4f5061", + "ItemId": "b18826be994070a79f9b52bc0f66f052", + "Type": "Preview", + "StartTicks": 13800000000, + "EndTicks": 14100000000 + } + ], + "TotalRecordCount": 3, + "StartIndex": 0 +} diff --git a/packages/core/test/jellyfin_client_test.dart b/packages/core/test/jellyfin_client_test.dart index 3771f53..436dc19 100644 --- a/packages/core/test/jellyfin_client_test.dart +++ b/packages/core/test/jellyfin_client_test.dart @@ -272,6 +272,38 @@ void main() { }); }); + group('media segments', () { + // Shape from Jellyfin's MediaSegmentDto (the live 10.11 server answers + // 200 with an empty list: no segment provider installed). + test('parses and sorts the ranges by start', () async { + http.on('GET', '/MediaSegments/b18826be994070a79f9b52bc0f66f052', fixture: 'media_segments'); + final s = await client.mediaSegments('b18826be994070a79f9b52bc0f66f052'); + expect(http.last.method, 'GET'); + expect(s.map((e) => e.type), [ + JellyfinSegmentType.intro, + JellyfinSegmentType.outro, + JellyfinSegmentType.preview, + ]); + expect((s.first.start, s.first.end), (const Duration(seconds: 30), const Duration(minutes: 2))); + expect(s[1].start, const Duration(minutes: 21, seconds: 30)); + expect(s.first.itemId, 'b18826be994070a79f9b52bc0f66f052'); + }); + + test('empty list, and none on servers without the endpoint', () async { + http.on('GET', '/MediaSegments/a', body: '{"Items":[],"TotalRecordCount":0,"StartIndex":0}'); + expect(await client.mediaSegments('a'), isEmpty); + expect(await client.mediaSegments('missing'), isEmpty); // 404 + http.on('GET', '/MediaSegments/x', status: 401); + await expectLater(client.mediaSegments('x'), + throwsA(isA().having((e) => e.isUnauthorized, 'unauthorized', true))); + }); + + test('unknown types stay unknown', () { + expect(JellyfinSegmentType.parse('Something'), JellyfinSegmentType.unknown); + expect(JellyfinSegmentType.parse(null), JellyfinSegmentType.unknown); + }); + }); + group('playback', () { test('playbackInfo asks for direct play and parses the session', () async { http.on('POST', '/Items/b18826be994070a79f9b52bc0f66f052/PlaybackInfo', fixture: 'playback_info'); diff --git a/packages/core/test/jellyfin_live_test.dart b/packages/core/test/jellyfin_live_test.dart index cd091c3..40eadf5 100644 --- a/packages/core/test/jellyfin_live_test.dart +++ b/packages/core/test/jellyfin_live_test.dart @@ -113,6 +113,12 @@ void main() { final src = pi.primarySource!; expect(src.supportsDirectPlay, isTrue); + // 10.10+: the endpoint answers (often an empty list: segments need a + // provider plugin); ranges are well formed when present. + for (final seg in await client.mediaSegments(eps.first.id)) { + expect(seg.end, greaterThan(seg.start)); + } + final http = HttpClient(); addTearDown(() => http.close(force: true)); final req = await http.getUrl(client.streamUrl(eps.first.id, mediaSourceId: src.id));