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

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

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

No files matched your search

+24 -6
View File
@@ -88,10 +88,27 @@ updates, playing the next Jellyfin episode automatically, video shaders
Integrations links Jellyfin servers (below). About shows the version. Integrations links Jellyfin servers (below). About shows the version.
Keys in a session: Space = ready / unready, Tab = side panel (people, log, Keys in a session: Space = ready / unready, Tab = side panel (people, log,
chat, change video), Left/Right = seek 10 s, F = fullscreen, Esc = close chat, change video), Left/Right = seek 10 s, Shift+Right = skip 1:30,
panel or ask to leave, Ctrl+R = reload video, `<` / `>` or Shift+P / 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, 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 ## Ports
@@ -145,15 +162,16 @@ Watching:
a button to link one; it plays as soon as you are signed in. a button to link one; it plays as soon as you are signed in.
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`, Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`,
Shift+P / Shift+N) put up the previous or next episode for everyone. In Shift+P / Shift+N) put up the previous or next episode for everyone. From
the last two minutes a "Next episode" button floats in the corner. When an 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 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 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 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 everyone). Anyone with an account can put the next one up; friends
watching via the host cannot. 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 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 match (language, then title, then codec and channels) is selected on the
next episode. This is per person; nobody else is affected. next episode. This is per person; nobody else is affected.
@@ -314,10 +314,14 @@ Future<({EpisodeLink? previous, EpisodeLink? next})> findAdjacentEpisodes(OwnPla
} }
} }
/// When the floating "Next episode" pill appears: the last two minutes of /// When the floating "Next episode" pill appears: at [endingStart] (the
/// [runtime] (all of it for shorter media). One place to switch it to the /// ED: Jellyfin Outro segment or ED-titled chapter, see `endingRange`)
/// item's outro / credits segment later. /// when one is known inside [runtime], else the last two minutes of
Duration nextPromptStart(JellyfinItem? item, Duration runtime) { /// [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); const lead = Duration(minutes: 2);
return runtime > lead ? runtime - lead : Duration.zero; return runtime > lead ? runtime - lead : Duration.zero;
} }
+93 -8
View File
@@ -12,7 +12,8 @@ import 'accounts.dart';
const trackPrefsBoxName = 'track_prefs'; const trackPrefsBoxName = 'track_prefs';
const _keyGlobal = 'global'; 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 { class MpvTrack {
final int id; final int id;
final JellyfinStreamType type; final JellyfinStreamType type;
@@ -21,6 +22,10 @@ class MpvTrack {
final String? codec; final String? codec;
final int? channels; final int? channels;
final bool isDefault; final bool isDefault;
final bool isForced;
final bool isHearingImpaired;
final bool isExternal;
final bool selected;
const MpvTrack({ const MpvTrack({
required this.id, required this.id,
required this.type, required this.type,
@@ -29,6 +34,10 @@ class MpvTrack {
this.codec, this.codec,
this.channels, this.channels,
this.isDefault = false, 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. /// As a stream for [matchTrack]; its index is the mpv id.
@@ -39,17 +48,93 @@ class MpvTrack {
title: title, title: title,
codec: codec, codec: codec,
channels: channels, channels: channels,
isDefault: isDefault); isDefault: isDefault,
isForced: isForced,
isHearingImpaired: isHearingImpaired);
/// Menu text: "English (eac3 2ch)", "Track 2". /// Menu text: "Japanese · E-AC3 2.0 · Commentary", "Track 2 · ASS".
String get label { String get label => [
final name = title ?? language ?? 'Track $id'; languageName(language) ?? 'Track $id',
final extra = [?codec, if (channels != null) '${channels}ch'].join(' '); [?codecName(codec), ?channelLayoutName(channels)].join(' '),
final lang = title != null && language != null ? ' [$language]' : ''; if (title != null && title != languageName(language)) title!,
return extra.isEmpty ? '$name$lang' : '$name$lang ($extra)'; 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 /// A viewer's audio/subtitle choice as traits (indices shift between
/// files). [subtitlesOff] wins over [subtitle]. /// files). [subtitlesOff] wins over [subtitle].
class TrackChoice { class TrackChoice {
@@ -0,0 +1,272 @@
import 'dart:convert';
import 'package:sync_relay_core/core.dart' show JellyfinMediaSegment, JellyfinSegmentType, JellyfinStreamType;
import '../jellyfin/track_prefs.dart' show MpvTrack;
// Chapters, segments and tracks of the open media as plain values, plus
// the skip rules built on them (OP / ED / the 1:30 fallback, chapter
// steps). No Flutter, no player: `PlaybackController` feeds its signals
// through these and any controls UI reads them.
/// One mpv chapter. [end] is the next chapter's start, or the duration for
/// the last one ([start] while the duration is unknown).
class MediaChapter {
final int index;
final String title;
final Duration start;
final Duration end;
const MediaChapter({required this.index, required this.title, required this.start, required this.end});
@override
bool operator ==(Object other) =>
other is MediaChapter &&
other.index == index &&
other.title == title &&
other.start == start &&
other.end == end;
@override
int get hashCode => Object.hash(index, title, start, end);
@override
String toString() => 'MediaChapter($index "$title" $start-$end)';
}
/// A marked range of the item from Jellyfin (MediaSegments): intro,
/// outro, recap, preview or commercial.
class MediaSegment {
final JellyfinSegmentType kind;
final Duration start;
final Duration end;
const MediaSegment({required this.kind, required this.start, required this.end});
factory MediaSegment.fromJellyfin(JellyfinMediaSegment s) =>
MediaSegment(kind: s.type, start: s.start, end: s.end);
@override
String toString() => 'MediaSegment(${kind.name} $start-$end)';
}
/// Where a skip range came from.
enum SkipSource { segment, chapter }
/// An opening or ending: skipping it seeks to [end].
class SkipRange {
final Duration start;
final Duration end;
final SkipSource source;
/// The chapter title when [source] is a chapter.
final String? title;
const SkipRange(this.start, this.end, this.source, {this.title});
@override
bool operator ==(Object other) =>
other is SkipRange &&
other.start == start &&
other.end == end &&
other.source == source &&
other.title == title;
@override
int get hashCode => Object.hash(start, end, source, title);
@override
String toString() => 'SkipRange(${source.name} $start-$end${title == null ? '' : ' "$title"'})';
}
Duration _secs(num s) => Duration(microseconds: (s * 1e6).round());
/// mpv `chapter-list` as JSON (`[{"title": "OP", "time": 30.0}, ...]`).
/// Ends come from the next start, the last from [duration].
List<MediaChapter> parseChapterList(String json, Duration duration) {
final Object? raw;
try {
raw = jsonDecode(json);
} on FormatException {
return const [];
}
if (raw is! List) return const [];
final starts = <(String, Duration)>[
for (final c in raw)
if (c is Map && c['time'] is num && (c['time'] as num).isFinite)
((c['title'] is String ? c['title'] as String : '').trim(), _secs(c['time'] as num)),
]..sort((a, b) => a.$2.compareTo(b.$2));
return chaptersFromStarts(starts, duration);
}
/// Chapters from (title, start) pairs in start order.
List<MediaChapter> chaptersFromStarts(List<(String, Duration)> starts, Duration duration) => [
for (var i = 0; i < starts.length; i++)
MediaChapter(
index: i,
title: starts[i].$1,
start: starts[i].$2,
end: i + 1 < starts.length
? starts[i + 1].$2
: (duration > starts[i].$2 ? duration : starts[i].$2),
),
];
/// mpv `track-list` as JSON: the audio and subtitle tracks (video and
/// unknown types left out), with mpv's selection.
List<MpvTrack> parseTrackList(String json) {
final Object? raw;
try {
raw = jsonDecode(json);
} on FormatException {
return const [];
}
if (raw is! List) return const [];
String? s(Object? v) => v is String && v.isNotEmpty ? v : null;
return [
for (final t in raw)
if (t is Map && t['id'] is int && (t['type'] == 'audio' || t['type'] == 'sub'))
MpvTrack(
id: t['id'] as int,
type: t['type'] == 'audio' ? JellyfinStreamType.audio : JellyfinStreamType.subtitle,
language: s(t['lang']),
title: s(t['title']),
codec: s(t['codec']),
channels: (t['demux-channel-count'] ?? t['audio-channels']) is int
? (t['demux-channel-count'] ?? t['audio-channels']) as int
: null,
isDefault: t['default'] == true,
isForced: t['forced'] == true,
isHearingImpaired: t['hearing-impaired'] == true,
isExternal: t['external'] == true,
selected: t['selected'] == true,
),
];
}
final _openingTitle = RegExp(r'\b(op|opening|intro)\d*\b', caseSensitive: false);
final _endingTitle = RegExp(r'\b(ed|ending|credits|outro)\d*\b', caseSensitive: false);
/// "OP", "OP1", "Opening", "Intro" (whole words, any case).
bool isOpeningTitle(String title) => _openingTitle.hasMatch(title);
/// "ED", "ED2", "Ending", "Credits", "Outro" (whole words, any case).
bool isEndingTitle(String title) => _endingTitle.hasMatch(title);
SkipRange? _range(List<MediaSegment> segments, List<MediaChapter> chapters,
JellyfinSegmentType kind, bool Function(String) titleMatches) {
for (final s in segments) {
if (s.kind == kind && s.end > s.start) return SkipRange(s.start, s.end, SkipSource.segment);
}
for (final c in chapters) {
if (c.end > c.start && titleMatches(c.title)) {
return SkipRange(c.start, c.end, SkipSource.chapter, title: c.title);
}
}
return null;
}
/// The opening: the first Jellyfin Intro segment, else the first chapter
/// titled like an opening ([isOpeningTitle]).
SkipRange? openingRange(List<MediaSegment> segments, List<MediaChapter> chapters) =>
_range(segments, chapters, JellyfinSegmentType.intro, isOpeningTitle);
/// The ending: the first Jellyfin Outro segment, else the first chapter
/// titled like an ending ([isEndingTitle]).
SkipRange? endingRange(List<MediaSegment> segments, List<MediaChapter> chapters) =>
_range(segments, chapters, JellyfinSegmentType.outro, isEndingTitle);
/// Index of the chapter playing at [position], or null (before the first
/// chapter, or none).
int? chapterIndexAt(List<MediaChapter> chapters, Duration position) {
int? at;
for (final c in chapters) {
if (c.start <= position) at = c.index;
}
return at;
}
/// mpv's `chapter-seek-threshold`: going back within this much of a
/// chapter's start goes to the chapter before it.
const chapterBackThreshold = Duration(seconds: 5);
/// Target of a chapter step ([delta] +1 = PgDn, -1 = PgUp), as mpv: back
/// goes to the current chapter's start, or the previous one within
/// [chapterBackThreshold] of it; forward to the next chapter's start.
/// Null when there is nowhere to go.
Duration? chapterStepTarget(List<MediaChapter> chapters, Duration position, int delta) {
if (chapters.isEmpty || delta == 0) return null;
final at = chapterIndexAt(chapters, position);
if (delta > 0) {
final next = (at ?? -1) + delta;
return next < chapters.length ? chapters[next].start : null;
}
if (at == null) return null;
var target = at + delta + 1;
if (position - chapters[at].start < chapterBackThreshold) target--;
return target >= 0 ? chapters[target].start : Duration.zero;
}
/// What the skip pill offers.
enum SkipKind {
/// "Skip OP": to the opening's end.
opening,
/// "Skip ED": to the ending's end, nothing further.
ending,
/// "Skip 1:30": no opening known, +[fallbackSkip] from where it plays.
fixed,
}
class SkipOffer {
final SkipKind kind;
/// Seek target (for [SkipKind.fixed] as of the position it was computed
/// at; the press itself skips relative to the moment it lands).
final Duration target;
const SkipOffer(this.kind, this.target);
@override
bool operator ==(Object other) => other is SkipOffer && other.kind == kind && other.target == target;
@override
int get hashCode => Object.hash(kind, target);
@override
String toString() => 'SkipOffer(${kind.name} -> $target)';
}
/// The fixed skip (pill without an opening, Shift+Right, the controls'
/// "1:30" button).
const fallbackSkip = Duration(seconds: 90);
/// The fallback pill needs media longer than this (an episode, not a clip).
const fallbackMinDuration = Duration(minutes: 20);
/// ... and shows during this first part only.
const fallbackWindow = Duration(minutes: 6);
/// No pill in the last second of a range: the skip would do nothing.
const _skipTail = Duration(seconds: 1);
/// The skip pill at [position]: inside the opening "Skip OP" (to its end),
/// inside the ending "Skip ED" (to its end only: what follows, preview or
/// more episode, is the viewer's call). Without any opening info on media
/// longer than [fallbackMinDuration], "Skip 1:30" during the first
/// [fallbackWindow]. Null otherwise.
SkipOffer? skipOfferAt({
required Duration position,
required Duration duration,
required SkipRange? opening,
required SkipRange? ending,
}) {
bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail;
if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end);
if (ending != null && inside(ending)) return SkipOffer(SkipKind.ending, ending.end);
if (opening == null && duration > fallbackMinDuration && position < fallbackWindow) {
final to = position + fallbackSkip;
return SkipOffer(SkipKind.fixed, to < duration ? to : duration);
}
return null;
}
/// "1:05", "1:02:03".
String formatClock(Duration d) {
final neg = d.isNegative;
final t = d.abs();
String two(int n) => n.toString().padLeft(2, '0');
final h = t.inHours, m = t.inMinutes % 60, s = t.inSeconds % 60;
final body = h > 0 ? '$h:${two(m)}:${two(s)}' : '$m:${two(s)}';
return neg ? '-$body' : body;
}
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.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 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../jellyfin/track_prefs.dart' show MpvTrack; import '../jellyfin/track_prefs.dart' show MpvTrack;
import '../shaders/shader_assets.dart'; import '../shaders/shader_assets.dart';
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../sync/state_diff.dart'; import '../sync/state_diff.dart';
import '../sync/user_seek.dart'; import '../sync/user_seek.dart';
import 'frame_watchdog.dart'; import 'frame_watchdog.dart';
import 'media_marks.dart';
/// Owns one mpv [Player] (and its [VideoController]) per session: created /// Owns one mpv [Player] (and its [VideoController]) per session: created
/// by [init] on join, released by [dispose] on leave, swapped by /// by [init] on join, released by [dispose] on leave, swapped by
@@ -38,6 +39,8 @@ class PlaybackController {
bool _probing = false; bool _probing = false;
final FrameWatchdog _watchdog = FrameWatchdog(); final FrameWatchdog _watchdog = FrameWatchdog();
StreamSubscription<PlayerLog>? _logSub; StreamSubscription<PlayerLog>? _logSub;
final List<StreamSubscription<Duration>> _timeSubs = [];
String _chapterJson = '';
// `glsl-shaders` as last set on the current player ('' = none). // `glsl-shaders` as last set on the current player ('' = none).
String _shaders = ''; String _shaders = '';
Future<void> _shaderOp = Future.value(); Future<void> _shaderOp = Future.value();
@@ -71,6 +74,44 @@ class PlaybackController {
final renderMode = signal(RenderMode.unknown); final renderMode = signal(RenderMode.unknown);
static final lastRenderMode = signal(RenderMode.unknown); static final lastRenderMode = signal(RenderMode.unknown);
// ------------------------------------------------- media marks (signals)
//
// What the open file has, for any controls UI (the session's seek bar,
// chapter / track menus and skip pills read these; nothing here knows
// about widgets). Reset when the player goes away; [segments] is owned
// by the coordinator (it survives Reload video, the item is the same).
/// mpv `chapter-list`: title and start; end = next start, or the
/// duration for the last one. Empty without chapters.
final chapters = signal<List<MediaChapter>>(const []);
/// Jellyfin MediaSegments of the current item (Intro / Outro / Recap /
/// Preview / Commercial). Only viewers with their own account fetch
/// them (relay viewers cannot call Jellyfin): empty otherwise.
final segments = signal<List<MediaSegment>>(const []);
/// Audio and subtitle tracks from mpv's `track-list` (id, language,
/// title, codec, channels, default / forced flags, [MpvTrack.selected]).
final tracks = signal<List<MpvTrack>>(const []);
/// Playback position, in 250 ms steps (enough for pills and menus; a
/// seek bar should listen to `player.stream.position`).
final position = signal(Duration.zero);
/// Duration of the open file (zero until known).
final duration = signal(Duration.zero);
/// The opening ([openingRange]: Intro segment, else an OP-titled
/// chapter) and the ending ([endingRange]: Outro, else ED-titled).
late final Computed<SkipRange?> opening =
computed(() => openingRange(segments.value, chapters.value));
late final Computed<SkipRange?> ending =
computed(() => endingRange(segments.value, chapters.value));
/// Index into [chapters] of the chapter playing now.
late final Computed<int?> currentChapter =
computed(() => chapterIndexAt(chapters.value, position.value));
/// Pinned mpv properties whose read-back differs from what was set /// Pinned mpv properties whose read-back differs from what was set
/// (mpv ignores invalid values silently), as `name: wanted -> got`. /// (mpv ignores invalid values silently), as `name: wanted -> got`.
final List<String> pinMismatches = []; final List<String> pinMismatches = [];
@@ -116,6 +157,7 @@ class PlaybackController {
renderMode.value = RenderMode.unknown; renderMode.value = RenderMode.unknown;
shaderNotice.value = null; shaderNotice.value = null;
_logSub = _player!.stream.log.listen(_onLog); _logSub = _player!.stream.log.listen(_onLog);
_watchMarks(_player!);
await _pinBufferProps(); await _pinBufferProps();
await applyShaders(); await applyShaders();
_watchdog.reset(); _watchdog.reset();
@@ -364,6 +406,9 @@ class PlaybackController {
Future<void> openUrl(String url, {Map<String, String>? headers}) async { Future<void> openUrl(String url, {Map<String, String>? headers}) async {
if (!_ready) return; if (!_ready) return;
try { try {
_chapterJson = '';
chapters.value = const [];
tracks.value = const [];
// An empty map, not null: media_kit caches headers per URI. // An empty map, not null: media_kit caches headers per URI.
await player.open(Media(url, httpHeaders: headers ?? const {}), play: false); await player.open(Media(url, httpHeaders: headers ?? const {}), play: false);
} catch (e, st) { } catch (e, st) {
@@ -372,28 +417,58 @@ class PlaybackController {
} }
} }
/// The audio / subtitle tracks of the open file (mpv's track-list). // Position / duration from media_kit's streams; chapter-list and
List<MpvTrack> get tracks { // track-list observed in mpv (read back as JSON).
final p = _player; void _watchMarks(SyncPlayer p) {
if (p == null) return const []; _timeSubs.add(p.stream.position.listen((d) {
MpvTrack? of(dynamic t, JellyfinStreamType type) { final ms = d.inMilliseconds;
final id = int.tryParse('${t.id}'); position.value = Duration(milliseconds: ms - ms % 250);
if (id == null) return null; }));
return MpvTrack( _timeSubs.add(p.stream.duration.listen((d) {
id: id, duration.value = d;
type: type, // The last chapter ends at the duration.
language: t.language as String?, if (_chapterJson.isNotEmpty) chapters.value = parseChapterList(_chapterJson, d);
title: t.title as String?, }));
codec: t.codec as String?, () async {
channels: (t.channelscount ?? t.audiochannels) as int?, try {
isDefault: t.isDefault == true, 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 [ void _setTracks(List<MpvTrack> list) {
for (final t in p.state.tracks.audio) ?of(t, JellyfinStreamType.audio), if (!listEquals(tracks.peek(), list)) tracks.value = list;
for (final t in p.state.tracks.subtitle) ?of(t, JellyfinStreamType.subtitle), }
];
/// Reads `track-list` now (after a selection: [tracks] then shows it at
/// once instead of on mpv's next change notice).
Future<void> _refreshTracks() async {
final p = _player;
if (p == null) return;
try {
final dynamic platform = p.platform;
final v = '${await platform?.getProperty('track-list')}';
if (identical(p, _player)) _setTracks(parseTrackList(v));
} catch (_) {}
}
void _clearMarks() {
_chapterJson = '';
chapters.value = const [];
tracks.value = const [];
position.value = Duration.zero;
duration.value = Duration.zero;
} }
/// Waits (bounded) until the open file's tracks are listed and mpv has /// Waits (bounded) until the open file's tracks are listed and mpv has
@@ -402,12 +477,12 @@ class PlaybackController {
Future<List<MpvTrack>> waitForTracks({Duration timeout = const Duration(seconds: 5)}) async { Future<List<MpvTrack>> waitForTracks({Duration timeout = const Duration(seconds: 5)}) async {
final end = DateTime.now().add(timeout); final end = DateTime.now().add(timeout);
while (_player != null && DateTime.now().isBefore(end)) { while (_player != null && DateTime.now().isBefore(end)) {
final t = tracks; final t = tracks.peek();
final hasAudio = t.any((x) => x.type == JellyfinStreamType.audio); final hasAudio = t.any((x) => x.type == JellyfinStreamType.audio);
if (t.isNotEmpty && (!hasAudio || (await currentTrackIds()).aid != null)) return t; if (t.isNotEmpty && (!hasAudio || (await currentTrackIds()).aid != null)) return t;
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
} }
return tracks; return tracks.peek();
} }
/// mpv's selected audio and subtitle track ids (null = none / off). /// mpv's selected audio and subtitle track ids (null = none / off).
@@ -440,6 +515,7 @@ class PlaybackController {
} catch (e) { } catch (e) {
debugPrint('select tracks failed: $e'); debugPrint('select tracks failed: $e');
} }
await _refreshTracks();
} }
double get positionSecs { double get positionSecs {
@@ -495,9 +571,14 @@ class PlaybackController {
_watchTimer = null; _watchTimer = null;
await _logSub?.cancel(); await _logSub?.cancel();
_logSub = null; _logSub = null;
for (final sub in _timeSubs) {
await sub.cancel();
}
_timeSubs.clear();
_player = null; _player = null;
_video = null; _video = null;
alive.value = false; alive.value = false;
_clearMarks();
await _settleUi(); await _settleUi();
try { try {
await p.dispose(); await p.dispose();
@@ -14,10 +14,13 @@ import 'update_indicator.dart';
const sessionShortcuts = [ const sessionShortcuts = [
('Space', 'Toggle ready'), ('Space', 'Toggle ready'),
('← / →', 'Seek back / forward 10 s'), ('← / →', 'Seek back / forward 10 s'),
('Shift+→', 'Skip 1:30 (for everyone)'),
('PgUp / PgDn', 'Previous / next chapter'),
('F', 'Toggle fullscreen'), ('F', 'Toggle fullscreen'),
('Tab', 'Open / close the people & chat panel'), ('Tab', 'Open / close the people & chat panel'),
('Ctrl+R', 'Reload the video'), ('Ctrl+R', 'Reload the video'),
('> or Shift+N', 'Put up the next Jellyfin episode'), ('> 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+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'),
('Ctrl+0', 'Shaders off'), ('Ctrl+0', 'Shaders off'),
('Esc', 'Close panel or fullscreen, else ask to leave'), ('Esc', 'Close panel or fullscreen, else ask to leave'),
@@ -1,9 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../jellyfin/session_media.dart'; import '../../jellyfin/session_media.dart';
import '../../jellyfin/track_prefs.dart';
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs; 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 // Jellyfin pieces of the session screen. Plain widgets fed by the
// coordinator's signals, so tests can pump them without the player. // coordinator's signals, so tests can pump them without the player.
@@ -37,66 +36,22 @@ class EpisodeSkipButton extends StatelessWidget {
} }
} }
/// Floating "Next episode ›" near the end of an episode: faint at rest, /// Floating "Next episode ›" from the ED (or the last two minutes):
/// lit on hover. Lives outside the auto-hiding controls, so it shows in /// faint at rest, lit on hover. Lives outside the auto-hiding controls, so
/// fullscreen and with the controls hidden. /// it shows in fullscreen and with the controls hidden.
class NextEpisodePill extends StatefulWidget { class NextEpisodePill extends StatelessWidget {
final EpisodeLink episode; final EpisodeLink episode;
final VoidCallback onPressed; final VoidCallback onPressed;
const NextEpisodePill({super.key, required this.episode, required this.onPressed}); const NextEpisodePill({super.key, required this.episode, required this.onPressed});
@override @override
State<NextEpisodePill> createState() => _NextEpisodePillState(); Widget build(BuildContext context) => FloatingPill(
} label: 'Next episode',
tooltip: 'Next: ${episode.label}',
class _NextEpisodePillState extends State<NextEpisodePill> { tapKey: const ValueKey('next-episode-pill'),
bool _hover = false; onPressed: onPressed,
@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),
],
),
),
),
),
),
),
); );
} }
}
/// At the end of an episode: "Next: S1E7 title" with Play now, and while /// At the end of an episode: "Next: S1E7 title" with Play now, and while
/// the auto-advance countdown runs, the seconds left and Cancel. /// the auto-advance countdown runs, the seconds left and Cancel.
@@ -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<MpvTrack> Function() tracks;
final Future<({int? aid, int? sid})> Function() current;
final void Function(TrackPick pick) onPick;
final ValueChanged<bool>? 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<void> _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<TrackPick> 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<TrackPick>(
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);
}
}
@@ -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<MediaChapter> chapters;
final ValueChanged<Duration> 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<ChapterSeekBar> createState() => _ChapterSeekBarState();
}
class _ChapterSeekBarState extends State<ChapterSeekBar> {
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<MediaChapter> chapters;
const PlayerSeekBar({super.key, required this.player, required this.chapters});
@override
State<PlayerSeekBar> createState() => _PlayerSeekBarState();
}
class _PlayerSeekBarState extends State<PlayerSeekBar> {
final List<StreamSubscription<Object>> _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<MediaChapter> chapters;
final int? current;
final ValueChanged<MediaChapter> onPick;
final FocusNode? focusNode;
final ValueChanged<bool>? 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<void> _open(BuildContext context) async {
final position = _menuPosition(context);
onMenuOpen?.call(true);
final pick = await showMenu<MediaChapter>(
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<MpvTrack> tracks;
final void Function(TrackPick pick) onPick;
final FocusNode? focusNode;
final ValueChanged<bool>? 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<void> _open(BuildContext context, List<MpvTrack> list) async {
final position = _menuPosition(context);
onMenuOpen?.call(true);
final pick = await showMenu<TrackPick>(
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<FloatingPill> createState() => _FloatingPillState();
}
class _FloatingPillState extends State<FloatingPill> {
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,
);
}
}
+123 -37
View File
@@ -4,6 +4,8 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../playback/media_marks.dart' show SkipOffer;
import '../jellyfin/browse_scope.dart' show BrowseStart; import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../session/health.dart'; import '../session/health.dart';
@@ -22,6 +24,7 @@ import '../widgets/window_drag_layer.dart';
import 'home/integrations_page.dart'; import 'home/integrations_page.dart';
import 'home/media_picker.dart'; import 'home/media_picker.dart';
import 'session/jellyfin_widgets.dart'; import 'session/jellyfin_widgets.dart';
import 'session/player_controls.dart';
import 'session/swap_panel.dart'; import 'session/swap_panel.dart';
class SessionScreen extends StatefulWidget { class SessionScreen extends StatefulWidget {
@@ -257,6 +260,17 @@ class _SessionScreenState extends State<SessionScreen> {
coord.playPreviousEpisode(); coord.playPreviousEpisode();
return true; 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) { if (event.logicalKey == LogicalKeyboardKey.tab) {
_lastKeyAt = now; _lastKeyAt = now;
_sheetOpen.value = !_sheetOpen.peek(); _sheetOpen.value = !_sheetOpen.peek();
@@ -320,7 +334,18 @@ class _SessionScreenState extends State<SessionScreen> {
visibleOnMount: true, visibleOnMount: true,
hideMouseOnControlsRemoval: true, hideMouseOnControlsRemoval: true,
controlsHoverDuration: const Duration(seconds: 3), 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: [ topButtonBar: [
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: _barHeight,
child: Row(
children: [
const Spacer(), const Spacer(),
MaterialDesktopCustomButton( MaterialDesktopCustomButton(
icon: const Icon(Icons.group), icon: const Icon(Icons.group),
@@ -333,43 +358,27 @@ class _SessionScreenState extends State<SessionScreen> {
builder: (context) => WindowButtons(focusNode: _noFocus, fullscreen: _isFullscreen.value, onToggleFullscreen: _toggleFullscreen), builder: (context) => WindowButtons(focusNode: _noFocus, fullscreen: _isFullscreen.value, onToggleFullscreen: _toggleFullscreen),
), ),
], ],
),
),
),
),
],
bottomButtonBar: [ bottomButtonBar: [
Expanded(
child: Column(
children: [
SignalBuilder( SignalBuilder(
builder: (context) => EpisodeSkipButton( builder: (context) => PlayerSeekBar(
focusNode: _noFocus, player: coord.playback.player,
episode: coord.previousEpisode.value, chapters: coord.playback.chapters.value,
next: false,
onPressed: coord.playPreviousEpisode,
), ),
), ),
SignalBuilder( Expanded(
builder: (context) => EpisodeSkipButton( child: Row(children: _bottomButtons(coord)),
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( return FocusTraversalGroup(
@@ -415,6 +424,73 @@ class _SessionScreenState extends State<SessionScreen> {
); );
} }
static const _barHeight = 56.0;
List<Widget> _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) { Widget _sessionControls(VideoState state) {
return Stack( return Stack(
children: [ children: [
@@ -575,8 +651,9 @@ class _SessionScreenState extends State<SessionScreen> {
} }
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and /// Jellyfin: the blocked card, the end-of-episode card (countdown) and
/// the "Next episode" pill. Outside the auto-hiding controls, so they /// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
/// also show in fullscreen. /// 1:30) above it. Outside the auto-hiding controls, so they also show
/// in fullscreen.
Widget _jellyfinOverlay() { Widget _jellyfinOverlay() {
final coord = SessionState.instance.coordinator; final coord = SessionState.instance.coordinator;
return Positioned.fill( return Positioned.fill(
@@ -587,6 +664,8 @@ class _SessionScreenState extends State<SessionScreen> {
final countdown = coord.autoAdvance.value; final countdown = coord.autoAdvance.value;
final finished = coord.serverState.value?.transport == 'finished'; final finished = coord.serverState.value?.transport == 'finished';
final pill = coord.nextPromptVisible.value; final pill = coord.nextPromptVisible.value;
final SkipOffer? skip = coord.skipOffer.value;
final showNext = next != null && pill;
return Stack( return Stack(
children: [ children: [
if (blocked != null) if (blocked != null)
@@ -600,7 +679,7 @@ class _SessionScreenState extends State<SessionScreen> {
if (next != null && (finished || countdown != null)) if (next != null && (finished || countdown != null))
Positioned( Positioned(
right: 24, right: 24,
bottom: 110, bottom: 138,
child: EndOfEpisodeCard( child: EndOfEpisodeCard(
next: next, next: next,
countdown: countdown, countdown: countdown,
@@ -608,11 +687,18 @@ class _SessionScreenState extends State<SessionScreen> {
onCancel: coord.cancelAutoAdvance, onCancel: coord.cancelAutoAdvance,
), ),
) )
else if (next != null && pill) else if (showNext || skip != null)
Positioned( Positioned(
right: 24, right: 24,
bottom: 96, bottom: 124,
child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode), 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),
],
),
), ),
], ],
); );
@@ -8,6 +8,7 @@ import '../jellyfin/accounts.dart';
import '../jellyfin/progress.dart'; import '../jellyfin/progress.dart';
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../jellyfin/track_prefs.dart'; import '../jellyfin/track_prefs.dart';
import '../playback/media_marks.dart';
import '../playback/playback_controller.dart'; import '../playback/playback_controller.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
@@ -136,6 +137,21 @@ class SessionCoordinator {
/// End-of-episode countdown; null when none runs here. /// End-of-episode countdown; null when none runs here.
final autoAdvance = signal<AutoAdvance?>(null); final autoAdvance = signal<AutoAdvance?>(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?> 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<MemberView?> selfMember = computed(() { late final Computed<MemberView?> selfMember = computed(() {
final r = serverState.value; final r = serverState.value;
if (r == null || sync.memberId.isEmpty) return null; if (r == null || sync.memberId.isEmpty) return null;
@@ -767,6 +783,33 @@ class SessionCoordinator {
_userSeek(target < 0 ? 0 : target); _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) { void sendChat(String text) {
// Enter in the chat field can fire both onSubmitted and the focused // Enter in the chat field can fire both onSubmitted and the focused
// send button's onPressed; the first send clears the field, so the // send button's onPressed; the first send clears the field, so the
@@ -863,6 +906,7 @@ class SessionCoordinator {
previousEpisode.value = null; previousEpisode.value = null;
nextEpisode.value = null; nextEpisode.value = null;
nextPromptVisible.value = false; nextPromptVisible.value = false;
playback.segments.value = const [];
_advanceTimer?.cancel(); _advanceTimer?.cancel();
_advanceTimer = null; _advanceTimer = null;
_advanceDoneFor = -1; _advanceDoneFor = -1;
@@ -988,6 +1032,7 @@ class SessionCoordinator {
}, },
); );
unawaited(_findEpisodes(plan, gen)); unawaited(_findEpisodes(plan, gen));
unawaited(_loadSegments(plan, ref, gen));
// Playing carries the tracks: wait (bounded) until the file is in. // Playing carries the tracks: wait (bounded) until the file is in.
await playback.waitForTracks(); await playback.waitForTracks();
if (gen != _jfGen) return; if (gen != _jfGen) return;
@@ -1009,6 +1054,18 @@ class SessionCoordinator {
if (r != null) _jellyfinTransport(r); 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<void> _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<void> _applyTrackChoice(JellyfinPlan plan, int gen) async { Future<void> _applyTrackChoice(JellyfinPlan plan, int gen) async {
final own = plan is OwnPlay ? plan : null; final own = plan is OwnPlay ? plan : null;
final choice = await trackPrefs.load(seriesId: own?.item?.seriesId); final choice = await trackPrefs.load(seriesId: own?.item?.seriesId);
@@ -1028,7 +1085,7 @@ class SessionCoordinator {
final plan = jellyfinPlan.peek(); final plan = jellyfinPlan.peek();
final own = plan is OwnPlay ? plan : null; final own = plan is OwnPlay ? plan : null;
await trackPrefs.save( 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); seriesId: own?.item?.seriesId);
final snap = await _snapshot(); final snap = await _snapshot();
if (snap != null) _reporter?.update(snap, force: true); if (snap != null) _reporter?.update(snap, force: true);
@@ -1047,7 +1104,8 @@ class SessionCoordinator {
var show = false; var show = false;
if (next != null && duration > 0 && autoAdvance.peek() == null && r.transport != 'finished') { if (next != null && duration > 0 && autoAdvance.peek() == null && r.transport != 'finished') {
final item = (jellyfinPlan.peek() as OwnPlay?)?.item; 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; show = snap.position * 1000 >= at.inMilliseconds;
} }
nextPromptVisible.value = show; nextPromptVisible.value = show;
@@ -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<String> _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<void> _poll(
FutureOr<bool> 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();
}
},
);
}
+17
View File
@@ -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/accounts.dart';
import 'package:watch_together/src/jellyfin/session_media.dart'; import 'package:watch_together/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.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'; import 'package:watch_together/src/session/session_coordinator.dart';
// Two account holders and a guest watch Jellyfin episodes together over a // Two account holders and a guest watch Jellyfin episodes together over a
@@ -134,6 +135,15 @@ class _FakeJellyfin {
if (path == '/Shows/$_series/Episodes') { if (path == '/Shows/$_series/Episodes') {
return json({'Items': [_item(_ep1), _item(_ep2)], 'TotalRecordCount': 2}); 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')) { if (path.startsWith('/Sessions/Playing')) {
res.statusCode = 204; res.statusCode = 204;
return res.close(); return res.close();
@@ -260,6 +270,13 @@ void main() {
}, 'jf access in the member list'); }, 'jf access in the member list');
expect(a.nextEpisode.peek()?.item.id, _ep2); expect(a.nextEpisode.peek()?.item.id, _ep2);
expect(b.nextEpisode.peek(), isNull, reason: 'guests cannot resolve the next one'); 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) { for (final x in all) {
x.toggleReady(); x.toggleReady();
+197
View File
@@ -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');
});
}
+198
View File
@@ -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<T>(String text) =>
find.ancestor(of: find.text(text), matching: find.byType(CheckedPopupMenuItem<T>));
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<Text>(title).data, 'ED');
expect(tester.widget<Text>(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<Text>(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<Text>(find.byKey(const ValueKey('seek-hover-time'))).data, '12:00');
});
testWidgets('drag seeks on every move and on release', (tester) async {
final seeks = <Duration>[];
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<AnimatedOpacity>(
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<CheckedPopupMenuItem<TrackPick>>(find.byType(CheckedPopupMenuItem<TrackPick>));
expect(items.map((i) => i.checked), [true, false]);
expect(find.text('Japanese · E-AC3 2.0'), findsOneWidget);
await tester.tap(item<TrackPick>('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<CheckedPopupMenuItem<TrackPick>>(find.byType(CheckedPopupMenuItem<TrackPick>));
expect(items.map((i) => i.checked), [true, false]);
await tester.tap(item<TrackPick>('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<CheckedPopupMenuItem<MediaChapter>>(
find.byType(CheckedPopupMenuItem<MediaChapter>));
expect(items.map((i) => i.checked), [false, false, true, false, false]);
expect(find.text('20:00'), findsOneWidget);
await tester.tap(item<MediaChapter>('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);
});
});
}
+54 -9
View File
@@ -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 over 5 s for more than 2 s seeks, ahead by more than 1.5 s sets rate 0.95
until level, otherwise nothing. until level, otherwise nothing.
- **User seeks**: the player is a `SyncPlayer` whose public `seek` is the - **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), user's: the session's seek bar (`ChapterSeekBar`, one `seek` per pointer
the arrow keys and `seekTo` all become explicit events. The player moves at 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 once (scrub preview); `UserSeekGate` sends one `seek` intent with a fresh
`seq` after 300 ms without another request (`seekDebounceMs`). While the `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 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 again for 10 s, so it cannot loop. Seeks, pause, buffering and a hidden window
disarm it. 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 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 branch: teardown races mpv's frame upload and the window goes black. Keep them
mounted and skip painting (`Visibility` with `maintainState`). 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, - `JellyfinClient`: one server + one user. Auth (Quick Connect, password,
`/Users/Me` check, logout), browsing (views, Resume, NextUp, Latest, paged `/Users/Me` check, logout), browsing (views, Resume, NextUp, Latest, paged
`/Items`, search, seasons, episodes), image URLs (tag + maxWidth, no token `/Items`, search, seasons, episodes), image URLs (tag + maxWidth, no token
needed), PlaybackInfo, stream URLs, Playing / Progress / Stopped reports, needed), PlaybackInfo, stream URLs, MediaSegments (a 404 = none),
played / unplayed / resume position. Playing / Progress / Stopped reports, played / unplayed / resume position.
- `normalizeServerUrl`, `parseJellyfinLink`: pasted addresses and item links - `normalizeServerUrl`, `parseJellyfinLink`: pasted addresses and item links
-> `(baseUrl, itemId)`; accounts are keyed by `ServerId` -> `(baseUrl, itemId)`; accounts are keyed by `ServerId`
(`/System/Info/Public`). (`/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 - **Episodes**: with `OwnPlay` on an episode, `adjacentEpisodes` (one
`/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the `/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the
previous and next episode. The controls show previous / next buttons previous and next episode. The controls show previous / next buttons
(also `<` `>`, Shift+P / Shift+N); from `nextPromptStart` (last 2 min of (also `<` `>`, Shift+P / Shift+N); from `nextPromptStart` (the ending's
the runtime; the single place to switch to an outro segment later) a start when one is known, see "Skip rules", else the last 2 min of the
floating "Next episode" pill shows outside the auto-hiding controls. Any 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 of them swaps to that episode's ref with the clicker's account. Guests
(relay, blocked) see none of it. (relay, blocked) see none of it.
- **Auto-advance** (Settings, on by default): at `finished` with a known - **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 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` the swapper's Cancel stops the swap; a seek by anyone ends `finished`
and with it every countdown. and with it every countdown.
- **Tracks**: the controls' subtitles button lists mpv's audio and subtitle - **Tracks**: the controls' Audio and Subtitles buttons list
tracks. A pick is applied, reported, and saved as a `TrackChoice` `PlaybackController.tracks`. A pick is applied, reported, and saved as a `TrackChoice`
(language, title, codec, channels, forced/HI: from the Jellyfin streams (language, title, codec, channels, forced/HI: from the Jellyfin streams
with `OwnPlay`, else from mpv's track list) per series and as the global 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 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 it: the swap panel opens on the series of the episode playing, its
season selected and the episode highlighted. 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 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 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`. `widgets/window_chrome_bar.dart`; both use `window_buttons.dart`); the session drag layer is `window_drag_layer.dart`.
-2
View File
@@ -49,8 +49,6 @@ in a session"):
- Queue: put several library items (a season, a pick list) up as a - Queue: put several library items (a season, a pick list) up as a
playlist that advances like a swap (shares `queue-add` / `queue-skip` playlist that advances like a swap (shares `queue-add` / `queue-skip`
with the backlog's Playlist item). 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 - Auto-advance cancel for everyone: today Cancel is per screen (the
designated swapper's Cancel stops the swap; a seek stops all). A shared designated swapper's Cancel stops the swap; a seek stops all). A shared
cancel needs a wire message. cancel needs a wire message.
@@ -499,6 +499,27 @@ class JellyfinClient {
Map<String, String> get authHeaders => Map<String, String> get authHeaders =>
{'Authorization': clientInfo.authorizationHeader(accessToken)}; {'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<List<JellyfinMediaSegment>> 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<String, dynamic>())
]..sort((a, b) => a.startTicks.compareTo(b.startTicks));
return list;
}
// ------------------------------------------------------------ progress // ------------------------------------------------------------ progress
/// `POST /Sessions/Playing`: playback started (Jellyfin shows the session /// `POST /Sessions/Playing`: playback started (Jellyfin shows the session
@@ -555,3 +555,54 @@ class JellyfinPlaybackInfo {
return mediaSources.isEmpty ? null : mediaSources.first; 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)';
}
@@ -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
}
@@ -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<JellyfinException>().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', () { group('playback', () {
test('playbackInfo asks for direct play and parses the session', () async { test('playbackInfo asks for direct play and parses the session', () async {
http.on('POST', '/Items/b18826be994070a79f9b52bc0f66f052/PlaybackInfo', fixture: 'playback_info'); http.on('POST', '/Items/b18826be994070a79f9b52bc0f66f052/PlaybackInfo', fixture: 'playback_info');
@@ -113,6 +113,12 @@ void main() {
final src = pi.primarySource!; final src = pi.primarySource!;
expect(src.supportsDirectPlay, isTrue); 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(); final http = HttpClient();
addTearDown(() => http.close(force: true)); addTearDown(() => http.close(force: true));
final req = await http.getUrl(client.streamUrl(eps.first.id, mediaSourceId: src.id)); final req = await http.getUrl(client.streamUrl(eps.first.id, mediaSourceId: src.id));