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

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

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

No files matched your search

@@ -314,10 +314,14 @@ Future<({EpisodeLink? previous, EpisodeLink? next})> findAdjacentEpisodes(OwnPla
}
}
/// When the floating "Next episode" pill appears: the last two minutes of
/// [runtime] (all of it for shorter media). One place to switch it to the
/// item's outro / credits segment later.
Duration nextPromptStart(JellyfinItem? item, Duration runtime) {
/// When the floating "Next episode" pill appears: at [endingStart] (the
/// ED: Jellyfin Outro segment or ED-titled chapter, see `endingRange`)
/// when one is known inside [runtime], else the last two minutes of
/// [runtime] (all of it for shorter media).
Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) {
if (endingStart != null && endingStart > Duration.zero && endingStart < runtime) {
return endingStart;
}
const lead = Duration(minutes: 2);
return runtime > lead ? runtime - lead : Duration.zero;
}
+94 -9
View File
@@ -12,7 +12,8 @@ import 'accounts.dart';
const trackPrefsBoxName = 'track_prefs';
const _keyGlobal = 'global';
/// One mpv track as media_kit lists it ([id] is mpv's `aid` / `sid`).
/// One audio or subtitle track of the open file as mpv's `track-list`
/// has it ([id] is mpv's `aid` / `sid`; [selected] is mpv's choice).
class MpvTrack {
final int id;
final JellyfinStreamType type;
@@ -21,6 +22,10 @@ class MpvTrack {
final String? codec;
final int? channels;
final bool isDefault;
final bool isForced;
final bool isHearingImpaired;
final bool isExternal;
final bool selected;
const MpvTrack({
required this.id,
required this.type,
@@ -29,6 +34,10 @@ class MpvTrack {
this.codec,
this.channels,
this.isDefault = false,
this.isForced = false,
this.isHearingImpaired = false,
this.isExternal = false,
this.selected = false,
});
/// As a stream for [matchTrack]; its index is the mpv id.
@@ -39,17 +48,93 @@ class MpvTrack {
title: title,
codec: codec,
channels: channels,
isDefault: isDefault);
isDefault: isDefault,
isForced: isForced,
isHearingImpaired: isHearingImpaired);
/// Menu text: "English (eac3 2ch)", "Track 2".
String get label {
final name = title ?? language ?? 'Track $id';
final extra = [?codec, if (channels != null) '${channels}ch'].join(' ');
final lang = title != null && language != null ? ' [$language]' : '';
return extra.isEmpty ? '$name$lang' : '$name$lang ($extra)';
}
/// Menu text: "Japanese · E-AC3 2.0 · Commentary", "Track 2 · ASS".
String get label => [
languageName(language) ?? 'Track $id',
[?codecName(codec), ?channelLayoutName(channels)].join(' '),
if (title != null && title != languageName(language)) title!,
if (isForced) 'Forced',
if (isHearingImpaired) 'SDH',
].where((p) => p.isNotEmpty).join(' · ');
@override
bool operator ==(Object other) =>
other is MpvTrack &&
other.id == id &&
other.type == type &&
other.language == language &&
other.title == title &&
other.codec == codec &&
other.channels == channels &&
other.isDefault == isDefault &&
other.isForced == isForced &&
other.isHearingImpaired == isHearingImpaired &&
other.isExternal == isExternal &&
other.selected == selected;
@override
int get hashCode => Object.hash(id, type, language, title, codec, channels, isDefault, isForced,
isHearingImpaired, isExternal, selected);
@override
String toString() => 'MpvTrack(${type.name} $id ${selected ? '*' : ''}$label)';
}
const _languages = {
'jpn': 'Japanese', 'ja': 'Japanese', 'eng': 'English', 'en': 'English',
'ger': 'German', 'deu': 'German', 'de': 'German', 'fre': 'French', 'fra': 'French',
'fr': 'French', 'spa': 'Spanish', 'es': 'Spanish', 'ita': 'Italian', 'it': 'Italian',
'por': 'Portuguese', 'pt': 'Portuguese', 'rus': 'Russian', 'ru': 'Russian',
'chi': 'Chinese', 'zho': 'Chinese', 'zh': 'Chinese', 'kor': 'Korean', 'ko': 'Korean',
'ara': 'Arabic', 'ar': 'Arabic', 'pol': 'Polish', 'pl': 'Polish', 'dut': 'Dutch',
'nld': 'Dutch', 'nl': 'Dutch', 'swe': 'Swedish', 'sv': 'Swedish', 'fin': 'Finnish',
'fi': 'Finnish', 'nor': 'Norwegian', 'no': 'Norwegian', 'dan': 'Danish', 'da': 'Danish',
'tur': 'Turkish', 'tr': 'Turkish', 'hin': 'Hindi', 'hi': 'Hindi', 'tha': 'Thai',
'th': 'Thai', 'vie': 'Vietnamese', 'vi': 'Vietnamese', 'ind': 'Indonesian', 'id': 'Indonesian',
'may': 'Malay', 'msa': 'Malay', 'ms': 'Malay', 'heb': 'Hebrew', 'he': 'Hebrew',
'gre': 'Greek', 'ell': 'Greek', 'el': 'Greek', 'cze': 'Czech', 'ces': 'Czech', 'cs': 'Czech',
'hun': 'Hungarian', 'hu': 'Hungarian', 'ukr': 'Ukrainian', 'uk': 'Ukrainian',
'rum': 'Romanian', 'ron': 'Romanian', 'ro': 'Romanian', 'und': 'Undetermined',
};
/// "jpn" -> "Japanese"; other codes upper-cased, null for none.
String? languageName(String? code) {
if (code == null || code.isEmpty) return null;
return _languages[code.toLowerCase()] ?? code.toUpperCase();
}
const _codecs = {
'eac3': 'E-AC3', 'ac3': 'AC3', 'aac': 'AAC', 'flac': 'FLAC', 'opus': 'Opus',
'vorbis': 'Vorbis', 'mp3': 'MP3', 'truehd': 'TrueHD', 'dts': 'DTS', 'mlp': 'MLP',
'alac': 'ALAC', 'ass': 'ASS', 'ssa': 'SSA', 'subrip': 'SRT', 'srt': 'SRT',
'webvtt': 'VTT', 'mov_text': 'TX3G', 'hdmv_pgs_subtitle': 'PGS', 'pgssub': 'PGS',
'dvd_subtitle': 'VobSub', 'dvdsub': 'VobSub', 'dvb_subtitle': 'DVB',
};
/// mpv / Jellyfin codec name for menus: "eac3" -> "E-AC3", "subrip" -> "SRT".
String? codecName(String? codec) {
if (codec == null || codec.isEmpty) return null;
final c = codec.toLowerCase();
if (c.startsWith('pcm')) return 'PCM';
return _codecs[c] ?? codec.toUpperCase();
}
/// Channel count as a layout: 2 -> "2.0", 6 -> "5.1", 8 -> "7.1".
String? channelLayoutName(int? channels) => switch (channels) {
null || <= 0 => null,
1 => '1.0',
2 => '2.0',
3 => '2.1',
6 => '5.1',
7 => '6.1',
8 => '7.1',
final n => '${n}ch',
};
/// A viewer's audio/subtitle choice as traits (indices shift between
/// files). [subtitlesOff] wins over [subtitle].
class TrackChoice {
@@ -0,0 +1,272 @@
import 'dart:convert';
import 'package:sync_relay_core/core.dart' show JellyfinMediaSegment, JellyfinSegmentType, JellyfinStreamType;
import '../jellyfin/track_prefs.dart' show MpvTrack;
// Chapters, segments and tracks of the open media as plain values, plus
// the skip rules built on them (OP / ED / the 1:30 fallback, chapter
// steps). No Flutter, no player: `PlaybackController` feeds its signals
// through these and any controls UI reads them.
/// One mpv chapter. [end] is the next chapter's start, or the duration for
/// the last one ([start] while the duration is unknown).
class MediaChapter {
final int index;
final String title;
final Duration start;
final Duration end;
const MediaChapter({required this.index, required this.title, required this.start, required this.end});
@override
bool operator ==(Object other) =>
other is MediaChapter &&
other.index == index &&
other.title == title &&
other.start == start &&
other.end == end;
@override
int get hashCode => Object.hash(index, title, start, end);
@override
String toString() => 'MediaChapter($index "$title" $start-$end)';
}
/// A marked range of the item from Jellyfin (MediaSegments): intro,
/// outro, recap, preview or commercial.
class MediaSegment {
final JellyfinSegmentType kind;
final Duration start;
final Duration end;
const MediaSegment({required this.kind, required this.start, required this.end});
factory MediaSegment.fromJellyfin(JellyfinMediaSegment s) =>
MediaSegment(kind: s.type, start: s.start, end: s.end);
@override
String toString() => 'MediaSegment(${kind.name} $start-$end)';
}
/// Where a skip range came from.
enum SkipSource { segment, chapter }
/// An opening or ending: skipping it seeks to [end].
class SkipRange {
final Duration start;
final Duration end;
final SkipSource source;
/// The chapter title when [source] is a chapter.
final String? title;
const SkipRange(this.start, this.end, this.source, {this.title});
@override
bool operator ==(Object other) =>
other is SkipRange &&
other.start == start &&
other.end == end &&
other.source == source &&
other.title == title;
@override
int get hashCode => Object.hash(start, end, source, title);
@override
String toString() => 'SkipRange(${source.name} $start-$end${title == null ? '' : ' "$title"'})';
}
Duration _secs(num s) => Duration(microseconds: (s * 1e6).round());
/// mpv `chapter-list` as JSON (`[{"title": "OP", "time": 30.0}, ...]`).
/// Ends come from the next start, the last from [duration].
List<MediaChapter> parseChapterList(String json, Duration duration) {
final Object? raw;
try {
raw = jsonDecode(json);
} on FormatException {
return const [];
}
if (raw is! List) return const [];
final starts = <(String, Duration)>[
for (final c in raw)
if (c is Map && c['time'] is num && (c['time'] as num).isFinite)
((c['title'] is String ? c['title'] as String : '').trim(), _secs(c['time'] as num)),
]..sort((a, b) => a.$2.compareTo(b.$2));
return chaptersFromStarts(starts, duration);
}
/// Chapters from (title, start) pairs in start order.
List<MediaChapter> chaptersFromStarts(List<(String, Duration)> starts, Duration duration) => [
for (var i = 0; i < starts.length; i++)
MediaChapter(
index: i,
title: starts[i].$1,
start: starts[i].$2,
end: i + 1 < starts.length
? starts[i + 1].$2
: (duration > starts[i].$2 ? duration : starts[i].$2),
),
];
/// mpv `track-list` as JSON: the audio and subtitle tracks (video and
/// unknown types left out), with mpv's selection.
List<MpvTrack> parseTrackList(String json) {
final Object? raw;
try {
raw = jsonDecode(json);
} on FormatException {
return const [];
}
if (raw is! List) return const [];
String? s(Object? v) => v is String && v.isNotEmpty ? v : null;
return [
for (final t in raw)
if (t is Map && t['id'] is int && (t['type'] == 'audio' || t['type'] == 'sub'))
MpvTrack(
id: t['id'] as int,
type: t['type'] == 'audio' ? JellyfinStreamType.audio : JellyfinStreamType.subtitle,
language: s(t['lang']),
title: s(t['title']),
codec: s(t['codec']),
channels: (t['demux-channel-count'] ?? t['audio-channels']) is int
? (t['demux-channel-count'] ?? t['audio-channels']) as int
: null,
isDefault: t['default'] == true,
isForced: t['forced'] == true,
isHearingImpaired: t['hearing-impaired'] == true,
isExternal: t['external'] == true,
selected: t['selected'] == true,
),
];
}
final _openingTitle = RegExp(r'\b(op|opening|intro)\d*\b', caseSensitive: false);
final _endingTitle = RegExp(r'\b(ed|ending|credits|outro)\d*\b', caseSensitive: false);
/// "OP", "OP1", "Opening", "Intro" (whole words, any case).
bool isOpeningTitle(String title) => _openingTitle.hasMatch(title);
/// "ED", "ED2", "Ending", "Credits", "Outro" (whole words, any case).
bool isEndingTitle(String title) => _endingTitle.hasMatch(title);
SkipRange? _range(List<MediaSegment> segments, List<MediaChapter> chapters,
JellyfinSegmentType kind, bool Function(String) titleMatches) {
for (final s in segments) {
if (s.kind == kind && s.end > s.start) return SkipRange(s.start, s.end, SkipSource.segment);
}
for (final c in chapters) {
if (c.end > c.start && titleMatches(c.title)) {
return SkipRange(c.start, c.end, SkipSource.chapter, title: c.title);
}
}
return null;
}
/// The opening: the first Jellyfin Intro segment, else the first chapter
/// titled like an opening ([isOpeningTitle]).
SkipRange? openingRange(List<MediaSegment> segments, List<MediaChapter> chapters) =>
_range(segments, chapters, JellyfinSegmentType.intro, isOpeningTitle);
/// The ending: the first Jellyfin Outro segment, else the first chapter
/// titled like an ending ([isEndingTitle]).
SkipRange? endingRange(List<MediaSegment> segments, List<MediaChapter> chapters) =>
_range(segments, chapters, JellyfinSegmentType.outro, isEndingTitle);
/// Index of the chapter playing at [position], or null (before the first
/// chapter, or none).
int? chapterIndexAt(List<MediaChapter> chapters, Duration position) {
int? at;
for (final c in chapters) {
if (c.start <= position) at = c.index;
}
return at;
}
/// mpv's `chapter-seek-threshold`: going back within this much of a
/// chapter's start goes to the chapter before it.
const chapterBackThreshold = Duration(seconds: 5);
/// Target of a chapter step ([delta] +1 = PgDn, -1 = PgUp), as mpv: back
/// goes to the current chapter's start, or the previous one within
/// [chapterBackThreshold] of it; forward to the next chapter's start.
/// Null when there is nowhere to go.
Duration? chapterStepTarget(List<MediaChapter> chapters, Duration position, int delta) {
if (chapters.isEmpty || delta == 0) return null;
final at = chapterIndexAt(chapters, position);
if (delta > 0) {
final next = (at ?? -1) + delta;
return next < chapters.length ? chapters[next].start : null;
}
if (at == null) return null;
var target = at + delta + 1;
if (position - chapters[at].start < chapterBackThreshold) target--;
return target >= 0 ? chapters[target].start : Duration.zero;
}
/// What the skip pill offers.
enum SkipKind {
/// "Skip OP": to the opening's end.
opening,
/// "Skip ED": to the ending's end, nothing further.
ending,
/// "Skip 1:30": no opening known, +[fallbackSkip] from where it plays.
fixed,
}
class SkipOffer {
final SkipKind kind;
/// Seek target (for [SkipKind.fixed] as of the position it was computed
/// at; the press itself skips relative to the moment it lands).
final Duration target;
const SkipOffer(this.kind, this.target);
@override
bool operator ==(Object other) => other is SkipOffer && other.kind == kind && other.target == target;
@override
int get hashCode => Object.hash(kind, target);
@override
String toString() => 'SkipOffer(${kind.name} -> $target)';
}
/// The fixed skip (pill without an opening, Shift+Right, the controls'
/// "1:30" button).
const fallbackSkip = Duration(seconds: 90);
/// The fallback pill needs media longer than this (an episode, not a clip).
const fallbackMinDuration = Duration(minutes: 20);
/// ... and shows during this first part only.
const fallbackWindow = Duration(minutes: 6);
/// No pill in the last second of a range: the skip would do nothing.
const _skipTail = Duration(seconds: 1);
/// The skip pill at [position]: inside the opening "Skip OP" (to its end),
/// inside the ending "Skip ED" (to its end only: what follows, preview or
/// more episode, is the viewer's call). Without any opening info on media
/// longer than [fallbackMinDuration], "Skip 1:30" during the first
/// [fallbackWindow]. Null otherwise.
SkipOffer? skipOfferAt({
required Duration position,
required Duration duration,
required SkipRange? opening,
required SkipRange? ending,
}) {
bool inside(SkipRange r) => position >= r.start && position < r.end - _skipTail;
if (opening != null && inside(opening)) return SkipOffer(SkipKind.opening, opening.end);
if (ending != null && inside(ending)) return SkipOffer(SkipKind.ending, ending.end);
if (opening == null && duration > fallbackMinDuration && position < fallbackWindow) {
final to = position + fallbackSkip;
return SkipOffer(SkipKind.fixed, to < duration ? to : duration);
}
return null;
}
/// "1:05", "1:02:03".
String formatClock(Duration d) {
final neg = d.isNegative;
final t = d.abs();
String two(int n) => n.toString().padLeft(2, '0');
final h = t.inHours, m = t.inMinutes % 60, s = t.inSeconds % 60;
final body = h > 0 ? '$h:${two(m)}:${two(s)}' : '$m:${two(s)}';
return neg ? '-$body' : body;
}
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
@@ -8,13 +9,13 @@ import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../jellyfin/track_prefs.dart' show MpvTrack;
import '../shaders/shader_assets.dart';
import '../shaders/shader_presets.dart';
import '../state/settings.dart';
import '../sync/state_diff.dart';
import '../sync/user_seek.dart';
import 'frame_watchdog.dart';
import 'media_marks.dart';
/// Owns one mpv [Player] (and its [VideoController]) per session: created
/// by [init] on join, released by [dispose] on leave, swapped by
@@ -38,6 +39,8 @@ class PlaybackController {
bool _probing = false;
final FrameWatchdog _watchdog = FrameWatchdog();
StreamSubscription<PlayerLog>? _logSub;
final List<StreamSubscription<Duration>> _timeSubs = [];
String _chapterJson = '';
// `glsl-shaders` as last set on the current player ('' = none).
String _shaders = '';
Future<void> _shaderOp = Future.value();
@@ -71,6 +74,44 @@ class PlaybackController {
final renderMode = signal(RenderMode.unknown);
static final lastRenderMode = signal(RenderMode.unknown);
// ------------------------------------------------- media marks (signals)
//
// What the open file has, for any controls UI (the session's seek bar,
// chapter / track menus and skip pills read these; nothing here knows
// about widgets). Reset when the player goes away; [segments] is owned
// by the coordinator (it survives Reload video, the item is the same).
/// mpv `chapter-list`: title and start; end = next start, or the
/// duration for the last one. Empty without chapters.
final chapters = signal<List<MediaChapter>>(const []);
/// Jellyfin MediaSegments of the current item (Intro / Outro / Recap /
/// Preview / Commercial). Only viewers with their own account fetch
/// them (relay viewers cannot call Jellyfin): empty otherwise.
final segments = signal<List<MediaSegment>>(const []);
/// Audio and subtitle tracks from mpv's `track-list` (id, language,
/// title, codec, channels, default / forced flags, [MpvTrack.selected]).
final tracks = signal<List<MpvTrack>>(const []);
/// Playback position, in 250 ms steps (enough for pills and menus; a
/// seek bar should listen to `player.stream.position`).
final position = signal(Duration.zero);
/// Duration of the open file (zero until known).
final duration = signal(Duration.zero);
/// The opening ([openingRange]: Intro segment, else an OP-titled
/// chapter) and the ending ([endingRange]: Outro, else ED-titled).
late final Computed<SkipRange?> opening =
computed(() => openingRange(segments.value, chapters.value));
late final Computed<SkipRange?> ending =
computed(() => endingRange(segments.value, chapters.value));
/// Index into [chapters] of the chapter playing now.
late final Computed<int?> currentChapter =
computed(() => chapterIndexAt(chapters.value, position.value));
/// Pinned mpv properties whose read-back differs from what was set
/// (mpv ignores invalid values silently), as `name: wanted -> got`.
final List<String> pinMismatches = [];
@@ -116,6 +157,7 @@ class PlaybackController {
renderMode.value = RenderMode.unknown;
shaderNotice.value = null;
_logSub = _player!.stream.log.listen(_onLog);
_watchMarks(_player!);
await _pinBufferProps();
await applyShaders();
_watchdog.reset();
@@ -364,6 +406,9 @@ class PlaybackController {
Future<void> openUrl(String url, {Map<String, String>? headers}) async {
if (!_ready) return;
try {
_chapterJson = '';
chapters.value = const [];
tracks.value = const [];
// An empty map, not null: media_kit caches headers per URI.
await player.open(Media(url, httpHeaders: headers ?? const {}), play: false);
} catch (e, st) {
@@ -372,28 +417,58 @@ class PlaybackController {
}
}
/// The audio / subtitle tracks of the open file (mpv's track-list).
List<MpvTrack> get tracks {
final p = _player;
if (p == null) return const [];
MpvTrack? of(dynamic t, JellyfinStreamType type) {
final id = int.tryParse('${t.id}');
if (id == null) return null;
return MpvTrack(
id: id,
type: type,
language: t.language as String?,
title: t.title as String?,
codec: t.codec as String?,
channels: (t.channelscount ?? t.audiochannels) as int?,
isDefault: t.isDefault == true,
);
}
// Position / duration from media_kit's streams; chapter-list and
// track-list observed in mpv (read back as JSON).
void _watchMarks(SyncPlayer p) {
_timeSubs.add(p.stream.position.listen((d) {
final ms = d.inMilliseconds;
position.value = Duration(milliseconds: ms - ms % 250);
}));
_timeSubs.add(p.stream.duration.listen((d) {
duration.value = d;
// The last chapter ends at the duration.
if (_chapterJson.isNotEmpty) chapters.value = parseChapterList(_chapterJson, d);
}));
() async {
try {
final dynamic platform = p.platform;
await platform?.observeProperty('chapter-list', (String v) async {
if (!identical(p, _player)) return;
_chapterJson = v;
chapters.value = parseChapterList(v, duration.peek());
});
await platform?.observeProperty('track-list', (String v) async {
if (!identical(p, _player)) return;
_setTracks(parseTrackList(v));
});
} catch (e) {
debugPrint('observe chapters / tracks failed: $e');
}
}();
}
return [
for (final t in p.state.tracks.audio) ?of(t, JellyfinStreamType.audio),
for (final t in p.state.tracks.subtitle) ?of(t, JellyfinStreamType.subtitle),
];
void _setTracks(List<MpvTrack> list) {
if (!listEquals(tracks.peek(), list)) tracks.value = list;
}
/// Reads `track-list` now (after a selection: [tracks] then shows it at
/// once instead of on mpv's next change notice).
Future<void> _refreshTracks() async {
final p = _player;
if (p == null) return;
try {
final dynamic platform = p.platform;
final v = '${await platform?.getProperty('track-list')}';
if (identical(p, _player)) _setTracks(parseTrackList(v));
} catch (_) {}
}
void _clearMarks() {
_chapterJson = '';
chapters.value = const [];
tracks.value = const [];
position.value = Duration.zero;
duration.value = Duration.zero;
}
/// Waits (bounded) until the open file's tracks are listed and mpv has
@@ -402,12 +477,12 @@ class PlaybackController {
Future<List<MpvTrack>> waitForTracks({Duration timeout = const Duration(seconds: 5)}) async {
final end = DateTime.now().add(timeout);
while (_player != null && DateTime.now().isBefore(end)) {
final t = tracks;
final t = tracks.peek();
final hasAudio = t.any((x) => x.type == JellyfinStreamType.audio);
if (t.isNotEmpty && (!hasAudio || (await currentTrackIds()).aid != null)) return t;
await Future.delayed(const Duration(milliseconds: 100));
}
return tracks;
return tracks.peek();
}
/// mpv's selected audio and subtitle track ids (null = none / off).
@@ -440,6 +515,7 @@ class PlaybackController {
} catch (e) {
debugPrint('select tracks failed: $e');
}
await _refreshTracks();
}
double get positionSecs {
@@ -495,9 +571,14 @@ class PlaybackController {
_watchTimer = null;
await _logSub?.cancel();
_logSub = null;
for (final sub in _timeSubs) {
await sub.cancel();
}
_timeSubs.clear();
_player = null;
_video = null;
alive.value = false;
_clearMarks();
await _settleUi();
try {
await p.dispose();
@@ -14,10 +14,13 @@ import 'update_indicator.dart';
const sessionShortcuts = [
('Space', 'Toggle ready'),
('← / →', 'Seek back / forward 10 s'),
('Shift+→', 'Skip 1:30 (for everyone)'),
('PgUp / PgDn', 'Previous / next chapter'),
('F', 'Toggle fullscreen'),
('Tab', 'Open / close the people & chat panel'),
('Ctrl+R', 'Reload the video'),
('> or Shift+N', 'Put up the next Jellyfin episode'),
('< or Shift+P', 'Put up the previous Jellyfin episode'),
('Ctrl+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'),
('Ctrl+0', 'Shaders off'),
('Esc', 'Close panel or fullscreen, else ask to leave'),
@@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../../jellyfin/session_media.dart';
import '../../jellyfin/track_prefs.dart';
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
import 'player_controls.dart' show FloatingPill;
// Jellyfin pieces of the session screen. Plain widgets fed by the
// coordinator's signals, so tests can pump them without the player.
@@ -37,65 +36,21 @@ class EpisodeSkipButton extends StatelessWidget {
}
}
/// Floating "Next episode ›" near the end of an episode: faint at rest,
/// lit on hover. Lives outside the auto-hiding controls, so it shows in
/// fullscreen and with the controls hidden.
class NextEpisodePill extends StatefulWidget {
/// Floating "Next episode ›" from the ED (or the last two minutes):
/// faint at rest, lit on hover. Lives outside the auto-hiding controls, so
/// it shows in fullscreen and with the controls hidden.
class NextEpisodePill extends StatelessWidget {
final EpisodeLink episode;
final VoidCallback onPressed;
const NextEpisodePill({super.key, required this.episode, required this.onPressed});
@override
State<NextEpisodePill> createState() => _NextEpisodePillState();
}
class _NextEpisodePillState extends State<NextEpisodePill> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: Tooltip(
message: 'Next: ${widget.episode.label}',
child: AnimatedScale(
duration: const Duration(milliseconds: 150),
scale: _hover ? 1.06 : 1,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: _hover ? 1 : 0.45,
child: GestureDetector(
key: const ValueKey('next-episode-pill'),
onTap: widget.onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: _hover ? primary : Colors.white24),
boxShadow: _hover
? [BoxShadow(color: primary.withValues(alpha: 0.45), blurRadius: 16)]
: const [],
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Next episode', style: TextStyle(color: Colors.white, fontSize: 13)),
SizedBox(width: 4),
Icon(Icons.chevron_right, color: Colors.white, size: 18),
],
),
),
),
),
),
),
);
}
Widget build(BuildContext context) => FloatingPill(
label: 'Next episode',
tooltip: 'Next: ${episode.label}',
tapKey: const ValueKey('next-episode-pill'),
onPressed: onPressed,
);
}
/// At the end of an episode: "Next: S1E7 title" with Play now, and while
@@ -231,98 +186,3 @@ class ViaHostBadge extends StatelessWidget {
);
}
}
/// A track pick from [TracksControlButton]: audio id, subtitle id, or off.
typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff});
/// Audio / subtitle menu for the controls bar. The choice is remembered
/// for the next Jellyfin item (per viewer).
class TracksControlButton extends StatelessWidget {
final FocusNode? focusNode;
final List<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,
);
}
}
+136 -50
View File
@@ -4,6 +4,8 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../playback/media_marks.dart' show SkipOffer;
import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart';
import '../session/health.dart';
@@ -22,6 +24,7 @@ import '../widgets/window_drag_layer.dart';
import 'home/integrations_page.dart';
import 'home/media_picker.dart';
import 'session/jellyfin_widgets.dart';
import 'session/player_controls.dart';
import 'session/swap_panel.dart';
class SessionScreen extends StatefulWidget {
@@ -257,6 +260,17 @@ class _SessionScreenState extends State<SessionScreen> {
coord.playPreviousEpisode();
return true;
}
// mpv's chapter keys.
if (event.logicalKey == LogicalKeyboardKey.pageDown ||
event.logicalKey == LogicalKeyboardKey.pageUp) {
_lastKeyAt = now;
return coord.stepChapter(event.logicalKey == LogicalKeyboardKey.pageDown ? 1 : -1);
}
if (shift && event.logicalKey == LogicalKeyboardKey.arrowRight) {
_lastKeyAt = now;
coord.skipForward();
return true;
}
if (event.logicalKey == LogicalKeyboardKey.tab) {
_lastKeyAt = now;
_sheetOpen.value = !_sheetOpen.peek();
@@ -320,56 +334,51 @@ class _SessionScreenState extends State<SessionScreen> {
visibleOnMount: true,
hideMouseOnControlsRemoval: true,
controlsHoverDuration: const Duration(seconds: 3),
// Our seek bar (chapter marks, hover title) sits in the bottom bar,
// which grows by its height; the top bar keeps its row at the top.
displaySeekBar: false,
buttonBarHeight: _barHeight + ChapterSeekBar.height,
topButtonBar: [
const Spacer(),
MaterialDesktopCustomButton(
icon: const Icon(Icons.group),
onPressed: () {
_sheetOpen.value = true;
coord.markInput();
},
),
SignalBuilder(
builder: (context) => WindowButtons(focusNode: _noFocus, fullscreen: _isFullscreen.value, onToggleFullscreen: _toggleFullscreen),
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: _barHeight,
child: Row(
children: [
const Spacer(),
MaterialDesktopCustomButton(
icon: const Icon(Icons.group),
onPressed: () {
_sheetOpen.value = true;
coord.markInput();
},
),
SignalBuilder(
builder: (context) => WindowButtons(focusNode: _noFocus, fullscreen: _isFullscreen.value, onToggleFullscreen: _toggleFullscreen),
),
],
),
),
),
),
],
bottomButtonBar: [
SignalBuilder(
builder: (context) => EpisodeSkipButton(
focusNode: _noFocus,
episode: coord.previousEpisode.value,
next: false,
onPressed: coord.playPreviousEpisode,
Expanded(
child: Column(
children: [
SignalBuilder(
builder: (context) => PlayerSeekBar(
player: coord.playback.player,
chapters: coord.playback.chapters.value,
),
),
Expanded(
child: Row(children: _bottomButtons(coord)),
),
],
),
),
SignalBuilder(
builder: (context) => EpisodeSkipButton(
focusNode: _noFocus,
episode: coord.nextEpisode.value,
next: true,
onPressed: coord.playNextEpisode,
),
),
const MaterialDesktopVolumeButton(),
_ReadyControlButton(focusNode: _noFocus),
_MarkAllReadyControlButton(focusNode: _noFocus),
_ReloadVideoControlButton(focusNode: _noFocus, onPressed: _reloadVideo),
const MaterialDesktopPositionIndicator(),
const Spacer(),
TracksControlButton(
focusNode: _noFocus,
tracks: () => coord.playback.tracks,
current: coord.playback.currentTrackIds,
onPick: (p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff),
onMenuOpen: (open) => _shaderMenuOpen = open,
),
ShaderControlButton(
focusNode: _noFocus,
renderMode: coord.playback.renderMode,
onPick: _pickShader,
onMenuOpen: (open) => _shaderMenuOpen = open,
),
const MaterialDesktopFullscreenButton(),
],
);
return FocusTraversalGroup(
@@ -415,6 +424,73 @@ class _SessionScreenState extends State<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) {
return Stack(
children: [
@@ -575,8 +651,9 @@ class _SessionScreenState extends State<SessionScreen> {
}
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
/// the "Next episode" pill. Outside the auto-hiding controls, so they
/// also show in fullscreen.
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
/// 1:30) above it. Outside the auto-hiding controls, so they also show
/// in fullscreen.
Widget _jellyfinOverlay() {
final coord = SessionState.instance.coordinator;
return Positioned.fill(
@@ -587,6 +664,8 @@ class _SessionScreenState extends State<SessionScreen> {
final countdown = coord.autoAdvance.value;
final finished = coord.serverState.value?.transport == 'finished';
final pill = coord.nextPromptVisible.value;
final SkipOffer? skip = coord.skipOffer.value;
final showNext = next != null && pill;
return Stack(
children: [
if (blocked != null)
@@ -600,7 +679,7 @@ class _SessionScreenState extends State<SessionScreen> {
if (next != null && (finished || countdown != null))
Positioned(
right: 24,
bottom: 110,
bottom: 138,
child: EndOfEpisodeCard(
next: next,
countdown: countdown,
@@ -608,11 +687,18 @@ class _SessionScreenState extends State<SessionScreen> {
onCancel: coord.cancelAutoAdvance,
),
)
else if (next != null && pill)
else if (showNext || skip != null)
Positioned(
right: 24,
bottom: 96,
child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
bottom: 124,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
spacing: 10,
children: [
if (skip != null) SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
],
),
),
],
);
@@ -8,6 +8,7 @@ import '../jellyfin/accounts.dart';
import '../jellyfin/progress.dart';
import '../jellyfin/session_media.dart';
import '../jellyfin/track_prefs.dart';
import '../playback/media_marks.dart';
import '../playback/playback_controller.dart';
import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart';
@@ -136,6 +137,21 @@ class SessionCoordinator {
/// End-of-episode countdown; null when none runs here.
final autoAdvance = signal<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(() {
final r = serverState.value;
if (r == null || sync.memberId.isEmpty) return null;
@@ -767,6 +783,33 @@ class SessionCoordinator {
_userSeek(target < 0 ? 0 : target);
}
/// A skip pill: Skip OP / Skip ED seek to the range's end, Skip 1:30
/// is [skipForward]. Through the user-seek path, so everyone follows.
void skip(SkipOffer offer) {
if (offer.kind == SkipKind.fixed) {
skipForward();
} else {
seekTo(offer.target.inMicroseconds / 1e6);
}
}
/// +[fallbackSkip] (1:30): the controls' button, Shift+Right and the
/// fallback pill. Relative to a seek in flight, like the arrow keys.
void skipForward() => nudge(fallbackSkip.inMicroseconds / 1e6);
/// PgDn (+1) / PgUp (-1): next / previous chapter as mpv steps them
/// ([chapterStepTarget]). False when there is no chapter to go to.
bool stepChapter(int delta) {
final base = _seekGate.target;
final at = base == null
? playback.position.peek()
: Duration(microseconds: (base * 1e6).round());
final target = chapterStepTarget(playback.chapters.peek(), at, delta);
if (target == null) return false;
seekTo(target.inMicroseconds / 1e6);
return true;
}
void sendChat(String text) {
// Enter in the chat field can fire both onSubmitted and the focused
// send button's onPressed; the first send clears the field, so the
@@ -863,6 +906,7 @@ class SessionCoordinator {
previousEpisode.value = null;
nextEpisode.value = null;
nextPromptVisible.value = false;
playback.segments.value = const [];
_advanceTimer?.cancel();
_advanceTimer = null;
_advanceDoneFor = -1;
@@ -988,6 +1032,7 @@ class SessionCoordinator {
},
);
unawaited(_findEpisodes(plan, gen));
unawaited(_loadSegments(plan, ref, gen));
// Playing carries the tracks: wait (bounded) until the file is in.
await playback.waitForTracks();
if (gen != _jfGen) return;
@@ -1009,6 +1054,18 @@ class SessionCoordinator {
if (r != null) _jellyfinTransport(r);
}
/// The item's MediaSegments (own account only; a server without the
/// endpoint or without a segment provider gives none).
Future<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 {
final own = plan is OwnPlay ? plan : null;
final choice = await trackPrefs.load(seriesId: own?.item?.seriesId);
@@ -1028,7 +1085,7 @@ class SessionCoordinator {
final plan = jellyfinPlan.peek();
final own = plan is OwnPlay ? plan : null;
await trackPrefs.save(
TrackChoice.of(aid: ids.aid, sid: ids.sid, source: own?.source, tracks: playback.tracks),
TrackChoice.of(aid: ids.aid, sid: ids.sid, source: own?.source, tracks: playback.tracks.peek()),
seriesId: own?.item?.seriesId);
final snap = await _snapshot();
if (snap != null) _reporter?.update(snap, force: true);
@@ -1047,7 +1104,8 @@ class SessionCoordinator {
var show = false;
if (next != null && duration > 0 && autoAdvance.peek() == null && r.transport != 'finished') {
final item = (jellyfinPlan.peek() as OwnPlay?)?.item;
final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()));
final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()),
endingStart: playback.ending.peek()?.start);
show = snap.position * 1000 >= at.inMilliseconds;
}
nextPromptVisible.value = show;