Chapters on the scrubber, Skip OP/ED, audio/subtitle menus
- PlaybackController exposes chapters, segments (Jellyfin MediaSegments for account holders), tracks, position/duration. - Own seek bar with chapter diamonds and chapter title on hover, seeking through the sync user-seek path. - Skip OP / Skip ED pills from Intro/Outro segments or chapter names (ED skips to its end only); Skip 1:30 fallback for >20 min videos without OP info; next-episode pill now starts at the ED. - PgUp/PgDn chapters, Shift+Right +1:30; Audio and Subtitles menus with count badges via the existing track path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ae4edc29e8
commit
bc98c5c665
21 files changed
+2068
-241
No files matched your search
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in new issue
Block a user