9 Commits
Author SHA1 Message Date
foxyz 1c915067aa Release v1.3.0
ci / updater-windows (push) Skipped
ci / core (push) Successful in 28s
ci / server (push) Successful in 50s
release / windows (push) Successful in 6m24s
release / linux (push) Successful in 4m20s
2026-10-16 07:36:10 +03:00
foxyzandClaude Opus 5.5 0924309abf Roadmap: tray (close to tray, like Discord)
ci / core (push) Successful in 28s
ci / server (push) Successful in 49s
ci / updater-windows (push) Successful in 6m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:31:22 +03:00
foxyzandClaude Opus 5.5 cf41a13528 CI: add the C++ ATL component on the Windows runner (notifications plugin needs atlbase.h)
ci / core (push) Successful in 27s
ci / server (push) Successful in 52s
ci / updater-windows (push) Successful in 9m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 06:42:20 +03:00
foxyzandClaude Opus 5.5 9bbc594048 Open media only once the video output is up (black video on X11 when joining mid-play)
ci / core (push) Successful in 33s
ci / server (push) Successful in 52s
ci / updater-windows (push) Failing after 7m48s
media_kit sets vo=libmpv before the render context exists; an open in
between failed mpv's video output ("No render context set") and played
black with sound. Seen on X11 joining a server with media already up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:32:45 +03:00
foxyzandClaude Opus 5.5 741b258ac1 Restructure step 6: what's next and watch tracking
ci / core (push) Successful in 29s
ci / server (push) Successful in 53s
ci / updater-windows (push) Failing after 7m45s
- What's next is the server's: state.advance counts down (10 s) when the
  media finishes with an Up next item first, holds while its performer or
  warm source is gone, and sets queuePlay at its end; cancel is shared, a
  seek or swap ends it; the server skips items nobody can put up. Shared
  rules in core whats_next.dart; the app's janitor, per-screen countdown
  and end-of-episode card are gone.
- The continuation (next Jellyfin episode, next file of a local show
  with its gaps) is an auto item under "Continues with", prepared like
  Up next's first item.
- Watch tracking by time In (D3): only time played while this viewer is
  in counts; away, paused, held, jumps and stalls never do (P7, P9).
  WatchTracker + device WatchHistory (files, links); Jellyfin progress
  reads the same watch; records are the AniList seam.
- A server restart no longer loses what played: apps hold their media
  and one offers it back (swap.restore, first offer wins), Up next is
  re-added, the source re-feeds, and the watch carries on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 02:06:04 +03:00
foxyzandClaude Opus 5.5 a87ee98faa Vendor media_kit_video 2.0.1 with the X11/EGL fix (hardware rendering on X11 with Flutter >= 3.38)
ci / core (push) Successful in 28s
ci / server (push) Successful in 51s
ci / updater-windows (push) Failing after 5m59s
Flutter >= 3.38 keeps its EGL contexts on the raster thread, so
media_kit's video output found none on the platform thread and fell
back to software rendering (no shaders, ~5 fps) on X11. The patched
output opens Flutter's EGL display itself, steps GDK's GLX context aside
on X11 and releases its own context. Patch from a friend's agent, kept
in third_party/media_kit_video/starsync/. Wayland/NVIDIA still renders
on the GPU (checked); the X11 path was verified on the friend's machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:13:14 +03:00
foxyzandClaude Opus 5.5 25b19f2d6a Restructure step 5: one media pipeline on a player host
- PlayerHost port (MediaKitHost): players by role, pins, hwdec policy,
  render probe, log routing, disposal order; the only media_kit user.
- MediaPipeline: plan (one pure switch over state, origin, caps,
  presence) -> resolve -> open (prepared player promoted, else fresh)
  -> ready; commands queued during a switch apply once (P15); viewers
  play the buffer and wait for a dropped source without seek loops (D6).
- Every web page resolved by our yt-dlp into an EDL with headers and
  cookies (danmaku excluded); mpv's ytdl_hook no longer used (ytdl=no).
- D11: sign-in errors say which cookies were used and whether they hold
  any for the site.
- Relay: 256 KiB slices read as they fill, superseded pulls released
  (seek-to-play ~4.2 s -> ~2.8 s, P29); own Jellyfin relay channel only
  when someone needs it (P28).
- STARSYNC_SMOKE run and a Windows CI smoke step after the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:08:54 +03:00
foxyzandClaude Opus 5.5 8970f2694b Roadmap: no upstream PRs; Windows libmpv update possible on its own
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:30:35 +03:00
foxyzandClaude Opus 5.5 72a3b9adac Roadmap: media_kit fork evaluation after the restructure
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:18:36 +03:00
221 changed files with 22389 additions and 2696 deletions

No files matched your search

+10 -1
View File
@@ -41,7 +41,8 @@ jobs:
working-directory: packages/server working-directory: packages/server
# The updater's Windows apply path (running exe parked as .old) only # The updater's Windows apply path (running exe parked as .old) only
# means something on real NTFS. Runner notes: see release.yml. # means something on real NTFS, and the close smoke run needs the real
# build. Runner notes: see release.yml.
updater-windows: updater-windows:
# Not for a "Release v…" commit: release.yml builds it on the same # Not for a "Release v…" commit: release.yml builds it on the same
# (only) Windows runner, which this job would hold up. # (only) Windows runner, which this job would hold up.
@@ -71,3 +72,11 @@ jobs:
- name: Windows build - name: Windows build
shell: powershell shell: powershell
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1 run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1
# The close smoke run on that build (P32): start, open a short clip in
# a player with a video output, close; it must exit within 45 s and
# leave no process. A runner that cannot show a window skips with a
# warning. Here rather than in a job of its own: one build, one runner.
- name: Smoke (start, open, close)
shell: powershell
timeout-minutes: 3
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/smoke_windows.ps1 -Limit 45
+35 -22
View File
@@ -224,8 +224,10 @@ Up next, add the folder to Files, or fill Link).
"Show (Season 3 - Alicization)" gives the season and its name. Shows "Show (Season 3 - Alicization)" gives the season and its name. Shows
are looked up on AniList for their proper name, cover and banner. A are looked up on AniList for their proper name, cover and banner. A
show's page has its seasons and episodes and a button for the next one show's page has its seasons and episodes and a button for the next one
after what you last played here; when episodes are missing on the way after what you last watched here; when episodes are missing on the way
it says so ("66–107 aren't in your folders — skip to 108?"). Matched it says so ("66–107 aren't in your folders — skip to 108?"). An
episode you put up continues with the next one at its end (see Up
next). Matched
the wrong show? **Wrong show?** searches AniList and pins the right one the wrong show? **Wrong show?** searches AniList and pins the right one
for the show everywhere or just one folder; **Episode numbers…** says for the show everywhere or just one folder; **Episode numbers…** says
which episode the first file is when the files count differently. Both which episode the first file is when the files count differently. Both
@@ -253,7 +255,7 @@ app open for that. Everyone shares one Up next and anyone can change it; what
you added stays when you leave. you added stays when you leave.
In the player, Up next sits in the top bar, right of the title: the In the player, Up next sits in the top bar, right of the title: the
next item's picture and title (or the next episode's). Rest the mouse on next item's picture and title (or what continues the episode). Rest the mouse on
it for a larger preview (who added it, its length, whether it is ready it for a larger preview (who added it, its length, whether it is ready
to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a
row to play it now (▶) or remove it (×), right-click it for more (copy its row to play it now (▶) or remove it (×), right-click it for more (copy its
@@ -261,17 +263,22 @@ title or link, open it in the browser or in Jellyfin). To add, press
Ctrl+V with the queue open (a copied link or video file goes in at once, Ctrl+V with the queue open (a copied link or video file goes in at once,
its title and picture showing while it is checked), type one after its title and picture showing while it is checked), type one after
**+ Add link**, or drop a link or a video on it. When a video ends, a card shows **+ Add link**, or drop a link or a video on it. When a video ends, a card shows
what is up next with a 10 s countdown (Cancel stops it on your screen) what is up next with a 10 s countdown everyone sees, and it plays;
and it plays; `>` or Shift+N plays it at once. Under the queue, Cancel stops it for everyone, and so does seeking back. `>` or Shift+N
**Continues with** shows the next episodes of the Jellyfin series plays it at once. Under the queue, **Continues with** shows what
playing (greyed: they play only once Up next is empty); **+** adds one, continues the episode playing: its next episode (a Jellyfin episode's,
**Clear** drops them for this episode. or a file's next one in your Files library, saying which numbers it
skips), added by whoever put the episode up, and greyed because it
plays only once Up next is empty; **Clear** drops it for everyone. The
further episodes of a Jellyfin series show under it; **+** adds one.
A link you pass on ("through me") or a file of yours can only be played A link you pass on ("through me") or a file of yours can only be played
by your app: if you left when its turn comes, it is skipped ("Skipped …: by your app: if you left when its turn comes, it is skipped ("Skipped …:
Foxyz left"). A Jellyfin item needs someone with an account on that Foxyz left"). If you are only gone for a moment, the countdown waits for
server. Up next is kept by the server while it runs; restarting the you. A Jellyfin item needs someone with an account on that server. Up
server empties it. next is kept by the server while it runs. If the server restarts, the
apps put back what was playing, where it was, and Up next with it (a
link the server fetched has to be added again).
## Schedule ## Schedule
@@ -473,10 +480,13 @@ Watching:
directly with their own sign-in. Jellyfin tracks progress per account: directly with their own sign-in. Jellyfin tracks progress per account:
your watched state, Continue watching and resume point follow what you your watched state, Continue watching and resume point follow what you
saw (reported on start, every 10 s, on pause, resume, seek and track saw (reported on start, every 10 s, on pause, resume, seek and track
change, and when it stops). Watched means you actually played most of change, and when it stops). Watched means you were there while most
it (or reached the ending after half), not where you stopped: opening of it played (or while half of it played and it reached the ending),
a watched episode to check a moment keeps it watched, and a quick peek not where it stopped: time you sat out does not count, skipping around
leaves no resume point. does not count, opening a watched episode to check a moment keeps it
watched, and a quick peek leaves no resume point. Files and links you
watch are remembered the same way on your computer (a show's next
episode in Files follows them).
- Friends without an account watch through the server: whoever put the - Friends without an account watch through the server: whoever put the
item up passes it on automatically, with their own sign-in, which never item up passes it on automatically, with their own sign-in, which never
leaves their computer (only the current item, only to people in the leaves their computer (only the current item, only to people in the
@@ -493,12 +503,11 @@ Episodes: the ⏮ / ⏭ buttons next to play (or `<` / `>`,
Shift+P / Shift+N) put up the previous or next episode for everyone. From Shift+P / Shift+N) put up the previous or next episode for everyone. From
the ending (when it is known, see Skipping above; else in the last two the ending (when it is known, see Skipping above; else in the last two
minutes) a "Next episode" button floats in the corner while the controls minutes) a "Next episode" button floats in the corner while the controls
are up. When an are up. With "Play the next episode automatically" (Settings, on by
episode ends, a card shows what is next with Play next; with "Play the default), the episode you put up continues with the next one: when it
next episode automatically" (Settings, on by default) it counts down 10 s ends, everyone sees a 10 s countdown, and anyone can cancel it (see Up
first, and Cancel stops it on your screen (seeking back stops it for next). Anyone with an account can put the next one up; friends watching
everyone). Anyone with an account can put the next one up; friends via the host cannot.
watching via the host cannot.
Audio and subtitles: the Audio and Subtitles buttons in the controls pick 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
@@ -533,7 +542,11 @@ export (picked or dropped; every site in it is kept). They stay on this
computer and are used by its yt-dlp only (your own playback, "through computer and are used by its yt-dlp only (your own playback, "through
me", and a server you host). Cookies log yt-dlp in as you on those me", and a server you host). Cookies log yt-dlp in as you on those
sites: keep the file private, and for YouTube consider a throwaway sites: keep the file private, and for YouTube consider a throwaway
account (Google may flag accounts used with yt-dlp). account (Google may flag accounts used with yt-dlp). When a page shows
no video, the message says which cookies were used: none, your file
(and whether it has any for that site), or your browser's (and how many
it has for that site), so you know whether to sign in there or add
cookies.
YouTube works in all three too, and so do pages and links that only YouTube works in all three too, and so do pages and links that only
offer streaming playlists (HLS `.m3u8`, DASH `.mpd`, the HLS of X videos). offer streaming playlists (HLS `.m3u8`, DASH `.mpd`, the HLS of X videos).
Through me and Server fetches it never show the link to anyone; they can't pass on live Through me and Server fetches it never show the link to anyone; they can't pass on live
+12 -3
View File
@@ -3,15 +3,16 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'app.dart'; import 'app.dart';
import 'src/app/app_graph.dart'; import 'src/app/app_graph.dart';
import 'src/files/thumbnails.dart' show thumbnailPlayers;
import 'src/platform/app_log.dart'; import 'src/platform/app_log.dart';
import 'src/platform/app_shutdown.dart' show closeAppWith; import 'src/platform/app_shutdown.dart' show closeAppWith;
import 'src/platform/desktop/desktop_app_dirs.dart'; import 'src/platform/desktop/desktop_app_dirs.dart';
import 'src/platform/desktop_entry.dart'; import 'src/platform/desktop_entry.dart';
import 'src/platform/smoke.dart';
import 'src/screens/home/ui.dart'; import 'src/screens/home/ui.dart';
import 'src/session/app_events.dart' show UpdateAvailable; import 'src/session/app_events.dart' show UpdateAvailable;
import 'src/state/settings.dart'; import 'src/state/settings.dart';
@@ -65,9 +66,12 @@ Future<void> main(List<String> args) async {
} catch (e, st) { } catch (e, st) {
debugPrint('load connect form failed: $e\n$st'); debugPrint('load connect form failed: $e\n$st');
} }
// The library's matches, pins and offsets (device-owned). // The library's matches, pins and offsets, and the watch history
// (device-owned).
unawaited(graph.library.ensureLoaded()); unawaited(graph.library.ensureLoaded());
MediaKit.ensureInitialized(); unawaited(graph.watch.history.ensureLoaded());
ports.players.init();
thumbnailPlayers = ports.players;
// yt-dlp for web page links: downloaded on first run, kept current. // yt-dlp for web page links: downloaded on first run, kept current.
ports.links.warmUp(); ports.links.warmUp();
// Linux: the launcher entry and icon Wayland needs (release installs). // Linux: the launcher entry and icon Wayland needs (release installs).
@@ -82,6 +86,11 @@ Future<void> main(List<String> args) async {
} }
}); });
runApp(AppScope(graph: graph, child: const WatchTogetherApp())); runApp(AppScope(graph: graph, child: const WatchTogetherApp()));
// CI's close smoke run: open a file, play, close (see smoke.dart).
final smoke = smokeMedia;
if (smoke != null) {
unawaited(WidgetsBinding.instance.endOfFrame.then((_) => runSmoke(graph, smoke)));
}
// Home: your own server, quietly (only this app can reach it until // Home: your own server, quietly (only this app can reach it until
// Invite). // Invite).
unawaited(graph.navigation.start()); unawaited(graph.navigation.start());
+27 -6
View File
@@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../library/library_store.dart'; import '../library/library_store.dart';
import '../library/local_next.dart';
import '../library/show_matcher.dart'; import '../library/show_matcher.dart';
import '../platform/app_log.dart' show appLogDir; import '../platform/app_log.dart' show appLogDir;
import '../platform/desktop/desktop_ports.dart'; import '../platform/desktop/desktop_ports.dart';
@@ -20,6 +21,8 @@ import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart'; import '../sfx/synth_sfx.dart';
import '../state/connect_prefs.dart'; import '../state/connect_prefs.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../watch/watch_history.dart';
import '../watch/watch_tracker.dart';
/// The composition root: builds the app's systems once (in `main`), wires /// The composition root: builds the app's systems once (in `main`), wires
/// them through their constructors and disposes them. No logic and no /// them through their constructors and disposes them. No logic and no
@@ -51,6 +54,9 @@ class AppGraph {
/// The library's matches (L2): local shows to AniList, pins, offsets. /// The library's matches (L2): local shows to AniList, pins, offsets.
final ShowMatcher library; final ShowMatcher library;
/// Watch tracking and the device's watch history (D3).
final WatchTracker watch;
AppGraph._({ AppGraph._({
required this.ports, required this.ports,
required this.sfx, required this.sfx,
@@ -61,6 +67,7 @@ class AppGraph {
required this.yourServer, required this.yourServer,
required this.navigation, required this.navigation,
required this.library, required this.library,
required this.watch,
}); });
/// The desktop app's graph. Tests pass their own [ports], [identity] /// The desktop app's graph. Tests pass their own [ports], [identity]
@@ -72,12 +79,14 @@ class AppGraph {
ServerStarter? serverStarter, ServerStarter? serverStarter,
JellyfinAccounts? jellyfin, JellyfinAccounts? jellyfin,
ShowMatcher? library, ShowMatcher? library,
WatchHistory? history,
}) { }) {
final p = ports ?? desktopPorts(); final p = ports ?? desktopPorts();
final settings = AppSettings.instance;
final sfx = SynthSfx( final sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(), volume: () => settings.soundVolume.peek(),
// A nudge is an explicit poke: it ignores its switch (not the volume). // A nudge is an explicit poke: it ignores its switch (not the volume).
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name), enabled: (k) => k == SfxKind.nudge || !settings.soundsOff.peek().contains(k.name),
presetFor: soundPresetFor, presetFor: soundPresetFor,
); );
final notifier = Notifier( final notifier = Notifier(
@@ -85,11 +94,19 @@ class AppGraph {
attention: p.shell.requestAttention, attention: p.shell.requestAttention,
system: p.notifications, system: p.notifications,
focusWindow: p.shell.focus, focusWindow: p.shell.focus,
prefs: () => AppSettings.instance.notifyPrefs, prefs: () => settings.notifyPrefs,
); );
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored()); final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
final coordinator = // Device-owned: the watch history box.
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin); final watch = WatchTracker(history: history ?? WatchHistory());
final coordinator = SessionCoordinator(
notifier: notifier,
connection: connection,
ytDlp: p.links,
jellyfin: jellyfin,
players: p.players,
settings: settings,
watch: watch);
// Device-owned: the library box; AnimeFlow's runs when signed in. // Device-owned: the library box; AnimeFlow's runs when signed in.
final matcher = library ?? final matcher = library ??
ShowMatcher( ShowMatcher(
@@ -97,7 +114,9 @@ class AppGraph {
aniList: AniListClient(), aniList: AniListClient(),
flow: AnimeFlowLookup(AnimeFlowClient.fromAccounts(coordinator.jellyfin)), flow: AnimeFlowLookup(AnimeFlowClient.fromAccounts(coordinator.jellyfin)),
); );
coordinator.fileRef = matcher.refForPath; coordinator
..fileRef = matcher.refForPath
..nextFile = LocalNextFinder(matcher: matcher, scan: p.folders.scan).nextAfter;
final forms = ConnectStore(); final forms = ConnectStore();
final yourServer = YourServer( final yourServer = YourServer(
forms: forms, forms: forms,
@@ -137,12 +156,14 @@ class AppGraph {
yourServer: yourServer, yourServer: yourServer,
navigation: navigation, navigation: navigation,
library: matcher, library: matcher,
watch: watch,
); );
} }
Future<void> dispose() async { Future<void> dispose() async {
navigation.dispose(); navigation.dispose();
await coordinator.dispose(); await coordinator.dispose();
await watch.dispose();
await yourServer.stop(); await yourServer.stop();
await sfx.dispose(); await sfx.dispose();
} }
@@ -148,7 +148,7 @@ class MinimalControls extends StatelessWidget {
const Expanded( const Expanded(
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()), child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()),
), ),
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.minimal, hold: hold), UpNextButton(queue: m.queue, look: UpNextLook.minimal, hold: hold),
const SizedBox(width: 4), const SizedBox(width: 4),
SignalBuilder( SignalBuilder(
builder: (context) { builder: (context) {
@@ -81,13 +81,16 @@ class PlayerControlsModel {
/// Everything the layout depends on (not the position: that changes /// Everything the layout depends on (not the position: that changes
/// four times a second and only the timeline needs it). /// four times a second and only the timeline needs it).
late final Computed<ControlsSnapshot> snapshot = computed(() { late final Computed<ControlsSnapshot> snapshot = computed(() {
final items = queue.items.value; final queued = queue.items.value.firstOrNull;
final continues = queue.continues.value;
final next = coord.nextEpisode.value; final next = coord.nextEpisode.value;
return ControlsSnapshot( return ControlsSnapshot(
play: play.value, play: play.value,
previousLabel: coord.previousEpisode.value?.label, previousLabel: coord.previousEpisode.value?.label,
nextLabel: items.isNotEmpty ? items.first.title : next?.label, // Up next's first item, else the next episode, else what continues
nextIsUpNext: items.isNotEmpty, // this (a file's next episode).
nextLabel: queued?.title ?? next?.label ?? continues?.title,
nextIsUpNext: queued != null,
hasChapters: chapters.value.isNotEmpty, hasChapters: chapters.value.isNotEmpty,
hasDuration: duration.value > Duration.zero, hasDuration: duration.value > Duration.zero,
alone: coord.alone.value, alone: coord.alone.value,
@@ -121,7 +124,7 @@ class PlayerControlsModel {
/// A seek bar's press or drag; through `player.seek`, the user-seek /// A seek bar's press or drag; through `player.seek`, the user-seek
/// path, so everyone follows. /// path, so everyone follows.
void seek(Duration to) => playback.player.seek(to); void seek(Duration to) => playback.userSeek(to);
/// Arrow keys: ±[secs], relative to a seek still in flight. /// Arrow keys: ±[secs], relative to a seek still in flight.
void nudge(double secs) => coord.nudge(secs); void nudge(double secs) => coord.nudge(secs);
@@ -137,9 +140,10 @@ class PlayerControlsModel {
void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6); void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6);
/// Up next's first item, else the next episode. False when neither. /// What plays next (Up next's first item, else the continuation), else
/// the next episode. False when neither.
bool next() { bool next() {
if (queue.items.peek().isNotEmpty) { if (queue.head.peek() != null) {
queue.skip(); queue.skip();
return true; return true;
} }
@@ -157,7 +161,7 @@ class PlayerControlsModel {
void playNextEpisode() => coord.playNextEpisode(); void playNextEpisode() => coord.playNextEpisode();
void setVolume(double v) => playback.player.setVolume(v.clamp(0.0, vol.maxVolume)); void setVolume(double v) => playback.setVolume(v.clamp(0.0, vol.maxVolume));
/// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓; /// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓;
/// flashes the volume readout. /// flashes the volume readout.
@@ -222,7 +222,9 @@ class ShaderPresetSetting extends StatelessWidget {
final preset = s.shaderPreset.value; final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value; final quality = s.shaderQuality.value;
final hasCustom = s.customShaders.value.isNotEmpty; final hasCustom = s.customShaders.value.isNotEmpty;
final mode = (renderMode ?? PlaybackController.lastRenderMode).value; final mode = (renderMode ??
context.getInheritedWidgetOfExactType<AppScope>()?.graph.ports.players.renderMode)
?.value;
final warning = switch (mode) { final warning = switch (mode) {
RenderMode.software => 'Software rendering: shaders have no effect', RenderMode.software => 'Software rendering: shaders have no effect',
RenderMode.softwareGl => 'OpenGL on the CPU: shaders will be slow', RenderMode.softwareGl => 'OpenGL on the CPU: shaders will be slow',
@@ -267,10 +269,10 @@ class ShaderPresetSetting extends StatelessWidget {
/// on the fly). `SYNC_RELAY_HWDEC` wins, as at player start. /// on the fly). `SYNC_RELAY_HWDEC` wins, as at player start.
Future<void> applyHwdecLive(PlaybackController playback) async { Future<void> applyHwdecLive(PlaybackController playback) async {
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return; if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
if (!playback.alive.peek()) return; final p = playback.current;
if (p == null) return;
try { try {
final dynamic platform = playback.player.platform; await p.setProperty('hwdec', AppSettings.instance.hwdec.peek());
await platform?.setProperty('hwdec', AppSettings.instance.hwdec.peek());
} catch (e) { } catch (e) {
debugPrint('live hwdec failed: $e'); debugPrint('live hwdec failed: $e');
} }
@@ -165,7 +165,7 @@ class StandardControls extends StatelessWidget {
return Row( return Row(
children: [ children: [
const Expanded(child: NowPlayingTitle()), const Expanded(child: NowPlayingTitle()),
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.standard, hold: hold), UpNextButton(queue: m.queue, look: UpNextLook.standard, hold: hold),
SignalBuilder( SignalBuilder(
builder: (context) { builder: (context) {
final full = m.fullscreen.value; final full = m.fullscreen.value;
+12 -16
View File
@@ -4,9 +4,11 @@ import 'dart:io';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:media_kit/media_kit.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../platform/desktop/media_kit_host.dart' show MediaKitHost;
import '../platform/ports/player_host.dart';
// Poster frames for the Files source, made by mpv itself (no ffmpeg on // Poster frames for the Files source, made by mpv itself (no ffmpeg on
// users' machines; Windows ships only libmpv). A hidden player per file // users' machines; Windows ships only libmpv). A hidden player per file
// with `vo=null`: mpv still keeps the last decoded frame, so // with `vo=null`: mpv still keeps the last decoded frame, so
@@ -23,6 +25,9 @@ class Thumbnail {
const thumbnailWidth = 480; const thumbnailWidth = 480;
/// The players thumbnails are made with (the app's host, set in `main`).
PlayerHost thumbnailPlayers = MediaKitHost()..init();
/// Where thumbnails are cached; swapped in tests. /// Where thumbnails are cached; swapped in tests.
Future<Directory> Function() thumbnailCacheRoot = getApplicationCacheDirectory; Future<Directory> Function() thumbnailCacheRoot = getApplicationCacheDirectory;
@@ -33,7 +38,7 @@ const _timeout = Duration(seconds: 20);
int _running = 0; int _running = 0;
final _waiting = <Completer<void>>[]; final _waiting = <Completer<void>>[];
final _inFlight = <String, Future<Thumbnail?>>{}; final _inFlight = <String, Future<Thumbnail?>>{};
final _players = <Player>{}; final _players = <HostedPlayer>{};
bool _closed = false; bool _closed = false;
/// App close: no new thumbnails, running ones stop at their next check /// App close: no new thumbnails, running ones stop at their next check
@@ -49,7 +54,7 @@ Future<void> cancelAllThumbnails({
for (final p in _players.toList()) { for (final p in _players.toList()) {
_players.remove(p); _players.remove(p);
try { try {
await p.dispose().timeout(deadline); await thumbnailPlayers.dispose(p).timeout(deadline);
} catch (_) {} } catch (_) {}
} }
} }
@@ -147,19 +152,10 @@ Future<Thumbnail?> _generate(
String out, String out,
bool Function() cancelled, bool Function() cancelled,
) async { ) async {
final player = Player( final player = await thumbnailPlayers.create(PlayerRole.thumbnail);
configuration: const PlayerConfiguration(
vo: 'null',
muted: true,
osc: false,
libass: false,
title: 'thumbnail',
bufferSize: 4 << 20,
),
);
_players.add(player); _players.add(player);
try { try {
final mpv = player.platform as NativePlayer; final mpv = player;
for (final (k, v) in [ for (final (k, v) in [
('ao', 'null'), ('ao', 'null'),
('aid', 'no'), ('aid', 'no'),
@@ -176,7 +172,7 @@ Future<Thumbnail?> _generate(
]) { ]) {
await mpv.setProperty(k, v); await mpv.setProperty(k, v);
} }
await player.open(Media(path), play: false); await player.open(path);
final target = File(out); final target = File(out);
final deadline = DateTime.now().add(_timeout); final deadline = DateTime.now().add(_timeout);
while (DateTime.now().isBefore(deadline)) { while (DateTime.now().isBefore(deadline)) {
@@ -199,6 +195,6 @@ Future<Thumbnail?> _generate(
return null; return null;
} finally { } finally {
// [cancelAllThumbnails] may have taken it already. // [cancelAllThumbnails] may have taken it already.
if (_players.remove(player)) await player.dispose(); if (_players.remove(player)) await thumbnailPlayers.dispose(player);
} }
} }
+11 -1
View File
@@ -9,6 +9,7 @@ import '../../library/show_matcher.dart';
import '../../shell/motion.dart'; import '../../shell/motion.dart';
import '../../platform/ports/folder_access.dart'; import '../../platform/ports/folder_access.dart';
import '../../app/app_graph.dart'; import '../../app/app_graph.dart';
import '../../watch/watch_history.dart';
import '../../widgets/app_menu.dart'; import '../../widgets/app_menu.dart';
import '../folder_store.dart'; import '../folder_store.dart';
import '../scanner.dart'; import '../scanner.dart';
@@ -127,6 +128,7 @@ class _FilesPageState extends State<FilesPage> {
late final ThumbCache _thumbs = late final ThumbCache _thumbs =
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!); widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
FolderStore get _store => widget.store ?? FolderStore.instance; FolderStore get _store => widget.store ?? FolderStore.instance;
WatchHistory get _history => AppScope.of(context).watch.history;
/// Null: Recent; [libraryPlace]: every folder. /// Null: Recent; [libraryPlace]: every folder.
String? _open = _lastOpen; String? _open = _lastOpen;
@@ -325,8 +327,15 @@ class _FilesPageState extends State<FilesPage> {
thumbs: _thumbs, thumbs: _thumbs,
); );
/// The last episode of [show] played here (Recent), by key. /// The last episode of [show] watched here (the watch history: the
/// newest one watched to the end), else the last one opened (Recent), by
/// key.
String? _lastPlayed(_Library lib, LocalShow show) { String? _lastPlayed(_Library lib, LocalShow show) {
for (final (key, entry) in _history.entries) {
if (!entry.played || !key.value.startsWith('file:')) continue;
final e = lib.byPath[key.value.substring(5)];
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
}
for (final p in _store.recent.value) { for (final p in _store.recent.value) {
final e = lib.byPath[p]; final e = lib.byPath[p];
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key; if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
@@ -342,6 +351,7 @@ class _FilesPageState extends State<FilesPage> {
final folders = _store.folders.value; final folders = _store.folders.value;
final recent = _store.recent.value; final recent = _store.recent.value;
_matcher.revision.value; _matcher.revision.value;
_history.revision.value;
final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace; final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace;
final showKey = open == null ? null : _openShows[open]; final showKey = open == null ? null : _openShows[open];
final main = fadeThrough( final main = fadeThrough(
+15 -14
View File
@@ -26,13 +26,14 @@ class PlayerSnapshot {
/// (the session shows one quiet toast) and never touches playback. A 401 /// (the session shows one quiet toast) and never touches playback. A 401
/// calls [onUnauthorized] and ends reporting. /// calls [onUnauthorized] and ends reporting.
/// ///
/// Watched or not is [WatchSession]'s rule, not Jellyfin's position /// Watched or not is [WatchSession]'s rule (time In, D3), not Jellyfin's
/// percentages (see ARCHITECTURE.md "Jellyfin in a session"): an item /// position percentages (see ARCHITECTURE.md "Watch tracking"): the
/// played before ([playedBefore]) stays played (re-marked after Playing, /// session's watch tracker feeds [watch] and this reporter only reads its
/// Progress without a position, Stopped without one, then marked played /// verdict. An item played before ([playedBefore]) stays played
/// again); otherwise Progress leaves the position out past /// (re-marked after Playing, Progress without a position, Stopped without
/// [_noPositionFrom] of the runtime so Jellyfin cannot mark it played on /// one, then marked played again); otherwise Progress leaves the position
/// its own, and Stopped carries what the rule says. /// out past [_noPositionFrom] of the runtime so Jellyfin cannot mark it
/// played on its own, and Stopped carries what the rule says.
class JellyfinProgressReporter { class JellyfinProgressReporter {
final JellyfinClient client; final JellyfinClient client;
final String itemId; final String itemId;
@@ -43,7 +44,8 @@ class JellyfinProgressReporter {
final void Function(Object error)? onError; final void Function(Object error)? onError;
final VoidCallback? onUnauthorized; final VoidCallback? onUnauthorized;
/// What this viewer watched of the item while it was open here. /// What this viewer watched of the item while it was open here (fed by
/// the watch tracker, not by this reporter).
final WatchSession watch; final WatchSession watch;
JellyfinProgressReporter({ JellyfinProgressReporter({
@@ -57,12 +59,14 @@ class JellyfinProgressReporter {
this.onError, this.onError,
this.onUnauthorized, this.onUnauthorized,
this.onStopped, this.onStopped,
WatchSession? watch,
DateTime Function()? clock, DateTime Function()? clock,
}) : _clock = clock ?? DateTime.now, }) : _clock = clock ?? DateTime.now,
_keepPlayed = playedBefore, _keepPlayed = playedBefore,
_runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond, _runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond,
watch = WatchSession( watch = (watch ?? WatchSession())
playedBefore: playedBefore, resumeBefore: resumeTicksBefore / jellyfinTicksPerSecond); ..playedBefore = playedBefore
..resumeBefore = resumeTicksBefore / jellyfinTicksPerSecond;
/// Past this share of the runtime Progress carries no position: Jellyfin /// Past this share of the runtime Progress carries no position: Jellyfin
/// marks an item played past its "max resume" share (90 % by default). /// marks an item played past its "max resume" share (90 % by default).
@@ -126,7 +130,6 @@ class JellyfinProgressReporter {
if (_playing || _dead) return; if (_playing || _dead) return;
_playing = true; _playing = true;
_mark(s); _mark(s);
watch.sample(s.position, playing: !s.paused, at: _clock());
_send('Playing', () => client.reportPlaybackStart(reportFor(s))); _send('Playing', () => client.reportPlaybackStart(reportFor(s)));
if (_keepPlayed) _send('played', () => client.markPlayed(itemId)); if (_keepPlayed) _send('played', () => client.markPlayed(itemId));
} }
@@ -136,7 +139,6 @@ class JellyfinProgressReporter {
if (!_playing || _dead) return; if (!_playing || _dead) return;
final last = _last, lastAt = _lastAt, sentAt = _lastSentAt; final last = _last, lastAt = _lastAt, sentAt = _lastSentAt;
final now = _clock(); final now = _clock();
watch.sample(s.position, playing: !s.paused, at: now);
var due = force || last == null || lastAt == null || sentAt == null; var due = force || last == null || lastAt == null || sentAt == null;
if (!due) { if (!due) {
final elapsed = now.difference(lastAt).inMilliseconds / 1000; final elapsed = now.difference(lastAt).inMilliseconds / 1000;
@@ -163,11 +165,10 @@ class JellyfinProgressReporter {
Future<void> stop(PlayerSnapshot s) { Future<void> stop(PlayerSnapshot s) {
if (!_playing || _dead) return _chain; if (!_playing || _dead) return _chain;
_playing = false; _playing = false;
watch.sample(s.position, playing: !s.paused, at: _clock());
final v = _keepPlayed final v = _keepPlayed
? const WatchVerdict(WatchOutcome.keepPlayed) ? const WatchVerdict(WatchOutcome.keepPlayed)
: watch.verdict(runtime: _runtime, endingStart: _endingStart); : watch.verdict(runtime: _runtime, endingStart: _endingStart);
debugPrint('jellyfin $itemId: $v (covered ${watch.covered.round()} s of ${_runtime.round()} s)'); debugPrint('jellyfin $itemId: $v (${watch.credited.round()} s In of ${_runtime.round()} s)');
final int? ticks; final int? ticks;
switch (v.outcome) { switch (v.outcome) {
case WatchOutcome.played || WatchOutcome.keepPlayed: case WatchOutcome.played || WatchOutcome.keepPlayed:
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../session/party.dart' show isPresent;
import 'accounts.dart'; import 'accounts.dart';
import 'format.dart'; import 'format.dart';
import '../library/media_refs.dart'; import '../library/media_refs.dart';
@@ -341,26 +340,3 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
const lead = Duration(minutes: 2); const lead = Duration(minutes: 2);
return runtime > lead ? runtime - lead : Duration.zero; return runtime > lead ? runtime - lead : Duration.zero;
} }
/// Who performs the auto-advance swap, so several counting clients do not
/// swap twice: the lowest member id among members that play the item with
/// their own account (`jf[serverId] == true`, present: `in` or joining). Only what every
/// client sees in `state` decides (who put the item up is not in it: a
/// late joiner never saw that swap), so all clients agree.
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
final eligible = [
for (final m in members)
if (isPresent(m) && m.jf[serverId] == true) m
];
if (eligible.isEmpty) return null;
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
return eligible.first.id;
}
/// "m2" before "m10": numeric suffix order, then plain text.
int compareMemberIds(String a, String b) {
final na = int.tryParse(a.replaceFirst(RegExp(r'^\D+'), ''));
final nb = int.tryParse(b.replaceFirst(RegExp(r'^\D+'), ''));
if (na != null && nb != null && na != nb) return na.compareTo(nb);
return a.compareTo(b);
}
@@ -0,0 +1,34 @@
import 'dart:io';
import 'package:sync_relay_core/core.dart';
import '../files/scanner.dart' show FolderScan;
import 'show_matcher.dart';
// The next episode of a local show (the library's `nextAfter`, numbers
// skipped on the way included): what continues a file this app put up
// (see ARCHITECTURE.md "What's next").
/// [path]'s next episode: from the library the Files page saw this run
/// ([ShowMatcher.showAt]), else from the file's own folder read now (one
/// [scan], grouped by `groupLocal` with that folder's name as a hint). Null when there is none (the last
/// episode, not an episode, an unreadable folder).
class LocalNextFinder {
final ShowMatcher matcher;
final Future<FolderScan> Function(String dir) scan;
LocalNextFinder({required this.matcher, required this.scan});
Future<LocalNext?> nextAfter(String path) async {
var show = matcher.showAt(path);
if (show == null) {
final dir = File(path).parent.path;
final found = await scan(dir);
// The folder itself is a hint (a bare `01.mkv` in `Bleach`).
final lib = groupLocal(found.videos, roots: [Directory(dir).parent.path]);
show = lib.shows.where((s) => s.files.any((e) => e.path == path)).firstOrNull;
}
final at = show?.files.where((e) => e.path == path).firstOrNull;
if (show == null || at == null || at.kind != LocalKind.episode) return null;
return show.nextAfter(at.key);
}
}
+11 -1
View File
@@ -129,6 +129,7 @@ class ShowMatcher {
// Files seen in scans this run, for refs of files put up. // Files seen in scans this run, for refs of files put up.
final _byPath = <String, LocalEpisode>{}; final _byPath = <String, LocalEpisode>{};
final _showByPath = <String, LocalShow>{};
final _entries = <int, AniListEntry>{}; final _entries = <int, AniListEntry>{};
final _flows = <int, FlowShow?>{}; final _flows = <int, FlowShow?>{};
final _jobs = <String, Future<void> Function()>{}; final _jobs = <String, Future<void> Function()>{};
@@ -152,13 +153,22 @@ class ShowMatcher {
// ------------------------------------------------------------- reads // ------------------------------------------------------------- reads
/// Remembers [library]'s files, so [refForPath] knows their show. /// Remembers [library]'s files, so [refForPath] and [showAt] know
/// their show.
void remember(LocalLibrary library) { void remember(LocalLibrary library) {
for (final e in library.files) { for (final e in library.files) {
_byPath[e.path] = e; _byPath[e.path] = e;
} }
for (final s in library.shows) {
for (final e in s.files) {
_showByPath[e.path] = s;
}
}
} }
/// The show the file at [path] belongs to, as seen in a scan this run.
LocalShow? showAt(String path) => _showByPath[path];
AniListEntry? entry(int id) { AniListEntry? entry(int id) {
final known = _entries[id]; final known = _entries[id];
if (known != null) return known; if (known != null) return known;
@@ -47,6 +47,8 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)), _step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
_step('sounds', graph.sfx.dispose(), const Duration(milliseconds: 500)), _step('sounds', graph.sfx.dispose(), const Duration(milliseconds: 500)),
]); ]);
// The host owns the disposal order: anything still alive goes now.
await _step('all players', graph.ports.players.disposeAll(), const Duration(milliseconds: 1500));
// Windows: media_kit_video frees a player's video output on a thread of // Windows: media_kit_video frees a player's video output on a thread of
// its own after dispose returns; it must be done before the engine // its own after dispose returns; it must be done before the engine
// stops, or the plugin's teardown waits on it for good. // stops, or the plugin's teardown waits on it for good.
@@ -4,6 +4,7 @@ import 'desktop_folders.dart';
import 'desktop_notifications.dart'; import 'desktop_notifications.dart';
import 'desktop_shell.dart'; import 'desktop_shell.dart';
import 'desktop_urls.dart'; import 'desktop_urls.dart';
import 'media_kit_host.dart';
/// The desktop's [Ports]. /// The desktop's [Ports].
Ports desktopPorts() { Ports desktopPorts() {
@@ -14,5 +15,6 @@ Ports desktopPorts() {
folders: const DesktopFolders(urls), folders: const DesktopFolders(urls),
links: AppYtDlp.instance, links: AppYtDlp.instance,
notifications: DesktopNotifications(), notifications: DesktopNotifications(),
players: MediaKitHost(),
); );
} }
@@ -0,0 +1,513 @@
import 'dart:async';
import 'dart:ffi' show Int8;
import 'dart:io';
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals.dart';
import 'package:sync_relay_core/core.dart' show LogThrottle, createLogger;
import '../../state/settings.dart';
import '../ports/player_host.dart';
// The desktop's players: media_kit over libmpv. Everything mpv-specific
// that is not about one media lives here: the cache and HTTP pins, hwdec,
// mpv's log level (media_kit has no API for it: `mpv_request_log_messages`
// on its handle), the render-mode probe and its diagnostics, mpv's log into
// the app log, and the order players are torn down in.
/// Read-ahead target: about two minutes rides out short outages and makes
/// skipping an intro instant.
const readAheadSecs = 150;
/// Forward demuxer cache of a session player. Holds [readAheadSecs] up to
/// ~14 Mbit/s (a 1.4 GB / 24 min file is ~8); higher bitrates get less
/// time. In RAM: `cache-on-disk=no` (media_kit sets it on without a
/// cache-dir, and mpv warned "Failed to create file cache." per open).
const sessionAheadBytes = 256 * 1024 * 1024;
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
/// seek back without holding another 256 MiB.
const sessionBackBytes = 64 * 1024 * 1024;
/// The preload's forward cache: its first seconds and the container's
/// index, at little memory. Raised to [sessionAheadBytes] when promoted.
const preloadAheadBytes = 48 * 1024 * 1024;
const preloadBackBytes = 8 * 1024 * 1024;
/// mpv's `volume-max` (its default is 130): the volume controls go this
/// far, past 100 % amplified.
const mpvMaxVolume = 150;
/// mpv's log level for session players. media_kit drains mpv's log on the
/// UI thread one line at a time, so a chatty level (ffmpeg retrying a
/// refused request logs every attempt) can starve the UI: warnings and
/// errors only, except during the render-mode probe, which needs mpv's
/// verbose GL init lines.
const sessionLogLevel = 'warn';
/// ffmpeg's HTTP options (mpv `stream-lavf-o`, comma form): reconnect after
/// a dropped connection, also on a streamed (growing) relay response, and a
/// server error (5xx) while connecting; a refused request (4xx) fails at
/// once. At most 3 tries per read, 4 s apart at most.
const streamLavfOptions = 'reconnect_streamed=1,reconnect_on_http_error=5xx,'
'reconnect_delay_max=4,reconnect_max_retries=3';
/// mpv echoes numbers in its own format (`150` reads back `150.000000`).
bool pinMatches(String wanted, String got) {
if (wanted == got) return true;
final a = double.tryParse(wanted), b = double.tryParse(got);
return a != null && b != null && a == b;
}
/// The `VideoController` behind [video], for the `Video` widget (the only
/// media_kit use outside this file).
VideoController videoControllerOf(HostedVideo video) => (video as _MkVideo).controller;
final _mpvLog = createLogger('mpv');
class MediaKitHost implements PlayerHost {
/// The hwdec setting ([AppSettings.hwdec] by default).
final String? Function() _hwdecSetting;
MediaKitHost({String? Function()? hwdecSetting})
: _hwdecSetting = hwdecSetting ?? (() => AppSettings.instance.hwdec.peek());
final _live = <_MkPlayer>{};
final _renderMode = signal(RenderMode.unknown);
final _http = StreamController<String>.broadcast();
final _throttle = LogThrottle(burst: 5);
Future<void> _retiring = Future.value();
var _closing = false;
@override
final List<String> pinMismatches = [];
@override
void init() => MediaKit.ensureInitialized();
@override
ReadonlySignal<RenderMode> get renderMode => _renderMode;
@override
Stream<String> get httpErrors => _http.stream;
@override
int get live => _live.length;
@override
Future<void> get retiring => _retiring;
@override
String? get hwdec {
final env = Platform.environment['SYNC_RELAY_HWDEC'];
if (env != null && env.isNotEmpty) return env;
return _hwdecSetting();
}
@override
Future<HostedPlayer> create(PlayerRole role, {bool muted = false}) async {
if (_closing) throw StateError('closing: no new players');
final mk = _SyncPlayer(
configuration: switch (role) {
PlayerRole.session => PlayerConfiguration(
bufferSize: sessionAheadBytes,
libass: true,
vo: Platform.environment['SYNC_RELAY_VO'],
logLevel: MPVLogLevel.warn),
PlayerRole.preload => PlayerConfiguration(
bufferSize: preloadAheadBytes,
libass: true,
vo: Platform.environment['SYNC_RELAY_VO'],
logLevel: MPVLogLevel.error),
PlayerRole.thumbnail => const PlayerConfiguration(
vo: 'null', muted: true, osc: false, libass: false, title: 'thumbnail', bufferSize: 4 << 20),
});
final p = _MkPlayer(this, mk, role);
_live.add(p);
if (role != PlayerRole.thumbnail) {
p._logSub = p.logs.listen((l) => _route(p, l));
final mismatches = <String>[];
await _pinCache(p, role, mismatches);
if (await p.pin('volume-max', '$mpvMaxVolume') case final m?) mismatches.add(m);
if (muted || role == PlayerRole.preload) {
if (await p.pin('mute', 'yes') case final m?) mismatches.add(m);
}
if (role == PlayerRole.session) {
pinMismatches
..clear()
..addAll(mismatches);
}
}
return p;
}
@override
Future<void> promote(HostedPlayer p) async {
final mk = p as _MkPlayer;
mk._role = PlayerRole.session;
mk.setLogLevel(sessionLogLevel);
final mismatches = <String>[];
if (await p.pin('mute', 'no') case final m?) mismatches.add(m);
await _pinCache(mk, PlayerRole.session, mismatches);
pinMismatches
..clear()
..addAll(mismatches);
}
Future<void> _pinCache(_MkPlayer p, PlayerRole role, List<String> out) async {
final session = role == PlayerRole.session;
for (final (k, v) in [
('demuxer-readahead-secs', '$readAheadSecs'),
('cache-secs', '$readAheadSecs'),
('demuxer-max-bytes', '${session ? sessionAheadBytes : preloadAheadBytes}'),
('demuxer-max-back-bytes', '${session ? sessionBackBytes : preloadBackBytes}'),
('cache-on-disk', 'no'),
if (hwdec case final h?) ('hwdec', h),
('stream-lavf-o', streamLavfOptions),
// Pages are resolved by the app's own yt-dlp (the media pipeline):
// mpv's Lua hook never runs one.
('ytdl', 'no'),
]) {
if (await p.pin(k, v) case final m?) out.add(m);
}
}
@override
HostedVideo video(HostedPlayer player, {bool probe = false}) {
final p = player as _MkPlayer;
if (probe) {
_logRenderEnv();
p.setLogLevel('v'); // back to the session level at the probe's end
}
// media_kit's video output sets hwdec itself on attach ('auto' unless
// configured), which used to override SYNC_RELAY_HWDEC.
final controller = VideoController(p.mk, configuration: VideoControllerConfiguration(hwdec: hwdec));
final v = _MkVideo(p, controller);
// mpv gets `vo=libmpv` before the output's render context exists; a
// media opened in between fails its video output ("No render context
// set") and plays black. Opens wait for the output (seen on X11, where
// the context comes up later, when joining with media already up).
p._videoReady = controller.platform.future
.then((_) {})
.timeout(const Duration(seconds: 5), onTimeout: () {})
.catchError((Object _) {});
if (probe) unawaited(_probeRender(p, v));
return v;
}
// ------------------------------------------------------------ the log
// mpv's warnings and errors to the app log, a few per kind at a time (a
// refused request makes ffmpeg retry and warn on every try); ffmpeg's
// HTTP status to [httpErrors]; the GL init lines while probing.
void _route(_MkPlayer p, PlayerLogLine log) {
if (log.prefix == 'ffmpeg') {
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
if (http != null) _http.add(http[0]!.trim());
}
if (log.prefix == 'libmpv_render') {
if (_glInfo.hasMatch(log.text)) _mpvLog.info('render: [libmpv_render] ${log.text.trim()}');
if (log.text.contains('suspected software renderer')) {
_probeMode = RenderMode.softwareGl;
_renderMode.value = RenderMode.softwareGl;
} else if (log.text.startsWith('GL_VERSION') && p._probing && _probeMode == RenderMode.unknown) {
_probeMode = RenderMode.gpu;
_renderMode.value = RenderMode.gpu;
}
}
final level = log.level;
if (level != 'warn' && !log.isError) return;
final text = log.text.trim();
if (text.isEmpty) return;
final who = p.role == PlayerRole.preload ? 'preload ' : '';
final pass = _throttle.allow('$who${log.prefix} $level ${text.length > 40 ? text.substring(0, 40) : text}');
if (pass == null) return;
final line = '$pass$who[${log.prefix}] $text';
level == 'warn' ? _mpvLog.info(line) : _mpvLog.warning(line);
}
// mpv's GL init lines (`GL_VENDOR='...'`, `Detected desktop OpenGL 4.6.`).
static final _glInfo =
RegExp(r'^\s*(GL_(VENDOR|RENDERER|VERSION|SHADING_LANGUAGE_VERSION)=|Detected (GLES|desktop OpenGL))');
// Once per app start: what decides how media_kit and mpv render (the
// display server, Flutter's and Mesa's overrides). Not secrets.
static bool _renderEnvLogged = false;
static void _logRenderEnv() {
if (_renderEnvLogged || !Platform.isLinux) return;
_renderEnvLogged = true;
const names = [
'XDG_SESSION_TYPE',
'WAYLAND_DISPLAY',
'DISPLAY',
'GDK_BACKEND',
'FLUTTER_LINUX_RENDERER',
'LIBGL_ALWAYS_SOFTWARE',
'__EGL_VENDOR_LIBRARY_FILENAMES',
];
final env = Platform.environment;
_mpvLog.info('render env: ${[for (final n in names) '$n=${env[n] ?? '-'}'].join(' ')}');
}
RenderMode _probeMode = RenderMode.unknown;
// media_kit's Linux/Windows video output creates mpv's render context
// while the controller is created: OpenGL when it can, else the software
// renderer, which never logs GL init (and ignores shaders). No GL line
// within 1.5 s of the output being up means software.
Future<void> _probeRender(_MkPlayer p, _MkVideo v) async {
p._probing = true;
_probeMode = RenderMode.unknown;
try {
await v.controller.platform.future;
await Future.delayed(const Duration(milliseconds: 1500));
} catch (_) {
return;
} finally {
p._probing = false;
if (!p.disposed) p.setLogLevel(sessionLogLevel);
}
if (p.disposed) return;
final mode = _probeMode;
if (mode != RenderMode.unknown) {
_renderMode.value = mode;
_mpvLog.info('render: ${mode.name} (${mode == RenderMode.gpu ? 'OpenGL on the GPU' : 'OpenGL on the CPU'})');
return;
}
_renderMode.value = RenderMode.software;
_mpvLog.info("render: software (no GL init logged: media_kit's own fallback; "
'its reason is on stderr, "media_kit: VideoOutput: ..." when run from a terminal)');
}
// Once per player, after its first frame: the decoder mpv picked; the
// first time per app start also mpv's and ffmpeg's versions.
static bool _versionsLogged = false;
Future<void> _logFirstFrame(_MkPlayer p) async {
try {
final current = await p.getProperty('hwdec-current');
final wanted = await p.getProperty('hwdec');
_mpvLog.info('render: first frame ${p.state.width}x${p.state.height}, '
'hwdec-current=${current.isEmpty ? 'no' : current} (hwdec=$wanted), mode ${_renderMode.peek().name}');
if (!_versionsLogged) {
_versionsLogged = true;
_mpvLog.info('render: ${await versions(p)}');
}
} catch (e) {
debugPrint('first frame info: $e');
}
}
@override
Future<String?> versions(HostedPlayer player) async {
try {
return '${await player.getProperty('mpv-version')}, ffmpeg ${await player.getProperty('ffmpeg-version')}';
} catch (_) {
return null;
}
}
// --------------------------------------------------------- tear-down
@override
Future<void> retire(HostedPlayer player) {
final p = player as _MkPlayer;
p._retired = true;
unawaited(p.mk.pause().catchError((_) {}));
return _retiring = () async {
await _settleUi();
await dispose(p);
}();
}
@override
Future<void> dispose(HostedPlayer player) async {
final p = player as _MkPlayer;
if (!_live.remove(p)) return;
p._retired = true;
await p._logSub?.cancel();
try {
await p.mk.dispose();
} catch (_) {}
}
@override
Future<void> disposeAll({Duration each = const Duration(milliseconds: 1500)}) async {
_closing = true;
await Future.wait([
for (final p in _live.toList()) dispose(p).timeout(each).catchError((Object _) {}),
]);
_closing = false;
}
// Two frames: one may already be mid-build when the view drops the
// output. Then the length of a route pop (the fullscreen route holds its
// own `Video` and leaves animated). Bounded, since a hidden window
// renders no frames; headless tests have no binding and skip it all.
static Future<void> _settleUi() async {
try {
for (var i = 0; i < 2; i++) {
await WidgetsBinding.instance.endOfFrame.timeout(const Duration(milliseconds: 300));
}
await Future.delayed(const Duration(milliseconds: 350));
} catch (_) {}
}
}
/// A [Player] whose public [seek] belongs to the user. media_kit's controls
/// call `player.seek`; with [onUserSeek] set those calls become explicit
/// events for the sync layer instead of moving mpv behind its back.
class _SyncPlayer extends Player {
_SyncPlayer({super.configuration});
void Function(Duration position)? onUserSeek;
@override
Future<void> seek(Duration duration) async {
final hook = onUserSeek;
if (hook == null) return super.seek(duration);
hook(duration);
}
Future<void> syncSeek(Duration duration) => super.seek(duration);
}
class _MkPlayer implements HostedPlayer {
final MediaKitHost host;
final _SyncPlayer mk;
PlayerRole _role;
_MkPlayer(this.host, this.mk, this._role) {
_firstFrame = mk.stream.width.listen((w) {
if (w == null || w <= 0 || _role == PlayerRole.thumbnail) return;
unawaited(_firstFrame?.cancel());
unawaited(host._logFirstFrame(this));
});
}
StreamSubscription<PlayerLogLine>? _logSub;
StreamSubscription<int?>? _firstFrame;
bool _probing = false;
bool _retired = false;
Future<void>? _videoReady; // set once a video output is attached
dynamic get _mpv => mk.platform;
@override
PlayerRole get role => _role;
@override
bool get disposed => _retired;
@override
HostedPlayerState get state {
final s = mk.state;
return HostedPlayerState(
position: s.position,
duration: s.duration,
buffer: s.buffer,
volume: s.volume,
playing: s.playing,
completed: s.completed,
width: s.width,
height: s.height,
);
}
@override
Stream<Duration> get positionStream => mk.stream.position;
@override
Stream<Duration> get durationStream => mk.stream.duration;
@override
Stream<Duration> get bufferStream => mk.stream.buffer;
@override
Stream<double> get volumeStream => mk.stream.volume;
@override
Stream<bool> get completedStream => mk.stream.completed;
@override
Stream<int?> get widthStream => mk.stream.width;
@override
Stream<PlayerLogLine> get logs => mk.stream.log.map((l) => PlayerLogLine(l.prefix, l.level, l.text));
@override
void Function(Duration position)? get onUserSeek => mk.onUserSeek;
@override
set onUserSeek(void Function(Duration position)? f) => mk.onUserSeek = f;
@override
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false}) async {
if (_videoReady case final ready?) await ready;
// An empty map, not null: media_kit caches headers per URI.
await mk.open(Media(url, httpHeaders: headers), play: play);
}
@override
Future<void> play() => mk.play();
@override
Future<void> pause() => mk.pause();
@override
Future<void> stop() => mk.stop();
@override
Future<void> seek(Duration position) => mk.seek(position);
@override
Future<void> syncSeek(Duration position) => mk.syncSeek(position);
@override
Future<void> setRate(double rate) => mk.setRate(rate);
@override
Future<void> setVolume(double volume) => mk.setVolume(volume);
// media_kit has no public property API: a guarded dynamic call on
// `player.platform` (its NativePlayer).
@override
Future<void> setProperty(String name, String value) async => await _mpv?.setProperty(name, value);
@override
Future<String> getProperty(String name) async => '${await _mpv?.getProperty(name)}';
@override
Future<void> observeProperty(String name, Future<void> Function(String value) onChange) async =>
await _mpv?.observeProperty(name, onChange);
@override
Future<void> command(List<String> args) async => await _mpv?.command(args);
@override
Future<String?> pin(String name, String value) async {
try {
await setProperty(name, value);
final got = await getProperty(name);
if (pinMatches(value, got)) return null;
debugPrint('pin $name=$value not applied, mpv has "$got"');
return '$name: $value -> $got';
} catch (e) {
debugPrint('pin $name=$value failed: $e');
return '$name: $value -> error $e';
}
}
// mpv_request_log_messages on media_kit's handle.
@override
void setLogLevel(String level) {
try {
final dynamic platform = mk.platform;
final name = level.toNativeUtf8();
try {
platform.mpv.mpv_request_log_messages(platform.ctx, name.cast<Int8>());
} finally {
malloc.free(name);
}
} catch (e) {
debugPrint('mpv log level $level not set: $e');
}
}
}
class _MkVideo implements HostedVideo {
@override
final _MkPlayer player;
final VideoController controller;
_MkVideo(this.player, this.controller);
@override
Future<void> get ready => controller.platform.future;
}
@@ -10,8 +10,10 @@ abstract interface class LinkResolver {
/// Gets it ready in the background (download on first run, updates). /// Gets it ready in the background (download on first run, updates).
void warmUp(); void warmUp();
/// mpv's `ytdl-raw-options` (sub languages, cookies). /// This device opens web pages itself (the media plan's `resolve`
Future<String> mpvRawOptions(); /// capability). Desktop: always (yt-dlp is downloaded when first
/// needed); a device without it gets pages passed on by someone else.
bool get resolvesLinks;
/// What [url] is, remembered for a while ([keep]); [fresh] asks again. /// What [url] is, remembered for a while ([keep]); [fresh] asks again.
Future<LinkProbe> probe(String url, {Duration keep, bool fresh}); Future<LinkProbe> probe(String url, {Duration keep, bool fresh});
@@ -0,0 +1,192 @@
import 'dart:async';
import 'package:signals/signals.dart';
// The media players as the app sees them: mpv instances, their video
// outputs, how the video is rendered, mpv's log and the order players are
// torn down in. Desktop: media_kit over libmpv
// (`platform/desktop/media_kit_host.dart`); nothing else in the app
// touches media_kit except the `Video` widget glue.
/// How the video output renders (shaders need [gpu]).
enum RenderMode {
unknown,
/// OpenGL on a real GPU: shaders work.
gpu,
/// OpenGL on a software rasterizer (llvmpipe): shaders crawl.
softwareGl,
/// The video output's own software fallback: shaders are ignored.
software,
}
/// What a player is for: its cache, log level and log routing.
enum PlayerRole {
/// The session's player (and a fresh one per media): full cache, mpv log
/// at `warn` into the app log.
session,
/// The hidden player holding what plays next: small cache, muted, log
/// at `error`.
preload,
/// A poster frame for Files: no audio or video output, no cache, no log.
thumbnail,
}
/// One line of mpv's log.
class PlayerLogLine {
final String prefix;
final String level;
final String text;
const PlayerLogLine(this.prefix, this.level, this.text);
bool get isError => level == 'error' || level == 'fatal';
}
/// A snapshot of a player's state.
class HostedPlayerState {
final Duration position;
final Duration duration;
/// End of the cached range (mpv `demuxer-cache-time`).
final Duration buffer;
final double volume;
final bool playing;
/// At the end of the file.
final bool completed;
final int? width;
final int? height;
const HostedPlayerState({
this.position = Duration.zero,
this.duration = Duration.zero,
this.buffer = Duration.zero,
this.volume = 100,
this.playing = false,
this.completed = false,
this.width,
this.height,
});
}
/// One mpv player. Each media gets its own (see ARCHITECTURE "Player
/// lifecycle"); [PlayerHost.retire] / [PlayerHost.disposeAll] end it.
abstract interface class HostedPlayer {
PlayerRole get role;
HostedPlayerState get state;
Stream<Duration> get positionStream;
Stream<Duration> get durationStream;
Stream<Duration> get bufferStream;
Stream<double> get volumeStream;
Stream<bool> get completedStream;
Stream<int?> get widthStream;
/// mpv's log lines at the player's level.
Stream<PlayerLogLine> get logs;
/// The player is gone (disposed or being disposed): commands do nothing.
bool get disposed;
/// Seeks from the video widget's own controls land here instead of in
/// mpv when set ([seek] goes through it too); [syncSeek] always moves
/// mpv.
void Function(Duration position)? onUserSeek;
/// Opens [url] (with [headers] on every request), paused unless [play].
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false});
Future<void> play();
Future<void> pause();
Future<void> stop();
/// A user seek: through [onUserSeek] when set, else [syncSeek].
Future<void> seek(Duration position);
/// Moves mpv (sync corrections, scrub preview).
Future<void> syncSeek(Duration position);
Future<void> setRate(double rate);
Future<void> setVolume(double volume);
Future<void> setProperty(String name, String value);
Future<String> getProperty(String name);
Future<void> observeProperty(String name, Future<void> Function(String value) onChange);
Future<void> command(List<String> args);
/// Sets [name] and reads it back: null when mpv took [value], else
/// `name: value -> got` (mpv ignores invalid values silently).
Future<String?> pin(String name, String value);
/// mpv's log level for this player (`warn`, `v`, ...).
void setLogLevel(String level);
}
/// A player's video output (a texture the `Video` widget shows).
abstract interface class HostedVideo {
HostedPlayer get player;
/// Completes once the output is up (its texture registered).
Future<void> get ready;
}
/// Creates, owns and disposes the app's players. One per app (the
/// composition root's `Ports`); tests make their own.
abstract interface class PlayerHost {
/// Once at start, before any player.
void init();
/// A new player for [role], set up before it is returned to the caller's
/// first await: cache, pins ([pinMismatches]), hwdec, log level.
Future<HostedPlayer> create(PlayerRole role, {bool muted = false});
/// [player] (a preload) becomes a session player: unmuted, the full
/// cache pins, the session's log level.
Future<void> promote(HostedPlayer player);
/// [player]'s video output, with the hwdec policy. [probe]: listen to
/// mpv's render init to learn [renderMode] (the first output of an app
/// start).
HostedVideo video(HostedPlayer player, {bool probe = false});
/// How video renders, as last learned (kept after leaving).
ReadonlySignal<RenderMode> get renderMode;
/// mpv `hwdec` for new players: env `SYNC_RELAY_HWDEC` wins over the
/// setting (Windows' auto is `auto-copy`).
String? get hwdec;
/// Pins that did not take on the last session player created.
List<String> get pinMismatches;
/// ffmpeg's HTTP errors (`HTTP error 403 Forbidden`), from whichever
/// player ffmpeg logs through (one per process).
Stream<String> get httpErrors;
/// `mpv-version` and `ffmpeg-version` of [player], for diagnostics.
Future<String?> versions(HostedPlayer player);
/// Ends [player] once the view let go of its texture (two frames and a
/// route pop); at most one retiring player lingers, a new [create] of a
/// session player waits for it ([retiring]).
Future<void> retire(HostedPlayer player);
/// The last retire, until done.
Future<void> get retiring;
/// Disposes [player] at once (no view showed it).
Future<void> dispose(HostedPlayer player);
/// App close: every live player goes, each bounded by [each], before
/// the engine does (a live mpv calls back into a dead isolate).
Future<void> disposeAll({Duration each});
/// Live players, for tests and the close.
int get live;
}
/// [log] says the media could not be opened: before a [duration] is known,
/// mpv's fatal "nothing to play" or a demuxer error.
bool mpvLoadFailed(PlayerLogLine log, Duration duration) {
return duration == Duration.zero &&
((log.prefix == 'cplayer' && log.level == 'fatal') || (log.prefix == 'demux' && log.level == 'error'));
}
@@ -1,5 +1,6 @@
import 'folder_access.dart'; import 'folder_access.dart';
import 'link_resolver.dart'; import 'link_resolver.dart';
import 'player_host.dart';
import 'shell_port.dart'; import 'shell_port.dart';
import 'system_notifications.dart'; import 'system_notifications.dart';
import 'url_opener.dart'; import 'url_opener.dart';
@@ -7,6 +8,7 @@ import 'url_opener.dart';
export 'app_dirs.dart'; export 'app_dirs.dart';
export 'folder_access.dart'; export 'folder_access.dart';
export 'link_resolver.dart'; export 'link_resolver.dart';
export 'player_host.dart';
export 'shell_port.dart'; export 'shell_port.dart';
export 'system_notifications.dart'; export 'system_notifications.dart';
export 'url_opener.dart'; export 'url_opener.dart';
@@ -19,11 +21,14 @@ class Ports {
final FolderAccess folders; final FolderAccess folders;
final LinkResolver links; final LinkResolver links;
final SystemNotifications notifications; final SystemNotifications notifications;
/// The media players (mpv): created, rendered and disposed here.
final PlayerHost players;
const Ports({ const Ports({
required this.shell, required this.shell,
required this.urls, required this.urls,
required this.folders, required this.folders,
required this.links, required this.links,
required this.notifications, required this.notifications,
required this.players,
}); });
} }
+70
View File
@@ -0,0 +1,70 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart' show debugPrint;
import '../app/app_graph.dart';
import 'app_shutdown.dart';
import 'ports/player_host.dart';
// The close smoke run (CI, `tool/smoke_windows.ps1` / `tool/smoke_linux.sh`):
// env `STARSYNC_SMOKE=<media file>` makes the app, once up, open that file
// in a session player with a video output through the media pipeline,
// play a second and close the normal way ([shutDownApp]). Progress goes to
// `STARSYNC_SMOKE_OUT` (one line per step: `start`, `opened <ms> render=<mode>`,
// `closing`, or `skip <why>` / `fail <why>`), so the script can tell a
// runner that cannot show a window (skip) from a close that hangs or
// leaves a process behind (fail).
/// The smoke run's media, or null for a normal start.
String? get smokeMedia {
final m = Platform.environment['STARSYNC_SMOKE'];
return m == null || m.isEmpty ? null : m;
}
Future<void> runSmoke(AppGraph graph, String media) async {
final outPath = Platform.environment['STARSYNC_SMOKE_OUT'];
void step(String line) {
debugPrint('smoke: $line'); // into the app log too
if (outPath == null || outPath.isEmpty) return;
try {
File(outPath).writeAsStringSync('$line\n', mode: FileMode.append, flush: true);
} catch (_) {}
}
step('start');
final sw = Stopwatch()..start();
final pipe = graph.coordinator.media;
try {
if (!await File(media).exists()) {
step('fail no media file at $media');
} else {
await pipe.start();
await pipe.playback.setVolume(0);
// The video output the session view would make: the render path
// (EGL / ANGLE) is what breaks on some machines.
final video = pipe.playback.video;
await video.ready.timeout(const Duration(seconds: 15));
final ok = await pipe.open(media);
final end = DateTime.now().add(const Duration(seconds: 20));
while (ok && pipe.playback.durationSecs <= 0 && DateTime.now().isBefore(end)) {
await Future.delayed(const Duration(milliseconds: 100));
}
if (pipe.playback.durationSecs <= 0) {
step('fail the media did not open in 20 s');
} else {
await pipe.playback.player.play();
await Future.delayed(const Duration(seconds: 1));
final mode = graph.ports.players.renderMode.peek();
step('opened ${sw.elapsedMilliseconds} render=${mode.name}');
if (mode == RenderMode.unknown) await Future.delayed(const Duration(seconds: 2));
}
}
} on TimeoutException {
step('skip the video output did not come up (no display for this runner?)');
} catch (e) {
step('fail $e');
}
step('closing');
await shutDownApp();
}
File diff suppressed because it is too large. Load diff
+2 -21
View File
@@ -6,15 +6,6 @@ import 'package:sync_relay_core/core.dart';
import '../platform/ports/link_resolver.dart'; import '../platform/ports/link_resolver.dart';
/// mpv's `ytdl-format` for links every player opens itself.
const ytdlFormat = 'bv*[height<=1080]+ba/b';
/// yt-dlp's `--sub-langs` for mpv's hook (its default is `all`), without
/// Bilibili's danmaku: an XML comment feed served raw-deflate, which
/// ffmpeg fails to inflate on every read (a warning each time) while the
/// video waits on it.
const ytdlSubLangs = 'all,-danmaku';
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with /// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
/// the hosted server. [probe] remembers what pasted links are for a while, /// the hosted server. [probe] remembers what pasted links are for a while,
/// so the Link preview and the swap ask once. /// so the Link preview and the swap ask once.
@@ -36,20 +27,10 @@ class AppYtDlp implements LinkResolver {
/// Gets yt-dlp ready in the background (download on first run, a daily /// Gets yt-dlp ready in the background (download on first run, a daily
/// update check afterwards). /// update check afterwards).
@override @override
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null)); bool get resolvesLinks => true;
/// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
/// yt-dlp folder (see [YtDlpCookies]).
@override @override
Future<String> mpvRawOptions() async { void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
try {
final cookies = (await ytDlp).cookies.mpvRawOptions();
return cookies.isEmpty ? subs : '$subs,$cookies';
} catch (_) {
return subs;
}
}
final _probes = <String, ({DateTime at, DateTime until, Future<LinkProbe> f})>{}; final _probes = <String, ({DateTime at, DateTime until, Future<LinkProbe> f})>{};
static const _probeTtl = Duration(minutes: 10); static const _probeTtl = Duration(minutes: 10);
+211 -135
View File
@@ -16,23 +16,27 @@ import '../screens/home/picked_media.dart';
import '../session/app_events.dart' show AddedToUpNext; import '../session/app_events.dart' show AddedToUpNext;
import '../session/media_art.dart'; import '../session/media_art.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../state/settings.dart';
import '../sync/avatar_cache.dart'; import '../sync/avatar_cache.dart';
import 'queue_logic.dart';
// Up next on this client (see ARCHITECTURE.md "Up next"): the server's // What's next on this app (see ARCHITECTURE.md "What's next" and "Up
// queue as signals, adding to it (probed, with a picture), the end-of-media // next"): the server's queue as signals (Up next and its continuation),
// countdown for its first item, putting an item up when this app is the // adding to it (probed, with a picture), the continuation of what this
// one that can, and keeping that item ready. // app put up (the next Jellyfin episode, the next file of a local show),
// the server's countdown as the end card shows it, putting an item up
// when the server asks this app (`queuePlay`), and keeping the item that
// plays next ready (the media pipeline prepares it).
/// The end-of-media card for the first Up next item. [secondsLeft] null: /// The end-of-media card for the first item (Up next's, else the
/// no countdown here (auto-advance off, cancelled, or another app puts /// continuation). [secondsLeft]: the server's countdown (`state.advance`);
/// it up), the card offers Play now only. /// null when none runs (cancelled, or nothing counts down), then the card
/// offers Play now only. [waitingFor]: the countdown ran out and the one
/// who puts the item up is not here (its name).
class UpNextCountdown { class UpNextCountdown {
final int mediaId; final int mediaId;
final QueueItem item; final QueueItem item;
final int? secondsLeft; final int? secondsLeft;
const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft}); final String? waitingFor;
const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft, this.waitingFor});
} }
/// The first item's readiness on this app. /// The first item's readiness on this app.
@@ -46,21 +50,33 @@ class QueueController {
late final PictureCache thumbs = PictureCache(coord.connection.client, late final PictureCache thumbs = PictureCache(coord.connection.client,
route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]); route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]);
/// Up next, in order (empty when nothing is queued or not in a server). /// Up next, in order, without the continuation (empty when nothing is
late final Computed<List<QueueItem>> items = /// queued or not in a server).
computed(() => coord.serverState.value?.queue ?? const <QueueItem>[]); late final Computed<List<QueueItem>> items = computed(
() => [for (final q in coord.serverState.value?.queue ?? const <QueueItem>[]) if (!q.auto) q]);
/// The continuation of what plays (`auto`: the next episode), shared by
/// everyone; null when none.
late final Computed<QueueItem?> continues =
computed(() => coord.serverState.value?.queue.where((q) => q.auto).firstOrNull);
/// What plays next: Up next's first item, else the continuation.
late final Computed<QueueItem?> head = computed(() => coord.serverState.value?.queue.firstOrNull);
/// The end card while the media has ended with something next (see
/// [UpNextCountdown]).
final countdown = signal<UpNextCountdown?>(null); final countdown = signal<UpNextCountdown?>(null);
/// The first item's id and how ready it is here (null: nothing to get /// The first item's id and how ready it is here (null: nothing to get
/// ready on this app). /// ready on this app).
final warm = signal<({String id, WarmState state})?>(null); final warm = signal<({String id, WarmState state})?>(null);
/// The natural continuation of what plays: the next episodes of the /// The next episodes of the Jellyfin series playing, on an app that
/// Jellyfin series (not queued; shown greyed under Up next). /// plays it with its own account (the first one is what continues it;
/// the rest show greyed under "Continues with", "+" adds one).
final continuation = signal<List<EpisodeLink>>(const []); final continuation = signal<List<EpisodeLink>>(const []);
/// The mediaId whose continuation was cleared here. /// The mediaId whose continuation was cleared (by anyone: `cancel`).
final _hiddenFor = signal(-1); final _hiddenFor = signal(-1);
/// The queue popover is open: the Up next button holds the controls up. /// The queue popover is open: the Up next button holds the controls up.
@@ -70,10 +86,12 @@ class QueueController {
// here, a file here). Never leaves this app. // here, a file here). Never leaves this app.
final Map<String, ({PickedMedia pick, DateTime at})> _local = {}; final Map<String, ({PickedMedia pick, DateTime at})> _local = {};
final Map<String, DateTime> _performing = {}; final Map<String, DateTime> _performing = {};
final Set<String> _skipped = {};
int _cancelledFor = -1;
Timer? _timer; Timer? _timer;
bool _attached = false; bool _attached = false;
// The mediaId whose continuation this app added (or is adding).
int _continuedFor = -1;
// A restarted server waits for what played (that mediaId) to come back.
int _restoreFrom = -1;
String _continuationFor = ''; String _continuationFor = '';
final _random = Random.secure(); final _random = Random.secure();
@@ -83,15 +101,26 @@ class QueueController {
if (_attached) return; if (_attached) return;
_attached = true; _attached = true;
coord.connection.states.listen(_onState); coord.connection.states.listen(_onState);
// `cancel` from anyone: no continuation for that media any more.
coord.connection.feeds.listen((f) {
final id = coord.serverState.peek()?.mediaId;
if (f.action == 'advance-cancel' && id != null) _hiddenFor.value = id;
});
effect(() { effect(() {
final next = coord.nextEpisode.value; final next = coord.nextEpisode.value;
final plan = coord.jellyfinPlan.value; final plan = coord.jellyfinPlan.value;
unawaited(_loadContinuation(next, plan)); unawaited(_loadContinuation(next, plan));
}); });
// The continuation and the state decide what to get ready; the media // What continues this media becomes known (the next episode found, a
// playing having started (or sitting out) lets the preload go. // file of ours up): add it.
effect(() {
coord.nextEpisode.value;
coord.ownFile.value;
untracked(() => _continue(coord.serverState.peek()));
});
// The state decides what to get ready; the media playing having
// started (or sitting out) lets the preload go.
effect(() { effect(() {
shownContinuation.value;
coord.settledMediaId.value; coord.settledMediaId.value;
coord.selfAway.value; coord.selfAway.value;
final r = coord.serverState.value; final r = coord.serverState.value;
@@ -319,62 +348,54 @@ class QueueController {
} }
} }
/// Plays the first Up next item now. /// Plays what comes next now (Up next's first item, else the
/// continuation).
void skip() { void skip() {
final first = items.peek().firstOrNull; final first = head.peek();
if (first != null) playNow(first.id); if (first != null) playNow(first.id);
} }
/// Cancel on the end-of-media card: no countdown here for this media. /// Cancel on the end card: the countdown stops for everyone, and the
/// continuation goes (`cancel`).
void cancelCountdown() { void cancelCountdown() {
final c = countdown.peek(); coord.markInput();
_timer?.cancel(); coord.connection.sendQueue(const QueueIntent.cancel());
_timer = null;
if (c == null) return;
_cancelledFor = c.mediaId;
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
} }
/// "Clear": hides the continuation of what plays now (and its next /// "Clear" under "Continues with": the continuation of what plays goes
/// episode does not come up by itself). /// for everyone (`cancel`) and does not come back for this media.
void clearContinuation() { void clearContinuation() {
final id = coord.serverState.peek()?.mediaId; final id = coord.serverState.peek()?.mediaId;
if (id != null) _hiddenFor.value = id; if (id != null) _hiddenFor.value = id;
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance(); cancelCountdown();
} }
/// The continuation applies to [mediaId]: nothing is queued and it was /// The further episodes of [continuation] (after the one that continues
/// not cleared. /// what plays), unless it was cleared for the media playing.
bool continuationShown(int mediaId) =>
(coord.serverState.peek()?.queue.isEmpty ?? true) && _hiddenFor.peek() != mediaId;
/// [continuation] unless cleared for the media playing.
late final Computed<List<EpisodeLink>> shownContinuation = computed(() { late final Computed<List<EpisodeLink>> shownContinuation = computed(() {
final id = coord.serverState.value?.mediaId; final id = coord.serverState.value?.mediaId;
return id != null && _hiddenFor.value == id ? const <EpisodeLink>[] : continuation.value; if (id == null || _hiddenFor.value == id) return const <EpisodeLink>[];
final auto = continues.value?.media.jellyfin?.itemId;
return [for (final e in continuation.value) if (e.item.id != auto) e];
}); });
// --------------------------------------------------------- per state // --------------------------------------------------------- per state
void _onState(StateBroadcast r) { void _onState(StateBroadcast r) {
_lastState = r; _lastState = r;
// What played came back after a restart under a new mediaId: a
// continuation cleared for it stays cleared.
if (_restoreFrom != -1 && r.media != null && _hiddenFor.peek() == _restoreFrom) _hiddenFor.value = r.mediaId;
_prune(r); _prune(r);
final queue = r.queue; // The server asks for an item (a countdown ran out, Play now from
// another app, nothing up yet): the app that puts it up does.
final play = r.queuePlay; final play = r.queuePlay;
if (play != null) { if (play != null) {
final item = queue.where((q) => q.id == play).firstOrNull; final item = r.queue.where((q) => q.id == play).firstOrNull;
if (item != null) _bringUp(item, r); if (item != null && queuePerformer(item, r.members).id == _me) _perform(item);
}
final finished = r.transport == 'finished';
if (queue.isNotEmpty && r.media == null) {
// Nothing up yet: the first item goes up at once.
_bringUp(queue.first, r);
} else if (finished && queue.isNotEmpty) {
_atEnd(r);
} else {
if (countdown.peek() != null) _stopCountdown();
if (!finished) _cancelledFor = -1;
} }
_syncCountdown(r);
_continue(r);
_warm(r); _warm(r);
} }
@@ -385,75 +406,133 @@ class QueueController {
final now = DateTime.now(); final now = DateTime.now();
_performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1)); _performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1));
final keys = {for (final q in r.queue) q.key}; final keys = {for (final q in r.queue) q.key};
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1)); // Kept while a restarted server waits for what played: Up next comes
final ids = {for (final q in r.queue) q.id}; // back with it.
_skipped.removeWhere((id) => !ids.contains(id)); if (_restoreFrom == -1) {
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
}
} }
/// [item] should go up now: by this app when it is the one that can; /// The end card from the server's state: the media ended with something
/// nobody can: the lowest member takes it off with the reason. /// next; the seconds of `state.advance` move on with a 1 s timer.
void _bringUp(QueueItem item, StateBroadcast r) { void _syncCountdown(StateBroadcast r) {
final perf = queuePerformer(item, r.members);
if (perf.id == null) {
final why = perf.why;
if (why != null && queueJanitor(r.members) == _me && _skipped.add(item.id)) {
coord.connection.sendQueue(QueueIntent.remove(item.id, why: why));
}
return;
}
if (perf.id == _me) _perform(item);
}
/// The media finished with Up next waiting: the card, and with
/// auto-advance on the countdown, for its first item.
void _atEnd(StateBroadcast r) {
// Up next goes before the next episode, even one already counting.
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance();
final head = r.queue.first;
final perf = queuePerformer(head, r.members);
if (perf.id == null && perf.why != null) {
// Taken off by [_bringUp]'s rule; the next state shows the next one.
_bringUp(head, r);
return;
}
final c = countdown.peek();
if (c != null && c.mediaId == r.mediaId) {
if (c.item.id != head.id || c.item.thumb != head.thumb) {
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: head, secondsLeft: c.secondsLeft);
}
return;
}
final auto = AppSettings.instance.autoAdvance.peek() && _cancelledFor != r.mediaId;
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: auto ? autoAdvanceSecs : null);
_timer?.cancel();
_timer = auto ? Timer.periodic(const Duration(seconds: 1), (_) => _tick()) : null;
}
void _tick() {
final c = countdown.peek();
final r = coord.serverState.peek();
final left = c?.secondsLeft;
if (c == null || left == null || r == null || r.mediaId != c.mediaId || r.transport != 'finished') {
_stopCountdown();
return;
}
if (left > 1) {
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item, secondsLeft: left - 1);
return;
}
_timer?.cancel();
_timer = null;
_cancelledFor = c.mediaId;
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
// Decided on the latest state: only the app that can puts it up.
final head = r.queue.firstOrNull; final head = r.queue.firstOrNull;
if (head != null && queuePerformer(head, r.members).id == _me) _perform(head); if (r.transport != 'finished' || head == null || r.media == null) {
_timer?.cancel();
_timer = null;
if (countdown.peek() != null) countdown.value = null;
return;
}
final a = r.advance != null && r.advance!.id == head.id ? r.advance : null;
int? left;
String? waiting;
if (a != null) {
left = advanceSecondsLeft(a, coord.connection.serverNow());
if (left == 0 && queuePerformer(head, r.members).id == null) {
waiting = r.members.where((m) => m.id == head.by).firstOrNull?.name ?? head.byName;
}
}
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: left, waitingFor: waiting);
if (a == null) {
_timer?.cancel();
_timer = null;
} else {
_timer ??= Timer.periodic(const Duration(seconds: 1), (_) {
final now = coord.serverState.peek();
if (now != null) _syncCountdown(now);
});
}
} }
void _stopCountdown() { // ------------------------------------------------------------ restart
_timer?.cancel();
_timer = null; /// The server restarted without media; what played ([mediaId]) is being
countdown.value = null; /// offered back (-1: that is over).
void restoreFrom(int mediaId) => _restoreFrom = mediaId;
/// What played is back after a server restart: Up next as [before] had
/// it comes back too. The app whose offer won ([winner]) adds the items
/// anyone can put up (links everyone opens, Jellyfin items); every app
/// its own links passed on and files (only it has them; [self] is its
/// member id then). A server-fetched link's address stayed with the old
/// server, so it is gone; the continuation comes back as usual.
void restoreAfterRestart(StateBroadcast before, String self, {required bool winner}) {
for (final q in before.queue) {
if (q.auto) continue;
final ok = switch (q.how) {
QueueHow.direct || QueueHow.jellyfin => winner,
QueueHow.through || QueueHow.file => self.isNotEmpty && q.by == self && _local.containsKey(q.key),
QueueHow.server => false,
};
if (!ok) continue;
final local = _local[q.key];
if (local != null) _local[q.key] = (pick: local.pick, at: DateTime.now());
final thumb = thumbs.bytesFor(q.thumb);
coord.connection.sendQueue(QueueIntent.add(
QueueItem(media: q.media, how: q.how, title: q.title, sub: q.sub, duration: q.duration, key: q.key),
thumb: thumb == null ? '' : base64Encode(thumb)));
}
_restoreFrom = -1;
}
// ------------------------------------------------------- continuation
/// Adds what continues the media playing (`auto`, PROTOCOL.md "Queue")
/// when this app may ([mayAddContinuation]: it put it up, or that
/// member is not in and this is the first who is), knows what comes
/// next, and it is not there or cancelled yet; with the setting on. A
/// Jellyfin episode played with this app's account continues with the
/// series' next one, a file this app put up with the next episode of its
/// show in the library (numbers skipped on the way said in its line).
void _continue(StateBroadcast? r) {
if (r == null || r.media == null || !coord.settings.autoAdvance.peek()) return;
final id = r.mediaId;
if (_continuedFor == id || _hiddenFor.peek() == id) return;
if (r.queue.any((q) => q.auto && q.forMedia == id)) {
_continuedFor = id;
return;
}
final swapBy = r.media?['swapBy'];
if (!mayAddContinuation(_me, swapBy is String ? swapBy : null, r.members)) return;
final ref = refOfStateMedia(r.media);
final next = coord.nextEpisode.peek();
if (ref != null) {
if (next == null || !sameJellyfinItem(ref, coord.currentRef.peek())) return;
_continuedFor = id;
unawaited(_addContinuation(id, next.pick));
return;
}
final own = coord.ownFile.peek();
final finder = coord.nextFile;
if (own == null || own.$1 != id || finder == null) return;
_continuedFor = id;
unawaited(() async {
try {
final n = await finder(own.$2);
if (n == null) return;
final gap = n.skipped;
await _addContinuation(id, FileMedia(n.episode.path),
note: gap == null ? null : 'skips ${gap.label}');
} catch (e) {
debugPrint('queue: what continues ${own.$2}: $e');
}
}());
}
Future<void> _addContinuation(int mediaId, PickedMedia pick, {String? note}) async {
try {
final b = await _build(pick);
if (b is! ({QueueItem item, Uint8List? thumb})) return;
final r = coord.serverState.peek();
if (r == null || r.mediaId != mediaId || _hiddenFor.peek() == mediaId) return;
final sub = [b.item.sub, ?note].where((s) => s.isNotEmpty).join(' · ');
final item = b.item.copyWith(auto: true, forMedia: mediaId, sub: sub);
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
final thumb = b.thumb;
coord.connection.sendQueue(QueueIntent.add(item, thumb: thumb == null ? '' : base64Encode(thumb)));
} catch (e) {
debugPrint('queue: adding what continues media $mediaId: $e');
}
} }
/// Puts [item] up from here (once per item while it is in flight). /// Puts [item] up from here (once per item while it is in flight).
@@ -530,9 +609,8 @@ class QueueController {
bool _ownAccount(String serverId) => bool _ownAccount(String serverId) =>
coord.jellyfin.account(serverId) != null && coord.jellyfin.statusOf(serverId) != JellyfinAccountStatus.expired; coord.jellyfin.account(serverId) != null && coord.jellyfin.statusOf(serverId) != JellyfinAccountStatus.expired;
/// What this app gets ready: Up next's first item, else (Up next empty) /// What this app gets ready: the first item of the queue (Up next's,
/// the first continuation episode (only here when this app plays the /// else the continuation). Every app does, each its own copy (see
/// series with its own account). Every app does, each its own copy (see
/// [_WarmTarget]); while sitting out only the probe of a link this app /// [_WarmTarget]); while sitting out only the probe of a link this app
/// puts up (its swap reads it). Null: nothing. /// puts up (its swap reads it). Null: nothing.
_WarmTarget? _warmTarget(StateBroadcast? r) { _WarmTarget? _warmTarget(StateBroadcast? r) {
@@ -550,7 +628,7 @@ class QueueController {
// The server's warm relay of the item, once bytes can flow: what // The server's warm relay of the item, once bytes can flow: what
// everyone without their own copy preloads. // everyone without their own copy preloads.
final relay = head.media.relay; final relay = head.media.relay;
final warmUrl = relay != null && relay.live ? coord.warmRelayUrl(head, relay) : null; final warmUrl = relay != null && relay.live ? coord.media.warmRelayUrl(head, relay) : null;
switch (head.how) { switch (head.how) {
case QueueHow.jellyfin: case QueueHow.jellyfin:
final ref = head.media.jellyfin; final ref = head.media.jellyfin;
@@ -579,9 +657,7 @@ class QueueController {
return performer ? _WarmTarget(head.id) : null; return performer ? _WarmTarget(head.id) : null;
} }
} }
final next = shownContinuation.peek().firstOrNull; return null;
if (next == null) return null;
return _WarmTarget('next:${next.item.id}', ref: mediaForPick(next.pick).jellyfin);
} }
/// Keeps the one item that plays next ready on this app, so a skip or /// Keeps the one item that plays next ready on this app, so a skip or
@@ -589,7 +665,7 @@ class QueueController {
/// puts it up (`AppYtDlp.probe`, kept while it stays first), a Jellyfin /// puts it up (`AppYtDlp.probe`, kept while it stays first), a Jellyfin
/// item resolved with this app's account /// item resolved with this app's account
/// ([SessionCoordinator.warmJellyfin]), and then opened in the hidden /// ([SessionCoordinator.warmJellyfin]), and then opened in the hidden
/// preload player ([SessionCoordinator.preloadMedia]) once the media /// preload player ([MediaPipeline.prepare]) once the media
/// playing has started. Done as soon as it is first, again before that /// playing has started. Done as soon as it is first, again before that
/// runs out, dropped when it is no longer first. /// runs out, dropped when it is no longer first.
void _warm(StateBroadcast? r) { void _warm(StateBroadcast? r) {
@@ -617,7 +693,7 @@ class QueueController {
/// Feeds the server's warm relay of Up next's first item from here when /// Feeds the server's warm relay of Up next's first item from here when
/// it names this member as its source (a link passed on, a file), or a /// it names this member as its source (a link passed on, a file), or a
/// Jellyfin item this app puts up with its own account when the server /// Jellyfin item this app puts up with its own account when the server
/// asks for a source; see [SessionCoordinator.feedWarm]. The server /// asks for a source; see [RelayFeeder.feedWarm]. The server
/// drops the relay (and with it the source) once the item is no longer /// drops the relay (and with it the source) once the item is no longer
/// first. /// first.
void _feedWarm(StateBroadcast? r) { void _feedWarm(StateBroadcast? r) {
@@ -633,7 +709,7 @@ class QueueController {
final pick = _local[head.key]?.pick; final pick = _local[head.key]?.pick;
if (pick is FileMedia) { if (pick is FileMedia) {
_fedWarm = key; _fedWarm = key;
coord.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path)))); coord.media.feeder.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
} else if (pick is LinkMedia) { } else if (pick is LinkMedia) {
_fedWarm = key; _fedWarm = key;
unawaited(() async { unawaited(() async {
@@ -642,7 +718,7 @@ class QueueController {
final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60)); final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
final y = await coord.ytDlp.ytDlp; final y = await coord.ytDlp.ytDlp;
if (_fedWarm != key) return; if (_fedWarm != key) return;
coord.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe)); coord.media.feeder.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe));
} catch (e) { } catch (e) {
debugPrint('queue: feeding ${head.id}: $e'); debugPrint('queue: feeding ${head.id}: $e');
} }
@@ -656,7 +732,7 @@ class QueueController {
final w = coord.warmJellyfin; final w = coord.warmJellyfin;
if (w == null || !sameJellyfinItem(w.ref, ref)) return; if (w == null || !sameJellyfinItem(w.ref, ref)) return;
_fedWarm = key; _fedWarm = key;
coord.feedWarm( coord.media.feeder.feedWarm(
head.id, RelayMedia.single(HttpRelayBytes(Uri.parse(w.plan.url), headers: w.plan.headers))); head.id, RelayMedia.single(HttpRelayBytes(Uri.parse(w.plan.url), headers: w.plan.headers)));
case QueueHow.direct: case QueueHow.direct:
case QueueHow.server: case QueueHow.server:
@@ -710,7 +786,7 @@ class QueueController {
// A refresh opens a link again (a page's stream URLs expire); the // A refresh opens a link again (a page's stream URLs expire); the
// same Jellyfin stream stays as it is. // same Jellyfin stream stays as it is.
_preloadFresh = fresh && ok && t.ref == null && open != null; _preloadFresh = fresh && ok && t.ref == null && open != null;
final waits = ok && open != null && coord.playback.preloadEnabled; final waits = ok && open != null && coord.media.preloadEnabled;
warm.value = (id: t.id, state: !ok ? WarmState.failed : (waits ? WarmState.warming : WarmState.ready)); warm.value = (id: t.id, state: !ok ? WarmState.failed : (waits ? WarmState.warming : WarmState.ready));
_syncPreload(coord.serverState.peek()); _syncPreload(coord.serverState.peek());
_warmTimer = Timer(ok ? _warmRefresh : _warmRetry, () => _resolveWarm(t, fresh: true)); _warmTimer = Timer(ok ? _warmRefresh : _warmRetry, () => _resolveWarm(t, fresh: true));
@@ -730,25 +806,25 @@ class QueueController {
final out = coord.selfAway.peek(); final out = coord.selfAway.peek();
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return; if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
final want = out ? null : _preloadWant; final want = out ? null : _preloadWant;
final playback = coord.playback; final media = coord.media;
if (want == null) { if (want == null) {
_preloadKey = ''; _preloadKey = '';
_preloadGen++; _preloadGen++;
if (playback.preloadUrl != null) unawaited(playback.dropPreload()); if (media.preloadUrl != null) unawaited(media.dropPrepared());
return; return;
} }
if (!playback.preloadEnabled) return; if (!media.preloadEnabled) return;
final key = '${want.url}|${want.headers}'; final key = '${want.url}|${want.headers}';
final fresh = _preloadFresh; final fresh = _preloadFresh;
// Asked for already: in flight, there, or failed (tried again with the // Asked for already: in flight, there, or failed (tried again with the
// next refresh). One that went away (Reload video) is asked again. // next refresh). One that went away (Reload video) is asked again.
if (!fresh && key == _preloadKey && (_preloadBusy || !_preloadOk || playback.preloadUrl == want.url)) return; if (!fresh && key == _preloadKey && (_preloadBusy || !_preloadOk || media.preloadUrl == want.url)) return;
_preloadFresh = false; _preloadFresh = false;
_preloadKey = key; _preloadKey = key;
_preloadBusy = true; _preloadBusy = true;
final gen = ++_preloadGen; final gen = ++_preloadGen;
final id = warm.peek()?.id; final id = warm.peek()?.id;
unawaited(coord.preloadMedia(want.url, headers: want.headers, fresh: fresh).then((ok) { unawaited(coord.media.prepare(want.url, headers: want.headers, fresh: fresh).then((ok) {
if (gen != _preloadGen) return; if (gen != _preloadGen) return;
_preloadBusy = false; _preloadBusy = false;
_preloadOk = ok; _preloadOk = ok;
+4 -55
View File
@@ -1,66 +1,15 @@
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../jellyfin/session_media.dart' show compareMemberIds; // Up next's words for an item. Who puts an item up is core's
import '../session/party.dart' show isPresent; // `queuePerformer` (whats_next.dart), read the same by the server and
// every app.
// Who puts an Up next item up. Pure: only what every client sees in
// `state` decides, so all clients agree without a wire message (like the
// Jellyfin auto-advance swapper).
/// Who puts [item] up: [id] a member id; null with [why] when nobody here
/// can (the item is skipped), null without [why] when nobody is around
/// right now (nothing happens).
typedef QueuePerformer = ({String? id, String? why});
/// - A link passed on by its adder, or the adder's file: only the adder
/// (their app has the file, the cookies). Gone (disconnected, in its
/// grace): it waits for them; left for good: skipped.
/// - Jellyfin: the adder when it plays that server with its own account,
/// else the lowest member id that does (`jf[serverId] == true`,
/// present: `in` or joining). Nobody: skipped.
/// - A link everyone opens, or one the server fetches: the adder when
/// present, else the lowest present member id.
QueuePerformer queuePerformer(QueueItem item, List<MemberView> members) {
MemberView? adder;
for (final m in members) {
if (m.id == item.by) adder = m;
}
bool active(MemberView m) => isPresent(m);
if (item.how.adderOnly) {
if (adder == null) return (id: null, why: '${_name(item)} left');
if (adder.presence == Presence.gone) return (id: null, why: null);
return (id: adder.id, why: null);
}
final ref = item.media.jellyfin;
if (item.how == QueueHow.jellyfin && ref != null) {
bool can(MemberView m) => active(m) && m.jf[ref.serverId] == true;
if (adder != null && can(adder)) return (id: adder.id, why: null);
final id = autoAdvanceLowest(members.where(can));
return id == null ? (id: null, why: 'nobody here has that Jellyfin server') : (id: id, why: null);
}
if (adder != null && active(adder)) return (id: adder.id, why: null);
return (id: autoAdvanceLowest(members.where(active)), why: null);
}
String _name(QueueItem item) => item.byName.isEmpty ? 'who added it' : item.byName;
/// The lowest member id of [members] ("m2" before "m10"), or null.
String? autoAdvanceLowest(Iterable<MemberView> members) {
final ids = [for (final m in members) m.id]..sort(compareMemberIds);
return ids.isEmpty ? null : ids.first;
}
/// Who takes an item nobody can put up off the queue (`remove` with why):
/// the lowest member id that is connected (not gone).
String? queueJanitor(List<MemberView> members) =>
autoAdvanceLowest(members.where((m) => m.presence != Presence.gone));
/// How [item] plays, in a few words for its row: "through Foxyz" (you: /// How [item] plays, in a few words for its row: "through Foxyz" (you:
/// "through you"), "server fetches", "everyone fetches", "Jellyfin", /// "through you"), "server fetches", "everyone fetches", "Jellyfin",
/// "Foxyz's file". /// "Foxyz's file".
String queueHowLabel(QueueItem item, {required String selfId}) { String queueHowLabel(QueueItem item, {required String selfId}) {
final mine = item.by == selfId && selfId.isNotEmpty; final mine = item.by == selfId && selfId.isNotEmpty;
final who = mine ? 'you' : _name(item); final who = mine ? 'you' : (item.byName.isEmpty ? 'who added it' : item.byName);
return switch (item.how) { return switch (item.how) {
QueueHow.through => 'through $who', QueueHow.through => 'through $who',
QueueHow.file => mine ? 'your file' : "$who's file", QueueHow.file => mine ? 'your file' : "$who's file",
+127 -81
View File
@@ -12,7 +12,6 @@ import '../controls/minimal/style.dart';
import '../jellyfin/images.dart'; import '../jellyfin/images.dart';
import '../jellyfin/session_media.dart' show EpisodeLink; import '../jellyfin/session_media.dart' show EpisodeLink;
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../session/session_coordinator.dart' show autoAdvanceSecs;
import '../shell/avatar.dart'; import '../shell/avatar.dart';
import '../shell/handoff.dart' show handoffHolds; import '../shell/handoff.dart' show handoffHolds;
import '../shell/library_link.dart' show LinkPreview, cachedLinkPreview; import '../shell/library_link.dart' show LinkPreview, cachedLinkPreview;
@@ -38,17 +37,14 @@ enum UpNextLook { standard, minimal }
/// The next item's picture, "UP NEXT" over its title, a +n count and ⏭ /// The next item's picture, "UP NEXT" over its title, a +n count and ⏭
/// (play it now) in the player's top bar, one flat control among its /// (play it now) in the player's top bar, one flat control among its
/// buttons; the title opens the queue, resting on it shows a preview card. /// buttons; the title opens the queue, resting on it shows a preview card.
/// Without Up next, the continuation ("NEXT · S1 E8" over its name, ⏭ /// Without Up next, the continuation ("NEXT · S1E8" over its name, ⏭
/// plays it). Nothing when both are empty. /// plays it). Nothing when both are empty.
class UpNextButton extends StatefulWidget { class UpNextButton extends StatefulWidget {
final QueueController queue; final QueueController queue;
/// Plays the continuation's first episode (the next episode).
final VoidCallback onPlayNextEpisode;
final UpNextLook look; final UpNextLook look;
/// Held while the preview card is up, so the controls stay up with it. /// Held while the preview card is up, so the controls stay up with it.
final ControlsHold? hold; final ControlsHold? hold;
const UpNextButton( const UpNextButton({super.key, required this.queue, required this.look, this.hold});
{super.key, required this.queue, required this.onPlayNextEpisode, required this.look, this.hold});
@override @override
State<UpNextButton> createState() => _UpNextButtonState(); State<UpNextButton> createState() => _UpNextButtonState();
@@ -143,9 +139,9 @@ class _UpNextButtonState extends State<UpNextButton> {
final thumbRadius = _minimal ? MinimalStyle.radius - 4 : 6.0; final thumbRadius = _minimal ? MinimalStyle.radius - 4 : 6.0;
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final items = _q.items.value; final items = _q.items.value;
final cont = _q.shownContinuation.value; final auto = _q.continues.value;
final open = _q.popoverOpen.value; final open = _q.popoverOpen.value;
if (items.isEmpty && cont.isEmpty && !open) { if (items.isEmpty && auto == null && !open) {
// Gone with the preview up: let go of the controls. // Gone with the preview up: let go of the controls.
if (_preview.isShowing) { if (_preview.isShowing) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -155,21 +151,15 @@ class _UpNextButtonState extends State<UpNextButton> {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final queued = items.isNotEmpty; final queued = items.isNotEmpty;
final item = items.firstOrNull; final item = items.firstOrNull ?? auto;
final episode = cont.firstOrNull;
final more = items.length - 1; final more = items.length - 1;
final String kicker; final String kicker;
final String title; final String title;
final Widget thumb; final Widget thumb;
if (item != null) { if (item != null) {
kicker = 'UP NEXT'; kicker = queued ? 'UP NEXT' : (item.sub.isEmpty ? 'NEXT' : 'NEXT · ${item.sub}');
title = item.title; title = item.title;
thumb = QueueThumb(queue: _q, item: item, width: thumbWidth, radius: thumbRadius); thumb = QueueThumb(queue: _q, item: item, width: thumbWidth, radius: thumbRadius);
} else if (episode != null) {
final n = episodeNumbers(episode.item);
kicker = n == null ? 'NEXT' : 'NEXT · $n';
title = episode.item.name;
thumb = _EpisodeThumb(queue: _q, episode: episode, width: thumbWidth, radius: thumbRadius);
} else { } else {
kicker = 'UP NEXT'; kicker = 'UP NEXT';
title = 'Nothing yet'; title = 'Nothing yet';
@@ -246,10 +236,8 @@ class _UpNextButtonState extends State<UpNextButton> {
), ),
), ),
); );
final playTip = queued final playTip = 'Play ${item?.title ?? ''} now ($playNextKeys)';
? 'Play ${item!.title} now ($playNextKeys)' final VoidCallback? play = item == null ? null : _q.skip;
: 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)';
final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null);
final playButton = _minimal final playButton = _minimal
? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play) ? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
: Tooltip( : Tooltip(
@@ -337,58 +325,33 @@ class UpNextPreview extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.all(_pad), padding: const EdgeInsets.all(_pad),
child: SignalBuilder(builder: (context) { child: SignalBuilder(builder: (context) {
final item = queue.items.value.firstOrNull; final queued = queue.items.value.firstOrNull;
final episode = queue.shownContinuation.value.firstOrNull; final item = queued ?? queue.continues.value;
final warm = queue.warm.value; final warm = queue.warm.value;
const picture = _width - 2 * _pad; const picture = _width - 2 * _pad;
if (item != null) { if (item == null) return const SizedBox.shrink();
final r = queue.coord.serverState.value; final r = queue.coord.serverState.value;
final selfId = queue.coord.connection.memberId; final selfId = queue.coord.connection.memberId;
final more = queue.items.value.length - 1; final more = queue.items.value.length - 1;
final ready = warm?.id == item.id ? warm!.state : null; final ready = warm?.id == item.id ? warm!.state : null;
return Column( final kicker = queued == null
mainAxisSize: MainAxisSize.min, ? 'CONTINUES WITH'
crossAxisAlignment: CrossAxisAlignment.start, : more > 0
children: [ ? 'UP NEXT · $more MORE AFTER IT'
QueueThumb(queue: queue, item: item, width: picture, radius: 8), : 'UP NEXT';
const SizedBox(height: 10),
Text(more > 0 ? 'UP NEXT · $more MORE AFTER IT' : 'UP NEXT', style: _kicker),
const SizedBox(height: 3),
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
if (item.sub.isNotEmpty)
Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
const SizedBox(height: 6),
_MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
_footer(ready, ready == null ? performerNote(queue, item, r) : null),
],
);
}
if (episode == null) return const SizedBox.shrink();
final n = episodeNumbers(episode.item);
final runtime = episode.pick.runtime;
final sub = [episode.item.seriesName ?? '', if (runtime != null) queueClock(runtime.inSeconds.toDouble())]
.where((s) => s.isNotEmpty)
.join(' · ');
final overview = episode.item.overview?.trim() ?? '';
final ready = warm?.id == 'next:${episode.item.id}' ? warm!.state : null;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_EpisodeThumb(queue: queue, episode: episode, width: picture, radius: 8), QueueThumb(queue: queue, item: item, width: picture, radius: 8),
const SizedBox(height: 10), const SizedBox(height: 10),
Text(n == null ? 'NEXT EPISODE' : 'NEXT EPISODE · $n', style: _kicker), Text(kicker, style: _kicker),
const SizedBox(height: 3), const SizedBox(height: 3),
Text(episode.item.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title), Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
if (sub.isNotEmpty) Text(sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub), if (item.sub.isNotEmpty) Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
if (overview.isNotEmpty) ...[ const SizedBox(height: 6),
const SizedBox(height: 6), _MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
Text(overview, _footer(ready, ready == null ? performerNote(queue, item, r) : null),
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white54, fontSize: 12, height: 1.35)),
],
_footer(ready, null),
], ],
); );
}), }),
@@ -481,8 +444,7 @@ class _EpisodeThumb extends StatelessWidget {
final QueueController queue; final QueueController queue;
final EpisodeLink episode; final EpisodeLink episode;
final double width; final double width;
final double radius; const _EpisodeThumb({required this.queue, required this.episode, required this.width});
const _EpisodeThumb({required this.queue, required this.episode, required this.width, this.radius = 6});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -490,7 +452,7 @@ class _EpisodeThumb extends StatelessWidget {
final client = queue.coord.jellyfin.clientFor(e.pick.serverId); final client = queue.coord.jellyfin.clientFor(e.pick.serverId);
final image = e.item.ownImage(JellyfinImageType.primary) ?? e.item.image(JellyfinImageType.thumb); final image = e.item.ownImage(JellyfinImageType.primary) ?? e.item.image(JellyfinImageType.thumb);
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(radius), borderRadius: BorderRadius.circular(6),
child: SizedBox( child: SizedBox(
width: width, width: width,
height: width * 9 / 16, height: width * 9 / 16,
@@ -838,6 +800,7 @@ class _QueuePanelState extends State<QueuePanel> {
), ),
child: SignalBuilder(builder: (context) { child: SignalBuilder(builder: (context) {
final items = _ordered(_q.items.value); final items = _ordered(_q.items.value);
final auto = _q.continues.value;
final cont = _q.shownContinuation.value; final cont = _q.shownContinuation.value;
final warm = _q.warm.value; final warm = _q.warm.value;
final r = _q.coord.serverState.value; final r = _q.coord.serverState.value;
@@ -848,7 +811,7 @@ class _QueuePanelState extends State<QueuePanel> {
children: [ children: [
_header(items.length), _header(items.length),
Flexible( Flexible(
child: items.isEmpty && cont.isEmpty && _adding.isEmpty child: items.isEmpty && auto == null && cont.isEmpty && _adding.isEmpty
? _empty() ? _empty()
: ListView( : ListView(
shrinkWrap: true, shrinkWrap: true,
@@ -900,7 +863,9 @@ class _QueuePanelState extends State<QueuePanel> {
adding: a, adding: a,
onDismiss: () => setState(() => _adding.remove(a)), onDismiss: () => setState(() => _adding.remove(a)),
), ),
if (cont.isNotEmpty) ..._continuation(cont, items.isEmpty && _adding.isEmpty), if (auto != null || cont.isNotEmpty)
..._continuation(auto, cont, items.isEmpty && _adding.isEmpty,
warm: auto != null && warm?.id == auto.id ? warm!.state : null),
], ],
), ),
), ),
@@ -956,7 +921,9 @@ class _QueuePanelState extends State<QueuePanel> {
), ),
); );
List<Widget> _continuation(List<EpisodeLink> cont, bool alone) => [ /// "Continues with": the continuation everyone shares ([auto], Clear
/// drops it for everyone), then the series' further episodes known here.
List<Widget> _continuation(QueueItem? auto, List<EpisodeLink> cont, bool alone, {WarmState? warm}) => [
Padding( Padding(
padding: EdgeInsets.fromLTRB(12, alone ? 0 : 8, 2, 0), padding: EdgeInsets.fromLTRB(12, alone ? 0 : 8, 2, 0),
child: Row( child: Row(
@@ -964,15 +931,17 @@ class _QueuePanelState extends State<QueuePanel> {
const Text('CONTINUES WITH', const Text('CONTINUES WITH',
style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)), style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)),
const Spacer(), const Spacer(),
TextButton( if (auto != null)
key: const ValueKey('queue-clear-continuation'), TextButton(
style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact), key: const ValueKey('queue-clear-continuation'),
onPressed: _q.clearContinuation, style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact),
child: const Text('Clear', style: TextStyle(fontSize: 12)), onPressed: _q.clearContinuation,
), child: const Text('Clear', style: TextStyle(fontSize: 12)),
),
], ],
), ),
), ),
if (auto != null) _ContinuesRow(item: auto, queue: _q, warm: warm),
for (final e in cont) _ContinuationRow(episode: e, queue: _q), for (final e in cont) _ContinuationRow(episode: e, queue: _q),
]; ];
@@ -1479,6 +1448,76 @@ class _WarmDotState extends State<_WarmDot> with SingleTickerProviderStateMixin
} }
} }
/// The continuation's row (the `auto` item): greyed like what is not
/// queued yet, its picture with how ready it is here, ▶ plays it now.
class _ContinuesRow extends StatefulWidget {
final QueueItem item;
final QueueController queue;
final WarmState? warm;
const _ContinuesRow({required this.item, required this.queue, required this.warm});
@override
State<_ContinuesRow> createState() => _ContinuesRowState();
}
class _ContinuesRowState extends State<_ContinuesRow> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final item = widget.item;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _hover ? 0.95 : 0.7,
child: Container(
key: ValueKey('queue-continues-${item.id}'),
height: queueRowHeight,
padding: const EdgeInsets.only(left: _lead, right: 8),
decoration: BoxDecoration(
color: _hover ? AppMenuStyle.hover : Colors.transparent,
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
),
child: Row(
children: [
Stack(
children: [
QueueThumb(queue: widget.queue, item: item, width: _thumb),
if (widget.warm != null) Positioned(top: 3, right: 3, child: _WarmDot(state: widget.warm!)),
],
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: _titleStyle),
Text(
[item.sub, queueClock(item.duration)].where((s) => s.isNotEmpty).join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _subStyle,
),
],
),
),
_RoundIcon(
key: ValueKey('queue-play-${item.id}'),
icon: Icons.play_arrow_rounded,
tooltip: 'Play now',
onPressed: () => widget.queue.playNow(item.id),
),
],
),
),
),
);
}
}
class _ContinuationRow extends StatefulWidget { class _ContinuationRow extends StatefulWidget {
final EpisodeLink episode; final EpisodeLink episode;
final QueueController queue; final QueueController queue;
@@ -1545,8 +1584,9 @@ class _ContinuationRowState extends State<_ContinuationRow> {
// -------------------------------------------------------------- end card // -------------------------------------------------------------- end card
/// When the media ended with Up next waiting: its picture, title and who /// When the media ended with something next (Up next's first item, else
/// added it, the countdown with Cancel, and Play now. /// the continuation): its picture, title and who added it, the server's
/// countdown with Cancel (for everyone), and Play now.
class UpNextCard extends StatelessWidget { class UpNextCard extends StatelessWidget {
final QueueController queue; final QueueController queue;
final UpNextCountdown countdown; final UpNextCountdown countdown;
@@ -1559,7 +1599,13 @@ class UpNextCard extends StatelessWidget {
final left = c.secondsLeft; final left = c.secondsLeft;
final r = queue.coord.serverState.value; final r = queue.coord.serverState.value;
final selfId = queue.coord.connection.memberId; final selfId = queue.coord.connection.memberId;
final waiting = left == null ? performerNote(queue, item, r) : null; final waitingFor = c.waitingFor;
final waiting = waitingFor != null
? 'Waiting for $waitingFor'
: left == null || left == 0
? performerNote(queue, item, r)
: null;
final what = item.auto ? 'Next' : 'Up next';
return AppGlass( return AppGlass(
key: const ValueKey('up-next-card'), key: const ValueKey('up-next-card'),
child: SizedBox( child: SizedBox(
@@ -1579,7 +1625,7 @@ class UpNextCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(left == null ? 'Up next' : 'Up next in $left s', Text(left == null || left == 0 ? what : '$what in $left s',
style: const TextStyle(color: Colors.white60, fontSize: 12)), style: const TextStyle(color: Colors.white60, fontSize: 12)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(item.title, Text(item.title,
@@ -1605,7 +1651,7 @@ class UpNextCard extends StatelessWidget {
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
child: TweenAnimationBuilder<double>( child: TweenAnimationBuilder<double>(
tween: Tween(end: left / autoAdvanceSecs), tween: Tween(end: left / RelayConstants.advanceSecs),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
builder: (context, v, _) => LinearProgressIndicator( builder: (context, v, _) => LinearProgressIndicator(
value: v, value: v,
@@ -220,8 +220,8 @@ class AutoAdvanceSetting extends StatelessWidget {
key: const ValueKey('auto-advance'), key: const ValueKey('auto-advance'),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Play the next episode automatically'), title: const Text('Play the next episode automatically'),
subtitle: const Text('When a Jellyfin episode ends, a 10 s countdown puts up the next one; ' subtitle: const Text('When you put up an episode (Jellyfin, or a file of a show in Files), the next one '
'anyone can cancel it'), 'continues it after a 10 s countdown everyone sees; anyone can cancel it'),
value: s.autoAdvance.value, value: s.autoAdvance.value,
onChanged: s.setAutoAdvance, onChanged: s.setAutoAdvance,
), ),
@@ -1,8 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../jellyfin/session_media.dart'; import '../../jellyfin/session_media.dart';
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
import '../../widgets/app_menu.dart' show AppGlass;
import 'player_controls.dart' show FloatingPill; import 'player_controls.dart' show FloatingPill;
// Jellyfin pieces of the session player. Plain widgets fed by the // Jellyfin pieces of the session player. Plain widgets fed by the
@@ -24,80 +22,6 @@ class NextEpisodePill extends StatelessWidget {
); );
} }
/// At the end of an episode: "Next: S1E7 title" with Play now, and while
/// the auto-advance countdown runs, the seconds left and Cancel.
class EndOfEpisodeCard extends StatelessWidget {
final EpisodeLink next;
final AutoAdvance? countdown;
final VoidCallback onPlayNow;
final VoidCallback onCancel;
const EndOfEpisodeCard({
super.key,
required this.next,
required this.countdown,
required this.onPlayNow,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
final c = countdown;
// The same card as Up next's (`UpNextCard`): the app's glass, 400 wide.
return AppGlass(
key: const ValueKey('end-of-episode'),
child: SizedBox(
width: 400,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s',
style: const TextStyle(color: Colors.white60, fontSize: 12)),
const SizedBox(height: 2),
Text('Next: ${next.label}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, height: 1.25)),
if (c != null) ...[
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: TweenAnimationBuilder<double>(
tween: Tween(end: c.secondsLeft / autoAdvanceSecs),
duration: const Duration(seconds: 1),
builder: (context, v, _) =>
LinearProgressIndicator(value: v, minHeight: 3, backgroundColor: const Color(0x1FFFFFFF)),
),
),
],
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (c != null)
TextButton(
key: const ValueKey('auto-advance-cancel'),
onPressed: onCancel,
child: const Text('Cancel')),
const SizedBox(width: 8),
FilledButton.icon(
key: const ValueKey('play-next'),
onPressed: onPlayNow,
icon: const Icon(Icons.skip_next_rounded, size: 18),
label: Text(c == null ? 'Play next' : 'Play now'),
),
],
),
],
),
),
),
);
}
}
/// This viewer cannot play the Jellyfin media: say why and offer to link /// This viewer cannot play the Jellyfin media: say why and offer to link
/// an account (never a silent stall). /// an account (never a silent stall).
class JellyfinBlockedCard extends StatelessWidget { class JellyfinBlockedCard extends StatelessWidget {
+2 -1
View File
@@ -391,7 +391,8 @@ class Connection {
void sendReady(bool ready, {String source = 'manual'}) => client.sendReady(ready, source: source); void sendReady(bool ready, {String source = 'manual'}) => client.sendReady(ready, source: source);
void sendForce(String target, bool ready) => client.sendForce(target, ready); void sendForce(String target, bool ready) => client.sendForce(target, ready);
void sendSeek(double position, {int seq = 0}) => client.sendSeek(position, seq: seq); void sendSeek(double position, {int seq = 0}) => client.sendSeek(position, seq: seq);
void sendSwap(HelloMedia media, {String? queueId}) => client.sendSwap(media, queueId: queueId); void sendSwap(HelloMedia media, {String? queueId, SwapRestore? restore}) =>
client.sendSwap(media, queueId: queueId, restore: restore);
void sendQueue(QueueIntent intent) => client.sendQueue(intent); void sendQueue(QueueIntent intent) => client.sendQueue(intent);
void sendReport({ void sendReport({
required double position, required double position,
@@ -0,0 +1,899 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart' show debugPrint, mapEquals, visibleForTesting;
import 'package:signals/signals.dart';
import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart';
import '../platform/ports/link_resolver.dart';
import '../platform/ports/player_host.dart';
import '../playback/playback_controller.dart';
import '../sync/state_diff.dart';
import 'app_events.dart';
import 'delivery.dart' show stateMediaRelay;
import 'media_plan.dart';
import 'relay_feeder.dart';
import 'switch_trace.dart';
export 'media_plan.dart';
final _log = createLogger('session');
// The one way to open media: plan → resolve → open → ready.
//
// - plan ([planMedia]): from the state, what this member serves and its
// capabilities, where this app reads the media from.
// - resolve: a Jellyfin item through this app's account, the server's
// login or the member passing it on ([openJellyfin]); a web page through
// this app's yt-dlp ([LinkResolver]: its stream URLs, an EDL of video and
// audio, the CDN's headers, its subtitles; mpv's own yt-dlp hook is
// off); a relay waits until it is fed.
// - open: a player through the [PlayerHost]: the prepared one (Up next's
// preload) promoted, else a fresh one per media; the same media again
// keeps its player.
// - ready: the buffer the gate needs is reported at once (the
// coordinator's report).
//
// Being a relay source ([RelayFeeder]) and the viewer's side of a source
// that drops ([recover]) live here too.
/// What the coordinator does next after [MediaPipeline.switchTo]: a
/// Jellyfin item resolves and opens through [MediaPipeline.openJellyfin]
/// (the coordinator keeps its per-item work: progress, episodes, tracks).
sealed class SwitchResult {
const SwitchResult();
}
final class SwitchDone extends SwitchResult {
const SwitchDone();
}
final class SwitchJellyfin extends SwitchResult {
final JellyfinSource source;
const SwitchJellyfin(this.source);
}
/// The session context the pipeline reads (the coordinator's connection
/// and accounts).
class MediaSession {
final ServerAddress Function() server;
final String Function() selfId;
final ClientCaps Function() caps;
final JellyfinAccounts jellyfin;
const MediaSession({required this.server, required this.selfId, required this.caps, required this.jellyfin});
static final none = MediaSession(
server: () => const ServerAddress('', 0, ''),
selfId: () => '',
caps: () => const ClientCaps(),
jellyfin: JellyfinAccounts(store: MemoryJellyfinStore()));
}
class MediaPipeline {
final PlayerHost host;
final LinkResolver? links;
final PlaybackController playback;
final SwitchTrace trace;
late final RelayFeeder feeder;
MediaSession session;
/// The player could not open the current media (raw words: the
/// coordinator turns them into `report.error`; [cookies]: which ones a
/// page's resolve used, D11).
void Function(String raw, {String? cookies}) onError = (_, {cookies}) {};
void Function(AppEvent event) notify = (_) {};
/// A media opened in the session player ([open]): the coordinator
/// reports at once (a promoted player already has its buffer).
void Function(int mediaId, String url) onOpened = (_, _) {};
MediaPipeline({
required this.host,
this.links,
PlaybackController? playback,
SwitchTrace? trace,
MediaSession? session,
}) : playback = playback ?? PlaybackController(host),
trace = trace ?? SwitchTrace(),
session = session ?? MediaSession.none {
feeder = RelayFeeder(server: () => this.session.server(), notify: (e) => notify(e));
}
// ------------------------------------------------------------ players
// The current player has had media opened in it (one fresh from
// [start] has not, so the first open after join or Reload video uses
// it).
bool _held = false;
/// A live session player exists.
bool get ready => playback.alive.peek();
/// The session's first player (on join); each new media gets its own.
Future<void> start() async {
if (playback.current != null) return;
final p = await host.create(PlayerRole.session);
_held = false;
playback.attach(p);
}
/// "Reload video": a fresh player and video output, the current media
/// opened again (the caller resyncs).
Future<void> reload() async {
final old = playback.detach();
if (old != null) await host.retire(old);
await start();
final url = _openUrl, headers = _openHeaders;
_openUrl = null;
// A page is resolved again: its stream URLs may have expired.
if (url != null) await open(url, headers: headers, fresh: true, resolve: _openResolve);
}
/// Leave: the prepared player and the session player go.
Future<void> release() async {
await dropPrepared();
final old = playback.detach();
_held = false;
if (old != null) await host.retire(old);
}
/// App close: every player goes now (no mpv may outlive the engine).
Future<void> disposeAll() async {
_pre = null;
playback.detach();
await host.disposeAll();
}
/// Replaces the current player with a fresh one (pins, hwdec, volume,
/// yt-dlp hook, shaders), its video output ready before the view swaps
/// to it when the view is up. True once it is the session player.
Future<bool> _freshPlayer() async {
await host.retiring;
final old = playback.current;
if (old == null) return false;
final HostedPlayer p;
try {
p = await host.create(PlayerRole.session);
} catch (e) {
debugPrint('fresh player: $e');
return false;
}
HostedVideo? video;
try {
// The view is up: the new output is created (and its texture
// registered) before the view swaps, so the old picture stays until
// then. Made by the view instead while the render mode is unknown,
// so the probe hears its GL init.
if (playback.hasVideo && host.renderMode.peek() != RenderMode.unknown) {
video = host.video(p);
await video.ready;
}
} catch (e) {
debugPrint('fresh player setup: $e');
}
// Released or reloaded meanwhile: that player stays.
if (!identical(old, playback.current)) {
await host.dispose(p);
return false;
}
final prev = playback.attach(p, video: video);
_held = true;
if (prev != null) unawaited(host.retire(prev));
return true;
}
// ------------------------------------------------------------ resolve
/// [url] is a web page this app resolves: http(s), not this server, not
/// named like a media file.
bool _isPage(String url) {
final uri = Uri.tryParse(url);
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return false;
final s = session.server();
if (uri.host == s.host && uri.port == s.port) return false;
return !hasMediaExtension(uri);
}
/// The resolve step for a link: a page through this app's yt-dlp (the
/// probe, remembered a while; [fresh] asks again: stream URLs expire)
/// gives what a player opens; a media file is opened as is, and so is a
/// stream opened with headers (a Jellyfin item's, with its token). Throws
/// a [ResolveFailure] in plain words, with the cookies used (D11).
Future<_Target> _resolve(String url, Map<String, String>? headers, {bool fresh = false}) async {
if (!_isPage(url) || (headers != null && headers.isNotEmpty)) return _Target(url, headers ?? const {});
final l = links;
if (l == null) throw const ResolveFailure('no link resolver here');
final LinkProbe probe;
try {
probe = await l.probe(url, fresh: fresh).timeout(const Duration(seconds: 60));
} catch (e) {
throw ResolveFailure('$e');
}
if (!probe.isPage) return _Target(url, headers ?? const {});
final play = probe.info?.play;
if (play == null) {
throw ResolveFailure(probe.ytDlpError ?? (probe.info == null ? 'Not a video page yt-dlp knows' : 'no format a player opens'),
cookies: probe.cookies);
}
return _Target(play.mpvUrl, {...play.headers, ...?headers}, subtitles: play.subtitles);
}
/// The page's subtitles on [p] once its media is in (not selected: the
/// usual choice applies).
Future<void> _addSubtitles(HostedPlayer p, List<YtDlpSubtitle> subs) async {
if (subs.isEmpty) return;
final end = DateTime.now().add(const Duration(seconds: 30));
while (p.state.duration <= Duration.zero) {
if (p.disposed || DateTime.now().isAfter(end)) return;
await Future.delayed(const Duration(milliseconds: 200));
}
for (final sub in subs) {
try {
await p.command(['sub-add', sub.url, 'auto', sub.name.isEmpty ? sub.lang : sub.name, sub.lang]);
} catch (e) {
debugPrint('subtitle ${sub.lang}: $e');
}
}
}
// --------------------------------------------------------------- open
String? _openUrl;
Map<String, String>? _openHeaders;
// [_openUrl] went through the page resolve (not a Jellyfin stream).
bool _openResolve = true;
bool _opening = false;
int _opens = 0;
/// The URL open (or opening) in the player; null for none.
String? get openUrl => _openUrl;
/// An open is in flight: the player's duration may still be the
/// previous media's.
bool get opening => _opening;
/// How many times this session opened a media (a drop and return must
/// not add one).
int get opens => _opens;
/// The mediaId whose media the player holds (or is opening); -1 none.
int playerMediaId = -1;
// [playerMediaId] at the last open: the same media again (a reconnect,
// joining back) keeps the player, any other gets a fresh one.
int _openedFor = -1;
int _mediaId = -1;
/// The media of the latest state is open here (or has nothing to open,
/// could not, or waits for the server): its mediaId. Up next prepares
/// what comes next only then, so the two never compete.
final settledMediaId = signal(-1);
/// Opens [url] (with [headers] on every request) in the session player:
/// the prepared player when it holds the same, else a fresh one when the
/// player held other media. False on failure (the error went to
/// [onError]). [resolve]: a web page is resolved first (not for a
/// Jellyfin stream).
Future<bool> open(String url, {Map<String, String>? headers, bool fresh = false, bool resolve = true}) async {
if (url == _openUrl) return true;
_opens++;
final mediaId = _mediaId;
_log.info('media $mediaId: open ${_urlKind(url, headers)}: ${logUrl(url)}'
'${headers == null || headers.isEmpty ? '' : ' (headers: ${headers.keys.join(', ')})'}');
final keepPlayer = playerMediaId == _openedFor;
_openedFor = playerMediaId;
_openUrl = url;
_openHeaders = headers;
_openResolve = resolve;
_opening = true;
try {
trace.mark(mediaId, SwitchTrace.open);
final promoted = await _openInPlayer(url, headers,
keepPlayer: keepPlayer,
fresh: fresh,
resolve: resolve,
onFresh: () => trace.mark(mediaId, SwitchTrace.player));
trace.mark(mediaId, promoted ? SwitchTrace.preloaded : SwitchTrace.opened);
_opening = false;
onOpened(mediaId, url);
return true;
} on ResolveFailure catch (e) {
_log.warning('media $mediaId: cannot resolve ${logUrl(url)}: ${e.message}${e.cookies == null ? '' : ' (${e.cookies})'}');
onError(e.message, cookies: e.cookies);
return false;
} catch (e) {
debugPrint('open media failed: $e');
onError('$e');
return false;
} finally {
_opening = false;
}
}
Future<bool> _openInPlayer(String url, Map<String, String>? headers,
{required bool keepPlayer, bool fresh = false, bool resolve = true, void Function()? onFresh}) async {
if (playback.current == null) return false;
final pre = _pre;
if (pre != null && pre.matches(url, headers) && await _promote(pre)) return true;
final target = resolve ? await _resolve(url, headers, fresh: fresh) : _Target(url, headers ?? const {});
if (!identical(target.url, url)) trace.mark(_mediaId, SwitchTrace.resolved);
if (_held && !keepPlayer && await _freshPlayer()) onFresh?.call();
final p = playback.current;
if (p == null) return false;
_held = true;
playback.beginOpen();
await p.open(target.url, headers: target.headers);
unawaited(_addSubtitles(p, target.subtitles));
return false;
}
/// What [url] is, for the log.
String _urlKind(String url, Map<String, String>? headers) {
if (url.startsWith('edl://')) return 'relay (tracks)';
final u = Uri.tryParse(url);
if (u == null || !(u.isScheme('http') || u.isScheme('https'))) return 'local file';
final s = session.server();
if (u.host == s.host && u.port == s.port && u.pathSegments.isNotEmpty) {
return switch (u.pathSegments.first) {
'r' => 'relay',
'm' => "the host's file",
'j' => "Jellyfin through the server's login",
_ => 'server URL',
};
}
if (headers != null && headers.isNotEmpty) return 'Jellyfin, own sign-in';
return 'link, fetched here';
}
// ----------------------------------------------------- plan and switch
// What this member serves: set at its swap, claimed by the state that
// names it as the relay's source.
MediaOrigin? _pendingOrigin;
(int, MediaOrigin)? _activeOrigin;
// The relayed media this viewer opens once it is live.
int _awaitLiveFor = -1;
/// This member's swap is about to be relayed from [origin].
void expectOrigin(MediaOrigin origin) => _pendingOrigin = origin;
/// Where this app reads [r]'s media from (claims a pending origin when
/// the state names this member as the source).
SourcePlan plan(StateBroadcast r) {
final relay = stateMediaRelay(r);
final self = session.selfId();
if (relay != null && relay.source.isNotEmpty && relay.source == self) {
final pending = _pendingOrigin;
if (pending != null) {
_pendingOrigin = null;
_activeOrigin = (r.mediaId, pending);
}
}
final active = _activeOrigin;
return planMedia(PlanInput(
state: r,
selfId: self,
caps: session.caps(),
server: session.server(),
origin: active != null && active.$1 == r.mediaId ? active.$2 : null,
ownJellyfin: (id) =>
session.jellyfin.account(id) != null &&
session.jellyfin.statusOf(id) != JellyfinAccountStatus.expired,
));
}
/// A new media [r.mediaId]: the previous one leaves the player at once
/// (nobody sees or hears it while the new one resolves, is fetched or
/// opens), then plan → resolve → open. A Jellyfin item comes back to
/// the coordinator ([SwitchJellyfin]).
Future<SwitchResult> switchTo(StateBroadcast r) async {
_mediaId = r.mediaId;
_awaitLiveFor = -1;
_recovery = null;
final source = plan(r);
if (feeder.mediaId != r.mediaId) feeder.stop();
_jellyfinFeed = null;
await leavePrevious(r.mediaId);
final name = (r.media?['name'] as String?) ?? '';
final queueId = stateMediaRelay(r)?.queueId ?? '';
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
switch (source) {
case JellyfinSource():
return SwitchJellyfin(source);
case OwnFile(:final path):
// The source first: viewers wait on it, not on this player.
feeder.serve(r.mediaId, RelayMedia.single(FileRelayBytes(File(path))),
announce: true, queueId: queueId, name: name, duration: duration);
await open(path);
case OwnLink(:final url, :final origin):
feeder.serve(r.mediaId, LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
announce: true, queueId: queueId, name: name, duration: duration);
await open(url);
case RelayedSource(:final url, :final waiting, :final fetch, :final sourceId):
if (waiting) {
// The server is still getting it (yt-dlp takes seconds), or the
// member passing it on has not dialled in yet: mpv gives up on a
// silent request after 5 s, so open once it is live.
_openUrl = null;
_awaitLiveFor = r.mediaId;
final who = r.members.where((m) => m.id == sourceId).firstOrNull?.name;
_log.info('media ${r.mediaId}: waiting for ${fetch ? 'the server' : who} before opening the relay');
} else {
await open(url);
}
case Unresolvable(:final reason):
_openUrl = null;
onError(reason);
case ServerFile(:final url) || DirectLink(:final url):
await open(url);
case NoSource():
_openUrl = null;
}
return const SwitchDone();
}
/// The previous media leaves the player at once; a reconnect to the
/// media the player holds keeps it.
Future<void> leavePrevious(int mediaId) async {
final held = playerMediaId;
playerMediaId = mediaId;
// Opened from the welcome, before any state: that was this media.
if (held == -1 && _openedFor == -1 && _openUrl != null) _openedFor = mediaId;
if (held == -1 || held == mediaId) return;
_openUrl = null;
_openHeaders = null;
await playback.stop();
}
/// The same media came back as [r.mediaId] (a restarted server put back
/// what played, `swap.restore`) and the player keeps it open: what this
/// member passes on is served again (its file, its link, the Jellyfin
/// item played with its own account: [ownJellyfin]).
void resumeAs(StateBroadcast r, {({String url, Map<String, String> headers})? ownJellyfin}) {
renumber(r.mediaId);
settledMediaId.value = r.mediaId;
final source = plan(r);
final name = (r.media?['name'] as String?) ?? '';
final queueId = stateMediaRelay(r)?.queueId ?? '';
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
switch (source) {
case OwnFile(:final path):
feeder.serve(r.mediaId, RelayMedia.single(FileRelayBytes(File(path))),
queueId: queueId, name: name, duration: duration);
case OwnLink(:final url, :final origin):
feeder.serve(r.mediaId, LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
queueId: queueId, name: name, duration: duration);
case JellyfinSource(:final ref, :final origin):
if (origin == null || ownJellyfin == null) return;
final media = RelayMedia.single(HttpRelayBytes(Uri.parse(ownJellyfin.url), headers: ownJellyfin.headers));
if (jellyfinRelayNeeded(r, ref, session.selfId())) {
feeder.serve(r.mediaId, media, queueId: queueId, name: name, duration: duration);
} else {
_jellyfinFeed = (mediaId: r.mediaId, ref: ref, media: media, queueId: queueId, name: name, duration: duration);
}
default:
break;
}
}
/// The media was settled here (opened, failed, nothing to open).
void settled(int mediaId) {
if (_mediaId == mediaId) settledMediaId.value = mediaId;
}
/// Same media, a new state: a relay that became live opens, a relay
/// opened while its source was away and never loaded opens again now
/// that it is back (D6), a Jellyfin stream this member holds back starts
/// once someone needs it.
Future<void> onState(StateBroadcast r) async {
final rec = _recovery;
final relay = stateMediaRelay(r);
final url = _openUrl;
if (rec != null && rec.mediaId == r.mediaId && relay != null && relay.live && !relay.sourceGone &&
url != null && !_opening && playback.durationSecs <= 0) {
_recovery = null;
_log.info('media ${r.mediaId}: the relay has its source again; opening it again (it never loaded)');
_openUrl = null;
_openedFor = -2; // a fresh player: the old one may be stuck in a broken open
await open(url, headers: _openHeaders, resolve: _openResolve);
}
if (_awaitLiveFor == r.mediaId) {
final source = plan(r);
if (source is RelayedSource && !source.waiting) {
_awaitLiveFor = -1;
await open(source.url);
}
}
final feed = _jellyfinFeed;
if (feed != null && feed.mediaId == r.mediaId && jellyfinRelayNeeded(r, feed.ref, session.selfId())) {
_jellyfinFeed = null;
_log.info('media ${r.mediaId}: someone needs the Jellyfin item passed on, starting the relay');
feeder.serve(r.mediaId, feed.media, queueId: feed.queueId, name: feed.name, duration: feed.duration);
}
}
/// Sitting out: nothing open, nothing pulled. A relay this member serves
/// goes on (it does not use the player).
Future<void> unload() async {
_awaitLiveFor = -1;
if (_mediaId == -1 && _openUrl == null) return;
_mediaId = -1;
playerMediaId = -1;
_openUrl = null;
_openHeaders = null;
_recovery = null;
if (ready) await playback.stop();
}
/// The next state's media is new to this player: it opens again from
/// scratch (a relay this member served for the old one stops).
void forget() {
_mediaId = -1;
playerMediaId = -1;
_openUrl = null;
_openHeaders = null;
_awaitLiveFor = -1;
_recovery = null;
_activeOrigin = null;
_jellyfinFeed = null;
feeder.stopAll();
}
/// The same media under a restarted server's numbering: the player keeps
/// it.
void renumber(int mediaId) => _mediaId = playerMediaId = _openedFor = mediaId;
/// A new session (connect): nothing held, nothing served.
void reset() {
_mediaId = -1;
playerMediaId = -1;
_openedFor = -1;
_openUrl = null;
_openHeaders = null;
_awaitLiveFor = -1;
_recovery = null;
_pendingOrigin = null;
_activeOrigin = null;
_jellyfinFeed = null;
settledMediaId.value = -1;
feeder.stopAll();
}
// ----------------------------------------------------------- Jellyfin
// A Jellyfin stream this member holds back: nobody present needs it
// passed on yet.
({int mediaId, JellyfinRef ref, RelayMedia media, String queueId, String name, double duration})? _jellyfinFeed;
/// The resolve step of a Jellyfin item: this app's account (or the
/// [warm] plan resolved ahead), else the server's login, else the member
/// passing it on, else blocked. This member's own item is passed on to
/// viewers without an account once someone needs it. Then it opens.
/// Null when [current] went false meanwhile.
Future<JellyfinPlan?> openJellyfin(JellyfinSource source, StateBroadcast r,
{OwnPlay? warm, required bool Function() current}) async {
// Until it resolves nothing is open: no syncing the previous media.
final prev = _openUrl;
_openUrl = null;
_opening = true;
if (ready) {
try {
await playback.player.pause();
} catch (_) {}
}
JellyfinPlan plan;
try {
plan = warm ??
await resolveJellyfinRef(session.jellyfin, source.ref,
relay: source.serverLogin != null, relayUrl: source.serverLogin, memberRelayUrl: source.memberRelay);
} finally {
if (current()) _opening = false;
}
if (!current()) return null;
trace.mark(r.mediaId, SwitchTrace.resolved);
final origin = source.origin;
if (origin != null) {
final name = (r.media?['name'] as String?) ?? '';
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
final queueId = stateMediaRelay(r)?.queueId ?? '';
RelayMedia? media;
if (plan is OwnPlay) {
media = RelayMedia.single(HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers));
} else if (origin.link != null) {
plan = RelayPlay(origin.link!);
media = RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!)));
}
if (media != null) {
if (jellyfinRelayNeeded(r, source.ref, session.selfId())) {
feeder.serve(r.mediaId, media, queueId: queueId, name: name, duration: duration);
} else {
_jellyfinFeed =
(mediaId: r.mediaId, ref: source.ref, media: media, queueId: queueId, name: name, duration: duration);
}
}
}
switch (plan) {
case BlockedPlay():
await playback.stop();
case RelayPlay(:final url):
// The same relay URL (a resumed member): the player still has it.
if (url == prev) {
_openUrl = url;
} else if (!await open(url, resolve: false)) {
return current() ? plan : null;
}
case OwnPlay(:final url, :final headers):
if (!await open(url, headers: headers, resolve: false)) return current() ? plan : null;
}
return current() ? plan : null;
}
// ------------------------------------------------------ recovery (D6)
// The relay this viewer reads lost its source and the buffer ran dry:
// what it waits for.
_Recovery? _recovery;
/// This viewer reads the current media through a relay whose source is
/// gone, or left: it plays what the server has.
bool get sourceDown => _recovery != null;
/// The user sought to [to] (applied at once): while the source is down,
/// that is the one try.
void noteUserSeek(double to) {
final rec = _recovery;
if (rec == null) return;
rec.tried = true;
rec.tryTo = to;
}
/// While the source is down, this player cannot be where the server is
/// ([serverPosition]): a seek waits, or the player is stuck short of it.
/// The report then says nothing is buffered, so the server waits for the
/// source instead of playing on without this viewer.
bool starved(double serverPosition) {
final rec = _recovery;
if (rec == null) return false;
return rec.seekTo != null || (playback.positionSecs - serverPosition).abs() > 3;
}
/// Seeks while the source is down, not applied (tests: never a loop).
@visibleForTesting
int heldBackSeeks = 0;
/// The viewer's side of a source that drops (D6): while the relay this
/// app reads has no source, it plays what is buffered. A seek past it
/// (the server's buffer may still hold it) is tried once; any further
/// one waits (mpv would wait on bytes nobody sends, time out after 5 s
/// and be asked again: the seek loop), and the report says nothing is
/// buffered ([starved]), so the server holds everyone with the reason
/// `source` ("waiting for Bob"). When the source is back, one seek to
/// where the server is resumes the player. Returns the [actions] to
/// apply.
List<PlayerAction> recover(StateBroadcast r, List<PlayerAction> actions, {required double serverPosition}) {
final relay = stateMediaRelay(r);
final url = _openUrl;
final viaRelay = url != null && (url.startsWith('edl://') || Uri.tryParse(url)?.pathSegments.firstOrNull == 'r');
final down = relay != null &&
viaRelay &&
!relay.fetch &&
relay.source != session.selfId() &&
(relay.sourceGone || !relay.live);
final rec = _recovery;
if (down) {
final at = playback.positionSecs;
final reach = at + playback.bufferedSecsAhead;
final now = _recovery ??= _Recovery(r.mediaId);
if (rec == null) {
_log.info('media ${r.mediaId}: the relay has no source; playing what is buffered '
'(to ${reach.toStringAsFixed(1)} s)');
}
final out = <PlayerAction>[];
for (final a in actions) {
if (a is SeekAction && (a.position > reach + 0.5 || a.position < at - 30)) {
final last = now.tryTo;
if (!now.tried && (last == null || (a.position - last).abs() > 1.5)) {
now.tried = true;
now.tryTo = a.position;
out.add(a);
continue;
}
heldBackSeeks++;
now.seekTo = a.position;
} else {
out.add(a);
}
}
return out;
}
_recovery = null;
if (rec == null || rec.mediaId != r.mediaId) return actions;
final stuck = rec.seekTo != null ||
(playback.positionSecs - serverPosition).abs() > 1.0 ||
playback.bufferedSecsAhead < 1;
_log.info('media ${r.mediaId}: the relay has its source again'
'${stuck ? '; resuming at ${serverPosition.toStringAsFixed(1)} s' : ''}');
if (!stuck) return actions;
// One seek to where the server is: the demuxer reads from there again.
return [
for (final a in actions) if (a is! SeekAction) a,
SeekAction(serverPosition),
];
}
// ------------------------------------------------------------ prepare
/// Preparing on (env `SYNC_RELAY_PRELOAD=0` turns it off; tests set it).
bool preloadEnabled = Platform.environment['SYNC_RELAY_PRELOAD'] != '0';
_Prepared? _pre;
/// The URL prepared now (null: none).
String? get preloadUrl => _pre?.url;
/// This viewer's URL of the warm relay of Up next's first [item]: the
/// same URL the media has once the item goes up, so the prepared player
/// is promoted.
String warmRelayUrl(QueueItem item, MediaRelay relay) =>
session.server().relayUrl(item.media.name.isEmpty ? item.title : item.media.name, item.id, relay.tracks);
/// Opens [url] early in a hidden player (paused at the start, muted, a
/// small cache, its own video output when the view is up), replacing any
/// other; completes once [need] seconds are buffered or the whole media
/// is: true, false when it failed (or was dropped or replaced
/// meanwhile). The same [url] and [headers] again is the same one unless
/// [fresh]. [open] for the same URL promotes it.
Future<bool> prepare(String url, {Map<String, String>? headers, double need = 4, bool fresh = false}) async {
if (!preloadEnabled || !ready) return false;
final had = _pre;
if (had != null && had.matches(url, headers) && !had.failed && !fresh) return had.ready.future;
// Set before any await: a second call for the same URL finds it.
_pre = null;
if (had != null) unawaited(_disposePrepared(had));
final pre = _pre = _Prepared(url, headers ?? const {});
try {
final target = await _resolve(url, headers, fresh: fresh);
if (!identical(_pre, pre)) {
pre.fail();
return false;
}
final p = pre.player = await host.create(PlayerRole.preload);
pre.logSub = p.logs.listen((log) {
if (mpvLoadFailed(log, p.state.duration)) pre.fail();
});
pre.subtitles = target.subtitles;
if (playback.hasVideo) {
pre.video = host.video(p);
await pre.video!.ready;
}
if (!identical(_pre, pre)) {
pre.fail();
await _disposePrepared(pre);
return false;
}
await p.open(target.url, headers: target.headers);
if (!pre.opened.isCompleted) pre.opened.complete();
} catch (e) {
debugPrint('preload failed: $e');
pre.fail();
if (identical(_pre, pre)) _pre = null;
await _disposePrepared(pre);
return false;
}
unawaited(_watchPrepared(pre, need));
return pre.ready.future;
}
Future<void> _watchPrepared(_Prepared pre, double need) async {
final end = DateTime.now().add(const Duration(seconds: 60));
while (identical(_pre, pre) && !pre.failed && DateTime.now().isBefore(end)) {
final st = pre.player!.state;
final secs = st.duration.inMilliseconds / 1000;
final buffered = (st.buffer - st.position).inMilliseconds / 1000;
if (secs > 0 && (buffered >= need || st.buffer.inMilliseconds / 1000 >= secs - 0.5)) {
if (!pre.ready.isCompleted) pre.ready.complete(true);
return;
}
await Future.delayed(const Duration(milliseconds: 200));
}
if (!pre.ready.isCompleted) pre.ready.complete(false);
}
/// Drops the prepared player (nothing to get ready, or something else
/// is).
Future<void> dropPrepared() async {
final pre = _pre;
if (pre == null) return;
_pre = null;
await _disposePrepared(pre);
}
Future<void> _disposePrepared(_Prepared pre) async {
pre.fail();
await pre.logSub?.cancel();
final p = pre.player;
if (p != null) await host.dispose(p);
}
/// [pre] becomes the session player; its buffer comes along.
Future<bool> _promote(_Prepared pre) async {
await pre.opened.future;
final old = playback.current;
if (!identical(_pre, pre) || old == null) return false;
_pre = null;
final p = pre.player;
if (pre.failed || p == null) {
unawaited(_disposePrepared(pre));
return false;
}
await pre.logSub?.cancel();
if (!pre.ready.isCompleted) pre.ready.complete(true);
await host.promote(p);
final prev = playback.attach(p, video: pre.video, loaded: true);
_held = true;
unawaited(_addSubtitles(p, pre.subtitles));
if (prev != null) unawaited(host.retire(prev));
return true;
}
Future<void> dispose() async {
await release();
feeder.stopAll();
await playback.dispose();
}
}
class _Recovery {
final int mediaId;
/// The latest seek held back (where the server went meanwhile).
double? seekTo;
/// The one seek past the buffer was tried ([tryTo]: where to).
bool tried = false;
double? tryTo;
_Recovery(this.mediaId);
}
class _Prepared {
final String url;
final Map<String, String> headers;
HostedPlayer? player;
HostedVideo? video;
StreamSubscription<PlayerLogLine>? logSub;
List<YtDlpSubtitle> subtitles = const [];
/// Completes once the media is handed to mpv (or it failed).
final opened = Completer<void>();
bool failed = false;
final ready = Completer<bool>();
_Prepared(this.url, this.headers);
bool matches(String url, Map<String, String>? headers) =>
url == this.url && mapEquals(headers ?? const {}, this.headers);
void fail() {
failed = true;
if (!ready.isCompleted) ready.complete(false);
if (!opened.isCompleted) opened.complete();
}
}
/// What a player opens for a link (the resolve step).
class _Target {
final String url;
final Map<String, String> headers;
final List<YtDlpSubtitle> subtitles;
const _Target(this.url, this.headers, {this.subtitles = const []});
}
/// A page could not be resolved: why ([message], yt-dlp's words) and which
/// cookies that used ([cookies], D11).
class ResolveFailure implements Exception {
final String message;
final String? cookies;
const ResolveFailure(this.message, {this.cookies});
@override
String toString() => message;
}
@@ -0,0 +1,230 @@
import 'package:sync_relay_core/core.dart';
import '../jellyfin/session_media.dart' show refOfStateMedia, relayUrlFor;
import 'delivery.dart' show stateMediaRelay;
import 'party.dart' show isPresent;
// The plan step of the media pipeline: from the server's state, what this
// member serves, its own capabilities and who is present, where this app
// reads the current media from. One switch, pure: `media_plan_test` holds
// the table.
/// What this member serves for the media it put up (set at its swap,
/// claimed by the state that names it as the relay's source).
sealed class MediaOrigin {
const MediaOrigin();
}
/// A file on this computer, passed on through the relay.
final class FileOrigin extends MediaOrigin {
final String path;
const FileOrigin(this.path);
}
/// A link this computer reads and passes on ("through me").
final class LinkOrigin extends MediaOrigin {
final String url;
/// What the link is (a page goes through [ytDlp]).
final LinkProbe probe;
final YtDlp ytDlp;
const LinkOrigin(this.url, this.probe, this.ytDlp);
}
/// A Jellyfin item: served with this install's account, else with the
/// pasted [link] (its api_key stays on this machine).
final class JellyfinOrigin extends MediaOrigin {
final String? link;
const JellyfinOrigin(this.link);
}
/// Where this app's server is, as this member dialled it.
class ServerAddress {
final String host;
final int port;
final String token;
const ServerAddress(this.host, this.port, this.token);
/// This viewer's `/m` URL of your server's file [name].
String fileUrl(String name) => 'http://$host:$port/m/default/$token/${Uri.encodeComponent(name)}';
/// This viewer's `/r` URL of a relay ([key]: the media id, or the queue
/// id of one started in Up next); with tracks, a pieces playlist or an
/// mpv EDL of every track.
String relayUrl(String name, String key, [List<RelayTrackSpec> tracks = const []]) =>
relayViewerUrl('http://$host:$port/r/default/$token/$key', name, tracks: tracks);
/// This member's `/j` URL of [ref] (the server's Jellyfin login).
String jellyfinUrl(JellyfinRef ref) => relayUrlFor(host, port, token, ref.itemId);
/// The relay channel this member feeds ([queueId]: the warm relay of
/// that Up next item).
Uri sourceChannel([String queueId = '']) => Uri.parse('ws://$host:$port/r/default/$token/source'
'${queueId.isEmpty ? '' : '?q=${Uri.encodeQueryComponent(queueId)}'}');
}
/// Where this app reads the current media from.
sealed class SourcePlan {
const SourcePlan();
/// The URL a player opens; null when there is nothing to open yet.
String? get url => null;
}
/// No media, or nothing this app can open (yet).
final class NoSource extends SourcePlan {
const NoSource();
}
/// This member passes its file on: the player plays the file itself, the
/// relay source feeds the server.
final class OwnFile extends SourcePlan {
final String path;
const OwnFile(this.path);
@override
String get url => path;
}
/// This member passes a link on: it plays the link itself (a page through
/// its yt-dlp), the relay source reads it for the server.
final class OwnLink extends SourcePlan {
@override
final String url;
final LinkOrigin origin;
const OwnLink(this.url, this.origin);
}
/// Your server's own file (`/m`).
final class ServerFile extends SourcePlan {
@override
final String url;
const ServerFile(this.url);
}
/// A link everyone opens itself; [page]: a web page, resolved by this
/// app's yt-dlp (the pipeline's resolve step).
final class DirectLink extends SourcePlan {
@override
final String url;
final bool page;
const DirectLink(this.url, {required this.page});
}
/// The server's relay (`/r`): a member passing it on ([sourceId]) or the
/// server's own fetch ([fetch]). [waiting]: nobody feeds it yet, so it opens
/// once `relay.live` (mpv gives up on a silent request after 5 s).
final class RelayedSource extends SourcePlan {
@override
final String url;
final bool fetch;
final String sourceId;
final bool waiting;
const RelayedSource(this.url, {required this.fetch, required this.sourceId, required this.waiting});
}
/// A Jellyfin item: this app's own account first ([ownAccount]), then the
/// server's login ([serverLogin], `/j`), then the member who put it up
/// ([memberRelay], `/r`); the resolve step decides. [origin]: this member
/// put it up (it passes it on to viewers without an account).
final class JellyfinSource extends SourcePlan {
final JellyfinRef ref;
final bool ownAccount;
final String? serverLogin;
final String? memberRelay;
final JellyfinOrigin? origin;
const JellyfinSource(this.ref, {required this.ownAccount, this.serverLogin, this.memberRelay, this.origin});
}
/// A page link this app cannot resolve (no yt-dlp here: `caps.resolve`
/// false): [via] names who can pass it on.
final class Unresolvable extends SourcePlan {
final String link;
final String via;
const Unresolvable(this.link, this.via);
String get reason => "This device can't open web pages; ${via.isEmpty ? 'ask someone' : via} to pass it on (Through me)";
}
/// What [planMedia] reads.
class PlanInput {
final StateBroadcast state;
final String selfId;
final ClientCaps caps;
final ServerAddress server;
/// What this member serves for [StateBroadcast.mediaId] (null: nothing).
final MediaOrigin? origin;
/// This app plays Jellyfin [serverId] with its own account.
final bool Function(String serverId) ownJellyfin;
const PlanInput({
required this.state,
required this.selfId,
required this.caps,
required this.server,
this.origin,
required this.ownJellyfin,
});
}
/// A relay nobody feeds yet: the server's fetch before it is read, or a
/// member's before its channel is up while that member is still here
/// (once it is gone or left, the server's buffer is all there is: open
/// that).
bool relayWaitsForSource(StateBroadcast r, MediaRelay relay) =>
!relay.live && !relay.sourceGone && (relay.fetch || r.members.any((m) => m.id == relay.source));
/// The members that can resolve a page link (`caps.resolve`), present
/// ones, lowest id first.
List<MemberView> linkResolvers(StateBroadcast r, {String except = ''}) =>
[for (final m in r.members) if (m.caps.resolve && isPresent(m) && m.id != except) m];
/// Where this app reads [input]'s media from.
SourcePlan planMedia(PlanInput input) {
final r = input.state;
final media = r.media;
if (media == null) return const NoSource();
final name = (media['name'] as String?) ?? '';
final relay = stateMediaRelay(r);
final mine = relay != null && relay.source.isNotEmpty && relay.source == input.selfId;
final origin = mine ? input.origin : null;
final key = relay == null || relay.queueId.isEmpty ? '${r.mediaId}' : relay.queueId;
final ref = refOfStateMedia(media);
if (ref != null) {
return JellyfinSource(
ref,
ownAccount: input.ownJellyfin(ref.serverId),
serverLogin: r.relay && input.server.token.isNotEmpty ? input.server.jellyfinUrl(ref) : null,
memberRelay: relay != null && !mine ? input.server.relayUrl(name, key) : null,
origin: origin is JellyfinOrigin ? origin : (mine ? const JellyfinOrigin(null) : null),
);
}
switch (origin) {
case FileOrigin(:final path):
return OwnFile(path);
case LinkOrigin(:final url):
return OwnLink(url, origin);
case JellyfinOrigin() || null:
break;
}
if (relay != null) {
// Another member's relay or the server's fetch; this member's own
// relay without its origin (a restarted app) reads the server's buffer.
return RelayedSource(input.server.relayUrl(name, key, relay.tracks),
fetch: relay.fetch, sourceId: relay.source, waiting: !mine && relayWaitsForSource(r, relay));
}
final url = media['url'];
if (url is String && url.isNotEmpty) {
final uri = Uri.tryParse(url);
final web = uri != null && (uri.isScheme('http') || uri.isScheme('https'));
final page = web && !hasMediaExtension(uri);
if (page && !input.caps.resolve) {
final who = linkResolvers(r, except: input.selfId).firstOrNull;
return Unresolvable(url, who == null ? '' : 'ask ${who.name}');
}
return DirectLink(url, page: page);
}
if (name.isNotEmpty && input.server.token.isNotEmpty) return ServerFile(input.server.fileUrl(name));
return const NoSource();
}
@@ -187,6 +187,12 @@ Notice _plain(AppEvent e) => switch (e) {
category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor), category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor),
MemberReturned(:final feed) => MemberReturned(:final feed) =>
Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor), Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor),
// Put back after a server restart: news, but not a change.
MediaSwapped(:final feed) when feed.reason == 'restore' => Notice(
category: NoticeCategory.upNext,
log: feed,
toast: '${feed.actor} put back what was playing',
actor: feed.actor),
MediaSwapped(:final feed) => Notice( MediaSwapped(:final feed) => Notice(
category: NoticeCategory.upNext, category: NoticeCategory.upNext,
log: feed, log: feed,
+2 -2
View File
@@ -12,8 +12,8 @@ const presencePolicy = PresencePolicy();
bool countsForReady(MemberView m) => m.error.isEmpty && presencePolicy.countsFor(m.presence, Purpose.readyCounter); bool countsForReady(MemberView m) => m.error.isEmpty && presencePolicy.countsFor(m.presence, Purpose.readyCounter);
/// Connected and watching (`in` or `joining`): can do things for the /// Connected and watching (`in` or `joining`): can do things for the
/// party now (put up an Up next item, the auto-advance swap). /// party now (put up an Up next item; core's [isPresentMember]).
bool isPresent(MemberView m) => m.presence == Presence.watching || m.presence == Presence.joining; bool isPresent(MemberView m) => isPresentMember(m);
/// Nobody but [selfId] is connected in [members] (D9, core's [isAlone]). /// Nobody but [selfId] is connected in [members] (D9, core's [isAlone]).
bool aloneIn(List<MemberView> members, String selfId) => bool aloneIn(List<MemberView> members, String selfId) =>
@@ -0,0 +1,150 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:signals/signals.dart';
import 'package:sync_relay_core/core.dart';
import 'app_events.dart';
import 'media_plan.dart' show ServerAddress;
import 'party.dart' show isPresent;
/// This member as a relay source: the current media's bytes (its file, a
/// link it passes on, its Jellyfin stream) on `/r/.../source`, and Up next's
/// first item on the warm relay (`?q=<queueId>`), which the swap that puts
/// the item up carries on as the media's. A source re-dials by itself
/// through a drop of this app (the server keeps the relay through the
/// grace; the same `info` continues it).
class RelayFeeder {
final ServerAddress Function() server;
final void Function(AppEvent event) notify;
RelayFeeder({required this.server, required this.notify});
RelaySource? _source;
int _mediaId = -1;
({String queueId, RelaySource src})? _warm;
/// This member serves the current media's bytes through the server.
final relaying = signal(false);
/// The media id this member serves (-1: none).
int get mediaId => _mediaId;
/// The tracks of Up next item [queueId] when this member feeds its warm
/// relay and has opened them (empty otherwise).
List<RelayTrack> warmTracks(String queueId) => _warm?.queueId == queueId ? _warm!.src.tracks : const [];
/// Serves [media] for media [mediaId] on this member's relay channel.
/// The server only pulls while someone watches through it. When the
/// media came from Up next item [queueId] whose warm relay this member
/// feeds, that source carries on instead. [name] and [duration] go in
/// the `info`, the same on every dial, so the server knows a re-dial
/// after a drop as the same media.
void serve(int mediaId, RelayMedia media,
{bool announce = false, String queueId = '', String name = '', double duration = 0}) {
// Already serving this media (it re-dials by itself through a drop).
if (_source != null && _mediaId == mediaId) {
unawaited(media.close());
return;
}
stop();
final warm = _warm;
if (queueId.isNotEmpty && warm != null && warm.queueId == queueId) {
_warm = null;
unawaited(media.close());
_adopt(mediaId, warm.src, announce: announce, started: true);
return;
}
final src = RelaySource(server().sourceChannel(), media, name: name, duration: duration);
_adopt(mediaId, src, announce: announce, started: false);
}
void _adopt(int mediaId, RelaySource src, {required bool announce, required bool started}) {
_source = src;
_mediaId = mediaId;
relaying.value = true;
if (announce) notify(const RelayStarted());
unawaited(() async {
if (!started) {
try {
await src.start();
} catch (e) {
debugPrint('relay source failed: $e');
if (identical(_source, src)) {
_clear();
notify(RelayFailed('$e'));
}
return;
}
}
await src.done;
if (identical(_source, src)) _clear();
}());
}
void _clear() {
_source = null;
_mediaId = -1;
relaying.value = false;
}
/// Stops serving the current media.
void stop() {
final src = _source;
_clear();
if (src != null) unawaited(src.close());
}
/// Feeds the warm relay of Up next item [queueId] from here with [media];
/// one already feeding it is kept. The server pulls only while someone
/// preloads it, and closes the channel once the item is no longer first
/// (the source then stops).
void feedWarm(String queueId, RelayMedia media) {
if (_warm?.queueId == queueId) {
unawaited(media.close());
return;
}
stopWarm();
final src = RelaySource(server().sourceChannel(queueId), media);
_warm = (queueId: queueId, src: src);
unawaited(() async {
try {
await src.start();
} catch (e) {
debugPrint('warm relay source of $queueId failed: $e');
if (identical(_warm?.src, src)) _warm = null;
return;
}
await src.done;
if (identical(_warm?.src, src)) _warm = null;
}());
}
void stopWarm() {
final w = _warm;
_warm = null;
if (w != null) unawaited(w.src.close());
}
/// Leave, or a media this member no longer serves.
void stopAll() {
stop();
stopWarm();
}
}
/// Someone present needs this member's relay of Jellyfin [ref]: the
/// server's own login does not pass it on (`state.relay`) and a present
/// member other than [selfId] does not play that server with its own
/// account. A member whose account list is not reported yet (`caps` say it
/// has Jellyfin, `jf` still empty) is asked again with the next state.
/// Without anyone needing it, no relay channel is opened.
bool jellyfinRelayNeeded(StateBroadcast r, JellyfinRef ref, String selfId) {
if (r.relay) return false;
for (final m in r.members) {
if (m.id == selfId || !isPresent(m)) continue;
if (m.jf[ref.serverId] == true) continue;
if (m.caps.jellyfin && m.jf.isEmpty) continue;
return true;
}
return false;
}
File diff suppressed because it is too large. Load diff
+14
View File
@@ -210,6 +210,20 @@ StatusLine? _line(StatusFacts f, StateBroadcast? r, MemberView? self, SourceTrou
return StatusLine(StatusKind.problem, "Can't play this: ${f.playerError}", return StatusLine(StatusKind.problem, "Can't play this: ${f.playerError}",
level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this"); level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this");
} }
// The server holds everyone for the relay's source (D6): a viewer's
// buffer ran dry while it is gone.
if (r.transport == 'held' && r.hold?.reason == HoldReason.source) {
final relayed = stateMediaRelay(r);
final id = relayed?.source ?? '';
final name = source?.name ??
r.members.where((m) => m.id == id).firstOrNull?.name ??
f.names?[id] ??
'the member passing it on';
return StatusLine(StatusKind.waiting, 'Waiting for $name',
detail: "$name's connection dropped; what the server had has played",
level: StatusLevel.warn,
short: 'Waiting for $name');
}
// The relay: what everyone reads comes through the server. // The relay: what everyone reads comes through the server.
final relay = f.ownPlan ? null : stateMediaRelay(r); final relay = f.ownPlan ? null : stateMediaRelay(r);
if (relay != null && relay.source != f.selfId) { if (relay != null && relay.source != f.selfId) {
+12 -2
View File
@@ -278,7 +278,7 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
case 'seek': case 'seek':
return (Icons.fast_forward, scheme.primary, 'seeked'); return (Icons.fast_forward, scheme.primary, 'seeked');
case 'swap': case 'swap':
return (Icons.swap_horiz, scheme.primary, 'changed the media'); return (Icons.swap_horiz, scheme.primary, f.reason == 'restore' ? 'put back what was playing' : 'changed the media');
case 'gone': case 'gone':
return (Icons.cloud_off, Colors.orange, 'lost signal'); return (Icons.cloud_off, Colors.orange, 'lost signal');
case 'return': case 'return':
@@ -296,6 +296,9 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
case 'queue-skip': case 'queue-skip':
final why = f.reason.isEmpty ? '' : ' · ${f.reason}'; final why = f.reason.isEmpty ? '' : ' · ${f.reason}';
return (Icons.redo_rounded, Colors.orange, 'skipped ${f.target ?? ''}$why'); return (Icons.redo_rounded, Colors.orange, 'skipped ${f.target ?? ''}$why');
case 'advance-cancel':
final what = f.target == null || f.target!.isEmpty ? '' : ' (${f.target} is off Up next)';
return (Icons.cancel_outlined, Colors.grey, 'cancelled what plays next$what');
case 'fetch-fail': case 'fetch-fail':
return (Icons.cloud_off, Colors.red, "couldn't fetch ${f.target ?? 'the video'}"); return (Icons.cloud_off, Colors.red, "couldn't fetch ${f.target ?? 'the video'}");
default: default:
@@ -304,9 +307,16 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
} }
} }
/// The server's own lines (pause / resume). /// The server's own lines (pause / resume, the end, an Up next item
/// nobody here can put up).
(IconData, Color, String) serverFeedStyle(FeedMessage f) => switch (f.action) { (IconData, Color, String) serverFeedStyle(FeedMessage f) => switch (f.action) {
'resume' => (Icons.play_arrow, Colors.green, 'Playback resumed'), 'resume' => (Icons.play_arrow, Colors.green, 'Playback resumed'),
'finished' => (Icons.stop_circle_outlined, Colors.grey, 'Finished'),
'queue-skip' => (
Icons.redo_rounded,
Colors.orange,
'Skipped ${f.target ?? ''}${f.reason.isEmpty ? '' : ' · ${f.reason}'}'
),
_ => (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness'), _ => (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness'),
}; };
+5 -18
View File
@@ -11,6 +11,7 @@ import '../controls/controls_probe.dart';
import '../controls/model/controls_model.dart'; import '../controls/model/controls_model.dart';
import '../controls/session_controls.dart'; import '../controls/session_controls.dart';
import '../controls/volume.dart'; import '../controls/volume.dart';
import '../platform/desktop/media_kit_host.dart' show videoControllerOf;
import '../queue/queue_ui.dart'; import '../queue/queue_ui.dart';
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock; import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
import '../screens/session/jellyfin_widgets.dart'; import '../screens/session/jellyfin_widgets.dart';
@@ -321,7 +322,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
Widget _video(VideoControlsBuilder? controls) => Video( Widget _video(VideoControlsBuilder? controls) => Video(
key: _videoKey, key: _videoKey,
controller: _coord.playback.video, controller: videoControllerOf(_coord.playback.video),
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false), subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
controls: controls, controls: controls,
); );
@@ -418,16 +419,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
}); });
} }
/// The action area's right end: the end-of-media card (Up next, else the /// The action area's right end: the end-of-media card (Up next, else
/// next episode; it stays up), else the pills, shown only with the /// the continuation; it stays up), else the pills, shown only with the
/// controls ([PillGate]). /// controls ([PillGate]).
Widget _actionSide() { Widget _actionSide() {
final coord = _coord; final coord = _coord;
return SignalBuilder( return SignalBuilder(
builder: (context) { builder: (context) {
final next = coord.nextEpisode.value; final next = coord.nextEpisode.value;
final countdown = coord.autoAdvance.value;
// Up next goes before the next episode.
final upNext = coord.queue.countdown.value; final upNext = coord.queue.countdown.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;
@@ -436,18 +435,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (upNext != null && finished) { if (upNext != null && finished) {
return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext)); return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext));
} }
// "Clear" in Up next hides the next episode for this media.
if (next != null && (finished || countdown != null) && coord.queue.shownContinuation.value.isNotEmpty) {
return ControlsHoldRegion(
hold: _hold,
child: EndOfEpisodeCard(
next: next,
countdown: countdown,
onPlayNow: coord.playNextEpisode,
onCancel: coord.cancelAutoAdvance,
),
);
}
if (!showNext && skip == null) return const SizedBox.shrink(); if (!showNext && skip == null) return const SizedBox.shrink();
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -653,7 +640,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
], ],
); );
}), }),
_MiniSeekBar(onSeek: (d) => playback.player.seek(d)), _MiniSeekBar(onSeek: playback.userSeek),
], ],
), ),
), ),
+1 -1
View File
@@ -152,7 +152,7 @@ class SettingsTab extends StatelessWidget {
} }
}, },
), ),
const SettingsPanel(title: 'Jellyfin playback', child: AutoAdvanceSetting()), const SettingsPanel(title: 'Episodes', child: AutoAdvanceSetting()),
const SettingsPanel(title: 'Jellyfin library', child: HideReReleasesSetting()), const SettingsPanel(title: 'Jellyfin library', child: HideReReleasesSetting()),
]; ];
case SettingsSection.notifications: case SettingsSection.notifications:
+3 -1
View File
@@ -59,7 +59,9 @@ class AppSettings {
final hwdecMode = signal(HwdecMode.auto); final hwdecMode = signal(HwdecMode.auto);
final hwdecCustom = signal(''); final hwdecCustom = signal('');
final autoCheckUpdates = signal(true); final autoCheckUpdates = signal(true);
/// Jellyfin episodes: at the end, count down 10 s and put up the next. /// An episode this app puts up (Jellyfin, a file of a local show) is
/// continued by the next one (Up next's `auto` item, the server's
/// countdown at its end).
final autoAdvance = signal(true); final autoAdvance = signal(true);
/// Session: send ready=false when the video changes (a swap keeps the /// Session: send ready=false when the video changes (a swap keeps the
/// server's ready flags). /// server's ready flags).
+2 -2
View File
@@ -288,8 +288,8 @@ class SyncClient {
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson()); _send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
} }
void sendSwap(HelloMedia media, {String? queueId}) { void sendSwap(HelloMedia media, {String? queueId, SwapRestore? restore}) {
_send(SwapIntent(media: media, queueId: queueId).toJson()); _send(SwapIntent(media: media, queueId: queueId, restore: restore).toJson());
} }
/// One change to Up next (see [QueueIntent]). /// One change to Up next (see [QueueIntent]).
-21
View File
@@ -1,26 +1,5 @@
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
/// A [Player] whose public [seek] belongs to the user. media_kit's seek bar
/// (and anything else in the video controls) calls `player.seek`; with
/// [onUserSeek] set those calls become explicit events for the sync layer
/// instead of moving mpv behind its back. Sync-driven seeks use [syncSeek].
class SyncPlayer extends Player {
SyncPlayer({super.configuration});
void Function(Duration position)? onUserSeek;
@override
Future<void> seek(Duration duration) async {
final hook = onUserSeek;
if (hook == null) return super.seek(duration);
hook(duration);
}
/// Seek mpv directly (preview while scrubbing, server corrections).
Future<void> syncSeek(Duration duration) => super.seek(duration);
}
/// Turns a burst of user seeks (a seek bar drag fires one per pointer move, /// Turns a burst of user seeks (a seek bar drag fires one per pointer move,
/// arrow keys one per press) into one `seek` intent, and tells the /// arrow keys one per press) into one `seek` intent, and tells the
/// coordinator when server seeks must not touch the player: /// coordinator when server seeks must not touch the player:
@@ -0,0 +1,187 @@
import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
// The device's watch history (device-owned, one Hive box): what this app
// watched of files and links, by [WatchKey], with the verdict of the
// watched rule (`WatchSession`, D3). Jellyfin keeps its own (reported by
// `JellyfinProgressReporter`); AniList later reads the tracker's records.
// See ARCHITECTURE.md "Watch tracking".
const watchHistoryBoxName = 'watch_history';
/// What a watch is about, independent of the opening: a file by its path
/// on this computer, a link by its URL, a Jellyfin item by its ids, else
/// the media's name and size (someone else's file, a link passed on).
class WatchKey {
final String value;
const WatchKey._(this.value);
factory WatchKey.file(String path) => WatchKey._('file:$path');
factory WatchKey.link(String url) => WatchKey._('link:$url');
factory WatchKey.jellyfin(JellyfinRef ref) => WatchKey._('jf:${ref.serverId}/${ref.itemId}');
factory WatchKey.media(String name, int size) => WatchKey._('media:$name|$size');
bool get isJellyfin => value.startsWith('jf:');
/// The key of `state.media` [media]: [ownFile] / [ownLink] when this app
/// put it up from there, else its Jellyfin ref, its shared link, its
/// name and size.
static WatchKey? of(Map<String, Object?>? media, {String? ownFile, String? ownLink}) {
if (media == null) return null;
if (ownFile != null) return WatchKey.file(ownFile);
final jf = media['jellyfin'];
if (jf is Map) {
final ref = JellyfinRef.fromJson(Map<String, Object?>.from(jf));
if (ref.serverId.isNotEmpty && ref.itemId.isNotEmpty) return WatchKey.jellyfin(ref);
}
if (ownLink != null) return WatchKey.link(ownLink);
final url = media['url'];
if (url is String && (url.startsWith('http://') || url.startsWith('https://'))) return WatchKey.link(url);
final name = media['name'];
final size = media['size'];
return WatchKey.media(name is String ? name : '', size is num ? size.toInt() : 0);
}
@override
bool operator ==(Object other) => other is WatchKey && other.value == value;
@override
int get hashCode => value.hashCode;
@override
String toString() => value;
}
/// One closed watch: what the tracker hands its sinks (the device history,
/// AniList later).
class WatchRecord {
final WatchKey key;
final String title;
final MediaRef? ref;
final WatchVerdict verdict;
/// Seconds the media played while this viewer was In, and its runtime
/// (0 when unknown).
final double timeIn;
final double runtime;
final DateTime at;
const WatchRecord({
required this.key,
required this.title,
required this.verdict,
required this.timeIn,
required this.runtime,
required this.at,
this.ref,
});
@override
String toString() => 'WatchRecord($key, $verdict, ${timeIn.round()} s In of ${runtime.round()} s)';
}
/// What the history holds for a key: watched (played), or where to
/// resume, and when.
class WatchEntry {
final bool played;
/// Seconds; 0 when played or none.
final double resumeAt;
final String title;
final MediaRef? ref;
final DateTime at;
const WatchEntry({required this.played, this.resumeAt = 0, this.title = '', this.ref, required this.at});
Map<String, Object?> toJson() => {
'played': played,
if (resumeAt > 0) 'resumeAt': resumeAt,
if (title.isNotEmpty) 'title': title,
if (ref != null) 'ref': ref!.toJson(),
'at': at.toUtc().toIso8601String(),
};
static WatchEntry? fromJson(Object? j) {
if (j is! Map) return null;
final at = DateTime.tryParse('${j['at']}');
if (at == null) return null;
final resume = j['resumeAt'];
MediaRef? ref;
try {
ref = MediaRef.fromJson(j['ref']);
} catch (_) {}
return WatchEntry(
played: j['played'] == true,
resumeAt: resume is num && resume.isFinite && resume > 0 ? resume.toDouble() : 0,
title: j['title'] is String ? j['title'] as String : '',
ref: ref,
at: at,
);
}
}
/// The device's watch history: [entry] per key, [record] from the
/// tracker. [revision] bumps on every change (pages that show it listen).
class WatchHistory {
final Map<String, Object?> _memory = {};
/// False: memory only (tests).
final bool persist;
Box? _box;
Future<void>? _load;
final revision = signal(0);
WatchHistory({this.persist = true});
Future<void> ensureLoaded() => _load ??= () async {
if (!persist) return;
try {
final box = _box = await Hive.openBox(watchHistoryBoxName);
for (final e in _memory.entries) {
await box.put(e.key, e.value);
}
_memory.clear();
revision.value++;
} catch (e, st) {
debugPrint('open watch history failed: $e\n$st');
}
}();
WatchEntry? entry(WatchKey key) => WatchEntry.fromJson(_box?.get(key.value) ?? _memory[key.value]);
/// A closed watch: played -> played (the resume point goes); part way
/// -> its resume point, unless it was played before (it stays played);
/// a peek changes nothing. Jellyfin items are not kept here.
void record(WatchRecord r) {
if (r.key.isJellyfin) return;
final before = entry(r.key);
final WatchEntry next;
switch (r.verdict.outcome) {
case WatchOutcome.played || WatchOutcome.keepPlayed:
next = WatchEntry(played: true, title: r.title, ref: r.ref, at: r.at);
case WatchOutcome.partial:
next = WatchEntry(
played: before?.played ?? false,
resumeAt: (before?.played ?? false) ? 0 : r.verdict.resumeAt ?? 0,
title: r.title,
ref: r.ref,
at: r.at);
case WatchOutcome.peek:
return;
}
final json = next.toJson();
final box = _box;
if (box == null) {
_memory[r.key.value] = json;
} else {
box.put(r.key.value, json).catchError((Object e) => debugPrint('watch history put ${r.key}: $e'));
}
revision.value++;
}
/// Every entry, newest first.
List<(WatchKey, WatchEntry)> get entries {
final raw = <String, Object?>{..._memory, if (_box != null) for (final k in _box!.keys) '$k': _box!.get(k)};
final out = [
for (final e in raw.entries)
if (WatchEntry.fromJson(e.value) case final w?) (WatchKey._(e.key), w),
];
out.sort((a, b) => b.$2.at.compareTo(a.$2.at));
return out;
}
}
@@ -0,0 +1,159 @@
import 'dart:async';
import 'package:sync_relay_core/core.dart';
import 'watch_history.dart';
// Watch tracking (ARCHITECTURE.md "Watch tracking", D3): one watch per
// media of the server (its `mediaId`), fed every second by the session
// with what this app sees, judged by core's `WatchSession` when it ends.
// The verdict goes to the device history (files and links) and out on
// [WatchTracker.records] (the seam for AniList later); Jellyfin's
// played/resume reports read the same session.
final _log = createLogger('watch');
/// What the session hands the tracker each second.
class WatchSample {
/// The server's media and its id.
final int mediaId;
final Map<String, Object?>? media;
/// This app's own source of it: the file it put up, the link it put up.
final String? ownFile;
final String? ownLink;
final double position;
/// The media plays here: the server plays it and this player has it.
final bool playing;
/// This viewer is In (`in`): not away, joining or gone.
final bool present;
/// The player's duration (0 while unknown) and where the ending starts.
final double duration;
final double? endingStart;
final DateTime at;
const WatchSample({
required this.mediaId,
required this.media,
required this.position,
required this.playing,
required this.present,
required this.at,
this.ownFile,
this.ownLink,
this.duration = 0,
this.endingStart,
});
}
class WatchTracker {
final WatchHistory history;
WatchTracker({WatchHistory? history}) : history = history ?? WatchHistory(persist: false);
final _records = StreamController<WatchRecord>.broadcast();
/// Every closed watch (peeks too): the device history takes files and
/// links; AniList progress later listens here.
Stream<WatchRecord> get records => _records.stream;
int _mediaId = -1;
WatchKey? _key;
String _title = '';
MediaRef? _ref;
double _runtime = 0;
double? _endingStart;
WatchSession? _session;
/// The watch of the media playing (null before the first sample).
WatchSession? get session => _session;
WatchKey? get key => _key;
/// One second of the session. A new media closes the watch of the one
/// before.
void sample(WatchSample s) {
if (s.mediaId != _mediaId) {
close(at: s.at);
if (s.media == null) return;
_open(s);
}
final session = _session;
if (session == null) return;
// A file or link this app put up is known once its swap landed.
final key = WatchKey.of(s.media, ownFile: s.ownFile, ownLink: s.ownLink);
if (key != null && key != _key && !(_key?.isJellyfin ?? false)) _key = key;
if (s.duration > 0) _runtime = s.duration;
_endingStart = s.endingStart;
session.sample(s.position, playing: s.playing, present: s.present, at: s.at);
}
void _open(WatchSample s) {
final key = WatchKey.of(s.media, ownFile: s.ownFile, ownLink: s.ownLink);
final before = key == null ? null : history.entry(key);
_mediaId = s.mediaId;
_key = key;
_title = '${s.media?['name'] ?? ''}';
try {
_ref = MediaRef.fromJson(s.media?['ref']);
} catch (_) {
_ref = null;
}
final d = s.media?['duration'];
_runtime = d is num && d > 0 ? d.toDouble() : 0;
_endingStart = null;
_session = WatchSession(playedBefore: before?.played ?? false, resumeBefore: before?.resumeAt ?? 0);
}
/// The same media goes on under another [mediaId] (a restarted server
/// numbers it anew): the watch carries on.
void renumber(int from, int to) {
if (_mediaId == from) _mediaId = to;
}
/// What Jellyfin says about the item before this opening (its played
/// state and resume point, read once it is open).
void before({required bool played, double resumeAt = 0}) {
final session = _session;
if (session == null) return;
session
..playedBefore = played
..resumeBefore = resumeAt;
}
/// The verdict so far (the runtime as last seen); null without a watch.
WatchVerdict? get verdict => _session?.verdict(runtime: _runtime, endingStart: _endingStart);
/// Records the watch as it stands (the end of the media; it may go on
/// after a seek back and be recorded again).
WatchRecord? checkpoint({DateTime? at}) {
final session = _session, key = _key;
if (session == null || key == null) return null;
final v = session.verdict(runtime: _runtime, endingStart: _endingStart);
final r = WatchRecord(
key: key,
title: _title,
ref: _ref,
verdict: v,
timeIn: session.timeIn,
runtime: _runtime,
at: at ?? DateTime.now().toUtc());
_log.info('$key: ${v.outcome.name} (${session.timeIn.round()} s In of ${_runtime.round()} s)');
history.record(r);
_records.add(r);
return r;
}
/// The watch ends (another media, leaving): recorded and forgotten.
WatchRecord? close({DateTime? at}) {
final r = _session == null ? null : checkpoint(at: at);
_mediaId = -1;
_key = null;
_session = null;
_ref = null;
_runtime = 0;
_endingStart = null;
return r;
}
/// The media the current watch is of (-1: none).
int get mediaId => _mediaId;
Future<void> dispose() => _records.close();
}
+3 -4
View File
@@ -538,10 +538,9 @@ packages:
media_kit_video: media_kit_video:
dependency: "direct main" dependency: "direct main"
description: description:
name: media_kit_video path: "../../third_party/media_kit_video"
sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d relative: true
url: "https://pub.dev" source: path
source: hosted
version: "2.0.1" version: "2.0.1"
meta: meta:
dependency: transitive dependency: transitive
+4 -2
View File
@@ -1,7 +1,7 @@
name: starsync name: starsync
description: Starsync desktop app (repo sync_relay_project). One binary. description: Starsync desktop app (repo sync_relay_project). One binary.
publish_to: none publish_to: none
version: 1.2.0 version: 1.3.0
environment: environment:
sdk: '>=3.13.0-0 <4.0.0' sdk: '>=3.13.0-0 <4.0.0'
@@ -10,7 +10,9 @@ dependencies:
sdk: flutter sdk: flutter
signals: ^7.0.0 signals: ^7.0.0
media_kit: ^1.1.0 media_kit: ^1.1.0
media_kit_video: ^2.0.1 # Vendored with Starsync's patches (third_party/media_kit_video/starsync).
media_kit_video:
path: ../../third_party/media_kit_video
media_kit_libs_video: ^1.0.0 media_kit_libs_video: ^1.0.0
path_provider: ^2.1.0 path_provider: ^2.1.0
web_socket_channel: ^3.0.0 web_socket_channel: ^3.0.0
@@ -362,6 +362,87 @@ void main() {
} }
}); });
test('a restarted server comes back empty: what played is offered back where it was, nothing reopens, '
'the watch goes on, Up next comes back', timeout: const Timeout(Duration(minutes: 2)), () async {
final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart'));
await first.start();
final port = first.boundPort;
final origin = await _origin(await _media());
SyncServer? second;
try {
final alice = await member(port, 'alice');
final bob = await member(port, 'bob');
await playTogether(alice, [alice, bob], 'http://127.0.0.1:${origin.port}/ep.mkv');
expect(await bob.queue.add(LinkMedia('http://127.0.0.1:${origin.port}/next.mkv', anyway: true)), isNull);
await _poll(() => alice.queue.items.peek().length == 1, 'Up next has an item');
await Future.delayed(const Duration(seconds: 4));
final opens = [alice.opens, bob.opens];
final watch = alice.watch.session;
final watched = watch!.timeIn;
expect(watched, greaterThan(2));
await first.stop();
await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting');
second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart'));
await second.start();
final engine = second.hub.engine;
await _poll(() => engine.media?.name == 'ep.mkv', 'the media offered back', timeout: const Duration(seconds: 30));
expect(engine.mediaId, 2, reason: 'one offer taken: the server started at 1');
expect(engine.positionAt(), greaterThan(4), reason: 'where it was, not the start');
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'),
'playing again', timeout: const Duration(seconds: 20));
expect([alice.opens, bob.opens], opens, reason: 'the players kept it: nothing reopened');
await _poll(() => engine.queue.items.length == 1, 'Up next back');
expect(engine.queue.items.single.media.url, endsWith('/next.mkv'));
// The watch went on through the restart (one watch, still counting).
await Future.delayed(const Duration(seconds: 2));
expect(identical(alice.watch.session, watch), isTrue);
expect(alice.watch.mediaId, engine.mediaId);
expect(watch.timeIn, greaterThan(watched + 1));
} finally {
await second?.stop();
await origin.close(force: true);
}
});
test('a restarted server comes back empty: the member passing its file on puts it back and feeds it again',
timeout: const Timeout(Duration(minutes: 2)), () async {
final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart'));
await first.start();
final port = first.boundPort;
final file = await _media();
SyncServer? second;
try {
final alice = await member(port, 'alice');
final bob = await member(port, 'bob');
expect(await alice.swapPicked(FileMedia(file)), isNull);
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 100), 'both opened it',
timeout: const Duration(seconds: 30));
for (final x in [alice, bob]) {
x.toggleReady();
}
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing), 'both playing');
await Future.delayed(const Duration(seconds: 3));
final aliceOpens = alice.opens;
await first.stop();
await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting');
second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart'));
await second.start();
final hub = second.hub;
await _poll(() => hub.engine.media?.name == 'coord_presence_120s.mkv', 'the file offered back',
timeout: const Duration(seconds: 30));
expect(hub.engine.positionAt(), greaterThan(3));
await _poll(() => hub.relay?.sourceToken == alice.connection.token && alice.relaying.peek(), 'alice feeds it again');
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'),
'playing again', timeout: const Duration(seconds: 30));
expect(alice.opens, aliceOpens, reason: 'her own file stayed open');
expect(hub.relay!.buffers.single.bytesToViewers, greaterThan(0), reason: 'bob reads the new relay');
} finally {
await second?.stop();
}
});
test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async { test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async {
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel')); final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel'));
await server.start(); await server.start();
+42 -23
View File
@@ -1,5 +1,3 @@
// restructure: rewrite in step 6 (Watch tracking): the progress-reporting
// checks; the guest-through-the-relay flow stays.
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@@ -14,6 +12,7 @@ import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/playback/media_marks.dart'; import 'package:starsync/src/playback/media_marks.dart';
import 'package:starsync/src/screens/home/picked_media.dart'; import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart'; import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/state/settings.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
// real sync server, real mpv and a local fake Jellyfin (never a real one): // real sync server, real mpv and a local fake Jellyfin (never a real one):
@@ -220,7 +219,8 @@ String _kind(_Call c) => c.path.split('/').last;
void main() { void main() {
test( test(
'account holders direct-play and report, the guest plays through the relay, all in sync', 'account holders direct-play and report what they watched, the guest plays through the relay, '
'the next episode continues it for everyone',
timeout: const Timeout(Duration(minutes: 4)), timeout: const Timeout(Duration(minutes: 4)),
() async { () async {
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
@@ -296,6 +296,11 @@ void main() {
expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos'); expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos');
} }
// Carol sits out for the rest of the episode: she is not tracked as
// having watched it (P7).
c.sitOut();
await _poll(() => c.selfAway.peek(), 'carol away');
// Pause (A unreadies): an immediate Progress with IsPaused. // Pause (A unreadies): an immediate Progress with IsPaused.
a.toggleReady(); a.toggleReady();
await _poll(() => !a.playback.player.state.playing, 'held'); await _poll(() => !a.playback.player.state.playing, 'held');
@@ -304,7 +309,7 @@ void main() {
_kind(s) == 'Progress' && s.body?['IsPaused'] == true), _kind(s) == 'Progress' && s.body?['IsPaused'] == true),
'paused progress'); 'paused progress');
a.toggleReady(); a.toggleReady();
await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again'); await _poll(() => [a, b].every((x) => x.playback.player.state.playing), 'playing again');
// Account holders see the "Next episode" prompt (this item is // Account holders see the "Next episode" prompt (this item is
// shorter than its 2 min lead); the guest cannot put it up. // shorter than its 2 min lead); the guest cannot put it up.
@@ -319,23 +324,28 @@ void main() {
_kind(s) == 'Progress' && s.body?['AudioStreamIndex'] == 2), _kind(s) == 'Progress' && s.body?['AudioStreamIndex'] == 2),
'track change reported'); 'track change reported');
// Near the end: finished -> Stopped, then one auto-advance swap. // Alice put the episode up and has the next one: it continues it,
a.seekTo(16); // in Up next for everyone.
await _poll(() => b.queue.continues.peek()?.media.jellyfin?.itemId == _ep2, 'the continuation');
final auto = b.queue.continues.peek()!;
expect(auto.by, a.connection.memberId);
// Played to the end: finished -> Stopped, the server's countdown
// on every screen, then one swap.
await _poll(() => a.serverState.peek()?.transport == 'finished', 'finished', await _poll(() => a.serverState.peek()?.transport == 'finished', 'finished',
timeout: const Duration(seconds: 20)); timeout: const Duration(seconds: 40));
final finishedId = a.serverState.peek()!.mediaId; final finishedId = a.serverState.peek()!.mediaId;
await _poll(() => a.autoAdvance.peek() != null && c.autoAdvance.peek() != null, await _poll(() => [a, b].every((x) => (x.queue.countdown.peek()?.secondsLeft ?? 0) > 0),
'both account holders count down'); 'everyone sees the countdown');
expect(b.autoAdvance.peek(), isNull); expect(a.serverState.peek()!.advance!.id, auto.id);
final swappers = [a.autoAdvance.peek()!.swapper, c.autoAdvance.peek()!.swapper]; expect(a.queue.countdown.peek()!.item.id, auto.id);
expect(swappers.where((s) => s), hasLength(1),
reason: 'exactly one designated swapper: the lowest member id (alice, m1)');
expect(a.autoAdvance.peek()!.swapper, isTrue);
await _poll(() => (a.serverState.peek()?.mediaId ?? 0) > finishedId, 'advanced', await _poll(() => (a.serverState.peek()?.mediaId ?? 0) > finishedId, 'advanced',
timeout: const Duration(seconds: 20)); timeout: const Duration(seconds: 25));
await Future.delayed(const Duration(seconds: 3)); await Future.delayed(const Duration(seconds: 3));
expect(a.serverState.peek()!.mediaId, finishedId + 1, reason: 'no double swap'); expect(a.serverState.peek()!.mediaId, finishedId + 1, reason: 'no double swap');
expect(refOfStateMedia(a.serverState.peek()!.media)!.itemId, _ep2); expect(refOfStateMedia(a.serverState.peek()!.media)!.itemId, _ep2);
expect(a.serverState.peek()!.queue, isEmpty, reason: 'the swap took the continuation');
c.joinBack();
await _poll(() => all.every((x) => x.playback.durationSecs > 19 && x.jellyfinPlan.peek() != null), await _poll(() => all.every((x) => x.playback.durationSecs > 19 && x.jellyfinPlan.peek() != null),
'all opened the next episode'); 'all opened the next episode');
expect(b.jellyfinPlan.peek(), isA<RelayPlay>()); expect(b.jellyfinPlan.peek(), isA<RelayPlay>());
@@ -383,13 +393,17 @@ void main() {
expect(ep1.last, 'Stopped'); expect(ep1.last, 'Stopped');
expect(ep1.where((k) => k == 'Stopped'), hasLength(1)); expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1); final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
// Part of it watched, then a seek to the end: not watched (see // Alice watched it through (time In, see WatchSession): Stopped
// WatchSession). A resume point capped at 85 % of the 20 s, so // without a position, then marked played.
// Jellyfin cannot mark it played on its own. expect(stopped1.body!.containsKey('PositionTicks'), isFalse, reason: 'watched: Jellyfin marks it played');
expect(stopped1.body!['PositionTicks'], lessThan(18 * 10000000), bool markedPlayed(String token) => jelly.calls.any((c) =>
reason: 'part way: resume point under 90 %, so not marked played'); c.method == 'POST' &&
expect(jelly.calls.any((c) => c.method == 'POST' && c.path == '/UserPlayedItems/$_ep1'), isFalse, c.path == '/UserPlayedItems/$_ep1' &&
reason: 'seeking to the end is not watching'); _FakeJellyfin.authFields(c.auth)['Token'] == token);
expect(markedPlayed('ATOKEN'), isTrue);
expect(markedPlayed('CTOKEN'), isFalse, reason: 'carol sat out most of it (P7)');
final carol1 = jelly.sessions(token: 'CTOKEN').lastWhere((s) => s.body?['ItemId'] == _ep1);
expect(_kind(carol1), 'Stopped', reason: 'sitting out stops her reports');
expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1'); expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1');
expect(ep2.first, 'Playing'); expect(ep2.first, 'Playing');
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped'); expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped');
@@ -442,7 +456,12 @@ void main() {
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC'); final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
final accB = JellyfinAccounts(store: MemoryJellyfinStore()); final accB = JellyfinAccounts(store: MemoryJellyfinStore());
await accB.ensureLoaded(validate: false); await accB.ensureLoaded(validate: false);
final a = SessionCoordinator(jellyfin: accA, trackPrefs: TrackPrefs(store: MemoryJellyfinStore())); // No continuation: its warm relay would be read while the test stops
// (a cut relay response fails a test, see DEVELOPMENT "Traps").
final a = SessionCoordinator(
jellyfin: accA,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
settings: AppSettings()..autoAdvance.value = false);
final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore())); final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
try { try {
await a.connect(ConnectParams( await a.connect(ConnectParams(
+62 -42
View File
@@ -1,5 +1,3 @@
// restructure: rewrite in step 6 (What's next + Watch tracking): the
// "progress" and "episodes" groups (reporting, auto-advance swapper).
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
@@ -14,8 +12,9 @@ import 'package:starsync/src/jellyfin/session_media.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/screens/home/media_picker.dart'; import 'package:starsync/src/screens/home/media_picker.dart';
import 'package:starsync/src/screens/session/jellyfin_widgets.dart'; import 'package:starsync/src/screens/session/jellyfin_widgets.dart';
import 'package:starsync/src/queue/queue_controller.dart' show UpNextCountdown;
import 'package:starsync/src/queue/queue_ui.dart' show UpNextCard;
import 'package:starsync/src/session/delivery.dart'; import 'package:starsync/src/session/delivery.dart';
import 'package:starsync/src/session/session_coordinator.dart' show AutoAdvance;
import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia; import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia;
import 'jellyfin_accounts_test.dart'; import 'jellyfin_accounts_test.dart';
@@ -212,9 +211,12 @@ void main() {
}); });
}); });
// The reporter reads the watch the session's tracker feeds (time In,
// D3); here the test feeds it.
group('progress', () { group('progress', () {
late JellyfinAccounts accounts; late JellyfinAccounts accounts;
late DateTime now; late DateTime now;
late WatchSession watch;
late JellyfinProgressReporter reporter; late JellyfinProgressReporter reporter;
final errors = <Object>[]; final errors = <Object>[];
var unauthorized = 0; var unauthorized = 0;
@@ -224,27 +226,35 @@ void main() {
now = DateTime(2026, 1, 1, 20); now = DateTime(2026, 1, 1, 20);
errors.clear(); errors.clear();
unauthorized = 0; unauthorized = 0;
watch = WatchSession();
reporter = JellyfinProgressReporter( reporter = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!, client: accounts.clientFor(fixtureServerId)!,
itemId: episodeId, itemId: episodeId,
source: fixtureSource(), source: fixtureSource(),
playSessionId: playSessionId, playSessionId: playSessionId,
watch: watch,
clock: () => now, clock: () => now,
onError: errors.add, onError: errors.add,
onUnauthorized: () => unauthorized++, onUnauthorized: () => unauthorized++,
); );
}); });
/// One second of the session at wall second [secs]: the watch gets its
/// sample (In unless [away]), the reporter its snapshot.
void tick(int secs, double pos, {bool paused = false, bool away = false, int? aid, int? sid}) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs));
watch.sample(pos, playing: !paused, present: !away, at: now);
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
}
Future<void> idle() => reporter.stop(const PlayerSnapshot(position: 0, paused: true)) Future<void> idle() => reporter.stop(const PlayerSnapshot(position: 0, paused: true))
.then((_) {}); .then((_) {});
test('Playing, Progress every 10 s and on pause / seek / track change, Stopped', () async { test('Playing, Progress every 10 s and on pause / seek / track change, Stopped', () async {
// English audio (mpv aid 2 = Jellyfin index 2), English full subs (sid 1 = 3). // English audio (mpv aid 2 = Jellyfin index 2), English full subs (sid 1 = 3).
reporter.start(const PlayerSnapshot(position: 0, paused: true, aid: 2, sid: 1)); reporter.start(const PlayerSnapshot(position: 0, paused: true, aid: 2, sid: 1));
void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) { void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) =>
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs)); tick(secs, pos, paused: paused, aid: aid, sid: sid);
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
}
at(1, 0); // resume -> Progress at(1, 0); // resume -> Progress
at(2, 1); at(2, 1);
@@ -282,15 +292,14 @@ void main() {
'MediaSourceId': episodeId, 'MediaSourceId': episodeId,
'PlaySessionId': playSessionId, 'PlaySessionId': playSessionId,
'PositionTicks': 0, 'PositionTicks': 0,
}, reason: '11 s watched is a peek: the resume point from before (none) stays'); }, reason: '11 s In is a peek: the resume point from before (none) stays');
expect(errors, isEmpty); expect(errors, isEmpty);
}); });
test('watched to the end: Stopped without a position, then marked played', () async { test('watched to the end: Stopped without a position, then marked played', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false)); reporter.start(const PlayerSnapshot(position: 0, paused: false));
for (var i = 1; i <= 1420; i++) { for (var i = 0; i <= 1420; i++) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: i)); tick(i, i.toDouble());
reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false));
} }
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true)); await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3), expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3),
@@ -302,6 +311,19 @@ void main() {
expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide'); expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide');
}); });
test('sat out for most of it: a resume point, never played (P7)', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false));
for (var i = 0; i <= 1420; i++) {
// In for five minutes, then away while the others watch on.
tick(i, i.toDouble(), away: i > 300);
}
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
expect(reports.map((r) => r.path.split('/').last), isNot(contains('UserPlayedItems')));
final stopped = reports.lastWhere((r) => r.path.endsWith('Stopped')).body;
expect(stopped['PositionTicks'], 300 * jellyfinTicksPerSecond,
reason: 'it resumes where this viewer stopped watching');
});
test('played before: a peek never unmarks it', () async { test('played before: a peek never unmarks it', () async {
final r = JellyfinProgressReporter( final r = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!, client: accounts.clientFor(fixtureServerId)!,
@@ -376,21 +398,6 @@ void main() {
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7'); expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
}); });
test('one auto-advance swapper: the lowest id among members with their own account', () {
final members = [
member('m10', 'Ann', jf: {fixtureServerId: true}),
member('m2', 'Bob', jf: {fixtureServerId: true}),
member('m1', 'Guest', jf: {fixtureServerId: false}),
member('m0', 'Old', jf: {fixtureServerId: true}, gone: true),
member('m3', 'Fresh'),
];
expect(autoAdvanceSwapper(members, fixtureServerId), 'm2');
expect(autoAdvanceSwapper(members.reversed.toList(), fixtureServerId), 'm2',
reason: 'order does not matter: every client agrees');
expect(autoAdvanceSwapper(members, 'other-server'), isNull);
expect(compareMemberIds('m9', 'm10'), lessThan(0));
});
test('member route: via server only for relay viewers of the current item', () { test('member route: via server only for relay viewers of the current item', () {
DeliveryRoute? route(MemberView m, {bool relay = true}) => DeliveryRoute? route(MemberView m, {bool relay = true}) =>
routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay); routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay);
@@ -508,23 +515,36 @@ void main() {
const JellyfinMedia( const JellyfinMedia(
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes')); serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async { testWidgets("end card: the server's countdown with Cancel, Play now; waiting for who puts it up",
var play = 0, cancel = 0; (tester) async {
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard( final queue = _graph.coordinator.queue;
next: next, countdown: null, onPlayNow: () => play++, onCancel: () => cancel++)))); const item = QueueItem(
expect(find.text('Next: S1E7 Battle with the Heroes'), findsOneWidget); id: 'q4',
expect(find.byKey(const ValueKey('auto-advance-cancel')), findsNothing); media: HelloMedia(name: 'ep7', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: fixtureServerId, itemId: nextEpisodeId)),
await tester.tap(find.byKey(const ValueKey('play-next'))); how: QueueHow.jellyfin,
expect(play, 1); title: 'Battle with the Heroes',
sub: 'S1E7',
by: 'm1',
byName: 'Ann',
auto: true,
forMedia: 3);
await tester.pumpWidget(scoped(_graph, app(UpNextCard(
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item, secondsLeft: 7)))));
expect(find.text('Next in 7 s'), findsOneWidget);
expect(find.text('Battle with the Heroes'), findsOneWidget);
expect(find.byKey(const ValueKey('up-next-cancel')), findsOneWidget);
expect(find.byKey(const ValueKey('up-next-play-now')), findsOneWidget);
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard( // Cancelled (for everyone): Play now only.
next: next, await tester.pumpWidget(scoped(_graph, app(UpNextCard(
countdown: AutoAdvance(mediaId: 3, secondsLeft: 7, next: next, swapper: false), queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item)))));
onPlayNow: () => play++, expect(find.text('Next'), findsOneWidget);
onCancel: () => cancel++)))); expect(find.byKey(const ValueKey('up-next-cancel')), findsNothing);
expect(find.text('Next episode in 7 s'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('auto-advance-cancel'))); // Ran out while the one who puts it up is gone: the card says so.
expect(cancel, 1); await tester.pumpWidget(scoped(_graph, app(UpNextCard(
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item, secondsLeft: 0, waitingFor: 'Ann')))));
expect(find.text('Waiting for Ann'), findsOneWidget);
}); });
testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async { testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async {
+152
View File
@@ -0,0 +1,152 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/session/media_plan.dart';
import 'package:starsync/src/session/relay_feeder.dart';
import 'package:starsync/src/session/status.dart';
// The plan step of the media pipeline as a table: the state, what this
// member serves and its capabilities -> where this app reads the media
// from.
const _server = ServerAddress('h', 9, 'TOK');
const _jf = JellyfinRef(serverId: 'S1', itemId: 'I1', title: 'Ep');
MemberView _m(String id, {Presence presence = Presence.watching, Map<String, bool> jf = const {}, ClientCaps caps = const ClientCaps()}) =>
MemberView(id: id, name: id.toUpperCase(), ready: false, inSet: true, buffered: 0, presence: presence, jf: jf, caps: caps);
StateBroadcast _state(Map<String, Object?>? media,
{List<MemberView>? members, bool relay = false, HoldInfo? hold, String transport = 'held'}) =>
StateBroadcast(
eventId: 1,
transport: transport,
position: 0,
serverTime: DateTime.utc(2026),
mediaId: 7,
media: media,
members: members ?? [_m('m1'), _m('m2')],
relay: relay,
hold: hold,
);
Map<String, Object?> _relay(MediaRelay r) => r.toJson();
SourcePlan _plan(StateBroadcast r,
{String self = 'm1', MediaOrigin? origin, bool resolve = true, Set<String> accounts = const {}}) =>
planMedia(PlanInput(
state: r,
selfId: self,
caps: ClientCaps(source: true, resolve: resolve),
server: _server,
origin: origin,
ownJellyfin: accounts.contains,
));
void main() {
group('plan: state + origin + caps -> source', () {
final probe = LinkProbe.file(Uri.parse('https://x/v.mkv'), 10);
final ytDlp = YtDlp('${Directory.systemTemp.path}/plan_no_ytdlp', enabled: false);
final rows = <(String, StateBroadcast, Map<String, Object?>, String)>[
('nothing up', _state(null), {}, 'NoSource'),
('your server\'s file', _state({'name': 'Ep 1.mkv', 'size': 5}), {}, 'ServerFile http://h:9/m/default/TOK/Ep%201.mkv'),
('a media file link', _state({'name': 'v', 'url': 'https://cdn/v.mkv'}), {}, 'DirectLink https://cdn/v.mkv page=false'),
('a page link', _state({'name': 'v', 'url': 'https://site/watch?v=1'}), {}, 'DirectLink https://site/watch?v=1 page=true'),
('a page link, no resolver here, m2 can',
_state({'name': 'v', 'url': 'https://site/watch?v=1'},
members: [_m('m1'), _m('m2', caps: const ClientCaps(resolve: true))]),
{'resolve': false},
"Unresolvable This device can't open web pages; ask M2 to pass it on (Through me)"),
('another member\'s relay, live',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2', live: true))}), {},
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
('another member\'s relay, not live yet: wait',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2'))}), {},
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=true'),
('its source left: open what the server has',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm9'))}), {},
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
('its source gone: open what the server has',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2', sourceGone: true))}), {},
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
('the server fetches, not read yet',
_state({'name': 'p', 'relay': _relay(const MediaRelay(fetch: true))}), {},
'RelayedSource http://h:9/r/default/TOK/7/p waiting=true'),
('a warm relay promoted: its queue id in the URL',
_state({'name': 'p', 'relay': _relay(const MediaRelay(source: 'm2', live: true, queueId: 'q3'))}), {},
'RelayedSource http://h:9/r/default/TOK/q3/p waiting=false'),
('your own file, passed on',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm1'))}), {'origin': const FileOrigin('/v/f.mkv')},
'OwnFile /v/f.mkv'),
('your own link, passed on',
_state({'name': 'p', 'relay': _relay(const MediaRelay(source: 'm1', live: true))}),
{'origin': LinkOrigin('https://site/p', probe, ytDlp)},
'OwnLink https://site/p'),
('your relay, origin forgotten (a restart): read the server\'s buffer',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm1', live: true))}), {},
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
('Jellyfin, own account',
_state({'name': 'Ep', 'jellyfin': _jf.toJson()}), {'accounts': {'S1'}},
'JellyfinSource own=true login=null member=null origin=false'),
('Jellyfin, the server\'s login',
_state({'name': 'Ep', 'jellyfin': _jf.toJson()}, relay: true), {},
'JellyfinSource own=false login=http://h:9/j/default/TOK/I1 member=null origin=false'),
('Jellyfin, a member passes it on',
_state({'name': 'Ep', 'jellyfin': _jf.toJson(), 'relay': _relay(const MediaRelay(source: 'm2'))}), {},
'JellyfinSource own=false login=null member=http://h:9/r/default/TOK/7/Ep origin=false'),
('Jellyfin, you put it up',
_state({'name': 'Ep', 'jellyfin': _jf.toJson(), 'relay': _relay(const MediaRelay(source: 'm1'))}),
{'accounts': {'S1'}, 'origin': const JellyfinOrigin(null)},
'JellyfinSource own=true login=null member=null origin=true'),
];
for (final (what, r, opts, want) in rows) {
test(what, () {
final got = _plan(r,
origin: opts['origin'] as MediaOrigin?,
resolve: opts['resolve'] as bool? ?? true,
accounts: opts['accounts'] as Set<String>? ?? const {});
expect(_describe(got), want);
});
}
});
test('a Jellyfin relay channel only when someone present needs it', () {
StateBroadcast with_(List<MemberView> ms, {bool relay = false}) =>
_state({'name': 'Ep', 'jellyfin': _jf.toJson()}, members: [_m('m1'), ...ms], relay: relay);
expect(jellyfinRelayNeeded(with_([_m('m2', jf: {'S1': true})]), _jf, 'm1'), isFalse, reason: 'everyone has an account');
expect(jellyfinRelayNeeded(with_([_m('m2', jf: {'S1': false})]), _jf, 'm1'), isTrue);
expect(jellyfinRelayNeeded(with_([_m('m2')]), _jf, 'm1'), isTrue, reason: 'no Jellyfin at all');
expect(jellyfinRelayNeeded(with_([_m('m2', caps: const ClientCaps(jellyfin: true))]), _jf, 'm1'), isFalse,
reason: 'its accounts are not reported yet: asked again with the next state');
expect(jellyfinRelayNeeded(with_([_m('m2')], relay: true), _jf, 'm1'), isFalse, reason: "the server's login passes it on");
expect(jellyfinRelayNeeded(with_([_m('m2', presence: Presence.away)]), _jf, 'm1'), isFalse, reason: 'sitting out');
expect(jellyfinRelayNeeded(with_([]), _jf, 'm1'), isFalse, reason: 'alone');
});
test("D6: held for the source: everyone's status waits for it by name", () {
final r = _state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2', live: true, sourceGone: true))},
members: [
_m('m1'),
MemberView(id: 'm2', name: 'Bob', ready: true, inSet: true, buffered: 0, presence: Presence.gone, goneSince: DateTime.utc(2026)),
],
hold: const HoldInfo(reason: HoldReason.source, members: ['m1']));
for (final own in [false, true]) {
final line = statusOf(StatusFacts(state: r, selfId: 'm1', ownPlan: own, now: DateTime.utc(2026))).line!;
expect(line.text, 'Waiting for Bob');
expect(line.kind, StatusKind.waiting);
expect(line.level, StatusLevel.warn);
}
});
}
String _describe(SourcePlan p) => switch (p) {
NoSource() => 'NoSource',
ServerFile(:final url) => 'ServerFile $url',
DirectLink(:final url, :final page) => 'DirectLink $url page=$page',
Unresolvable() => 'Unresolvable ${p.reason}',
RelayedSource(:final url, :final waiting) => 'RelayedSource $url waiting=$waiting',
OwnFile(:final path) => 'OwnFile $path',
OwnLink(:final url) => 'OwnLink $url',
JellyfinSource(:final ownAccount, :final serverLogin, :final memberRelay, :final origin) =>
'JellyfinSource own=$ownAccount login=$serverLogin member=$memberRelay origin=${origin != null}',
};
@@ -0,0 +1,106 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/playback/yt_dlp_app.dart';
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart';
// Commands that come while a media is switching (no player yet, or its
// media not loaded) wait and go to the new player once, so mpv never gets a
// seek it cannot run ("error running command seek", "event:5").
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
Future<String> _media() async {
final path = '$_tmp/media_switch_60s.mp4';
if (await File(path).exists()) return path;
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
'-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-movflags', '+faststart', 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), Duration every = const Duration(milliseconds: 100)}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(every);
}
fail('timed out waiting for $what');
}
void main() {
test('commands during a switch wait and apply once to the new player, without mpv errors',
timeout: const Timeout(Duration(minutes: 2)), () async {
final file = await _media();
// A host that answers a media request only after 2 s (a slow CDN):
// the new player has nothing loaded for that long.
final slow = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
slow.listen((req) async {
final size = await File(file).length();
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mp4');
if (r.status == 206 || r.status == 200) await Future.delayed(const Duration(seconds: 2));
req.response.statusCode = r.status;
r.headers.forEach(req.response.headers.set);
try {
if (r.status == 200 || r.status == 206) {
await req.response.addStream(File(file).openRead(r.start, (r.end ?? size - 1) + 1));
}
await req.response.close();
} catch (_) {}
});
final server = SyncServer(const AppConfig(port: 0, secret: 'p15', name: 'p15'));
await server.start();
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
await acc.ensureLoaded(validate: false);
final c = SessionCoordinator(
jellyfin: acc,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
ytDlp: AppYtDlp(YtDlp('$_tmp/media_switch_no_ytdlp', enabled: false)));
final errors = <String>[];
try {
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'p15', name: 'solo'));
await c.playback.setVolume(0);
final before = c.serverState.peek()?.mediaId ?? 0;
expect(await c.swapPicked(LinkMedia('http://127.0.0.1:${slow.port}/clip.mp4')), isNull);
// The switch: the state names the new media, the new player waits on
// the slow host.
await _poll(() => (c.serverState.peek()?.mediaId ?? 0) > before && c.media.openUrl != null, 'switching');
final p = c.playback.player;
final sub = p.logs.listen((l) {
if (l.text.contains('error running command') || l.text.contains('event:5')) errors.add(l.text);
});
expect(c.playback.switching, isTrue, reason: 'its media is not loaded yet');
final seeks = c.playback.seeksSent;
// The user skips ahead twice, changes the volume and readies, all
// while the media is still on its way.
c.seekTo(12);
c.seekTo(20);
await c.playback.setVolume(30);
c.toggleReady();
expect(c.playback.seeksSent, seeks, reason: 'nothing reached mpv yet');
await _poll(() => !c.playback.switching, 'loaded', timeout: const Duration(seconds: 20));
await _poll(() => c.playback.playing && c.playback.positionSecs > 20, 'playing from 20 s');
expect(c.playback.positionSecs, lessThan(23), reason: 'the latest seek, applied once it loaded');
expect(c.playback.seeksSent - seeks, 1, reason: 'the queued seeks went out as one');
expect(c.playback.player.state.volume, 30, reason: 'the volume went to the new player');
await sub.cancel();
expect(errors, isEmpty, reason: 'no command reached a player without its media');
} finally {
await c.dispose();
await server.stop();
await slow.close(force: true);
}
});
}
+56 -52
View File
@@ -1,25 +1,20 @@
// restructure: rewrite in step 6 (What's next): who performs an item and
// the janitor become server state.
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/files/scanner.dart' show FolderScan;
import 'package:starsync/src/library/library_store.dart';
import 'package:starsync/src/library/local_next.dart';
import 'package:starsync/src/library/show_matcher.dart';
import 'package:starsync/src/queue/queue_logic.dart'; import 'package:starsync/src/queue/queue_logic.dart';
MemberView member(String id, import 'show_matcher_test.dart' show FakeAniList;
{bool gone = false, bool away = false, Map<String, bool> jf = const {}, String name = ''}) =>
MemberView( // What's next on the app: an item's words, and what continues a file of
id: id, // a local show (who puts an item up and the countdown are core's and the
name: name.isEmpty ? id : name, // server's: core `whats_next_test`, `queue_test`).
ready: false,
inSet: true,
buffered: 0,
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching,
jf: jf);
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem( QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
id: 'q1', id: 'q1',
media: how == QueueHow.jellyfin media: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
? const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i'))
: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
how: how, how: how,
title: 'x', title: 'x',
by: by, by: by,
@@ -27,49 +22,58 @@ QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => Queue
); );
void main() { void main() {
group('queuePerformer', () {
test('a link passed on or a file: only its adder; gone waits for it, left means skipped', () {
for (final how in [QueueHow.through, QueueHow.file]) {
expect(queuePerformer(item(how), [member('m1'), member('m2', away: true)]).id, 'm2');
final waits = queuePerformer(item(how), [member('m1'), member('m2', gone: true)]);
expect((waits.id, waits.why), (null, null), reason: 'a gone adder may come back within its grace');
final left = queuePerformer(item(how), [member('m1'), member('m3')]);
expect(left.id, isNull);
expect(left.why, 'Bob left');
}
});
test('Jellyfin: the adder with access, else the lowest member with access, else skipped', () {
final access = {'S': true};
expect(queuePerformer(item(QueueHow.jellyfin), [member('m1', jf: access), member('m2', jf: access)]).id, 'm2');
expect(
queuePerformer(item(QueueHow.jellyfin), [member('m10', jf: access), member('m3', jf: access), member('m2')])
.id,
'm3');
expect(queuePerformer(item(QueueHow.jellyfin), [member('m3', jf: access, away: true), member('m2')]).why,
isNotNull);
});
test('a link everyone opens or the server fetches: the adder, else the lowest active member', () {
for (final how in [QueueHow.direct, QueueHow.server]) {
expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2');
expect(queuePerformer(item(how), [member('m4'), member('m2', gone: true), member('m3')]).id, 'm3');
final nobody = queuePerformer(item(how), [member('m2', away: true)]);
expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip');
}
});
});
test('the janitor is the lowest member that is not gone', () {
expect(queueJanitor([member('m9'), member('m1', gone: true), member('m2')]), 'm2');
});
test('labels', () { test('labels', () {
expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you'); expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you');
expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob'); expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob');
expect(queueHowLabel(item(QueueHow.file), selfId: 'm1'), "Bob's file"); expect(queueHowLabel(item(QueueHow.file), selfId: 'm1'), "Bob's file");
expect(queueHowLabel(item(QueueHow.file, byName: ''), selfId: 'm1'), "who added it's file");
expect(queueClock(3725), '1:02:05'); expect(queueClock(3725), '1:02:05');
expect(queueClock(65), '1:05'); expect(queueClock(65), '1:05');
expect(queueClock(0), ''); expect(queueClock(0), '');
}); });
group('what continues a file of a local show', () {
const dir = '/lib/Bleach';
String ep(int n) => '$dir/[Judas] Bleach - ${n.toString().padLeft(3, '0')}.mkv';
// 064 and 065 are there, 066..107 are not.
final files = [for (final n in [63, 64, 65, 108, 109]) LocalFile(ep(n), 1 << 30, DateTime(2026))];
late List<String> scanned;
late LocalNextFinder finder;
late ShowMatcher matcher;
setUp(() {
scanned = [];
matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: FakeAniList(), delay: (_) async {});
finder = LocalNextFinder(
matcher: matcher,
scan: (d) async {
scanned.add(d);
return FolderScan(videos: files, hiddenCount: 0, hiddenExamples: const [], subtitles: const {}, truncated: false);
});
});
test('from its own folder when the library has not seen it this run', () async {
final next = await finder.nextAfter(ep(64));
expect(next!.episode.path, ep(65));
expect(next.skipped, isNull);
expect(scanned, [dir]);
});
test('numbers skipped on the way are said', () async {
final next = await finder.nextAfter(ep(65));
expect(next!.episode.path, ep(108));
expect(next.skipped!.label, '66–107');
});
test('the last one, or not an episode: nothing continues it', () async {
expect(await finder.nextAfter(ep(109)), isNull);
expect(await finder.nextAfter('$dir/notes.mkv'), isNull);
});
test('the library the Files page saw is used without a scan', () async {
matcher.remember(groupLocal(files, roots: const ['/lib']));
expect((await finder.nextAfter(ep(63)))!.episode.path, ep(64));
expect(scanned, isEmpty);
});
});
} }
+46 -21
View File
@@ -1,30 +1,31 @@
// restructure: rewrite in step 5 (Media pipeline): preload and the warm
// relay become one pipeline; keep the timing per source.
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; 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_core/core.dart';
import 'package:sync_relay_server/server.dart'; import 'package:sync_relay_server/server.dart';
import 'package:starsync/src/jellyfin/accounts.dart'; import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/session_media.dart'; import 'package:starsync/src/jellyfin/session_media.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/playback/yt_dlp_app.dart'; import 'package:starsync/src/playback/yt_dlp_app.dart';
import 'package:starsync/src/queue/queue_controller.dart' show WarmState; import 'package:starsync/src/queue/queue_controller.dart' show WarmState;
import 'package:starsync/src/screens/home/picked_media.dart'; import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart'; import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/session/switch_trace.dart'; import 'package:starsync/src/session/switch_trace.dart';
import 'package:starsync/src/state/settings.dart';
// How long an Up next switch takes, end to end, over real mpv and a real // How long an Up next switch takes through the media pipeline, end to
// sync server: two Jellyfin account holders and a guest (through the // end, over real mpv and a real sync server: two Jellyfin account holders
// server's /j login) watch an episode, then ⏭ puts up the next episode, // and a guest (through the server's /j login) watch an episode, then ⏭ puts
// then a direct link, then a link passed on "through" alice, then one the // up the next episode, then a direct link, then a link passed on "through"
// server fetches (both relayed: the guests preload them from the server's // alice, then one the server fetches (both relayed: the guests prepare
// warm relay of the item). The fake Jellyfin and the link's host answer with a // them from the server's warm relay of the item). Every client prepares
// delay and a capped rate, like a server elsewhere. Prints each client's // the next item in a hidden player (`MediaPipeline.prepare`) and the swap
// phases (SwitchTrace) and the wall time from ⏭ to the server playing. // promotes it. The fake Jellyfin and the link's host answer with a delay
// and a capped rate, like a server elsewhere. Prints each client's phases
// (SwitchTrace) and the wall time from ⏭ to the server playing.
// //
// WT_SWITCH_PRELOAD=0 turns the preload off (the cold path, for comparison); // WT_SWITCH_PRELOAD=0 turns the preload off (the cold path, for comparison);
// WT_SWITCH_DELAY_MS and WT_SWITCH_RATE_KBS set the remote's delay and rate. // WT_SWITCH_DELAY_MS and WT_SWITCH_RATE_KBS set the remote's delay and rate.
@@ -202,7 +203,12 @@ void main() {
'Up next: ⏭ to a Jellyfin episode, then to a direct link, timed per client', 'Up next: ⏭ to a Jellyfin episode, then to a direct link, timed per client',
timeout: const Timeout(Duration(minutes: 4)), timeout: const Timeout(Duration(minutes: 4)),
() async { () async {
MediaKit.ensureInitialized(); // mpv's warnings, as the host writes them to the app log.
final mpvLines = <String>[];
// (The root's records, the app's logging not being hierarchical.)
final logSub = createLogger('mpv').onRecord.listen((r) {
if (r.loggerName == 'mpv') mpvLines.add(r.message);
});
final preload = Platform.environment['WT_SWITCH_PRELOAD'] != '0'; final preload = Platform.environment['WT_SWITCH_PRELOAD'] != '0';
final file = await _media(); final file = await _media();
final jelly = _FakeJellyfin(file); final jelly = _FakeJellyfin(file);
@@ -236,9 +242,21 @@ void main() {
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC'); final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
final accB = JellyfinAccounts(store: MemoryJellyfinStore()); final accB = JellyfinAccounts(store: MemoryJellyfinStore());
await accB.ensureLoaded(validate: false); await accB.ensureLoaded(validate: false);
SessionCoordinator coord(JellyfinAccounts acc) => SessionCoordinator( final hosts = <SessionCoordinator, MediaKitHost>{};
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp) SessionCoordinator coord(JellyfinAccounts acc) {
..playback.preloadEnabled = preload; final host = MediaKitHost()..init();
// Up next alone: no continuation of the episodes (it would be
// queued after the items this test adds and times).
final c = SessionCoordinator(
jellyfin: acc,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
ytDlp: ytDlp,
players: host,
settings: AppSettings()..autoAdvance.value = false)
..media.preloadEnabled = preload;
hosts[c] = host;
return c;
}
final a = coord(accA), b = coord(accB), c = coord(accC); final a = coord(accA), b = coord(accB), c = coord(accC);
final all = {'alice': a, 'guest': b, 'carol': c}; final all = {'alice': a, 'guest': b, 'carol': c};
ConnectParams params(String name, [HelloMedia? media]) => ConnectParams params(String name, [HelloMedia? media]) =>
@@ -252,7 +270,7 @@ void main() {
if (preload) { if (preload) {
for (final x in all.values) { for (final x in all.values) {
expect(x.queue.warm.peek()?.state, WarmState.ready, reason: 'ready on every client before ⏭'); expect(x.queue.warm.peek()?.state, WarmState.ready, reason: 'ready on every client before ⏭');
expect(x.playback.preloadUrl, isNotNull, reason: 'preloaded on every client'); expect(x.media.preloadUrl, isNotNull, reason: 'preloaded on every client');
} }
} }
final clock = Stopwatch()..start(); final clock = Stopwatch()..start();
@@ -269,11 +287,14 @@ void main() {
} }
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all play $what'); await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all play $what');
// The promoted player is the session player: marks, tracks and // The promoted player is the session player: marks, tracks and
// the volume carried over, the clock moves. // the volume carried over, the clock moves; the old one retired.
for (final x in all.values) { for (final x in all.values) {
expect(x.playback.durationSecs, greaterThan(59)); expect(x.playback.durationSecs, greaterThan(59));
expect(x.playback.player.state.volume, 0, reason: 'volume kept'); expect(x.playback.player.state.volume, 0, reason: 'volume kept');
await _poll(() => x.playback.tracks.peek().isNotEmpty, 'tracks listed'); await _poll(() => x.playback.tracks.peek().isNotEmpty, 'tracks listed');
final host = hosts[x]!;
await host.retiring;
expect(host.live, lessThanOrEqualTo(2), reason: 'the session player and at most the next prepared one');
} }
final at = a.playback.positionSecs; final at = a.playback.positionSecs;
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
@@ -286,7 +307,7 @@ void main() {
await b.connect(params('guest')); await b.connect(params('guest'));
await c.connect(params('carol')); await c.connect(params('carol'));
for (final x in all.values) { for (final x in all.values) {
await x.playback.player.setVolume(0); await x.playback.setVolume(0);
} }
await _poll(() => all.values.every((x) => x.playback.durationSecs > 59), 'all opened episode 1'); await _poll(() => all.values.every((x) => x.playback.durationSecs > 59), 'all opened episode 1');
expect(await a.queue.add(pickForRef(accA, const JellyfinRef(serverId: _sid, itemId: _ep2, title: 'Second'))!), expect(await a.queue.add(pickForRef(accA, const JellyfinRef(serverId: _sid, itemId: _ep2, title: 'Second'))!),
@@ -332,14 +353,17 @@ void main() {
// ignore: avoid_print // ignore: avoid_print
print(report.join('\n')); print(report.join('\n'));
expect(serverErrors, isEmpty); expect(serverErrors, isEmpty);
// No command reached a player still opening, and the
// cache is in RAM on every player (no "Failed to create file cache").
expect(mpvLines.where((l) => l.contains('error running command') || l.contains('file cache')), isEmpty);
// Nothing queued: no preload. Queued and taken off again: dropped. // Nothing queued: no preload. Queued and taken off again: dropped.
if (preload) { if (preload) {
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'nothing preloaded'); await _poll(() => all.values.every((x) => x.media.preloadUrl == null), 'nothing preloaded');
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull); expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
await _poll(() => all.values.every((x) => x.playback.preloadUrl == linkUrl), 'preloaded again'); await _poll(() => all.values.every((x) => x.media.preloadUrl == linkUrl), 'preloaded again');
a.queue.remove(a.serverState.peek()!.queue.first.id); a.queue.remove(a.serverState.peek()!.queue.first.id);
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'dropped when taken off'); await _poll(() => all.values.every((x) => x.media.preloadUrl == null), 'dropped when taken off');
} }
// Preloaded everywhere: every client starts from its preload. // Preloaded everywhere: every client starts from its preload.
if (preload) { if (preload) {
@@ -348,6 +372,7 @@ void main() {
} }
} }
} finally { } finally {
await logSub.cancel();
for (final x in all.values) { for (final x in all.values) {
await x.dispose(); await x.dispose();
} }
+9 -6
View File
@@ -4,7 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart'; import 'package:sync_relay_server/server.dart';
import 'package:starsync/src/playback/playback_controller.dart'; import 'package:starsync/src/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/session/media_pipeline.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path; final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
@@ -42,13 +43,15 @@ void main() {
final server = SyncServer(AppConfig( final server = SyncServer(AppConfig(
port: 0, secret: '', name: 'readahead', mediaPath: media)); port: 0, secret: '', name: 'readahead', mediaPath: media));
await server.start(); await server.start();
final p = PlaybackController(); final host = MediaKitHost();
final m = MediaPipeline(host: host);
final p = m.playback;
try { try {
await p.init(); await m.start();
expect(p.pinMismatches, isEmpty, expect(host.pinMismatches, isEmpty,
reason: 'every pinned mpv property took effect'); reason: 'every pinned mpv property took effect');
final token = server.session.issueToken('probe', ''); final token = server.session.issueToken('probe', '');
await p.openUrl( await m.open(
'http://127.0.0.1:${server.boundPort}/m/default/$token/readahead.mp4'); 'http://127.0.0.1:${server.boundPort}/m/default/$token/readahead.mp4');
var best = 0.0; var best = 0.0;
final end = DateTime.now().add(const Duration(seconds: 30)); final end = DateTime.now().add(const Duration(seconds: 30));
@@ -62,7 +65,7 @@ void main() {
expect(best, greaterThan(120), expect(best, greaterThan(120),
reason: 'paused player reads ahead about two minutes'); reason: 'paused player reads ahead about two minutes');
} finally { } finally {
await p.dispose(); await m.dispose();
await server.stop(); await server.stop();
} }
}, },
+226 -2
View File
@@ -35,15 +35,130 @@ Future<String> _media() async {
} }
Future<void> _poll(FutureOr<bool> Function() cond, String what, Future<void> _poll(FutureOr<bool> Function() cond, String what,
{Duration timeout = const Duration(seconds: 30)}) async { {Duration timeout = const Duration(seconds: 30), Duration every = const Duration(milliseconds: 200)}) async {
final deadline = DateTime.now().add(timeout); final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) { while (DateTime.now().isBefore(deadline)) {
if (await cond()) return; if (await cond()) return;
await Future.delayed(const Duration(milliseconds: 200)); await Future.delayed(every);
} }
fail('timed out waiting for $what'); fail('timed out waiting for $what');
} }
/// 60 s at about 3 Mbit/s: long enough that a slow source's relay holds
/// only part of it. An mp4 with its index first: headless players have no
/// video track selected (vid=no), and mkv's cues list only the video, so
/// an mkv would be seeked by scanning from its start (an app's player,
/// with its video output, uses the cues).
Future<String> _longMedia() async {
final path = '$_tmp/relay_e2e_60s.mp4';
if (await File(path).exists()) return path;
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=24',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
'-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-b:v', '3M', '-minrate', '3M', '-maxrate', '3M',
'-bufsize', '2M', '-g', '24', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', path,
]);
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
return path;
}
/// A file read at most [bytesPerSec] (a source's uplink).
class _SlowFile implements RelayBytes {
final File file;
final int bytesPerSec;
_SlowFile(this.file, this.bytesPerSec);
@override
Future<int> size() => file.length();
@override
Stream<List<int>> read(int start, int end) async* {
const chunk = 32 * 1024;
final sw = Stopwatch()..start();
var sent = 0;
await for (final c in file.openRead(start, end + 1)) {
for (var o = 0; o < c.length; o += chunk) {
final part = c.sublist(o, o + chunk > c.length ? c.length : o + chunk);
sent += part.length;
final due = sent * 1000 ~/ bytesPerSec - sw.elapsedMilliseconds;
if (due > 0) await Future<void>.delayed(Duration(milliseconds: due));
yield part;
}
}
}
@override
Future<void> close() async {}
}
/// A member that is only a source: it sits out (out of the gate), puts
/// [file] up relayed and feeds it at [bytesPerSec]. [drop] goes away
/// without a `bye` (the member `gone`, the relay kept); [back] returns as
/// the same member and feeds again.
class _BareSource {
final int port;
final File file;
final String name;
static const _client = 'bareSourceClient01';
var _seq = 0;
WebSocket? ws;
String token = '';
RelaySource? src;
Timer? _alive;
_BareSource(this.port, this.file) : name = 'slow.mp4';
Future<void> join() async {
final w = ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
final welcome = Completer<Map>();
w.listen((f) {
final j = jsonDecode('$f') as Map;
if (j['t'] == 'welcome' && !welcome.isCompleted) welcome.complete(j);
}, onError: (_) {});
w.add(jsonEncode(HelloMessage(
secret: 'pw', user: const HelloUser(name: 'bob'), client: _client, seq: ++_seq)
.toJson()));
token = (await welcome.future.timeout(const Duration(seconds: 5)))['token'] as String;
w.add(jsonEncode(const PresenceIntent(state: Presence.away).toJson()));
// Not silent, like an app (a silent member goes gone).
_alive?.cancel();
_alive = Timer.periodic(const Duration(seconds: 1),
(_) => w.add(jsonEncode({'t': 'report', 'position': 0, 'buffered': 0, 'eventId': 0})));
}
Future<void> swap() async {
ws!.add(jsonEncode({
't': 'swap',
'media': {'name': name, 'size': await file.length(), 'duration': 0, 'relay': <String, Object?>{}},
}));
}
Future<void> feed(int bytesPerSec) async {
final s = src = RelaySource(Uri.parse('ws://127.0.0.1:$port/r/default/$token/source'),
RelayMedia.single(_SlowFile(file, bytesPerSec)),
name: name);
await s.start();
}
/// Gone without a word: the channel and the socket close, no `bye`.
Future<void> drop() async {
_alive?.cancel();
await src?.close();
await ws?.close();
}
Future<void> back(int bytesPerSec) async {
await join();
await feed(bytesPerSec);
}
Future<void> close() async {
_alive?.cancel();
await src?.close();
await ws?.close();
}
}
/// Serves one file with ranges; [requests] counts hits. /// Serves one file with ranges; [requests] counts hits.
Future<(HttpServer, List<String>)> _origin(String file) async { Future<(HttpServer, List<String>)> _origin(String file) async {
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
@@ -267,4 +382,113 @@ void main() {
await proxy.close(); await proxy.close();
} }
}); });
// A seek into a part the relay has not buffered yet. The source's
// uplink is about 3x the media's bitrate; the time from the seek to the
// player playing there again is printed and loosely bounded.
test('a seek past what a slow source has sent plays within a few seconds',
timeout: const Timeout(Duration(minutes: 2)), () async {
final file = File(await _longMedia());
final alice = await member('alice');
final bob = _BareSource(server.boundPort, file);
try {
await bob.join();
await bob.swap();
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
await bob.feed(1200 * 1024);
await _poll(() => alice.playback.durationSecs > 59, 'alice opened it');
alice.toggleReady();
await _poll(() => alice.playback.playing && alice.playback.positionSecs > 2, 'playing');
final took = <int>[];
for (final to in [40.0, 52.0]) {
final sw = Stopwatch()..start();
alice.seekTo(to);
await _poll(() => alice.playback.playing && alice.playback.positionSecs > to + 0.5, 'played at $to',
every: const Duration(milliseconds: 20));
took.add(sw.elapsedMilliseconds);
}
// ignore: avoid_print
print('relay seek: seek to an unbuffered part until playing there: ${took.join(', ')} ms');
for (final t in took) {
expect(t, lessThan(5000), reason: "a seek into what the source has not sent yet (4 s of buffer at about 3x the bitrate)");
}
} finally {
await bob.close();
}
});
// D6, the viewer's side: the source goes, a seek lands past what
// the server got from it, and the buffer runs dry.
test('the source drops: what is buffered plays, a seek past it waits for the source, no seek loop, it resumes (D6)',
timeout: const Timeout(Duration(minutes: 3)), () async {
final file = File(await _longMedia());
final alice = await member('alice');
final carol = await member('carol');
final bob = _BareSource(server.boundPort, file);
final errors = <String>[];
final subs = <StreamSubscription<Object?>>[];
try {
await bob.join();
await bob.swap();
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
// Just above the bitrate: the relay holds only a little ahead.
await bob.feed(560 * 1024);
await _poll(() => alice.playback.durationSecs > 59 && carol.playback.durationSecs > 59, 'both opened it');
for (final x in [alice, carol]) {
subs.add(x.playback.player.logs.listen((l) {
if (l.text.contains('error running command')) errors.add(l.text);
}));
x.toggleReady();
}
await _poll(() => alice.playback.playing && alice.playback.positionSecs > 4, 'playing');
await bob.drop();
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.sourceGone ?? false, 'source gone');
expect(alice.status.peek().line?.text, startsWith("bob's connection dropped"));
final seeks = [alice.playback.seeksSent, carol.playback.seeksSent];
// Carol skips to where the source never got to.
carol.seekTo(50);
await _poll(() => alice.serverState.peek()!.hold?.reason == HoldReason.source, 'held for the source',
timeout: const Duration(seconds: 60));
expect(alice.status.peek().line?.text, 'Waiting for bob');
expect(carol.status.peek().line?.text, 'Waiting for bob');
// Held for a while: no seek loop into bytes nobody sends.
await Future.delayed(const Duration(seconds: 12));
final sent = [alice.playback.seeksSent - seeks[0], carol.playback.seeksSent - seeks[1]];
expect(sent.every((n) => n <= 1), isTrue, reason: 'one try each, no loop: $sent');
expect(alice.serverState.peek()!.transport, 'held');
expect([alice.playerError.peek(), carol.playerError.peek()], everyElement(isEmpty), reason: 'not a broken player');
// Someone joins meanwhile: it opens what the server has and waits too.
final dave = await member('dave');
subs.add(dave.playback.player.logs.listen((l) {
if (l.text.contains('error running command')) errors.add(l.text);
}));
await _poll(() => dave.status.peek().line?.text == 'Waiting for bob', 'dave waits for bob');
dave.toggleReady();
await bob.back(4 * 1024 * 1024);
await _poll(() {
final relay = stateMediaRelay(alice.serverState.peek()!);
return relay != null && relay.live && !relay.sourceGone;
}, 'source back');
await _poll(
() =>
alice.serverState.peek()!.transport == 'playing' &&
alice.playback.playing &&
alice.playback.positionSecs > 50.5 &&
carol.playback.positionSecs > 50.5 &&
dave.playback.positionSecs > 50.5,
'all playing at 50 again',
timeout: const Duration(seconds: 40));
// ignore: avoid_print
print('D6: seeks while the source was gone $sent, held back '
'${alice.media.heldBackSeeks} + ${carol.media.heldBackSeeks}');
expect(errors, isEmpty, reason: 'no command reached a player without its media');
} finally {
for (final s in subs) {
await s.cancel();
}
await bob.close();
}
});
} }
+12 -10
View File
@@ -2,7 +2,8 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:starsync/src/playback/playback_controller.dart'; import 'package:starsync/src/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/session/media_pipeline.dart';
import 'package:starsync/src/shaders/shader_assets.dart'; import 'package:starsync/src/shaders/shader_assets.dart';
import 'package:starsync/src/shaders/shader_presets.dart'; import 'package:starsync/src/shaders/shader_presets.dart';
import 'package:starsync/src/state/settings.dart'; import 'package:starsync/src/state/settings.dart';
@@ -21,12 +22,14 @@ void main() {
support.createSync(recursive: true); support.createSync(recursive: true);
shaderSupportDir = () async => support; shaderSupportDir = () async => support;
final s = AppSettings.instance; final s = AppSettings.instance;
final p = PlaybackController(); final m = MediaPipeline(host: MediaKitHost());
final p = m.playback;
try { try {
// The saved choice goes on at player creation. // The saved choice goes on at player creation.
s.setShaderPreset(ShaderPreset.a); s.setShaderPreset(ShaderPreset.a);
await p.init(); await m.start();
final dynamic mpv = p.player.platform; await p.applyShaders();
final mpv = p.player;
final modeA = final modeA =
await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const []); await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const []);
expect(shaderListMatches(modeA, await mpv.getProperty('glsl-shaders')), isTrue); expect(shaderListMatches(modeA, await mpv.getProperty('glsl-shaders')), isTrue);
@@ -59,17 +62,16 @@ void main() {
expect(await mpv.getProperty('glsl-shaders'), good.path); expect(await mpv.getProperty('glsl-shaders'), good.path);
// A recreated player (Reload video) gets the choice again. // A recreated player (Reload video) gets the choice again.
await p.recreate(); await m.reload();
expect(await (p.player.platform as dynamic).getProperty('glsl-shaders'), await p.applyShaders();
good.path); expect(await p.player.getProperty('glsl-shaders'), good.path);
s.setShaderPreset(ShaderPreset.off); s.setShaderPreset(ShaderPreset.off);
await p.applyShaders(announce: true); await p.applyShaders(announce: true);
expect(await (p.player.platform as dynamic).getProperty('glsl-shaders'), expect(await p.player.getProperty('glsl-shaders'), '');
'');
expect(p.shaderNotice.value?.text, 'Shaders: Off'); expect(p.shaderNotice.value?.text, 'Shaders: Off');
} finally { } finally {
await p.dispose(); await m.dispose();
} }
}); });
} }
+2 -2
View File
@@ -7,12 +7,12 @@ import 'package:flutter_test/flutter_test.dart';
/// (`AppGraph`, widgets through `AppScope`); this list may only shrink. /// (`AppGraph`, widgets through `AppScope`); this list may only shrink.
/// Lower a number when a use goes; never raise one or add a class. /// Lower a number when a use goes; never raise one or add a class.
const baseline = { const baseline = {
'AppSettings': 55, 'AppSettings': 49,
'JellyfinAccounts': 10, 'JellyfinAccounts': 10,
'UpdateController': 8, 'UpdateController': 8,
'Profile': 8, 'Profile': 8,
'FolderStore': 6, 'FolderStore': 6,
'AppYtDlp': 4, 'AppYtDlp': 2,
'RecentLinks': 3, 'RecentLinks': 3,
}; };
@@ -26,8 +26,7 @@ Future<void> _poll(
Future<void> _quiet(SessionCoordinator c) async { Future<void> _quiet(SessionCoordinator c) async {
await c.playback.player.setVolume(0); await c.playback.player.setVolume(0);
try { try {
final dynamic platform = c.playback.player.platform; await c.playback.player.setProperty('loop-file', 'inf');
await platform?.setProperty('loop-file', 'inf');
} catch (_) {} } catch (_) {}
} }
+23 -22
View File
@@ -1,14 +1,16 @@
// restructure: rewrite in step 6 (Media pipeline + player host): the
// fresh player per video.
import 'dart:async'; import 'dart:async';
import 'package:flutter_test/flutter_test.dart'; 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_core/core.dart';
import 'package:sync_relay_server/server.dart'; import 'package:sync_relay_server/server.dart';
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/session/session_coordinator.dart'; import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/sync/state_diff.dart'; import 'package:starsync/src/sync/state_diff.dart';
// "Reload video" through the media pipeline: a fresh player from the
// host, the old one retired, the same media opened again and resynced,
// the ready flag untouched; leaving releases every player the host made.
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4'; const _mediaPath = '/home/foxyz/Videos/chiyo.mp4';
Future<void> _poll( Future<void> _poll(
@@ -26,47 +28,46 @@ Future<void> _poll(
void main() { void main() {
test( test(
'reload video: new player, same media, resynced, ready untouched; ' 'reload video: a fresh player from the host, same media, resynced, ready untouched; '
'leave disposes the player', 'leave releases every player',
timeout: const Timeout(Duration(minutes: 2)), timeout: const Timeout(Duration(minutes: 2)),
() async { () async {
MediaKit.ensureInitialized(); final server = SyncServer(const AppConfig(port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
final server = SyncServer(const AppConfig(
port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
await server.start(); await server.start();
final c = SessionCoordinator(); final host = MediaKitHost()..init();
final c = SessionCoordinator(players: host);
try { try {
await c.connect(ConnectParams( await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'reload', name: 'solo'));
host: '127.0.0.1', await c.playback.setVolume(0);
port: server.boundPort,
secret: 'reload',
name: 'solo'));
await c.playback.player.setVolume(0);
await _poll(() => c.serverState.peek() != null, 'server state'); await _poll(() => c.serverState.peek() != null, 'server state');
c.toggleReady(); c.toggleReady();
await _poll( await _poll(() => c.playback.playing && c.playback.positionSecs > 2, 'solo playback past 2 s');
() => c.playback.player.state.playing && c.playback.positionSecs > 2, final opens = c.opens;
'solo playback past 2 s');
final old = c.playback.player; final old = c.playback.player;
final gen = c.playback.generation.peek(); final gen = c.playback.generation.peek();
await c.reloadVideo(); await c.reloadVideo();
expect(identical(c.playback.player, old), isFalse); expect(identical(c.playback.player, old), isFalse, reason: 'a fresh player');
expect(old.disposed, isTrue, reason: 'the old one retired');
expect(host.live, 1, reason: 'only the new one is alive');
expect(c.playback.generation.peek(), gen + 1); expect(c.playback.generation.peek(), gen + 1);
expect(c.opens, opens + 1, reason: 'the same media opened again');
expect(c.playback.volume.peek(), 0, reason: 'the volume carried over');
expect(c.selfMember.peek()?.ready, isTrue); expect(c.selfMember.peek()?.ready, isTrue);
await _poll(() { await _poll(() {
final r = c.serverState.peek(); final r = c.serverState.peek();
if (r == null || !c.playback.player.state.playing) return false; if (r == null || !c.playback.playing) return false;
final want = serverPositionAt(r, DateTime.now().toUtc()); final want = serverPositionAt(r, DateTime.now().toUtc());
return (c.playback.positionSecs - want).abs() < 1.5 && return (c.playback.positionSecs - want).abs() < 1.5 && c.playback.positionSecs > 2;
c.playback.positionSecs > 2;
}, 'reloaded player playing at the synced position'); }, 'reloaded player playing at the synced position');
expect(c.selfMember.peek()?.ready, isTrue); expect(c.selfMember.peek()?.ready, isTrue);
expect(c.serverState.peek()?.transport, 'playing'); expect(c.serverState.peek()?.transport, 'playing');
await c.leave(); await c.leave();
expect(c.playback.alive.peek(), isFalse); expect(c.playback.alive.peek(), isFalse);
await host.retiring;
expect(host.live, 0, reason: 'leaving releases every player');
} finally { } finally {
await c.dispose(); await c.dispose();
await server.stop(); await server.stop();
+1 -1
View File
@@ -27,7 +27,7 @@ void main() {
// dispose would wait on them for ever; the test process ending ends it. // dispose would wait on them for ever; the test process ending ends it.
setUpAll(() async { setUpAll(() async {
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
await coord.playback.init(); await coord.media.start();
}); });
testWidgets('wheel over the video: readout for about a second', (tester) async { testWidgets('wheel over the video: readout for about a second', (tester) async {
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/watch/watch_history.dart';
import 'package:starsync/src/watch/watch_tracker.dart';
// Watch tracking (D3): one watch per media of the server, time In from the
// presence the server reports, the device history for files and links,
// every closed watch out on `records` (the seam for AniList later).
final _t0 = DateTime.utc(2026, 1, 1);
void main() {
late WatchTracker tracker;
late List<WatchRecord> records;
setUp(() {
tracker = WatchTracker(history: WatchHistory(persist: false));
records = [];
tracker.records.listen(records.add);
});
tearDown(() => tracker.dispose());
const episode = {'name': 'Bleach - 064.mkv', 'size': 100, 'duration': 1440, 'ref': {'kind': 'episode', 'title': 'Bleach', 'absolute': 64}};
/// [secs] seconds of media [mediaId] from wall second [t], playing,
/// with self [present] or not.
int run(int mediaId, Map<String, Object?> media, int t, int secs,
{bool present = true, bool playing = true, String? ownFile, double from = 0}) {
for (var i = 0; i <= secs; i++) {
tracker.sample(WatchSample(
mediaId: mediaId,
media: media,
ownFile: ownFile,
position: from + i,
playing: playing,
present: present,
duration: 1440,
at: _t0.add(Duration(seconds: t + i)),
));
}
return t + secs + 1;
}
test('a file watched through goes into the history as watched; the next media closes the watch', () async {
const path = '/lib/Bleach/Bleach - 064.mkv';
final t = run(2, episode, 0, 1200, ownFile: path);
run(3, const {'name': 'next', 'size': 1}, t, 1);
await Future<void>.delayed(Duration.zero);
expect(records.first.key, WatchKey.file(path));
expect(records.first.verdict.outcome, WatchOutcome.played);
expect(records.first.ref?.absolute, 64);
final e = tracker.history.entry(WatchKey.file(path))!;
expect((e.played, e.resumeAt, e.title), (true, 0.0, 'Bleach - 064.mkv'));
expect(tracker.mediaId, 3);
});
test('sat out for most of it: not watched (P7), a resume point instead', () async {
const url = 'https://example.com/v.mp4';
final media = {'name': 'v', 'size': 0, 'duration': 1440, 'url': url};
var t = run(2, media, 0, 300);
t = run(2, media, t, 1100, present: false, from: 300);
tracker.close(at: _t0.add(Duration(seconds: t)));
await Future<void>.delayed(Duration.zero);
expect(records.single.verdict.outcome, WatchOutcome.partial);
expect(records.single.timeIn, closeTo(300, 2));
final e = tracker.history.entry(WatchKey.link(url))!;
expect(e.played, isFalse);
expect(e.resumeAt, closeTo(300, 1), reason: 'where this viewer was when it sat out');
});
test('a peek changes nothing; a played item stays played', () async {
const path = '/lib/x.mkv';
run(2, episode, 0, 1200, ownFile: path);
tracker.close();
expect(tracker.history.entry(WatchKey.file(path))!.played, isTrue);
run(4, episode, 0, 20, ownFile: path);
expect(tracker.session!.playedBefore, isTrue, reason: 'the history says it was watched');
tracker.close();
expect(tracker.history.entry(WatchKey.file(path))!.played, isTrue);
final other = WatchKey.of(const {'name': 'peeked', 'size': 5})!;
run(5, const {'name': 'peeked', 'size': 5, 'duration': 1440}, 0, 20);
tracker.close();
expect(tracker.history.entry(other), isNull);
});
test('Jellyfin: the watch is shared with its reports, not kept in the device history', () async {
final media = {
'name': 'ep',
'size': 0,
'duration': 1440,
'jellyfin': {'serverId': 'S', 'itemId': 'i'},
};
run(2, media, 0, 10);
tracker.before(played: true, resumeAt: 0);
expect(tracker.verdict!.outcome, WatchOutcome.keepPlayed);
tracker.close();
await Future<void>.delayed(Duration.zero);
expect(records.single.key.isJellyfin, isTrue);
expect(tracker.history.entries, isEmpty);
});
test('keys: own file, Jellyfin, own or shared link, else name and size', () {
expect(WatchKey.of(const {'name': 'a', 'size': 1}, ownFile: '/x.mkv'), WatchKey.file('/x.mkv'));
expect(
WatchKey.of(const {
'name': 'a',
'jellyfin': {'serverId': 'S', 'itemId': 'i'}
}),
WatchKey.jellyfin(const JellyfinRef(serverId: 'S', itemId: 'i')));
expect(WatchKey.of(const {'name': 'a', 'relay': {}}, ownLink: 'https://x/y'), WatchKey.link('https://x/y'));
expect(WatchKey.of(const {'name': 'a', 'url': 'https://x/z'}), WatchKey.link('https://x/z'));
expect(WatchKey.of(const {'name': 'a', 'size': 3}), WatchKey.media('a', 3));
expect(WatchKey.of(null), isNull);
});
}
+146
View File
@@ -0,0 +1,146 @@
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:starsync/src/files/scanner.dart' show scanFolder;
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/library/library_store.dart';
import 'package:starsync/src/library/local_next.dart';
import 'package:starsync/src/library/show_matcher.dart';
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/watch/watch_history.dart';
import 'package:starsync/src/watch/watch_tracker.dart';
import 'show_matcher_test.dart' show FakeAniList;
// What's next with real coordinators, real mpv and a dedicated server: a
// file of a local show put up by a member is continued by the show's next
// episode (numbers skipped on the way said), the server's countdown runs
// on every screen, the member who has the file puts it up, a cancel is
// shared, and what was watched goes into the device's watch history.
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
final _show = '$_tmp/whats_next_e2e/Show';
/// `Show - NN.mp4`, 6 s each, with audio (headless mpv needs it) and the
/// index first.
Future<String> _episode(int n) async {
final path = '$_show/Show - ${n.toString().padLeft(2, '0')}.mp4';
if (await File(path).exists()) return path;
await Directory(_show).create(recursive: true);
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24',
'-f', 'lavfi', '-i', 'sine=frequency=${300 + 100 * n}:sample_rate=48000',
'-t', '6', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-movflags', '+faststart', 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: 200));
}
fail('timed out waiting for $what');
}
void main() {
late SyncServer server;
late List<SessionCoordinator> all;
late List<Object> serverErrors;
Future<SessionCoordinator> member(String name, {Future<LocalNext?> Function(String)? nextFile}) async {
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
await acc.ensureLoaded(validate: false);
final c = SessionCoordinator(
jellyfin: acc,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
watch: WatchTracker(history: WatchHistory(persist: false)))
..nextFile = nextFile;
all.add(c);
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name));
await c.playback.player.setVolume(0);
return c;
}
setUp(() async {
MediaKit.ensureInitialized();
all = [];
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps'));
// A relay read cut short at a swap or the stop is reported to the
// zone the server started in (DEVELOPMENT "Traps").
serverErrors = [];
await runZonedGuarded(server.start, (e, _) {
if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return;
serverErrors.add(e);
});
});
tearDown(() async {
for (final x in all) {
await x.dispose();
}
await server.stop();
expect(serverErrors, isEmpty, reason: 'server errors');
});
test('a file of a show continues with its next episode; the countdown and a cancel are shared',
timeout: const Timeout(Duration(minutes: 3)), () async {
final ep1 = await _episode(1);
for (final n in [3, 4]) {
await _episode(n);
}
final matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: FakeAniList(), delay: (_) async {});
final alice = await member('alice', nextFile: LocalNextFinder(matcher: matcher, scan: scanFolder).nextAfter);
final bob = await member('bob');
expect(await alice.swapPicked(FileMedia(ep1)), isNull);
await _poll(() => bob.queue.continues.peek() != null, 'the continuation, for everyone');
final next = bob.queue.continues.peek()!;
expect((next.how, next.by, next.auto), (QueueHow.file, alice.connection.memberId, true));
expect(next.media.name, 'Show - 03.mp4');
expect(next.sub, contains('skips 2'));
expect(bob.queue.items.peek(), isEmpty, reason: 'it is not an Up next item of its own');
// Both ready: it plays to its end; the countdown is on both screens.
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 5), 'both opened episode 1');
final first = alice.serverState.peek()!.mediaId;
for (final x in [alice, bob]) {
x.toggleReady();
}
await _poll(() => alice.serverState.peek()?.transport == 'finished', 'episode 1 ended');
await _poll(() => [alice, bob].every((x) => (x.queue.countdown.peek()?.secondsLeft ?? 0) > 0), 'the countdown');
expect(bob.queue.countdown.peek()!.item.id, next.id);
await _poll(() => (bob.serverState.peek()?.mediaId ?? 0) > first, 'episode 3 up', timeout: const Duration(seconds: 20));
expect(bob.serverState.peek()!.media!['name'], 'Show - 03.mp4');
expect(bob.serverState.peek()!.media!['swapBy'], alice.connection.memberId, reason: 'who has the file put it up');
// Watched through: in the device history of both (alice's by its path,
// bob's by its name and size), recorded as the next one came up.
await _poll(() => alice.watch.history.entry(WatchKey.file(ep1))?.played ?? false, "alice's history");
await _poll(() => bob.watch.history.entries.any((e) => e.$2.played), "bob's history");
// Episode 3 continues with 4; at its end bob cancels: for everyone.
await _poll(() => alice.queue.continues.peek()?.media.name == 'Show - 04.mp4', 'episode 4 next');
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 5 && x.playback.player.state.playing),
'both play episode 3', timeout: const Duration(seconds: 20));
final third = alice.serverState.peek()!.mediaId;
await _poll(() => (alice.queue.countdown.peek()?.secondsLeft ?? 0) > 0, 'the countdown again');
bob.queue.cancelCountdown();
await _poll(() => alice.queue.countdown.peek()?.secondsLeft == null && alice.queue.continues.peek() == null,
'cancelled for alice too');
await Future.delayed(const Duration(seconds: 12));
expect(alice.serverState.peek()!.mediaId, third, reason: 'nothing came up');
expect(alice.serverState.peek()!.transport, 'finished');
});
}
+91 -27
View File
@@ -2,8 +2,10 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart'; import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/playback/playback_controller.dart'; import 'package:starsync/src/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/playback/yt_dlp_app.dart';
import 'package:starsync/src/session/media_pipeline.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path; final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
const _referer = 'https://www.bilibili.com/'; const _referer = 'https://www.bilibili.com/';
@@ -22,7 +24,8 @@ Future<(String, String)> _media() async {
return (v, a); return (v, a);
} }
/// A CDN like Bilibili's: 403 without the page's Referer. /// A CDN like Bilibili's: 403 without the page's Referer. `/video/...` is
/// the page itself (HTML: the app's probe sends it to yt-dlp).
class _Cdn { class _Cdn {
_Cdn(this._server, this._files) { _Cdn(this._server, this._files) {
_server.listen(_serve); _server.listen(_serve);
@@ -30,11 +33,27 @@ class _Cdn {
final HttpServer _server; final HttpServer _server;
final Map<String, String> _files; final Map<String, String> _files;
int ok = 0, forbidden = 0; int ok = 0, forbidden = 0;
final cookies = <String?>[];
String url(String name) => 'http://127.0.0.1:${_server.port}/$name'; String url(String name) => 'http://127.0.0.1:${_server.port}/$name';
String page(String id) => 'http://127.0.0.1:${_server.port}/video/$id';
int subtitles = 0;
Future<void> _serve(HttpRequest req) async { Future<void> _serve(HttpRequest req) async {
final res = req.response; final res = req.response;
if (req.uri.pathSegments.firstOrNull == 'video') {
res.headers.contentType = ContentType.html;
res.write('<html><title>Fake page</title></html>');
await res.close();
return;
}
if (req.uri.pathSegments.lastOrNull == 'en.vtt') {
subtitles++;
res.write('WEBVTT\n\n00:00.000 --> 00:05.000\nHello\n');
await res.close();
return;
}
final file = _files[req.uri.pathSegments.lastOrNull]; final file = _files[req.uri.pathSegments.lastOrNull];
if (file == null || req.headers.value('referer') != _referer) { if (file == null || req.headers.value('referer') != _referer) {
forbidden++; forbidden++;
@@ -43,6 +62,7 @@ class _Cdn {
return; return;
} }
ok++; ok++;
cookies.add(req.headers.value('cookie'));
final bytes = await File(file).readAsBytes(); final bytes = await File(file).readAsBytes();
var start = 0, end = bytes.length - 1; var start = 0, end = bytes.length - 1;
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? ''); final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
@@ -59,25 +79,37 @@ class _Cdn {
} }
} }
/// A stand-in yt-dlp printing what yt-dlp gives for a split-track page: /// A stand-in yt-dlp (the managed copy in its own folder) printing what
/// `requested_formats` with per-format `http_headers`, none at the top. /// yt-dlp gives for a split-track page: `requested_formats` with
Future<String> _fakeYtDlp(_Cdn cdn, {required bool headers}) async { /// per-format `http_headers` and `cookies`, subtitles with Bilibili's
final dir = await Directory('$_tmp/ytdl_headers_fake').create(recursive: true); /// danmaku among them. [fail]: it finds no formats instead.
Future<AppYtDlp> _fakeYtDlp(_Cdn cdn, {required bool headers, bool fail = false}) async {
final dir = Directory('$_tmp/ytdl_direct_${headers ? 'h' : 'n'}${fail ? 'f' : ''}');
if (dir.existsSync()) dir.deleteSync(recursive: true);
dir.createSync(recursive: true);
final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{}; final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{};
final json = jsonEncode({ final json = jsonEncode({
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake', 'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
'format_id': 'v+a', 'ext': 'mp4', 'format_id': 'v+a', 'ext': 'mp4',
'requested_formats': [ 'requested_formats': [
{'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4', {'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4',
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h}, 'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h,
'cookies': 'SESSDATA=abc; Domain=.127.0.0.1; Path=/'},
{'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a', {'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a',
'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h}, 'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h},
], ],
'requested_subtitles': {
'en': {'url': cdn.url('en.vtt'), 'ext': 'vtt', 'name': 'English'},
'danmaku': {'url': cdn.url('danmaku.xml'), 'ext': 'xml'},
},
}); });
final f = File('${dir.path}/yt-dlp-${headers ? 'h' : 'n'}'); final f = File('${dir.path}/yt-dlp');
await f.writeAsString("#!/bin/sh\ncat <<'EOF'\n$json\nEOF\n"); await f.writeAsString('#!/bin/sh\n'
'case "\$1" in --version) echo 2099.01.01; exit 0;; esac\n'
'${fail ? 'echo "ERROR: [fake] BV1: No video formats found!" >&2; exit 1\n' : "cat <<'EOF'\n$json\nEOF\n"}');
await Process.run('chmod', ['+x', f.path]); await Process.run('chmod', ['+x', f.path]);
return f.path; await File('${dir.path}/last-check').writeAsString(DateTime.now().toIso8601String());
return AppYtDlp(YtDlp(dir.path, updateEvery: const Duration(days: 9999)));
} }
void main() { void main() {
@@ -85,32 +117,39 @@ void main() {
late HttpServer server; late HttpServer server;
setUpAll(() async { setUpAll(() async {
MediaKit.ensureInitialized();
final (v, a) = await _media(); final (v, a) = await _media();
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
cdn = _Cdn(server, {'v.m4s': v, 'a.m4s': a}); cdn = _Cdn(server, {'v.m4s': v, 'a.m4s': a});
}); });
tearDownAll(() => server.close(force: true)); tearDownAll(() => server.close(force: true));
// A page "everyone fetches" opens through the app's own resolve: its
// stream URLs (an EDL of video + audio) with the CDN's headers; mpv's
// yt-dlp hook is off.
test( test(
"mpv's yt-dlp hook sends each format's Referer to the CDN", "a page plays from yt-dlp's stream URLs with each CDN's Referer and the cookies; subtitles but danmaku",
timeout: const Timeout(Duration(minutes: 1)), timeout: const Timeout(Duration(minutes: 1)),
() async { () async {
final p = PlaybackController(); final host = MediaKitHost()..init();
final m = MediaPipeline(host: host, links: await _fakeYtDlp(cdn, headers: true));
final p = m.playback;
try { try {
await p.init(); await m.start();
expect(p.pinMismatches, isEmpty, reason: 'stream-lavf-o and the rest took effect'); expect(host.pinMismatches, isEmpty, reason: 'stream-lavf-o, ytdl=no and the rest took effect');
await p.useYtDlp(await _fakeYtDlp(cdn, headers: true)); expect(await p.player.getProperty('ytdl'), 'no');
await p.openUrl('https://www.bilibili.com/video/BV1fake'); expect(await m.open(cdn.page('BV1fake')), isTrue);
final end = DateTime.now().add(const Duration(seconds: 20)); final end = DateTime.now().add(const Duration(seconds: 20));
while (DateTime.now().isBefore(end) && p.duration.peek() == Duration.zero) { while (DateTime.now().isBefore(end) && (p.duration.peek() == Duration.zero || cdn.subtitles == 0)) {
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
} }
expect(p.duration.peek(), greaterThan(Duration.zero), reason: 'both tracks opened'); expect(p.duration.peek(), greaterThan(Duration.zero), reason: 'both tracks opened');
expect(cdn.forbidden, 0, reason: 'every request had the Referer'); expect(cdn.forbidden, 0, reason: 'every request had the Referer');
expect(cdn.ok, greaterThanOrEqualTo(2)); expect(cdn.ok, greaterThanOrEqualTo(2));
expect(cdn.cookies, everyElement('SESSDATA=abc'));
expect(cdn.subtitles, 1, reason: 'the page\'s subtitles are added, danmaku is not');
await _until(() => p.tracks.peek().any((t) => t.isExternal), 'the subtitle track listed');
} finally { } finally {
await p.dispose(); await m.dispose();
} }
}, },
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script', skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
@@ -120,29 +159,54 @@ void main() {
'a 403 is a load error, not a reconnect loop', 'a 403 is a load error, not a reconnect loop',
timeout: const Timeout(Duration(minutes: 1)), timeout: const Timeout(Duration(minutes: 1)),
() async { () async {
final p = PlaybackController(); final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: false));
final p = m.playback;
final errors = <String>[]; final errors = <String>[];
p.onLoadError = errors.add; p.onLoadError = errors.add;
try { try {
await p.init(); await m.start();
var logs = 0; var logs = 0;
final sub = p.player.stream.log.listen((_) => logs++); final sub = p.player.logs.listen((_) => logs++);
await p.useYtDlp(await _fakeYtDlp(cdn, headers: false));
final before = cdn.forbidden; final before = cdn.forbidden;
await p.openUrl('https://www.bilibili.com/video/BV2fake'); await m.open(cdn.page('BV2fake'));
await Future.delayed(const Duration(seconds: 8)); await Future.delayed(const Duration(seconds: 8));
await sub.cancel(); await sub.cancel();
// ignore: avoid_print // ignore: avoid_print
print('403s: ${cdn.forbidden - before}, log lines: $logs, errors: $errors'); print('403s: ${cdn.forbidden - before}, log lines: $logs, errors: $errors');
expect(cdn.forbidden - before, lessThan(10), reason: 'no retry loop on 403'); expect(cdn.forbidden - before, lessThan(10), reason: 'no retry loop on 403');
expect(errors, hasLength(1)); expect(errors, hasLength(1));
// The status only when ffmpeg logs through this mpv (see _onLog). // The status only when ffmpeg logs through this mpv (see the host).
expect(errors.single, anyOf(contains('403'), contains('could not be opened'))); expect(errors.single, anyOf(contains('403'), contains('could not be opened')));
expect(logs, lessThan(30), reason: 'warnings and errors only'); expect(logs, lessThan(30), reason: 'warnings and errors only');
} finally { } finally {
await p.dispose(); await m.dispose();
} }
}, },
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script', skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
); );
// D11: yt-dlp finds no video; the error names the cookies it used.
test('a page yt-dlp finds no video on: the error says which cookies were used', () async {
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: true, fail: true));
final got = <(String, String?)>[];
m.onError = (raw, {cookies}) => got.add((raw, cookies));
try {
await m.start();
expect(await m.open(cdn.page('BV3fake')), isFalse);
expect(got.single.$1, contains('No video formats found'));
expect(got.single.$2, 'no cookies were used');
expect(withCookieNote(playErrorText(got.single.$1), got.single.$2),
'No video found: it may need signing in (no cookies were used)');
} finally {
await m.dispose();
}
}, skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script');
}
Future<void> _until(bool Function() cond, String what) async {
final end = DateTime.now().add(const Duration(seconds: 10));
while (!cond()) {
if (DateTime.now().isAfter(end)) fail('timed out waiting for $what');
await Future.delayed(const Duration(milliseconds: 100));
}
} }
+413 -197
View File
@@ -7,11 +7,12 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
| Path | What | Rule | | Path | What | Rule |
|---|---|---| |---|---|---|
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`), the library's L1 (`library/`: release and folder names, `groupLocal`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter | | `packages/core` | protocol messages, sync engine (`SyncEngine`, with what's next: `whatsNext`), constants, config, Range parsing, logging, Jellyfin client, updater, the rules every side reads alike (`whats_next.dart`: who puts an Up next item up, who adds the continuation), watched rule (`WatchSession`), the library's L1 (`library/`: release and folder names, `groupLocal`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter |
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter | | `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
| `apps/desktop` | Flutter app: UI, `AppGraph` (composition root), `SyncClient`, `Connection`, `SessionCoordinator`, `Navigation`, `YourServer` (`ServerIsolate`), `PlaybackController` (media_kit/mpv), update UI (`UpdateController`) | ships with `starsync-server` | | `apps/desktop` | Flutter app: UI, `AppGraph` (composition root), `SyncClient`, `Connection`, `SessionCoordinator`, `Navigation`, `YourServer` (`ServerIsolate`), `MediaPipeline` (opening media), `QueueController` (what's next on this app), `WatchTracker` (watch tracking), `PlaybackController` (the session player's marks and commands), the player host (`PlayerHost`, `MediaKitHost`: the only media_kit user besides the `Video` widget glue), update UI (`UpdateController`) | ships with `starsync-server` |
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only | | `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
| `deploy` | systemd unit for the dedicated server | | | `deploy` | systemd unit for the dedicated server | |
| `third_party/media_kit_video` | upstream media_kit_video 2.0.1 with Starsync's patches (`starsync/README.md`: Flutter ≥ 3.38 EGL display on the platform thread, the X11 GLX step-aside) | vendored; re-apply patches on update |
Guard: `packages/core/test/flutter_free_test.dart` fails if anything under Guard: `packages/core/test/flutter_free_test.dart` fails if anything under
`core/lib` imports Flutter, `dart:ui` or `signals_flutter`; CI runs `core/lib` imports Flutter, `dart:ui` or `signals_flutter`; CI runs
@@ -132,11 +133,14 @@ only stopping sharing sends guests out.
`AppGraph` (`app/app_graph.dart`) is built once in `main.dart` `AppGraph` (`app/app_graph.dart`) is built once in `main.dart`
(`AppGraph.build`) and constructs and disposes the app's systems: the (`AppGraph.build`) and constructs and disposes the app's systems: the
`Ports`, the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the `Ports` (the player host among them, handed to the coordinator), the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the
`SessionCoordinator`, the connect form and recents (`ConnectStore`, `SessionCoordinator` (handed the settings and the watch tracker), the
`state/connect_prefs.dart`), `YourServer`, `Navigation` and the library's connect form and recents (`ConnectStore`, `state/connect_prefs.dart`),
matches (`ShowMatcher`, "Library", whose `refForPath` it hands the `YourServer`, `Navigation`, the library's matches (`ShowMatcher`,
coordinator as `fileRef`); it wires them "Library", whose `refForPath` it hands the coordinator as `fileRef`, and
a `LocalNextFinder` over it as `nextFile`) and watch tracking
(`WatchTracker` with the device's `WatchHistory`, "Watch tracking"); it
wires them
through their constructors (and gives `Profile` the form and the push) and through their constructors (and gives `Profile` the form and the push) and
has no logic or signals of its own. Widgets reach it through one has no logic or signals of its own. Widgets reach it through one
`AppScope` InheritedWidget (`AppScope.of(context)`, readable in `AppScope` InheritedWidget (`AppScope.of(context)`, readable in
@@ -264,8 +268,8 @@ Loosely modeled on Syncplay, with a different readiness design.
pause after a newer play. Between events: drift pause after a newer play. Between events: drift
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**: a hosted player's public `seek` is the user's (its
user's: the session's seek bar (`ChapterSeekBar`, one `seek` per pointer `syncSeek` moves mpv): the session's seek bar (`ChapterSeekBar`, one `seek` per pointer
move while dragging and one on release, like media_kit's), the arrow 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 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
@@ -274,8 +278,9 @@ Loosely modeled on Syncplay, with a different readiness design.
most), server seeks never touch the player; the echo of our own seek is not most), server seeks never touch the player; the echo of our own seek is not
applied either (the player is already there). A server seek that has not applied either (the player is already there). A server seek that has not
landed after 1.5 s is re-applied once (`_checkSeekLanded`), to the server landed after 1.5 s is re-applied once (`_checkSeekLanded`), to the server
position recomputed now, and only while its event is still current and the position recomputed now, and only while its event is still current, the
user is not seeking. user is not seeking and the relay it reads has its source ("Viewer
recovery" under "Media pipeline").
- **Liveness**: WS ping every 5 s; a socket without any frame for 15 s - **Liveness**: WS ping every 5 s; a socket without any frame for 15 s
(`silence`) is closed (4015) and its member goes `gone`; the grace, the (`silence`) is closed (4015) and its member goes `gone`; the grace, the
`gone` notice, the joiner bound and the joining timeout follow. The `gone` notice, the joiner bound and the joining timeout follow. The
@@ -299,11 +304,35 @@ Loosely modeled on Syncplay, with a different readiness design.
unstable", 9 s is lost (well under the server's 15 s). A welcome that unstable", 9 s is lost (well under the server's 15 s). A welcome that
resumed needs nothing re-sent; a new member (grace over, server resumed needs nothing re-sent; a new member (grace over, server
restarted) gets the app's ready and sitting out again, and the media restarted) gets the app's ready and sitting out again, and the media
opens again only when it changed (or its URL named the old token). The opens again only when it changed (or its URL named the old token). A
restarted server that has no media while this app had some gets it
back ("Server restart" below). The
app's own `ping` every second keeps it under the server's silence limit app's own `ping` every second keeps it under the server's silence limit
even with nothing playing. Cancel during setup aborts the join. Leave even with nothing playing. Cancel during setup aborts the join. Leave
and closing the window send `bye` (see "App close" under Desktop UI); and closing the window send `bye` (see "App close" under Desktop UI);
Update and restart does not. Update and restart does not.
- **Server restart** (the queue stays in memory, the server stores
nothing): a reconnect that finds another `welcome.instance` with no
media, while this app had media up, holds it: the player plays on and
states without media are not applied (the watch goes on with it, see
"Watch tracking"). One app offers it back (`swap.restore`: the media,
this player's position, whether it played); who goes first is read
from the old state alone, so the apps agree: who put it up or passes
it on after 0.5 s, the others by their old member id from 3 s on,
1.5 s apart, each only while the server is still empty and only what
it can put up again (`_offerBack`: a Jellyfin item with its own
account, its file, the link it put up, a link everyone opens). The
server keeps the first offer. When it is back as the same media under
a new `mediaId`, the player keeps it unless its URL named the old
token (a relay viewer's `/r/`, the host's `/m/`: opened again), and
what this member passes on is served again (`MediaPipeline.resumeAs`:
its file, its link, its Jellyfin item for viewers without an account);
the watch, the Jellyfin reports and a cleared continuation carry over.
Up next comes back too (`QueueController.restoreAfterRestart`): the
app whose offer won adds the links everyone opens and the Jellyfin
items, every app its own links passed on and files; a server-fetched
link is lost with the old server. Nobody offering within 30 s: the
app lets go of it.
- **Auto sit-out** (`AutoSitOut`, `session/auto_sit_out.dart`; Settings - **Auto sit-out** (`AutoSitOut`, `session/auto_sit_out.dart`; Settings
"Auto sit-out", `AppSettings.autoSitOut`, default on): not playing and "Auto sit-out", `AppSettings.autoSitOut`, default on): not playing and
no input for 5 min sets this member `away` (reason `idle`); minimised no input for 5 min sets this member `away` (reason `idle`); minimised
@@ -406,8 +435,8 @@ Three sources, same sync. Any of them can also go through the relay
- **Link**: the media field holds an http(s) URL. It travels as `media.url` - **Link**: the media field holds an http(s) URL. It travels as `media.url`
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
directly (a web page through mpv's yt-dlp hook, see "yt-dlp"); the directly (a web page through its own yt-dlp, see "yt-dlp"); the server
server is only the clock. A pasted link that is only the clock. A pasted link that
`parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the `parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the
ServerId is known (in the link, else `/System/Info/Public`), so its ServerId is known (in the link, else `/System/Info/Public`), so its
`api_key` is never shared (`mediaForLink`, host and swap alike). `api_key` is never shared (`mediaForLink`, host and swap alike).
@@ -452,7 +481,8 @@ open-ended), then every seek is a fresh `bytes=N-` connection. A server
outage of a few seconds is ridden out from mpv's cache with no error. Longer outage of a few seconds is ridden out from mpv's cache with no error. Longer
outages exhaust the cache and rely on reconnect (not chaos-tested yet). outages exhaust the cache and rely on reconnect (not chaos-tested yet).
mpv settings (`PlaybackController`): `Player` with `bufferSize` 256 MiB mpv settings (the player host, `platform/desktop/media_kit_host.dart`,
set on every player before its first open): `Player` with `bufferSize` 256 MiB
(media_kit's `bufferSize` sets both `demuxer-max-bytes` and (media_kit's `bufferSize` sets both `demuxer-max-bytes` and
`demuxer-max-back-bytes`), `libass` on; pinned via runtime property: `demuxer-max-back-bytes`), `libass` on; pinned via runtime property:
`demuxer-readahead-secs=150`, `cache-secs=150` (about two minutes of `demuxer-readahead-secs=150`, `cache-secs=150` (about two minutes of
@@ -463,21 +493,25 @@ file is ~8), `demuxer-max-back-bytes=67108864`,
(comma form; this mpv build rejects colon-joined): a dropped connection (comma form; this mpv build rejects colon-joined): a dropped connection
or a 5xx is retried, at most 3 times per read and 4 s apart at most; a or a 5xx is retried, at most 3 times per read and 4 s apart at most; a
4xx (a CDN refusing a request) fails at once instead of being retried 4xx (a CDN refusing a request) fails at once instead of being retried
(older ffmpeg ignores `reconnect_max_retries`). A load that fails (mpv's (older ffmpeg ignores `reconnect_max_retries`), `ytdl=no` (pages are
resolved by the app, never by mpv's Lua hook). A load that fails (mpv's
fatal "No video or audio streams selected", or an EDL track that cannot fatal "No video or audio streams selected", or an EDL track that cannot
be opened) before a duration is known is reported once per open be opened) before a duration is known is reported once per open
(`onLoadError`), with ffmpeg's HTTP status when this player gets (`onLoadError`), with ffmpeg's HTTP status when this player gets
ffmpeg's log (ffmpeg logs through one mpv instance per process: the ffmpeg's log (ffmpeg logs through one mpv instance per process: the
first one, and once that is disposed the next one created; the preload first one, and once that is disposed the next one created; the host
player passes the HTTP errors it gets on to the session player). Since passes the HTTP errors any player gets on as `httpErrors`). Since every
every media gets its own player, the session player is often not that media gets its own player, the session player is often not that
instance: the status is a bonus, load errors are detected either way. That, a yt-dlp hook error and a failed `openUrl` become instance: the status is a bonus, load errors are detected either way.
the coordinator's `playerError` in plain words (`playErrorText`, core That, a page's resolve error (yt-dlp's words and the cookies it used,
D11, see "yt-dlp") and a failed open become the coordinator's
`playerError` in plain words (`playErrorText`, core
`link_probe.dart`: 412/precondition/login/cookies/sign in → "This site `link_probe.dart`: 412/precondition/login/cookies/sign in → "This site
needs cookies — Settings → Player → yt-dlp → Cookies", 403 → "Not allowed needs cookies — Settings → Player → yt-dlp → Cookies", 403 → "Not allowed
(403)", 404/410 → "Not found (404)", "Unsupported URL" → "Not a video (403)", 404/410 → "Not found (404)", "Unsupported URL" → "Not a video
page yt-dlp knows", network errors → "Couldn't reach the site", anything page yt-dlp knows", network errors → "Couldn't reach the site", anything
else as is). It rides in `report.error` (see "Player error" in the sync else as is; `withCookieNote` adds the cookies to the two sign-in texts).
It rides in `report.error` (see "Player error" in the sync
model) until the media opens (a new duration) or the server's `mediaId` model) until the media opens (a new duration) or the server's `mediaId`
moves on; the pill shows "Can't play this: ..." with Retry (Reload moves on; the pill shows "Can't play this: ..." with Retry (Reload
video). The cache is in RAM: `cache-on-disk=no` is pinned (media_kit video). The cache is in RAM: `cache-on-disk=no` is pinned (media_kit
@@ -486,8 +520,8 @@ sets it on without a `cache-dir`, so mpv fell back to RAM anyway, warning
ignores invalid values silently, so every pin is read back and a mismatch is ignores invalid values silently, so every pin is read back and a mismatch is
logged and kept in `pinMismatches`. Measured against the `/m` endpoint logged and kept in `pinMismatches`. Measured against the `/m` endpoint
(`readahead_e2e_test`): a paused player buffers ~147 s ahead (10 s before). (`readahead_e2e_test`): a paused player buffers ~147 s ahead (10 s before).
media_kit has no public `setProperty`/`getProperty`; the code calls them media_kit has no public `setProperty`/`getProperty`; the host calls them
through a guarded dynamic call on `player.platform`. Hardware decoding comes from Settings (auto, off, custom) through a guarded dynamic call on `player.platform` (`HostedPlayer`). Hardware decoding comes from Settings (auto, off, custom)
and is passed to the `VideoController`; env `SYNC_RELAY_HWDEC` (e.g. `no`) and is passed to the `VideoController`; env `SYNC_RELAY_HWDEC` (e.g. `no`)
overrides it. Auto is mpv `auto` on Linux and `auto-copy` on Windows overrides it. Auto is mpv `auto` on Linux and `auto-copy` on Windows
(`autoHwdec`, also for a blank custom value): media_kit renders through (`autoHwdec`, also for a blank custom value): media_kit renders through
@@ -516,10 +550,17 @@ set (M + S). A missing file, a path containing the list separator, a
read-back mismatch, or an mpv `libmpv_render` error while shaders are on read-back mismatch, or an mpv `libmpv_render` error while shaders are on
turns shaders off (saved) with a toast. turns shaders off (saved) with a toast.
A player with a video output opens media only once that output is up:
media_kit sets mpv's `vo=libmpv` before the output's render context
exists, and a media opened in between gets no video output ("No render
context set", black picture with sound). On X11 the context comes up
later, which widens that gap (joining with media already up).
Render mode: media_kit's Linux/Windows video output creates mpv's render Render mode: media_kit's Linux/Windows video output creates mpv's render
context (OpenGL, else its software renderer) when the `VideoController` is context (OpenGL, else its software renderer) when the `VideoController` is
created, and nothing in its API says which. The player therefore requests created, and nothing in its API says which. The host therefore requests
mpv logs at `v` while the controller is created: OpenGL init logs mpv logs at `v` while the first output of an app start is created
(`PlayerHost.video(probe: true)`): OpenGL init logs
`GL_VERSION` under `libmpv_render` `GL_VERSION` under `libmpv_render`
(`RenderMode.gpu`; "suspected software renderer" means llvmpipe, (`RenderMode.gpu`; "suspected software renderer" means llvmpipe,
`softwareGl`); none within 1.5 s of the controller being ready means the `softwareGl`); none within 1.5 s of the controller being ready means the
@@ -534,48 +575,121 @@ unset); from that `v` window only mpv's GL init lines (`GL_VENDOR`,
frame, `hwdec-current` and the `hwdec` asked for (the first one per app frame, `hwdec-current` and the `hwdec` asked for (the first one per app
start also `mpv-version` and `ffmpeg-version`). Why media_kit fell back to start also `mpv-version` and `ffmpeg-version`). Why media_kit fell back to
software it only prints on stderr (`media_kit: VideoOutput: ...`), which software it only prints on stderr (`media_kit: VideoOutput: ...`), which
the app does not capture. Otherwise the session player logs at `warn` the app does not capture. Otherwise a session player logs at `warn`
(`PlaybackController.logLevel`, switched with `mpv_request_log_messages` (`sessionLogLevel`; the host switches levels with
on media_kit's handle): media_kit drains mpv's log on the UI thread, one `mpv_request_log_messages` on media_kit's handle, the only raw mpv call
message at a time, so a chatty level, or ffmpeg logging every retry of a in the app): media_kit drains mpv's log on the UI thread, one message at
refused request, could starve the UI. Shader and yt-dlp hook errors are a time, so a chatty level, or ffmpeg logging every retry of a refused
`error` lines and still arrive. The thumbnail players keep request, could starve the UI. Shader errors are `error` lines and still
media_kit's default (`error`) and nothing listens to their log. The hardware path is arrive. The host writes every session and preload player's warnings and
errors to the app log (`mpv`, a few per kind at a time, `preload `
before the prepared player's). Thumbnail players log nothing. The hardware path is
fragile: media_kit borrows whatever EGL context GDK left current on the main fragile: media_kit borrows whatever EGL context GDK left current on the main
thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change
can silently fall back to software. can silently fall back to software.
Player lifecycle: a `Player` and its `VideoController` live for one **Player host** (`PlayerHost`, `platform/ports/player_host.dart`; the
media: created on join, disposed on leave, replaced by `recreate()` for desktop's `MediaKitHost`, one per app in `Ports.players`, tests make
"Reload video" (button or Ctrl+R; volume kept), and replaced on every their own): creates players by role (`session`: the full cache, `warn`;
media change, so nothing a player went through (a stuck video output, a `preload`: the prepared one, 48 MiB ahead, muted, `error`; `thumbnail`:
frozen frame) carries over to the next media. `openUrl` promotes the no outputs, no cache, no log) with every pin set before the caller's
preload when it holds the new media (see "Getting ready" under "Up first command (`pinMismatches` lists the read-backs that differ), gives
next"); otherwise, when the player already held media, it creates a fresh a player its video output (`HostedVideo`, with the hwdec policy:
one first, set up as on join (pins, hwdec, yt-dlp hook, shaders, log `SYNC_RELAY_HWDEC`, else the setting, `auto-copy` on Windows), runs the
level, observers) and, with the view up and the render mode known, its render-mode probe and its diagnostics (`renderMode`, also after leaving),
`VideoController` created and ready before the view swaps to it (else the routes mpv's log, and owns the order players go in: `retire` (pause, let
view creates it and the render-mode probe runs on it). Both go through the view drop the texture: two frames and a route pop, then dispose; a
one hand-over (`_adopt`): observers move over, the view swaps to the new new session player waits for the last one, so at most one old player
output (`generation`), volume (and so mute) and shaders carry over, and lingers), `dispose` (one no view showed) and `disposeAll` (app close:
the old player is paused and disposed once the view let go of its every live player, each bounded). A `HostedPlayer` is one mpv: state and
texture (as on leave). A fresh player waits for the previous old one to streams (position, duration, buffer, volume, completed, width, log),
be gone, so at most one old player lingers. The first open after join or open, play/pause/stop, `seek` (the user's, see "User seeks") and
Reload video uses the player just created; joining back to the media the `syncSeek`, rate, volume, properties, commands, `pin` (set and read
player held keeps it (`_openedFor`); a dropped connection never touches back), its log level. Nothing else in the app imports media_kit except
the player. Cost (`SwitchTrace` the `Video` widget glue (`session_player.dart`, `videoControllerOf`).
`player`, headless, so without the `VideoController`): 45 to 65 ms per
swap for one app in the process (mpv created and initialised, then **Media pipeline** (`MediaPipeline`, `session/media_pipeline.dart`, owned
pinned), about 150 ms with three in one process by the coordinator): the one way to open media, plan → resolve → open →
(`queue_switch_e2e_test` with `WT_SWITCH_PRELOAD=0`), against a cold ready.
switch of about 1.5 s. `openUrl` opens with `play: false`; play/pause/seek/rate come only from - Plan (`planMedia`, `session/media_plan.dart`, pure; `media_plan_test`
`StateDiff` actions, the play gate (`localPlayAllowed`: self in-set and holds the table): from `state.media`, what this member serves (its
ready) or the user's own seeks (see Sync model). A server seek within 0.5 s of the current position is skipped. `leave()` `MediaOrigin`: a file or a link it passes on, a Jellyfin item it put
stops the player (otherwise audio keeps playing). On media change up; set at its swap, claimed by the state that names it the relay's
(`mediaId` bump) the coordinator stops the previous media at once (no old source), its own capabilities and who is present, where this app reads
picture or sound while the new one resolves, is fetched or opens), then opens the new URL the media from (`SourcePlan`): your server's file (`/m`), a link
(or promotes the preload holding it) and `StateDiff` seeks to 0, holds everyone opens (`DirectLink`; a page is resolved here), another
and resets rate. member's relay or the server's fetch (`/r`, `waiting` until it is fed:
`relayWaitsForSource`), its own file or link (it plays its own copy and
feeds the relay), a Jellyfin item (`JellyfinSource`: own account, the
server's login `/j`, the member passing it on `/r`), or `Unresolvable`
(a page on a device that cannot resolve: `LinkResolver.resolvesLinks`
false; who can is read from `members[].caps.resolve`; desktop always
can, so this is Android's plug-in point).
- Resolve: a Jellyfin item through this app's account (or the plan
resolved ahead for Up next, `warmJellyfin`), else the server's login,
else the member passing it on, else blocked (`openJellyfin`); a web
page through the app's own yt-dlp (`LinkResolver.probe`: yt-dlp's
stream URLs, an EDL of video and audio, the CDN's headers with its
cookies, the subtitles; see "yt-dlp"), failing in plain words with the
cookies that were used (`ResolveFailure`, D11); a relay not fed yet is
opened once `relay.live`.
- Open: a player per media through the host. The prepared player
(Up next's, see "Getting ready") is promoted when it holds the same URL
and headers; else, when the session player already held media, a fresh
one takes over (pins, volume, shaders; with the view up and the render
mode known, its video output ready before the view swaps to it, else
the view makes it and the probe runs on it); the same media again (a
reconnect, joining back) keeps the player. Every hand-over is
`PlaybackController.attach`: log, marks and track observers move over,
the view swaps to the new output (`generation`), volume and shaders
carry over, the old player is retired. The first open after join or
Reload video uses the player just created; a dropped connection never
touches the player. "Reload video" (button or Ctrl+R) retires the
player, starts a fresh one and opens the media again (a page resolved
afresh: its stream URLs may have expired). Cost (`SwitchTrace` `player`,
headless, without the video output): 45 to 65 ms per swap for one app
in the process, about 150 ms with three in one process
(`queue_switch_e2e_test` with `WT_SWITCH_PRELOAD=0`), against a cold
switch of about 1.6 s.
- Ready: the coordinator reports at once after an open (`onOpened`) and
when the buffer reaches the gate's need while held, not on the next
tick.
- Commands: play/pause/seek/rate come only from `StateDiff` actions, the
play gate (`localPlayAllowed`: self in-set and ready) or the user's
own seeks (see Sync model); a server seek within 0.5 s of the current
position is skipped. They go through `PlaybackController.apply`: while
a switch is under way (no player, or its media has no duration yet)
they wait, the latest seek, transport and rate apply once to the new
player when its media is in; mpv never gets a seek it cannot run
(media_kit logged "error running command seek" / `event:5` for each).
The volume is the controller's (`setVolume`), on every next player.
Opens are paused (`play: false`). On media change (`mediaId` bump) the
previous media stops at once (no old picture or sound while the new
one resolves, is fetched or opens) and `StateDiff` seeks to 0, holds
and resets rate. Leaving stops and releases the players.
- Being a relay source (`RelayFeeder`, `session/relay_feeder.dart`): the
current media's channel and Up next's warm relay (see "Relay"), with
the step-1 re-dial (a source rides through this app's own drop). A
Jellyfin item this member put up is passed on only once someone
present needs it (`jellyfinRelayNeeded`: the server's login does not
cover it and a present member does not play that server with its own
account; one whose `caps` say Jellyfin but whose `jf` is not reported
yet is asked again with the next state), so a session where everyone
has an account opens no relay channel.
- Viewer recovery (D6): while the relay this app reads has no source
(`sourceGone`, or not live with the source away), it plays what is
buffered. A seek past that (a server seek, a drift correction) is
tried once; more wait, the seek check does not re-apply, and the
report says nothing is buffered once this player cannot be where the
server is (`starved`), so the server holds everyone with the reason
`source` ("Waiting for Bob"; see "App events and platform ports").
When the source is back, one seek to the server position resumes the
player, and a player that never loaded meanwhile (it opened while the
source was gone and its first bytes never came) opens again in a fresh
player. Measured (`relay_e2e`, a source just above the bitrate that
drops, a seek past what the server got, a member joining meanwhile):
at most one seek per viewer while it is gone, nobody's player broken,
all play from the seek once it is back.
Chapters, segments, tracks (`PlaybackController` signals, values from Chapters, segments, tracks (`PlaybackController` signals, values from
`playback/media_marks.dart`, no widgets; any controls UI reads them): `playback/media_marks.dart`, no widgets; any controls UI reads them):
@@ -716,7 +830,7 @@ and URL, played together by viewers as an mpv EDL
(it writes it to a playlist and `loadlist`s it) and mpv keeps both (it writes it to a playlist and `loadlist`s it) and mpv keeps both
streams in sync across seeks; one URL keeps the app's "one media URL" streams in sync across seeks; one URL keeps the app's "one media URL"
path (sync, swap, reopen) as is. The source member plays the page path (sync, swap, reopen) as is. The source member plays the page
itself (yt-dlp hook) like any other link. itself (its own resolve, see "yt-dlp") like any other link.
A page with only segmented streams (HLS `m3u8_native`, DASH A page with only segmented streams (HLS `m3u8_native`, DASH
`http_dash_segments`, e.g. X's HLS, generic `.m3u8`/`.mpd`) is relayed as `http_dash_segments`, e.g. X's HLS, generic `.m3u8`/`.mpd`) is relayed as
@@ -757,11 +871,11 @@ How bytes move:
6.6 s unchunked) and even beats mpv reading YouTube directly, since the 6.6 s unchunked) and even beats mpv reading YouTube directly, since the
buffer reads ahead at full speed. buffer reads ahead at full speed.
- The server keeps one buffer per track of a relayed media (one for a - The server keeps one buffer per track of a relayed media (one for a
file), a `RelayBuffer` for bytes: 1 MiB slices in file), a `RelayBuffer` for bytes: 256 KiB slices in
memory, filled by one sequential pull at a time. Kept: the first and memory (`relaySliceBytes`), filled by one sequential pull at a time. Kept: the first and
last 2 MiB (container indexes; mpv reads both on open and on seeks), last 2 MiB (container indexes; mpv reads both on open and on seeks),
64 MiB behind the slowest viewer and 256 MiB past the furthest, the 64 MiB behind the slowest viewer and 256 MiB past the furthest, the
same as each viewer's mpv buffer (`PlaybackController`), so a skip same as each viewer's mpv buffer (the player host's pins), so a skip
inside the viewers' buffer window is served from memory. Evicted by inside the viewers' buffer window is served from memory. Evicted by
distance from the viewers, never LRU. With 2+ viewers the source sends distance from the viewers, never LRU. With 2+ viewers the source sends
each byte about once. Several tracks split the ahead and behind budgets each byte about once. Several tracks split the ahead and behind budgets
@@ -789,12 +903,21 @@ How bytes move:
opened in 0.02 s from the buffer. opened in 0.02 s from the buffer.
- Filling a byte buffer: a viewer blocked on a slice first (oldest wait - Filling a byte buffer: a viewer blocked on a slice first (oldest wait
first, handing the source over at slice boundaries), then the pinned first, handing the source over at slice boundaries), then the pinned
slices, then read-ahead from the slowest viewer. Nothing is fetched while nobody slices, then read-ahead from the slowest viewer. A viewer reads the
watches through the relay (a Jellyfin source with only account holders slice being filled as its bytes arrive, not once it is whole (mpv's
uploads nothing). demuxer, blocked in a read, takes a seek only once that read returns).
Nothing is fetched while nobody watches through the relay (a Jellyfin
source with only account holders uploads nothing).
- mpv leaves superseded requests open but unread after a seek: only a - mpv leaves superseded requests open but unread after a seek: only a
viewer's newest request moves its position or asks the source for viewer's newest request moves its position or asks the source for
anything; older ones still get bytes that are already buffered. anything; older ones still get bytes that are already buffered, and a
request a newer one superseded stops holding the pull at once, so a
seek's pull starts without waiting for the old slice to finish.
Measured (`relay_e2e` "a seek past what a slow source has sent", a
source at about 3x a 3 Mbit/s mp4, the gate's 4 s of buffer): a seek
into what the source has not sent plays there after 2.8 to 2.9 s (4.0
to 4.3 s with 1 MiB slices served whole and the superseded request
holding the pull).
- Flow control on the source (`RelayWindow`): one in-flight budget per - Flow control on the source (`RelayWindow`): one in-flight budget per
connection, 2 x delivery rate x min RTT (rate max-filtered over 2 s), connection, 2 x delivery rate x min RTT (rate max-filtered over 2 s),
clamped 256 KiB to 8 MiB; the server acks bytes as they arrive. A clamped 256 KiB to 8 MiB; the server acks bytes as they arrive. A
@@ -834,20 +957,21 @@ How bytes move:
media_kit's mpv gives up on a silent request after 5 s media_kit's mpv gives up on a silent request after 5 s
(`network-timeout`) with "could not be opened". So viewers open a (`network-timeout`) with "could not be opened". So viewers open a
member relay's `/r/` URL only once `relay.live` is set, as for a member relay's `/r/` URL only once `relay.live` is set, as for a
server fetch (`_waitForSource`; "waiting for <name> to pass the video server fetch (`relayWaitsForSource` in the media plan; "waiting for
on…" meanwhile), unless the source already left (then they open what <name> to pass the video on…" meanwhile), unless the source already
the server has); Up next preloads wait the same way. The putter starts left (then they open what the server has); Up next preloads wait the
same way. The putter starts
its source before opening its own player, and the source reads its its source before opening its own player, and the source reads its
tracks' sizes at once. tracks' sizes at once.
- Source away (D6): viewers keep playing from the buffer; a request - Source away (D6): viewers keep playing from the buffer; a request
waits up to 60 s for missing bytes. The source member going `gone` waits up to 60 s for missing bytes. The source member going `gone`
does not end the relay: it lives through the source's grace does not end the relay: it lives through the source's grace
(`media.relay.sourceGone`, `ServerHub.refreshRelaySource`). The source (`media.relay.sourceGone`, `ServerHub.refreshRelaySource`). The source
(`RelaySource`, kept by the coordinator through the app's own drop) (`RelaySource`, kept by the media pipeline's `RelayFeeder` through the
reconnects its channel with backoff (its token lasts through the app's own drop) reconnects its channel with backoff (its token lasts
grace; a half-open channel is caught by its 5 s WebSocket ping) and through the grace; a half-open channel is caught by its 5 s WebSocket
sends its `info` again, the same on every dial (the coordinator passes ping) and sends its `info` again, the same on every dial (the feeder
the media's `name` and `duration`): the same as its first (sizes, piece passes the media's `name` and `duration`): the same as its first (sizes, piece
shapes, kinds, and name and duration when given) carries on, the shapes, kinds, and name and duration when given) carries on, the
buffer filler pulling what is missing; another one is closed with buffer filler pulling what is missing; another one is closed with
4002 and ends the relay (`ServerHub.relayOtherMedia`); a second channel 4002 and ends the relay (`ServerHub.relayOtherMedia`); a second channel
@@ -860,6 +984,10 @@ How bytes move:
a viewer opening then opens the buffer instead of waiting) and "<name> a viewer opening then opens the buffer instead of waiting) and "<name>
left — playing what the server has" once it left the server; the source left — playing what the server has" once it left the server; the source
gone for 5 s (`sourceDropNotice`) or leaving is also a toast, once each. gone for 5 s (`sourceDropNotice`) or leaving is also a toast, once each.
A viewer's buffer running dry holds everyone (`state.hold.reason`
`source`: "Waiting for <name>"); the viewer's side, never seeking past
what it has and resuming when the source is back, is "Viewer recovery"
under "Media pipeline".
- Stats (`relay_stats.dart`, `ServerHub.sampleRates` on the 1 s - Stats (`relay_stats.dart`, `ServerHub.sampleRates` on the 1 s
heartbeat; wire in [PROTOCOL.md](PROTOCOL.md)): the source's rate is heartbeat; wire in [PROTOCOL.md](PROTOCOL.md)): the source's rate is
its bytes over the time a pull is in flight (`FeedClock`, one per media its bytes over the time a pull is in flight (`FeedClock`, one per media
@@ -942,18 +1070,32 @@ all three link modes. Nothing is bundled or installed by hand.
→ Player → yt-dlp → Cookies (a browser and profile, or a file picked or → Player → yt-dlp → Cookies (a browser and profile, or a file picked or
dropped, kept whole: every site in it; "Remove cookies"; the shell's dropped, kept whole: every site in it; "Remove cookies"; the shell's
own drop target is off on Settings so only this box takes the file); your server shares the folder, own drop target is off on Settings so only this box takes the file); your server shares the folder,
so it uses them too. mpv's hook gets the same as `ytdl-raw-options` so it uses them too. A player gets them through the app's resolve
(`cookies=%N%<path>` / `cookies-from-browser=%N%<spec>`, length-quoted), (the format's cookies as its Cookie header), never on its own. The dedicated
re-set before each external link when it changed, after
`sub-langs=all,-danmaku` (Bilibili's danmaku XML is served raw-deflate:
ffmpeg warns on every read and the video waits on the track). The dedicated
server: `--yt-dlp-cookies <file>` / `ytDlpCookies` converted into its server: `--yt-dlp-cookies <file>` / `ytDlpCookies` converted into its
folder at start; without it the copy and browser setting are removed. folder at start; without it the copy and browser setting are removed.
Values are never logged and never leave the machine. Values are never logged and never leave the machine.
A resolve that fails in a way that may be about signing in (cookies,
log in, "No video formats found") says which cookies it used (D11,
`YtDlp.cookieNote`, `YtDlpException.cookies`, `LinkProbe.cookies`,
`withCookieNote`): none; the file and how many of its cookies are for
the site (`cookieSite`); a browser and how many cookies for the site
yt-dlp reads from it (one run without a URL, `--cookies-from-browser
<spec> --cookies <private file>`, which writes the browser's jar to a
0600 file in the yt-dlp folder, counted and deleted at once), or that
it could not read them. "No video found: it may need signing in (used
Firefox's cookies, which have none for bilibili.com: sign in to
bilibili.com there)".
- **Resolve** (`YtDlp.resolve`, `resolveArgs`): `yt-dlp -J --no-playlist --no-warnings - **Resolve** (`YtDlp.resolve`, `resolveArgs`): `yt-dlp -J --no-playlist --no-warnings
--ignore-config` plus the cookies, 25 s timeout: title, uploader, duration, thumbnail, --ignore-config -f bv*[height<=1080]+ba/b --write-subs --sub-langs
live or not, and what a relay reads (`pickTracks`), each format with all,-danmaku` plus the cookies, 25 s timeout: title, uploader, duration,
its URL, `http_headers` and `http_chunk_size`. Only plain http(s) thumbnail, live or not; what a player opens (`YtDlpPlay`: yt-dlp's
`requested_formats`, else the top-level format; http(s), HLS, or a DASH
manifest; one URL, or an mpv EDL of the video and the audio; the first
format's `http_headers` plus its `cookies` as a Cookie header; the
requested subtitles but Bilibili's danmaku, an XML comment feed); and
what a relay reads (`pickTracks`), each format with its URL,
`http_headers` and `http_chunk_size`. For a relay only plain http(s)
formats without DRM count: formats without DRM count:
- the best single file (`pickSingleFile`: video and audio not `none`, a - the best single file (`pickSingleFile`: video and audio not `none`, a
video container, tallest up to 1080p, else the shortest above), unless video container, tallest up to 1080p, else the shortest above), unless
@@ -986,18 +1128,19 @@ all three link modes. Nothing is bundled or installed by hand.
under the button) and offers "Put it up anyway" (`LinkMedia.anyway`), under the button) and offers "Put it up anyway" (`LinkMedia.anyway`),
for when the server or a friend with cookies may still open it; for when the server or a friend with cookies may still open it;
`swapLink` refuses it the same way for any other caller. `swapLink` refuses it the same way for any other caller.
- **Everyone fetches it**: the swap sends the page URL. Before opening an - **Everyone fetches it**: the swap sends the page URL. Every app
external link every app points mpv's hook at its managed copy resolves it with its own yt-dlp (the media pipeline's resolve step,
(`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`, `LinkResolver.probe`, waiting for the download on first use) and opens
once per player), waiting for the download unless the URL names a media what yt-dlp picked (`YtDlpPlay.mpvUrl`) with its headers as mpv's
file. A hook error is a player error (see the mpv settings under "Media"). The hook (mpv `http-header-fields` (media_kit's `Media.httpHeaders`), for every
0.41) gives mpv the first requested format's `http_headers` (User-Agent track of the EDL: Bilibili's CDN answers 403 without the Referer. Its
as `user-agent`; Referer, Cookie, X-Forwarded-For as file-local subtitles are added once the media is in (`sub-add <url> auto`). mpv's
`http-header-fields`, which win over the empty list media_kit sets on own Lua yt-dlp hook is off (`ytdl=no`): one resolve path for every mode,
load) and its `cookies` (`stream-lavf-o`), for every track of its EDL: cookies and headers always the app's, and a libmpv without Lua plays
Bilibili's CDN answers 403 without the Referer. The app never opens a links the same. A resolve that fails is a player error in plain words,
resolved format URL in mpv itself (the putter of "Through me" plays the with the cookies it used. Stream URLs are bound to the resolving
page through the hook too); relays read them with yt-dlp's headers. machine, so each app resolves its own; a page is resolved again at
Reload video and for Up next's 30-minute refresh (they expire).
- **Through me**: the putter's probe must give tracks, and a pieces - **Through me**: the putter's probe must give tracks, and a pieces
track's playlist must be readable, else the swap is refused ("Live track's playlist must be readable, else the swap is refused ("Live
streams can't be passed on", DRM, or nothing a relay can read) with a streams can't be passed on", DRM, or nothing a relay can read) with a
@@ -1044,29 +1187,19 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
should be) or a track change; Stopped on swap, leave, sitting out, window close should be) or a track change; Stopped on swap, leave, sitting out, window close
(bounded to 1.5 s inside the graceful leave) and at `finished`; seeking (bounded to 1.5 s inside the graceful leave) and at `finished`; seeking
back from the end sends Playing again. Reports carry `PositionTicks` back from the end sends Playing again. Reports carry `PositionTicks`
(see "Watched rule"), `IsPaused` and mpv's `aid`/`sid` as Jellyfin (see "Watched on Jellyfin"), `IsPaused` and mpv's `aid`/`sid` as Jellyfin
stream indices (`jellyfinIndexForMpvTrack`, `SubtitleStreamIndex -1` = stream indices (`jellyfinIndexForMpvTrack`, `SubtitleStreamIndex -1` =
off). They go out in order on one chain; failures are logged, one quiet off). They go out in order on one chain; failures are logged, one quiet
toast per session, playback is never touched; a 401 marks the account toast per session, playback is never touched; a 401 marks the account
expired and ends reporting. expired and ends reporting.
- **Watched rule** (`WatchSession` in `packages/core/lib/src/watch_session.dart`, - **Watched on Jellyfin**: the watch of the item is the session's (see
pure, meant for any tracker): each opening samples the position every "Watch tracking"); `JellyfinProgressReporter` reads its verdict and
tick; only normal playback counts (forward, at most 2x speed + 1.5 s, never samples itself. Jellyfin decides played from
samples at most 10 s apart; seeks, skips and pauses add nothing). The
union of the watched stretches is the coverage (rewatching counts once),
alongside the plain watched time, first / last / furthest position and
start / end time. At a stop: played before this opening -> keep played;
coverage >= 70 % of the runtime, or the ending reached (furthest position
past the Outro / ED start or into the last 15 %, at most 4 min, whichever
is earlier) with coverage >= 40 % -> played (someone who watched that
far and stops is done with it); coverage under max(2 min, 5 %) (at most a quarter of a short
item) -> a peek; else part way, resuming at the last position. An
unknown runtime never makes it played. Jellyfin decides played from
positions alone (10.11): Playing sets a resumable item unplayed, a positions alone (10.11): Playing sets a resumable item unplayed, a
Progress or Stopped position past "max resume" (90 %) marks it played, Progress or Stopped position past "max resume" (90 %) marks it played,
one in between keeps a resume point. So `JellyfinProgressReporter` one in between keeps a resume point. So the reporter
(played before = `UserData.Played` read at open with (played before = `UserData.Played` read at open with
`/UserItems/{id}/UserData`): `/UserItems/{id}/UserData`, handed to the watch with the resume point):
- played (before, or by the rule at an earlier stop of this opening): - played (before, or by the rule at an earlier stop of this opening):
`UserPlayedItems` right after Playing, Progress without `UserPlayedItems` right after Playing, Progress without
`PositionTicks` (leaves the saved position and played state), Stopped `PositionTicks` (leaves the saved position and played state), Stopped
@@ -1089,22 +1222,10 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
there is one); from `nextPromptStart` (the ending's there is one); from `nextPromptStart` (the ending's
start when one is known, see "Skip rules", else the last 2 min of the start when one is known, see "Skip rules", else the last 2 min of the
runtime) a floating "Next episode" pill shows while the controls are up runtime) a floating "Next episode" pill shows while the controls are up
(see "Player controls"). Auto-advance still waits for `finished`. Any (see "Player controls"). Any of them puts that episode up with the
of them swaps to that episode's ref with the clicker's account. Guests clicker's account (through Up next when it is the continuation there).
(relay, blocked) see none of it. At the end, the next episode continues the item by itself (see "What's
- **Auto-advance** (Settings, on by default): at `finished` with a known next"). Guests (relay, blocked) see none of it.
next episode, every client with the setting on shows a 10 s countdown
card (Cancel / Play now; without the setting, a "Next" card with Play
next). Exactly one client swaps: `autoAdvanceSwapper`, the lowest member
id among present members (`in` or `joining`) whose `jf` for that
server is true. Only what
every client sees in `state` decides it (who put the item up is not in
`state`; a late joiner never saw that swap), so clients agree without a
wire change. Cancel is local: it stops the countdown on that screen, and
the swapper's Cancel stops the swap; a seek by anyone ends `finished`
and with it every countdown. Only while Up next is empty and its
continuation was not cleared (see "Up next"); a queued item replaces
a countdown already running.
- **Tracks**: the controls' Audio and Subtitles buttons (or ⚙ when - **Tracks**: the controls' Audio and Subtitles buttons (or ⚙ when
hidden from the bar) list `PlaybackController.tracks`. A pick is applied, reported, and saved as a `TrackChoice` hidden from the bar) list `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
@@ -1117,6 +1238,43 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
Link goes through `mediaForLink`, a Jellyfin pick as a ref, a file Link goes through `mediaForLink`, a Jellyfin pick as a ref, a file
through `ServerIsolate.setFile` (host) or the relay (`swapFile`). through `ServerIsolate.setFile` (host) or the relay (`swapFile`).
## What's next
One system decides which item plays next and when, and has it prepared
ahead. The server holds the decision (`SyncEngine.whatsNext`, on every
tick and queue change; wire in [PROTOCOL.md](PROTOCOL.md), "Queue"); each
app's `QueueController` (`lib/src/queue/`) adds to the one list, puts an
item up when the server asks it, shows the countdown and keeps the next
item ready ("Getting ready" under "Up next").
- **One list**: the queue, Up next's items, then at most one `auto`
item, the continuation of the media playing (`for` its `mediaId`).
Everyone sees it; any swap that does not put it up drops it.
- **The continuation**: added by the app that put the media up (or, when
that member is not `in`, the first member who is: core's
`mayAddContinuation`), when it knows what comes next and the setting
"Play the next episode automatically" is on there: a Jellyfin episode
played with its own account continues with the series' next one
(`adjacentEpisodes`), a file this app put up with the next episode of
its show (`LocalNextFinder`: the library the Files page saw this run,
else the file's own folder scanned and grouped; `LocalShow.nextAfter`,
so the numbers skipped on the way are said in its second line, "skips
66–107"). It goes up like any item of its kind (a Jellyfin ref, the
adder's file), with a picture, and adds no feed line. A `cancel`
(Clear, or Cancel on the end card) drops it for everyone, and no app
adds it again for that media (the feed's `advance-cancel`).
- **When**: with nothing up, the first item is asked for at once. When
the media finishes with an item first, the server's countdown starts
(`state.advance`, 10 s, the same on every screen); a new first item
takes it over with the same end; a seek, `cancel` (shared) or a swap
ends it. At its end the server sets `queuePlay` and the app that puts
the item up (`queuePerformer`) swaps it in. It holds while that member
is `gone` (in its grace) or nobody is around, or while the item's warm
relay waits for a `gone` source, and asks once they are back. An item
asked for that nobody here can put up (its adder left, nobody has its
Jellyfin server) is skipped by the server (feed `queue-skip`, actor
`server`), and the countdown goes on with the next one.
## Up next ## Up next
The queue: what plays after the current media. The server holds the list The queue: what plays after the current media. The server holds the list
@@ -1139,27 +1297,28 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
and a file go up as a `key` only: the app keeps the URL or path in and a file go up as a `key` only: the app keeps the URL or path in
memory under it and nothing else leaves it. The adder gets a toast memory under it and nothing else leaves it. The adder gets a toast
"Added … to Up next"; everyone else an activity-log line. "Added … to Up next"; everyone else an activity-log line.
- **Who puts an item up** (`queuePerformer`, from `state` alone, so all - **Who puts an item up** (core's `queuePerformer`, from `state` alone,
clients agree): a link passed on or a file, only its adder (`gone`: so the server and every app agree): a link passed on or a file, only its adder (`gone`:
the item waits for it through its grace; left for good: skipped, "Bob the item waits for it through its grace; left for good: skipped, "Bob
left"); a Jellyfin item, its adder when it plays that server with its left"); a Jellyfin item, its adder when it plays that server with its
own account, else the lowest present member id that does (`jf`, which own account, else the lowest present member id that does (`jf`, which
lists every working account; nobody: skipped); a link everyone opens lists every working account; nobody: skipped); a link everyone opens
or the server fetches, its adder when present, else the lowest present or the server fetches, its adder when present, else the lowest present
member id (present: `in` or `joining`, `isPresent` in member id (present: `in` or `joining`, `isPresentMember`). An item asked
`session/party.dart`). A skip is a `remove` with the reason from the for that nobody can put up is skipped by the server (see "What's
lowest member id that is not `gone` (`queueJanitor`); the feed's next"); an item whose swap fails is taken off by the app that tried,
`queue-skip` toasts on every screen. with the reason; the feed's `queue-skip` toasts on every screen.
- **Advancing**: at `finished` with Up next waiting, every screen shows - **Advancing**: at `finished` with something next, every screen shows
`UpNextCard` (picture, title, who added it and how; with auto-advance on `UpNextCard` (picture, title, who added it and how; the server's
a 10 s countdown with Cancel, else Play now only); at zero the app that countdown with Cancel while `state.advance` runs, "Waiting for Bob"
puts the first item up does, with a swap naming it (`swap.queueId`), so when it ran out without the one who puts it up, else Play now only;
see "What's next"). The app that puts the item up does when the server
asks (`state.queuePlay`), with a swap naming it (`swap.queueId`), so
the server takes it off in the same step and a second swap of it is the server takes it off in the same step and a second swap of it is
ignored. Cancel is per screen like the next-episode countdown. "Play ignored. "Play now" anywhere (a row, the card, the Up next button's ⏭,
now" anywhere (a row, the card, the Up next button's ⏭, `>` / Shift+N) swaps `>` / Shift+N) swaps directly from the app that can, else sends `play`
directly from the app that can, else sends `play` and that app reacts and that app reacts to `state.queuePlay`. With nothing up at all, the
to `state.queuePlay`. With nothing up at all, the first item goes up as server asks for the first item as soon as it is added. The swap goes through the usual paths
soon as it is added. The swap goes through the usual paths
(`swapPicked`, `swapJellyfin`; a server-fetched item through (`swapPicked`, `swapJellyfin`; a server-fetched item through
`swapQueued`, without the URL, its picture as the art), so relays, art `swapQueued`, without the URL, its picture as the art), so relays, art
and refusals work as for any swap; when it fails the item is skipped and refusals work as for any swap; when it fails the item is skipped
@@ -1168,14 +1327,15 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
out comes back first; any other queued swap leaves the swapper out. out comes back first; any other queued swap leaves the swapper out.
- **Getting ready** (`QueueController._warm`): every app keeps the one - **Getting ready** (`QueueController._warm`): every app keeps the one
item that plays next ready all the time, its own copy, so a skip, Play item that plays next ready all the time, its own copy, so a skip, Play
now or the end of the media starts it at once. That is the first Up now or the end of the media starts it at once. That is the queue's
next item, else, with Up next empty, the first "Continues with" first item (Up next's, else the continuation, which is in the queue
episode (on the apps that have it); while sitting out only the probe for everyone); while sitting out only the probe
of a link this app puts up (its swap reads it). Per of a link this app puts up (its swap reads it). Per
kind: a Jellyfin item is resolved with this app's account kind: a Jellyfin item is resolved with this app's account
(`resolveJellyfinRef`: PlaybackInfo and the item) into (`resolveJellyfinRef`: PlaybackInfo and the item) into
`SessionCoordinator.warmJellyfin`, which `_playJellyfin` takes instead `SessionCoordinator.warmJellyfin`, which the media pipeline's resolve
of resolving when the same ref goes up within 35 minutes and the (`openJellyfin`) takes instead of asking again when the same ref goes
up within 35 minutes and the
account is unchanged; without an account it is the server's `/j` URL account is unchanged; without an account it is the server's `/j` URL
when `state.relayNext` says the server's login passes it on (see when `state.relayNext` says the server's login passes it on (see
"Jellyfin relay"). A link everyone opens is that link; the app that "Jellyfin relay"). A link everyone opens is that link; the app that
@@ -1184,35 +1344,36 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
read it from there). A link passed on "through me" and a file on read it from there). A link passed on "through me" and a file on
their adder's app (the link, the path; not the host's own file, which their adder's app (the link, the path; not the host's own file, which
plays from `/m`), which also feeds the server's warm relay of it plays from `/m`), which also feeds the server's warm relay of it
(`QueueController._feedWarm`, `SessionCoordinator.feedWarm`; a (`QueueController._feedWarm`, `RelayFeeder.feedWarm`; a
Jellyfin item's from the member who puts it up, with its resolved Jellyfin item's from the member who puts it up, with its resolved
stream, when the server asks for one); everyone else, and everyone stream, when the server asks for one); everyone else, and everyone
for a server-fetched link, preloads that warm relay's URL for a server-fetched link, preloads that warm relay's URL
(`SessionCoordinator.warmRelayUrl`) once `queue[0].media.relay.live` (`MediaPipeline.warmRelayUrl`) once `queue[0].media.relay.live`
says bytes can flow (see "Relay"). At the swap the adder's app skips says bytes can flow (see "Relay"). At the swap the adder's app skips
re-reading the link's sizes (its warm source has them) and that re-reading the link's sizes (its warm source has them) and that
source carries on as the media's. It source carries on as the media's. It
is resolved as soon as it is first there, again every 30 minutes, and is resolved as soon as it is first there, again every 30 minutes, and
dropped when it stops being first. dropped when it stops being first.
**Preload**: once resolved, the stream is opened in a second, hidden **Preload**: once resolved, the stream is opened early by the media
mpv player (`PlaybackController.preload`, through pipeline (`MediaPipeline.prepare`, a page through the same resolve as
`SessionCoordinator.preloadMedia`): paused at the start, muted, with any open) in a second, hidden player (the host's `preload` role):
its own video output when the session view is up (media_kit starts a paused at the start, muted, with its own video output when the
player with `vid=no`; the output decodes the first frame), the same session view is up (media_kit starts a player with `vid=no`; the
pins, hwdec and yt-dlp hook as the session player but a 48 MiB forward output decodes the first frame), the same pins and hwdec as the
and 8 MiB back cache (`preloadBytes`), mpv log at `error`. One at a session player but a 48 MiB forward and 8 MiB back cache
time; it waits until the media playing has started here (`preloadAheadBytes`), mpv log at `error`. One at a time; it waits
(`SessionCoordinator.settledMediaId`: opened, failed or nothing to until the media playing has started here
(`MediaPipeline.settledMediaId`: opened, failed or nothing to
open), so it never competes with that open and a preload a swap is open), so it never competes with that open and a preload a swap is
about to take is never replaced first; a removed, reordered or replaced about to take is never replaced first; a removed, reordered or replaced
first item swaps or drops it then, a manual swap to something else first item swaps or drops it then, a manual swap to something else
keeps it (still next). A link is opened again at the 30-minute refresh keeps it (still next). A link is opened again at the 30-minute refresh
(stream URLs expire); a failed preload is not retried until then and (stream URLs expire); a failed preload is not retried until then and
changes nothing (the usual open follows). When the coordinator opens changes nothing (the usual open follows). When the pipeline opens the
the same URL and headers (`openUrl`), the preload is promoted instead same URL and headers, the preload is promoted instead of a fresh
of a fresh player: it becomes the session player through the same player: it becomes the session player through the same hand-over (see
hand-over (see "Player lifecycle" under "Media"), unmuted and with the "Media pipeline" under "Media"), unmuted and with the full cache pins
full cache pins. Its buffer comes along, (`PlayerHost.promote`), its subtitles added. Its buffer comes along,
so the report sent right after the open already passes the gate: the so the report sent right after the open already passes the gate: the
start no longer waits for anyone's open. A promoted player is no start no longer waits for anyone's open. A promoted player is no
longer the first mpv of the process, so ffmpeg's HTTP status reaches longer the first mpv of the process, so ffmpeg's HTTP status reaches
@@ -1220,33 +1381,33 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
errors themselves are still detected; see "Media"). Off with env errors themselves are still detected; see "Media"). Off with env
`SYNC_RELAY_PRELOAD=0`. Measured (`queue_switch_e2e_test`, a fake `SYNC_RELAY_PRELOAD=0`. Measured (`queue_switch_e2e_test`, a fake
Jellyfin and link host answering after 120 ms at 24 Mbit/s, two Jellyfin and link host answering after 120 ms at 24 Mbit/s, two
account holders and a `/j` guest): ⏭ to the server playing 59 to account holders and a `/j` guest): ⏭ to the server playing 74 to
86 ms for the next episode and 35 ms for a direct link, against about 87 ms for the next episode and 35 ms for a direct link, against about
1.5 s cold; at 300 ms and 12 Mbit/s 52 and 103 ms against about 2.9 s. 1.6 to 1.8 s cold; at 300 ms and 12 Mbit/s 52 and 103 ms against about
Relayed, from the warm relay (same setup, loopback server): a link 2.9 s. Relayed, from the warm relay (same setup, loopback server): a
passed on 21 to 22 ms, a server-fetched link 35 to 37 ms, against link passed on 36 to 38 ms, a server-fetched link 46 to 53 ms, against
about 1.6 s cold. about 1.6 s cold.
`SwitchTrace` (env `SYNC_RELAY_TRACE_SWITCH=1` prints it) times each `SwitchTrace` (env `SYNC_RELAY_TRACE_SWITCH=1` prints it) times each
phase of a media change: state, resolved, open, player (a fresh phase of a media change: state, resolved, open, player (a fresh
player is up; see "Player lifecycle" under "Media"), opened or player is up; see "Media pipeline" under "Media"), opened or
preloaded, buffered, playing. preloaded, buffered, playing.
The row of the item shows a dot on its picture's corner (a tooltip The row of the item shows a dot on its picture's corner (a tooltip
says which): hollow waiting, pulsing getting ready (resolving, then says which): hollow waiting, pulsing getting ready (resolving, then
preloading its first 4 s), filled ready, red failed (tried again every preloading its first 4 s), filled ready, red failed (tried again every
2 minutes). 2 minutes).
- **Continues with**: not queued, computed here like the next episode: - **Continues with**: the continuation (the `auto` item, see "What's
with an own-account Jellyfin episode playing, the next three episodes next"), greyed under Up next for everyone, ▶ plays it now; "Clear"
of the series (`/Shows/{id}/Episodes`, cached), greyed under Up next, drops it for everyone (`cancel`). Under it, on an app playing a
"+" adds one. "Clear" hides them for the media playing and keeps its Jellyfin episode with its own account, the series' further episodes
next-episode countdown from starting. (`/Shows/{id}/Episodes`, cached), "+" adds one to Up next.
- **UI**: `UpNextButton` is one of the player's top-bar controls, before - **UI**: `UpNextButton` is one of the player's top-bar controls, before
the window controls (see "Player overlay" under Desktop UI), in the look the window controls (see "Player overlay" under Desktop UI), in the look
of the skin's buttons (`UpNextLook`); it shows and hides with the controls, of the skin's buttons (`UpNextLook`); it shows and hides with the controls,
which an open popover holds up: the first item's picture (`QueueThumb`, which an open popover holds up: the first item's picture (`QueueThumb`,
the rows' faint box and icon until it arrives), "UP NEXT" over its the rows' faint box and icon until it arrives), "UP NEXT" over its
title, a +n count, ⏭ plays it now; without Up next the continuation title, a +n count, ⏭ plays it now; without Up next the continuation
(the episode's image, "NEXT · S1 E8" over its name, ⏭ = next episode); (its picture, "NEXT · S1E8" (its second line) over its name, ⏭ plays
nothing when both are empty. Resting on it 600 ms (popover closed) it); nothing when both are empty. Resting on it 600 ms (popover closed)
shows `UpNextPreview` under it, right edges aligned, in `AppGlass`: shows `UpNextPreview` under it, right edges aligned, in `AppGlass`:
the picture large, title, second line, "length · added by (avatar) the picture large, title, second line, "length · added by (avatar)
name · how" (an episode: series, length, overview), how ready it is name · how" (an episode: series, length, overview), how ready it is
@@ -1352,7 +1513,54 @@ show or movie, `ProviderIds` in the item fields; from a pick's lines,
title). Swaps (`swapFile`, `swapJellyfin`, `swapLink`) and Up next adds title). Swaps (`swapFile`, `swapJellyfin`, `swapLink`) and Up next adds
carry it; Up next names a file item by it. Consumers: receivers' now carry it; Up next names a file item by it. Consumers: receivers' now
playing title and episode line (`refTitle`), the swapper's art (by the playing title and episode line (`refTitle`), the swapper's art (by the
AniList id); later the watch tracking keys on it. AniList id), the watch tracking's records (see "Watch tracking").
## Watch tracking
What this device watched, by D3: watched means at least as good as
Jellyfin's own, and a peek, skipping around or a screenshot visit never
counts. The truth is split: the device holds its watch history, Jellyfin
its played state and resume point (reported now), AniList its list
(later).
- **The rule** (`WatchSession` in `packages/core/lib/src/watch_session.dart`,
pure): one per opening, sampled every second with the position,
whether the media plays here (the server's transport is playing and
this player plays it) and whether this viewer is In (its presence in
`state`). The basis is time In: wall time it played while this viewer
was `in`. Time sat out (Away), joining, gone, paused or held never
counts, nor does a jump (skipping around adds only what was played);
samples more than 10 s apart add nothing. A resume carries the earlier
watch: when what played starts within 60 s of the resume point, the
time up to it counts too. At a stop: played before -> keep played;
time In >= 70 % of the runtime, or the ending reached (furthest
position while In past the Outro / ED start or into the last 15 %, at
most 4 min, whichever is earlier) with time In >= 40 % -> played;
under max(2 min, 5 %) (at most a quarter of a short item) -> a peek;
else part way, resuming at the last position seen while In. An unknown
runtime never makes it played.
- **The tracker** (`WatchTracker`, `lib/src/watch/`, made by the
composition root): one watch per media of the server (`mediaId`), fed
by the coordinator's 1 s tick, also through drops (the player goes
on); a reconnect or a server restart never ends it while the media
plays on (it follows the media to its new `mediaId`, and goes on while
a restarted server waits for it, see "Server restart"); another media
or leaving closes it, the end of the media
(`finished`) records it as it stands (a seek back goes on with it).
Each record (`WatchRecord`: key, title, `media.ref`, verdict, time In,
runtime) goes out on `records`, where AniList progress will listen,
and to the device's history. Keys (`WatchKey`): a file this app put up
by its path, a Jellyfin item by its ids, a link this app put up or
everyone opens by its URL, else name and size (someone else's file).
- **The history** (`WatchHistory`, Hive box `watch_history`): files and
links only (Jellyfin keeps its own): played, or a resume point (a part
way watch of an item played before keeps it played); a peek changes
nothing. Played before (and the resume point) for the next opening
come from it; the Files page's "Next" follows the newest watched
episode of a show, else the last one opened (Recent).
- **Jellyfin**: the reporter reads the same watch (see "Jellyfin in a
session", "Watched on Jellyfin"); Jellyfin's played state and resume
point read at open become the watch's "before".
## App events and platform ports ## App events and platform ports
@@ -1369,8 +1577,10 @@ Jellyfin item with its own account, so the relay is not its concern),
that moves by the minute. `Status.line` is the player's pill (most that moves by the minute. `Status.line` is the player's pill (most
pressing first: reconnecting with Cancel, unstable, refused or replaced pressing first: reconnecting with Cancel, unstable, refused or replaced
with Retry, connecting, your player's fault or error with Reload, the with Retry, connecting, your player's fault or error with Reload, the
server getting the video, the relay's source dropped / left / not server getting the video, everyone held for the relay's source
passing it on yet, finished, someone loading, someone who cannot play ("Waiting for <name>": `state.hold.reason` `source`, a viewer's buffer
ran dry while it is gone; for everyone, own-account viewers too), the
relay's source dropped / left / not passing it on yet, finished, someone loading, someone who cannot play
it, you not ready, who everyone waits for; nothing while it plays and it, you not ready, who everyone waits for; nothing while it plays and
everyone is fine, and only connection lines while you sit out), each everyone is fine, and only connection lines while you sit out), each
with a `StatusLevel` (ok and info never colour anything, warn amber, with a `StatusLevel` (ok and info never colour anything, warn amber,
@@ -1466,8 +1676,12 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
- `FolderAccess` (`DesktopFolders`): folder and file dialogs - `FolderAccess` (`DesktopFolders`): folder and file dialogs
(file_picker), the Files scan, folder exists, file stat, showing a (file_picker), the Files scan, folder exists, file stat, showing a
folder in the file manager. Paths for now. folder in the file manager. Paths for now.
- `LinkResolver` (`AppYtDlp`): the app's yt-dlp, link probes, mpv's raw - `LinkResolver` (`AppYtDlp`): the app's yt-dlp, link probes (what a
options, warm-up. page plays, see "yt-dlp"), whether this device resolves pages
(`resolvesLinks`, the media plan's capability), warm-up.
- `PlayerHost` (`MediaKitHost`): the media players ("Player host" under
"Media"); Files' thumbnails make theirs with it too
(`thumbnailPlayers`, set in `main`).
- `SystemNotifications` (`DesktopNotifications`, - `SystemNotifications` (`DesktopNotifications`,
`platform/desktop/desktop_notifications.dart`): `show(title, body, tag, `platform/desktop/desktop_notifications.dart`): `show(title, body, tag,
payload)` and `onClick(payload)`, through flutter_local_notifications' payload)` and `onClick(payload)`, through flutter_local_notifications'
@@ -1481,8 +1695,7 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
(`AppDirs`, "Desktop UI"), and the copy of the data from before the (`AppDirs`, "Desktop UI"), and the copy of the data from before the
rename; resolved in `main` before the composition root exists, so not a rename; resolved in `main` before the composition root exists, so not a
`Ports` field. `Ports` field.
Still direct: the player (`PlaybackController`, media_kit, and its Still direct: the updater's restart
`AppYtDlp.instance.mpvRawOptions`), the updater's restart
(`Process.start`), the session's own file reads for what it plays or (`Process.start`), the session's own file reads for what it plays or
relays (`QueueController`, thumbnails, cookies import), app-private relays (`QueueController`, thumbnails, cookies import), app-private
caches. caches.
@@ -1694,7 +1907,8 @@ panel) and chat messages (Copy message, and a link in it).
servers), player (the shared rows below, the controls' choices, custom servers), player (the shared rows below, the controls' choices, custom
shader files), shader files),
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
`AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds `AnimeFlowClient.fromAccounts`, "Play the next episode automatically":
whether this app adds the continuation, see "What's next"), notifications & sounds
(what pops up, chat pop-ups, joins and leaves while sitting out or in (what pops up, chat pop-ups, joins and leaves while sitting out or in
the background), updates, shortcuts, about. the background), updates, shortcuts, about.
- **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one - **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one
@@ -1811,8 +2025,8 @@ panel) and chat messages (Copy message, and a link in it).
16 px from the bottom while they are hidden (`AnimatedPositioned`, 16 px from the bottom while they are hidden (`AnimatedPositioned`,
the shell's `ease` / `easeTime`); in it the status pill centred the shell's `ease` / `easeTime`); in it the status pill centred
(moved left only as far as it must to clear the right end, never (moved left only as far as it must to clear the right end, never
overlapping it) and at the right end the end card (`UpNextCard`, else overlapping it) and at the right end the end card (`UpNextCard`, which
`EndOfEpisodeCard`, which stay up) or the action pills (Skip OP / ED / stays up) or the action pills (Skip OP / ED /
1:30 over Next episode, `PillGate`). The Jellyfin blocked card sits in 1:30 over Next episode, `PillGate`). The Jellyfin blocked card sits in
the middle. Two surfaces: cards and popovers use `AppGlass` the middle. Two surfaces: cards and popovers use `AppGlass`
(`widgets/app_menu.dart`, the menus' recipe: blur, dark tint, hairline (`widgets/app_menu.dart`, the menus' recipe: blur, dark tint, hairline
@@ -1947,8 +2161,8 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
volume, skip offer, render mode; the shell's fullscreen, chat open and volume, skip offer, render mode; the shell's fullscreen, chat open and
unread count) and what they do (`togglePlay` = ready, as Space; unread count) and what they do (`togglePlay` = ready, as Space;
`seek` through `player.seek`, the user path; `nudge`, `skipForward`, `seek` through `player.seek`, the user path; `nudge`, `skipForward`,
`skip`, `stepChapter`, `pickChapter`; `next` = Up next's first item, `skip`, `stepChapter`, `pickChapter`; `next` = the queue's first item
else the next episode, `previous`; volume set / step (flashes) / mute; (Up next's, else the continuation), else the next episode, `previous`; volume set / step (flashes) / mute;
`pickTrack`, `pickShader`; time display; fullscreen, chat, change `pickTrack`, `pickShader`; time display; fullscreen, chat, change
video, collapse, leave, reload, mark everyone ready, link). The video, collapse, leave, reload, mark everyone ready, link). The
player keys call the same actions. `ControlsSnapshot` (computed) is player keys call the same actions. `ControlsSnapshot` (computed) is
@@ -2016,8 +2230,9 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
`shown`) or the mouse is on it (which holds the controls up too), and `shown`) or the mouse is on it (which holds the controls up too), and
Skip OP / Skip 1:30 also for 5 s when an offer of a new kind appears Skip OP / Skip 1:30 also for 5 s when an offer of a new kind appears
(Skip ED and Next episode never without the controls). (Skip ED and Next episode never without the controls).
- Volume: mpv's `volume-max` is pinned to `PlaybackController.maxVolume` - Volume: mpv's `volume-max` is pinned to 150 per player by the host
(150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the (`PlaybackController.maxVolume`); the volume is the controller's and
goes to every next player; `VolumeBar` runs 0..150 with a tick at 100 and the
part above in amber; the wheel and ↑ / ↓ step 5 % (`volumeStep`), part above in amber; the wheel and ↑ / ↓ step 5 % (`volumeStep`),
clamped. The wheel works anywhere on the full player (`VolumeWheel` clamped. The wheel works anywhere on the full player (`VolumeWheel`
around the player's stack): `WheelSteps` makes a mouse notch one step around the player's stack): `WheelSteps` makes a mouse notch one step
@@ -2040,10 +2255,11 @@ Alt+F4, and Update and restart): the window position is saved and the
window hides at once. Then every mpv player goes, before the engine does window hides at once. Then every mpv player goes, before the engine does
(a live one calls its wakeup callback into a dead isolate): the session (a live one calls its wakeup callback into a dead isolate): the session
player, the preload and the player still retiring player, the preload and the player still retiring
(`PlaybackController.disposeAll`, 1.5 s each, via (`MediaPipeline.disposeAll` → `PlayerHost.disposeAll`, 1.5 s each, via
`SessionCoordinator.releasePlayers`, which keeps the position for the `SessionCoordinator.releasePlayers`, which keeps the position for the
Jellyfin stop report), the Files thumbnail jobs (`cancelAllThumbnails`) Jellyfin stop report), the Files thumbnail jobs (`cancelAllThumbnails`)
and the sound engine (500 ms), side by side; on Windows 300 ms more, and the sound engine (500 ms), side by side, then anything the host
still holds (the host owns the disposal order); on Windows 300 ms more,
since media_kit_video frees a video output on a thread of its own after since media_kit_video frees a video output on a thread of its own after
dispose returns, and an output still up when the engine stops blocks the dispose returns, and an output still up when the engine stops blocks the
plugin's teardown (`~VideoOutput` waits on a texture callback that never plugin's teardown (`~VideoOutput` waits on a texture callback that never
+67 -21
View File
@@ -6,7 +6,10 @@ Layout and rules: [ARCHITECTURE.md](ARCHITECTURE.md).
Needs the Flutter SDK (beta channel, Dart 3.13+) and, for the Linux desktop Needs the Flutter SDK (beta channel, Dart 3.13+) and, for the Linux desktop
build, the usual GTK toolchain (clang, cmake, ninja, pkg-config, libgtk-3-dev). build, the usual GTK toolchain (clang, cmake, ninja, pkg-config, libgtk-3-dev).
mpv libraries come from `media_kit_libs_video`. `flutter_soloud` (the app's mpv libraries come from `media_kit_libs_video`. `media_kit_video` is the patched copy in
`third_party/media_kit_video` (see its `starsync/README.md`); test video
output changes under X11 (`GDK_BACKEND=x11`, ideally a real X11 session)
and Wayland. `flutter_soloud` (the app's
sounds) compiles SoLoud and miniaudio from source in its build hook with sounds) compiles SoLoud and miniaudio from source in its build hook with
that C++ toolchain (MSVC on Windows); it needs no audio dev headers (miniaudio that C++ toolchain (MSVC on Windows); it needs no audio dev headers (miniaudio
loads ALSA / PulseAudio / JACK at run time) and, with `no_xiph_libs` set in loads ALSA / PulseAudio / JACK at run time) and, with `no_xiph_libs` set in
@@ -78,16 +81,21 @@ and `e2e_check` (simulated viewers against a live server).
Three suites, run from each package's folder: Three suites, run from each package's folder:
- `cd packages/core && dart test` (a few seconds): protocol round-trips, - `cd packages/core && dart test` (a few seconds): protocol round-trips,
what a player opens for a page (`YtDlpPlay`) and the cookie notes of a
failed resolve against a stand-in yt-dlp (`cookies_test`, D11),
the sync engine (clock, gate, presence and grace, correction, end of the sync engine (clock, gate, presence and grace, correction, end of
file), the `PresencePolicy` table, queue model, the library's L1 file), the `PresencePolicy` table, queue model and what's next (who
puts an item up, the countdown on a fake clock), the library's L1
(release and folder names, `groupLocal` over a real anime folder's (release and folder names, `groupLocal` over a real anime folder's
names: shows across folders, holes, extras, a movie), names: shows across folders, holes, extras, a movie),
relay pieces, updater, logging redaction, watch rule, yt-dlp and relay pieces, updater, logging redaction, the watched rule (time In),
yt-dlp and
Jellyfin client parsing against captured fixtures. Jellyfin client parsing against captured fixtures.
- `cd packages/server && dart test` (well under a minute): live servers - `cd packages/server && dart test` (well under a minute): live servers
on loopback: auth and version refusal, identity, takeover and grace on loopback: auth and version refusal, identity, takeover and grace
(`presence_test` on a fake clock), media and relay byte serving with (`presence_test` on a fake clock), media and relay byte serving with
source drops, Jellyfin relay against a fake, queue, session log, source drops, Jellyfin relay against a fake, queue (and the shared
countdown on a fake clock), session log,
isolate `serve`, sharing (`share_test`: a listener attached and detached isolate `serve`, sharing (`share_test`: a listener attached and detached
under the same members and Up next, the host key standing in for the under the same members and Up next, the host key standing in for the
password). password).
@@ -95,7 +103,10 @@ Three suites, run from each package's folder:
controls, settings, schedule), pure app logic (`StateDiff`, the library's L2 matching against a controls, settings, schedule), pure app logic (`StateDiff`, the library's L2 matching against a
fake AniList (`show_matcher_test`), `media.ref` producers, fake AniList (`show_matcher_test`), `media.ref` producers,
shader list, sound engine with a fake backend, invite addresses, the shader list, sound engine with a fake backend, invite addresses, the
version gate), `singletons_test` (the `.instance` count, see version gate, the media plan's table with the Jellyfin relay rule and
the "Waiting for" status (`media_plan_test`), watch tracking and the
device history (`watch_tracker_test`), what continues a file of a
local show (`queue_logic_test`)), `singletons_test` (the `.instance` count, see
ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e` ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e`
tests plus `coord_presence`. The whole suite takes a couple of minutes; tests plus `coord_presence`. The whole suite takes a couple of minutes;
run it only before a restructure step lands, otherwise only the files run it only before a restructure step lands, otherwise only the files
@@ -108,21 +119,40 @@ else is offline: Jellyfin and link hosts are local fakes.
Real-mpv tests: `sync_playback_e2e`, `coord_presence` (drops through a Real-mpv tests: `sync_playback_e2e`, `coord_presence` (drops through a
`FlakyProxy`: a half-open link and return, 10 flaps, `replaced`, `bye`, `FlakyProxy`: a half-open link and return, 10 flaps, `replaced`, `bye`,
a dead or restarted server), `video_reload_e2e`, a dead or restarted server; a restarted server that came back empty gets
what played back at its position, a link everyone opens and a file
passed on, nothing reopened, the watch and Up next carrying on), `video_reload_e2e` (a fresh player from the
host, the old one retired, leave releases them all),
`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `chapters_mpv_e2e`, `seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `chapters_mpv_e2e`,
`ytdl_headers_e2e`, `relay_e2e` (a dedicated server with no file of its `ytdl_headers_e2e` (a page through the app's own resolve against a
stand-in yt-dlp and a Referer-checking CDN: the EDL, the headers and
cookies, subtitles without danmaku, a 403, the D11 error),
`media_switch_e2e` (commands during a switch wait and apply once, no mpv
errors), `relay_e2e` (a dedicated server with no file of its
own: a guest's file, links passed on or fetched by the server, a late own: a guest's file, links passed on or fetched by the server, a late
source, a source that drops and resumes), `jellyfin_e2e`, source, a source that drops and resumes; a seek past what a slow source
`queue_switch_e2e` (times Up next switches against a delayed, rate-capped has sent, timed; a source that drops while a viewer seeks past
fake Jellyfin and link host: an episode, a direct link, a link passed on the buffer and another joins: no seek loop, "Waiting for", all resume
and a server-fetched one; `WT_SWITCH_PRELOAD=0` for the cold path, (D6)), `jellyfin_e2e` (account holders report what they watched, one
who sat out is not marked watched, the next episode continues for
everyone), `whats_next_e2e` (a file of a show continued by its next
episode with the gap said, the server's countdown on both screens, a
shared cancel, the watch history), `queue_switch_e2e` (times Up next switches
through the media pipeline against a delayed, rate-capped fake Jellyfin
and link host: an episode, a direct link, a link passed on and a
server-fetched one, each prepared and promoted; no mpv "error running
command" or "file cache" lines; `WT_SWITCH_PRELOAD=0` for the cold path,
`WT_SWITCH_DELAY_MS` / `WT_SWITCH_RATE_KBS` for the link). The sync, `WT_SWITCH_DELAY_MS` / `WT_SWITCH_RATE_KBS` for the link). The sync,
reload and end-of-file ones expect `~/Videos/chiyo.mp4`; the others reload and end-of-file ones expect `~/Videos/chiyo.mp4`; the others
generate media with ffmpeg into `WT_TEST_TMP` (default: system temp; all generate media with ffmpeg into `WT_TEST_TMP` (default: system temp; all
desktop tests that write files use it). Headless mpv (no video output desktop tests that write files use it). Headless mpv (no video output
attached) won't open video-only files, so test media needs an audio attached) won't open video-only files, so test media needs an audio
track. Tests always log media_kit "S/W rendering" because they have no track. Tests always log media_kit "S/W rendering" because they have no
display; that says nothing about the app. `sound_device_e2e` opens the display; that says nothing about the app. A headless player has no video
track selected (`vid=no`), and an mkv's cues list only the video, so
seek tests over a relay or HTTP use an mp4 with its index first
(`+faststart`); setting `vid=auto` on a headless player crashes mpv when
it is disposed. `sound_device_e2e` opens the
real audio device (flutter_soloud) and plays every sound of every preset real audio device (flutter_soloud) and plays every sound of every preset
quietly (`WT_SFX_VOLUME`, default 0.1). quietly (`WT_SFX_VOLUME`, default 0.1).
@@ -160,12 +190,12 @@ in [ROADMAP.md](ROADMAP.md)):
| Scenario | Covered today | Missing, added in | | Scenario | Covered today | Missing, added in |
|---|---|---| |---|---|---|
| Drop and reconnect while sharing a relay | server `flaky_link_test` (blackhole: `gone` with ready, queue item and relay kept, the same member back; reset), `sync_wave_test` (takeover by client id, `replaced`, `stale`), `presence_test` (grace on a fake clock, restart), `relay_test` "the source drops (D6)" (`sourceGone`, buffer served, same `info` continues, another 4002, a second channel 4003, leave ends it); desktop `relay_e2e` "the source drops uncleanly and comes back" (gone past the server's silence, the relay kept, the source re-dials and continues), `coord_presence` (a half-open drop while playing: same member, no reopen, the state kept; 10 flaps: at most one lost/back sound; `replaced` is terminal; a restarted server gets the ready again), `connection_test` (the hello's id, `seq`, caps, `feedAfter`) | | | Drop and reconnect while sharing a relay | server `flaky_link_test` (blackhole: `gone` with ready, queue item and relay kept, the same member back; reset), `sync_wave_test` (takeover by client id, `replaced`, `stale`), `presence_test` (grace on a fake clock, restart), `relay_test` "the source drops (D6)" (`sourceGone`, buffer served, same `info` continues, another 4002, a second channel 4003, leave ends it); desktop `relay_e2e` "the source drops uncleanly and comes back" (gone past the server's silence, the relay kept, the source re-dials and continues), `coord_presence` (a half-open drop while playing: same member, no reopen, the state kept; 10 flaps: at most one lost/back sound; `replaced` is terminal; a restarted server gets the ready again, and what played if it came back empty), server `queue_test` (the first restore offer wins), `connection_test` (the hello's id, `seq`, caps, `feedAfter`) | |
| Join mid-playback | core `sync_engine_test` "D2: joiners" (never changes the transport while playing, at most 15 s while held, joining ends on error or timeout), `presence_test` (the table); server `relay_test` "a late joiner gets its own /r/ URL"; desktop `coord_presence` (the returning member plays on in sync) | | | Join mid-playback | core `sync_engine_test` "D2: joiners" (never changes the transport while playing, at most 15 s while held, joining ends on error or timeout), `presence_test` (the table); server `relay_test` "a late joiner gets its own /r/ URL"; desktop `coord_presence` (the returning member plays on in sync) | |
| Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (the host sat out through `presence` on your shared server), `notice_table_test` (the feed actions one-to-one, matched by id; sitting out gets only chat, nudges, "starting now", Up next and the chosen joins and leaves; alone no social sounds), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out) | step 6: not tracked as watched (P7) | | Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (the host sat out through `presence` on your shared server), `notice_table_test` (the feed actions one-to-one, matched by id; sitting out gets only chat, nudges, "starting now", Up next and the chosen joins and leaves; alone no social sounds), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out); core `watch_session_test`, desktop `watch_tracker_test`, `jellyfin_session_test` "sat out", `jellyfin_e2e` (sat out: not tracked as watched, P7) | |
| Swap, also while someone is Away or gone | core `sync_engine_test` "swap" (a gone member stays out of the gate, comes back joining), "away" (a swap leaves it away), `queue_test` (a swap takes its item, drops the `auto` one); server `queue_test` (a gone adder's item waits), `relay_test` (a swap drops the buffer); desktop `sync_playback_e2e` | step 5: no commands reach a player mid-switch (P15) | | Swap, also while someone is Away or gone | core `sync_engine_test` "swap" (a gone member stays out of the gate, comes back joining), "away" (a swap leaves it away), `queue_test` (a swap takes its item, drops the `auto` one); server `queue_test` (a gone adder's item waits), `relay_test` (a swap drops the buffer); desktop `sync_playback_e2e`, `media_switch_e2e` (no command reaches a player mid-switch) | |
| Up next advance | core `queue_test` (skip, play request timeout, `auto` and `cancel`); server `queue_test`, `relay_test` "warm relay"; desktop `jellyfin_session_test` (auto-advance swapper, end card countdown), `queue_switch_e2e` | step 5: preparing it through the media pipeline; step 6: the countdown as server state (`state.advance`) | | Up next advance | core `queue_test` (skip, play request timeout, `auto` and `cancel`; the countdown: start, follow the first item, seek, cancel, hold for a gone performer or source, skip what nobody can put up), `whats_next_test`; server `queue_test` (the countdown and cancel shared, a skip by the server); `relay_test` "warm relay"; desktop `jellyfin_session_test` (end card), `jellyfin_e2e` and `whats_next_e2e` (the continuation and the countdown end to end), `queue_switch_e2e` (prepared and promoted through the media pipeline) | |
| Close the app cleanly, incl. `bye` | server `lifecycle_test` (a close keeps the member `gone`, `bye` leaves at once, server stop closes sockets); desktop `server_isolate_test` (stop frees the port; Stop sharing tells guests with 4016 and keeps you and Up next; a friend's server and back keeps yours), `coord_presence` (Stop sharing and your server's stop release their ports; leave sends `bye` and others see "left" at once, leaving for a restart keeps the member `gone`) | step 5: the Windows close smoke job (P32) | | Close the app cleanly, incl. `bye` | server `lifecycle_test` (a close keeps the member `gone`, `bye` leaves at once, server stop closes sockets); desktop `server_isolate_test` (stop frees the port; Stop sharing tells guests with 4016 and keeps you and Up next; a friend's server and back keeps yours), `coord_presence` (Stop sharing and your server's stop release their ports; leave sends `bye` and others see "left" at once, leaving for a restart keeps the member `gone`), `video_reload_e2e` (leave releases every player); the close smoke run on the built app (`tool/smoke_windows.ps1` in CI, `tool/smoke_linux.sh` by hand) | |
A test file whose first line is `// restructure: rewrite in step N` pins A test file whose first line is `// restructure: rewrite in step N` pins
behaviour that step of the restructure ([ROADMAP.md](ROADMAP.md)) changes; behaviour that step of the restructure ([ROADMAP.md](ROADMAP.md)) changes;
@@ -174,7 +204,20 @@ before.
CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus
`dart compile exe` of the dedicated server (fails if Flutter creeps into `dart compile exe` of the dedicated server (fails if Flutter creeps into
`packages/server`). Desktop tests are not in CI. `packages/server`). Desktop tests are not in CI. The Windows job runs the
updater tests, the release build and the close smoke run on that build
(`tool/smoke_windows.ps1`, P32): the app starts with
`STARSYNC_SMOKE=<clip>` (a 5 s clip from ffmpeg, else a WAV the script
writes) under its own profile (`STARSYNC_PROFILE=smoke`), opens the clip
in a player with a video output through the media pipeline
(`platform/smoke.dart`), plays a second and closes the normal way; the
steps go to `STARSYNC_SMOKE_OUT` (`start`, `opened <ms> render=<mode>`,
`closing`, or `skip` / `fail` with why). It fails when the app does not
exit within 45 s or leaves a `starsync` process, skips with a warning
when the runner cannot bring the app or its video output up, and prints
the `smoke:`, `close:` and `mpv: render` lines of the app log.
`tool/smoke_linux.sh` is the same on Linux (needs a display; after
`flutter build linux --release`).
Traps: Traps:
- Never pump media_kit's `Video` in `flutter test`: the runner hangs (kill - Never pump media_kit's `Video` in `flutter test`: the runner hangs (kill
@@ -207,7 +250,8 @@ User pushes tags; CI does the rest.
2. Review, then `git push --follow-tags`. 2. Review, then `git push --follow-tags`.
3. `.gitea/workflows/release.yml` runs one job per platform: Linux builds 3. `.gitea/workflows/release.yml` runs one job per platform: Linux builds
with `tool/package_linux.sh`, Windows (runner `yuuka-windows`, host mode, with `tool/package_linux.sh`, Windows (runner `yuuka-windows`, host mode,
PowerShell only) with `tool/package_windows.ps1`. Each then runs PowerShell only; `tool/setup_flutter_windows.ps1` also adds Visual
Studio's C++ ATL component once, which the notifications plugin needs) with `tool/package_windows.ps1`. Each then runs
`dart tool/release_publish.dart vX.Y.Z <archive>`, which creates the `dart tool/release_publish.dart vX.Y.Z <archive>`, which creates the
release if the other job hasn't yet and uploads (or replaces) its asset. release if the other job hasn't yet and uploads (or replaces) its asset.
By hand: package, then the same `dart tool/release_publish.dart` line. By hand: package, then the same `dart tool/release_publish.dart` line.
@@ -302,8 +346,10 @@ is off), not software rendering.
- `web_socket_channel` 3.x: `channel.ready` errors are unhandled on refused - `web_socket_channel` 3.x: `channel.ready` errors are unhandled on refused
connections; swallow them and report through the stream's `onError`. connections; swallow them and report through the stream's `onError`.
Streams are single-subscription: one listener per socket. Streams are single-subscription: one listener per socket.
- media_kit has no public `Player.setProperty`; the code reaches mpv through a - media_kit has no public `Player.setProperty`; the player host
dynamic call on `player.platform`. Cache size goes through (`MediaKitHost`) reaches mpv through a dynamic call on
`player.platform`, and nothing outside it imports media_kit except the
`Video` widget glue. Cache size goes through
`PlayerConfiguration(bufferSize:)`. `PlayerConfiguration(bufferSize:)`.
- Hot restart with any media_kit `Player` alive (session, thumbnails) - Hot restart with any media_kit `Player` alive (session, thumbnails)
used to SIGSEGV on mpv's `core` thread: each mpv handle's wakeup callback used to SIGSEGV on mpv's `core` thread: each mpv handle's wakeup callback
+12 -1
View File
@@ -78,7 +78,13 @@ brings you back.
6. **Swap.** Anyone can change the video by pasting another link. Everybody 6. **Swap.** Anyone can change the video by pasting another link. Everybody
stops at 0 and the gate runs again for everyone there; ready flags stay stops at 0 and the gate runs again for everyone there; ready flags stay
as they were (a setting unreadies you instead). as they were (a setting unreadies you instead).
7. **Leave.** Closing the app or Leave says goodbye and drops you from the 7. **What's next.** Up next is one list on the server for everyone, and
an episode you put up is followed by its next one. When a video ends
with something next, the server counts down 10 s on every screen;
anyone can cancel it for everyone (a seek stops it too). At zero the
app that has what plays next puts it up; if that person is away for a
moment, the countdown waits for them.
8. **Leave.** Closing the app or Leave says goodbye and drops you from the
server; the others keep going. If you vanish without a goodbye, after 15 s server; the others keep going. If you vanish without a goodbye, after 15 s
of silence you are "gone": you stop blocking but keep your place of silence you are "gone": you stop blocking but keep your place
(ready flag, sitting out, Up next items, the video you pass on) for (ready flag, sitting out, Up next items, the video you pass on) for
@@ -90,6 +96,11 @@ anyone, and sit anyone out (the host too). Someone sitting out stays
connected and in the chat, but their app loads nothing and nobody waits connected and in the chat, but their app loads nothing and nobody waits
for them until they join back. for them until they join back.
Watched or not is counted by the time you were there while it played:
sitting out, skipping around or a quick look never counts as watching.
Jellyfin hears it for your account; files and links are remembered on
your computer.
## Where video comes from ## Where video comes from
- **Link**: you paste an http(s) URL. By default your computer opens it and - **Link**: you paste an http(s) URL. By default your computer opens it and
+32 -12
View File
@@ -98,6 +98,7 @@ yet; the restructure step named next to them does.
{"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}} {"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}}
{"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}} {"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}}
{"t":"swap","media":{...},"queueId":"q3"} puts up an Up next item {"t":"swap","media":{...},"queueId":"q3"} puts up an Up next item
{"t":"swap","media":{...},"restore":{"position":512.3,"transport":"playing"}} puts back what played (see below)
{"t":"report","position":123.1,"buffered":6.2,"eventId":41, {"t":"report","position":123.1,"buffered":6.2,"eventId":41,
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true},"error":"Not allowed (403)"} "duration":1440.5,"mediaId":2,"jf":{"<serverId>":true},"error":"Not allowed (403)"}
{"t":"ping","c":1767225600000} {"t":"ping","c":1767225600000}
@@ -205,6 +206,13 @@ Field notes:
every member's `ready` and puts every member in the set (`inSet`; who every member's `ready` and puts every member in the set (`inSet`; who
counts is up to presence); clients that want to be unready for the new counts is up to presence); clients that want to be unready for the new
media send `ready` themselves (gate: ARCHITECTURE.md, "Ready gate"). media send `ready` themselves (gate: ARCHITECTURE.md, "Ready gate").
- `swap.restore`: what played before the server restarted, offered back
by a member that still holds it (`position` seconds, `transport`
`playing`, `held` or `finished` as it was). The server takes it only
while it has no media; any later one is ignored, so the first offer
wins. It is a swap otherwise (relays, art, the gate), held at
`position` (`finished`: at the end), its feed `swap` line with
`reason` `restore`.
- Frames before `welcome` (other than `hello`) are ignored; unparsable or - Frames before `welcome` (other than `hello`) are ignored; unparsable or
unknown frames are ignored silently. Frames unknown frames are ignored silently. Frames
from a socket another hello took over are ignored. from a socket another hello took over are ignored.
@@ -282,8 +290,8 @@ Field notes:
`source` (the same while the relay's source is `gone`), `start` (nobody `source` (the same while the relay's source is `gone`), `start` (nobody
counts for the gate: someone has to ready up). `members`: the member counts for the gate: someone has to ready up). `members`: the member
ids the gate waits for (empty for `start`). ids the gate waits for (empty for `start`).
- `state.advance` (**reserved**, step 6): the shared countdown to Up next - `state.advance` (omitted when none runs): the shared countdown to Up
(see "Queue"). next's first item at the end of the media (see "Queue").
- `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms - `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms
(the clock `serverTime` uses). With `t3` the local receive time, the offset (the clock `serverTime` uses). With `t3` the local receive time, the offset
is `s - (c + t3) / 2`, accurate to half the round trip. is `s - (c + t3) / 2`, accurate to half the round trip.
@@ -299,9 +307,10 @@ Field notes:
the actor set itself; `reason` `idle`/`minimised`/`bye`, empty for the actor set itself; `reason` `idle`/`minimised`/`bye`, empty for
manual), `in` (back from away), `swap`, `seek`, `pause`, `resume`, manual), `in` (back from away), `swap`, `seek`, `pause`, `resume`,
`finished`, `chat`, `rename`, `avatar`, `queue-add`, `queue-remove`, `finished`, `chat`, `rename`, `avatar`, `queue-add`, `queue-remove`,
`queue-skip`, `advance-cancel` (`target` the dropped continuation's `queue-skip` (actor `server` when the server skipped it), `advance-cancel`
title, if any), `fetch-fail` (the server could not fetch the current (`target` the dropped continuation's title, if any), `fetch-fail` (the server could not fetch the current
media, `target` its name). `reason` carries `auto`/`manual` for ready. media, `target` its name). `reason` carries `auto`/`manual` for ready,
`restore` for a swap that put back what played (`swap.restore`).
`rename`: `actor` is the new name, `target` the old one. `avatar`: the `rename`: `actor` is the new name, `target` the old one. `avatar`: the
actor changed only the picture. `queue-*`: `target` is the item's actor changed only the picture. `queue-*`: `target` is the item's
title, `reason` (skip) why it could not play (see "Queue"). title, `reason` (skip) why it could not play (see "Queue").
@@ -623,7 +632,7 @@ state "queue":[<item>,...], "queuePlay":"q3"
<item> = {"id":"q3","media":{<swap media>},"how":"direct"|"server"|"through"|"file"|"jellyfin", <item> = {"id":"q3","media":{<swap media>},"how":"direct"|"server"|"through"|"file"|"jellyfin",
"title":"...","sub":"...","duration":213,"thumb":"<hash>", "title":"...","sub":"...","duration":213,"thumb":"<hash>",
"by":"m2","byName":"Bob","key":"<adder's handle>","auto":true?,"for":2?} "by":"m2","byName":"Bob","key":"<adder's handle>","auto":true?,"for":2?}
state "advance":{"id":"q3","at":"<server time>","secs":10} reserved state "advance":{"id":"q3","at":"<server time>","secs":10}
``` ```
- `add`: `item` without `id`, `by`, `byName`, `thumb` (the server sets - `add`: `item` without `id`, `by`, `byName`, `thumb` (the server sets
@@ -634,7 +643,8 @@ state "advance":{"id":"q3","at":"<server time>","secs":10} reserved
item is queued. `title` and `sub` are cleaned like media names and cut item is queued. `title` and `sub` are cleaned like media names and cut
to 200 characters (an empty title becomes the media's name); `duration` to 200 characters (an empty title becomes the media's name); `duration`
(seconds, omitted when unknown) is informational. At most 100 items; (seconds, omitted when unknown) is informational. At most 100 items;
over that the sender gets `err` `Up next is full (100 items)`. over that the sender gets `err` `Up next is full (100 items)`. With
nothing up, the first item is asked for at once (`queuePlay`).
- `how` and what `media` must carry: - `how` and what `media` must carry:
- `direct`: an http(s) `url`; everyone opens the link. - `direct`: an http(s) `url`; everyone opens the link.
- `server`: an http(s) `url` and `relay` `{"fetch":true}`, on a server - `server`: an http(s) `url` and `relay` `{"fetch":true}`, on a server
@@ -654,7 +664,10 @@ state "advance":{"id":"q3","at":"<server time>","secs":10} reserved
- `play`: moves `id` first and sets `state.queuePlay` to it: a request for - `play`: moves `id` first and sets `state.queuePlay` to it: a request for
the member who can put it up to do so now. `skip` is `play` of the the member who can put it up to do so now. `skip` is `play` of the
first item. `queuePlay` ends with any swap, when the item is removed, first item. `queuePlay` ends with any swap, when the item is removed,
or after 30 s. or after 30 s. An item asked for that nobody here can put up (its
adder left for good, nobody has its Jellyfin server; the rule is
core's `queuePerformer`, ARCHITECTURE "What's next") is taken off by the
server: feed `queue-skip`, actor `server`, `reason` why.
- `auto` (with `for`, the `mediaId` it continues): the continuation (the - `auto` (with `for`, the `mediaId` it continues): the continuation (the
next episode). Only for the current media (else `err` `bad`), and only next episode). Only for the current media (else `err` `bad`), and only
from the member who put it up (`media.swapBy`) or, when that member is from the member who put it up (`media.swapBy`) or, when that member is
@@ -662,12 +675,19 @@ state "advance":{"id":"q3","at":"<server time>","secs":10} reserved
one: a newer one replaces it; it stays after the other items (an `add` one: a newer one replaces it; it stays after the other items (an `add`
goes before it, `next` puts an item first, `move` never passes it, it goes before it, `next` puts an item first, `move` never passes it, it
does not move itself); any swap that does not put it up drops it. does not move itself); any swap that does not put it up drops it.
Adding it gives no feed line.
- `cancel`: drops the `auto` item and the countdown for everyone (feed - `cancel`: drops the `auto` item and the countdown for everyone (feed
`advance-cancel`); nothing to drop, nothing happens. `advance-cancel`); nothing to drop, nothing happens.
- `state.advance` (**reserved**, step 6): the shared countdown when the - `state.advance`: the shared countdown, started when the media finishes
media finishes and an item is first; at `at` + `secs` the server would with an item first (or an item comes first while it is finished);
set `queuePlay` to `id`, holding while its performer or source is `secs` is 10, `at` its start (server clock). A new first item takes it
`gone`. The server never starts one yet; `cancel` and any swap clear it. over with the same end. At `at` + `secs` the server sets `queuePlay` to
`id` and the countdown is over; while the member who puts the item up
is not around (its adder `gone`, nobody present) or the item's warm
relay waits for a `gone` source, it holds, and asks once they are
back. A seek (the end of `finished`), an empty queue, `cancel` and any
swap clear it; after `cancel` it does not start again for that media,
nor again for the item it already asked for.
- `swap.queueId`: the swap puts that item up and takes it off the queue - `swap.queueId`: the swap puts that item up and takes it off the queue
in the same step. A swap naming an item that is no longer queued is in the same step. A swap naming an item that is no longer queued is
ignored (someone put it up first). For a `server` item the swap may ignored (someone put it up first). For a `server` item the swap may
+91 -53
View File
@@ -35,19 +35,7 @@ Problems are numbered; delete a line when its fix ships.
- D11 Login-only sites: error texts say which cookies were used. - D11 Login-only sites: error texts say which cookies were used.
**Problems:** **Problems:**
- P2 Viewers of a relay whose source is gone: mpv loops on failed seeks
past what the server buffered (viewer-side recovery, step 5).
- P7 Sat-out people get tracked as watched on Jellyfin.
- P9 The watched rule (position coverage) misses real watches (D3).
- P15 Commands reach a player mid-switch (`seek 90.0`, `event:5` errors).
- P17 Preload, warm relay and the fresh player per video are layered on
the player instead of being one media pipeline.
- P18 Bilibili "no video formats" with browser cookies set; the hint
wording misleads (D11).
- P25 ssh to Gitea blocked through the proxy (pushes go over https). - P25 ssh to Gitea blocked through the proxy (pushes go over https).
- P28 Log noise: "Failed to create file cache"; an idle relay channel for
Jellyfin items everyone has an account for.
- P29 Relay start delay after a seek into an unbuffered part.
- P30 Shaders don't work on Windows. Fix in 1.2.1-dev.3 (list read-back - P30 Shaders don't work on Windows. Fix in 1.2.1-dev.3 (list read-back
check; Windows hwdec `auto-copy`): a Windows user must confirm shaders check; Windows hwdec `auto-copy`): a Windows user must confirm shaders
stay on and the EGL-surface error is gone. stay on and the EGL-surface error is gone.
@@ -56,8 +44,11 @@ Problems are numbered; delete a line when its fix ships.
`watch-together 2>&1 | grep VideoOutput` from a terminal if software. `watch-together 2>&1 | grep VideoOutput` from a terminal if software.
- P32 The app freezes on exit, mostly reported on Windows. 1.2.1-dev.3: - P32 The app freezes on exit, mostly reported on Windows. 1.2.1-dev.3:
orderly close, Linux `_exit` (0 crashes in 41 closes vs 10/10 before), orderly close, Linux `_exit` (0 crashes in 41 closes vs 10/10 before),
Windows normal close + TerminateProcess + watchdogs, untested on Windows normal close + TerminateProcess + watchdogs; the player host
Windows; if it still hangs: Event Viewer 1000/1002, a Task Manager dump, now owns the disposal order. CI's Windows job runs a close smoke run on
every build (`tool/smoke_windows.ps1`); its first runs on the runner
must show whether it passes or skips there (no interactive desktop). If
a real close still hangs: Event Viewer 1000/1002, a Task Manager dump,
the `close:` log lines. the `close:` log lines.
- P33 No volume readout on wheel: likely fixed by the controls rework - P33 No volume readout on wheel: likely fixed by the controls rework
(1.2.1-dev.1; a test covers both skins); confirm in real use. (1.2.1-dev.1; a test covers both skins); confirm in real use.
@@ -68,24 +59,14 @@ the automatic continuation and countdown, relay state), the device
(client id, library and folders, match corrections, watch history, (client id, library and folders, match corrections, watch history,
settings, notification prefs, what this app is sourcing) and the settings, notification prefs, what this app is sourcing) and the
integrations (Jellyfin played/resume, AniList list). integrations (Jellyfin played/resume, AniList list).
5. Media pipeline + player host port: plan → resolve → open → ready,
commands queued during a switch, viewer-side recovery when a source
drops (no mpv seek loops, play what's buffered), resolver and source
chosen by capability; a Windows CI smoke job (start, open, close
within N s, no orphan process). Fixes P2 P15 P17 P18 P28 P29, P30–P32;
D11.
6. What's next (decides which/when, asks the pipeline to prepare;
continuation and countdown as server state, shared cancel) + Watch
tracking (time In from the presence history, device history, Jellyfin
now, AniList later). Fixes P7 P9; D3.
7. Release 1.3.0: publish the manifest (protocol 16 makes it a required 7. Release 1.3.0: publish the manifest (protocol 16 makes it a required
update for 1.2.x) and update the VPS dedicated server in the same update for 1.2.x) and update the VPS dedicated server in the same
window; check a 1.2.x app gets a clear "update the app" message. window; check a 1.2.x app gets a clear "update the app" message.
Composition root (in place, ARCHITECTURE "Composition root"): the Composition root (in place, ARCHITECTURE "Composition root"): the
remaining `.instance` singletons move into `AppGraph` as the steps touch remaining `.instance` singletons move into `AppGraph` as the steps touch
them; `singletons_test` holds the count (today: AppSettings 55, them; `singletons_test` holds the count (today: AppSettings 49,
JellyfinAccounts 10, UpdateController 8, Profile 8, FolderStore 6, JellyfinAccounts 10, UpdateController 8, Profile 8, FolderStore 6,
AppYtDlp 4, RecentLinks 3). AppYtDlp 2, RecentLinks 3).
Not in the restructure: P25 (ops). Not in the restructure: P25 (ops).
@@ -126,6 +107,56 @@ your server leaves them there without you, unwarned; a join refused
after dialling (wrong password) costs your own member there (`bye`, back after dialling (wrong password) costs your own member there (`bye`, back
as a new member). Your server writes its session log even when you are as a new member). Your server writes its session log even when you are
alone. alone.
Left of step 6 (what's next, watch tracking): a human check, on Linux
now, of the server's countdown and the end card in real use (Cancel
from another screen, a seek stopping it, "Waiting for" while the one who
puts it up is gone), of a file continuation on the real folder (Bleach
"skips 66–107" across its folders, SAO S3 into its OP/ED folder, the
last episode of a show) and of Jellyfin's played state and resume point
under time In against a real Jellyfin (a full watch, one sat out, a
resume), and of a dev server restarted mid-episode (what played comes
back where it was for everyone, a Jellyfin item passed on to a guest
re-fed: only files and links are covered by tests). Not built: AniList
progress (the tracker's `records` are its seam); nothing resumes a file
or link at the history's resume point (it only keeps it); no watched
marks on Files' episode rows (only "Next" follows the history) and no
way to see or clear the history. The continuation of a file comes only
from the app that put it up (only it has the file). "Play the next episode
automatically" is the setting of whoever puts an episode up; Up next's
own items count down whatever it says. A play request the performer
does not act on ends after 30 s without a new countdown for that item
(Play now remains). A warm relay waiting for a `gone` source holds the
countdown even when the one who puts the item up is there. After a
server restart, a relayed file or link whose member is not back, or a
Jellyfin item nobody back has an account for, cannot be put back (the
apps let go after 30 s); a server-fetched Up next item is lost (its
address stayed with the old server).
Left of step 5 (media pipeline): a human check, on Linux now and on
Windows at 1.3.0, of a page link in "Everyone fetches it" through the
app's own resolve (YouTube's 1080p video + audio EDL, Bilibili with its
Referer and cookies, X's HLS) and of its subtitles, and of the D11 text
with browser cookies (Bilibili without a login there); a relay viewer
during a real source drop (the bytes run out, "Waiting for <name>", the
return); the first Windows CI runs of the close smoke step. Not built:
a viewer cannot tell what the server's relay buffer holds, so a seek
past its own mpv buffer while the source is away is tried once and then
waits for the source even when the server had it; `Unresolvable` (a
device without yt-dlp) only says who could pass the link on, nothing
routes it there by itself (Android); a page's stream URLs come from the
probe cache (10 minutes, 35 for Up next's first item) and are resolved
again only at Reload video and the 30-minute refresh; a format whose
CDN wants per-track headers that differ gets the first track's (as
mpv's hook did); Bilibili's danmaku is dropped, not shown. The Jellyfin
item this member put up re-feeds its relay after an app restart (the
state still names it the source).
Relay start (P29's rest): a seek into what the source has not sent
plays after about 2.8 s in the e2e; a human check of a real session
on dev.8+. The source's in-flight window still starts at 256 KiB and
grows with the measured rate, so the speed climbs from low after a
seek or a new link; the start of a freshly put-up link was never
measured on its own. Levers if it still shows: start the window from
the last rate, prefetch from the source as soon as a link goes up,
a smaller gate buffer when only the relay is behind.
Left of step 4 (library): a human check of the Files library on the Left of step 4 (library): a human check of the Files library on the
real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders
with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the
@@ -137,9 +168,7 @@ swapper's bytes (now chosen by the ref's AniList id). Relay logins are
sent at Invite: an account linked or signed in again later relays only sent at Invite: an account linked or signed in again later relays only
after Stop sharing and Invite. "Season N" without a name walks AniList's after Stop sharing and Invite. "Season N" without a name walks AniList's
sequels, which can land on the wrong cour where AniList splits seasons; sequels, which can land on the wrong cour where AniList splits seasons;
Wrong show? fixes it. The next episode of a local show lives on its page Wrong show? fixes it. The `library` box keeps every AniList entry seen (no pruning);
(from Recent); no countdown or "Continues with" for files yet (backlog
2). The `library` box keeps every AniList entry seen (no pruning);
AniList's episode titles exist only where it lists streaming episodes. AniList's episode titles exist only where it lists streaming episodes.
Left of step 2: system notifications need a human check (Linux: the Left of step 2: system notifications need a human check (Linux: the
notification shows and a click brings the window up, also on Wayland, notification shows and a click brings the window up, also on Wayland,
@@ -165,13 +194,8 @@ Opened in step 1: [PROTOCOL.md](PROTOCOL.md) is proto 16 (version
ranges and the refusal frame, error codes and close codes, lasting members by client id with takeover and `seq`, grace and ranges and the refusal frame, error codes and close codes, lasting members by client id with takeover and `seq`, grace and
`bye`, presence and `PresencePolicy`, `state.hold`, feed ids, `seq` and `bye`, presence and `PresencePolicy`, `state.hold`, feed ids, `seq` and
replay, relay `sourceGone` and source resume, lasting `queue[].by`, replay, relay `sourceGone` and source resume, lasting `queue[].by`,
`auto` items and `cancel`, `media.ref`, `members[].app`/`caps`), server, `auto` items, `cancel` and `state.advance`, `media.ref`, `members[].app`/`caps`), server,
core and app. Still open on the wire, frozen at the 1.3.0 release: core and app. Still open on the wire, frozen at the 1.3.0 release:
- `state.advance` (step 6): the server starts the countdown when the
media finishes and an item is first, sets `queuePlay` at its end and
holds it while the item's performer or source is `gone`. Today it is
parsed and broadcast but never started; `auto` items and `cancel` are
live.
- `hello.client` optionally derived per server (HMAC of an install - `hello.client` optionally derived per server (HMAC of an install
secret and the dialled host:port). secret and the dialled host:port).
- TLS: `/health.tls` is always false; `wss://` only with a CA-valid - TLS: `/health.tls` is always false; `wss://` only with a CA-valid
@@ -180,21 +204,32 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
## Backlog ## Backlog
0. **media_kit fork evaluation** (after the restructure): upstream
media-kit releases stalled at media_kit_video 2.0.1 (Dec 2025) though
main still gets fixes; `Predidit/media-kit` is active (138 ahead, 146
behind upstream) with native D3D11 on Windows (no ANGLE: may fix P30
and the dxva2-egl error), a bundled libmpv on Linux, triple-buffered
Linux rendering, patched FFmpeg. Check: Windows shaders, whether its
it plays `edl://` URLs with `http-header-fields` (pages resolve in
the app since step 5, so mpv's Lua and ytdl_hook are not needed), offline
CI builds (it downloads binaries in a build hook), and the upstream
fixes it lacks. Until then we vendor upstream 2.0.1 with the X11/EGL
patch in `third_party/media_kit_video`.
Upstream isn't merging outside work (20 open PRs, no release since
Dec 2025): no upstream PRs. Independent of the fork: the Windows
libmpv is a 2023 build fetched by `media_kit_libs_windows_video`;
vendoring that package with a current mpv-dev build (and checksum)
updates Windows' mpv on its own.
1. **Auto-skip OP/ED** (undecided): who decides and how (shared for the 1. **Auto-skip OP/ED** (undecided): who decides and how (shared for the
server, personal, per show) is open. Constraint: a skip is a seek and server, personal, per show) is open. Constraint: a skip is a seek and
seeks are shared, and only real ranges (segments, OP/ED chapters) should seeks are shared, and only real ranges (segments, OP/ED chapters) should
ever be skipped. ever be skipped.
2. **Files**: the next episode of a local show (the library's 2. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
`nextAfter`, gaps included) at the end of one, with the countdown, and bundled Windows libmpv plays the app's `edl://` URLs (video + audio
under Up next's "Continues with". Tracking for file series comes with with headers) through media_kit. YouTube: yt-dlp wants a JS runtime
step 6. (deno) for full extraction.
3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
bundled Windows libmpv has the ytdl hook (Lua) and plays `edl://` URLs
through media_kit; if not, resolve in the app and open the relay
tracks' formats. YouTube: yt-dlp wants a JS runtime (deno) for full
extraction. Formats that need yt-dlp's cookies.
- Live streams through the relay (today refused: the sync model needs a - Live streams through the relay (today refused: the sync model needs a
fixed duration and seeking). fixed duration and seeking).
- `tool/relay_e2e.dart`: a two-track case (bytes and pieces) over a - `tool/relay_e2e.dart`: a two-track case (bytes and pieces) over a
@@ -208,15 +243,15 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
- Thumbnails and scrubbing previews of a relayed media with tracks - Thumbnails and scrubbing previews of a relayed media with tracks
should open the video track's URL alone, not the EDL. should open the video track's URL alone, not the EDL.
4. **AniList account**: sign in (OAuth via a localhost callback), "My list" 3. **AniList account**: sign in (OAuth via a localhost callback), "My list"
filter on the Schedule from the user's AniList lists. This is the filter on the Schedule from the user's AniList lists. This is the
Schedule's personal tracking; AnimeFlow has no per-user follow. Schedule's personal tracking; AnimeFlow has no per-user follow.
5. **Ops**: Check shaders on Windows; run the relay e2e "the source drops 4. **Ops**: Check shaders on Windows; run the relay e2e "the source drops
uncleanly and comes back" a few times under the full suite (its uncleanly and comes back" a few times under the full suite (its
predecessor failed about half the time there). predecessor failed about half the time there).
6. **Windows: first real use.** Check the rename at 1.3.0: a 1.2.x 5. **Windows: first real use.** Check the rename at 1.3.0: a 1.2.x
install updates and restarts (`watch_together.exe`, a copy of install updates and restarts (`watch_together.exe`, a copy of
`starsync.exe`), the data in `%APPDATA%\foxyz\Starsync` came over from `starsync.exe`), the data in `%APPDATA%\foxyz\Starsync` came over from
`%APPDATA%\com.example\watch_together` and Documents, the next update `%APPDATA%\com.example\watch_together` and Documents, the next update
@@ -226,7 +261,7 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
fullscreen controls, hosting (firewall prompt), in-app update from one fullscreen controls, hosting (firewall prompt), in-app update from one
release to the next. The rest of the desktop tests are not in CI. release to the next. The rest of the desktop tests are not in CI.
7. **Relay follow-ups** (see ARCHITECTURE "Relay"). First real use: owner's 6. **Relay follow-ups** (see ARCHITECTURE "Relay"). First real use: owner's
PC -> VPS -> friend, Jellyfin and a file. Then: PC -> VPS -> friend, Jellyfin and a file. Then:
- Source takeover: when the source leaves, another member with an - Source takeover: when the source leaves, another member with an
account on that Jellyfin (or the same file: name, size, hash) takes account on that Jellyfin (or the same file: name, size, hash) takes
@@ -247,6 +282,13 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
fetch of member-resolved links (the member need not upload or stay) fetch of member-resolved links (the member need not upload or stay)
is worth building. is worth building.
7. **Tray** (like Discord): close to the tray instead of quitting, so
the app stays on the server and its system notifications keep coming;
a tray menu (Open, Quit; maybe Leave the server). Quit stays the
orderly shutdown (P32). Linux trays vary (StatusNotifierItem; GNOME
shows none without an extension), so close-to-tray needs a fallback
to quitting where no tray exists.
8. **Jellyfin** (epic, below). 8. **Jellyfin** (epic, below).
9. **Smaller items** 9. **Smaller items**
@@ -277,10 +319,6 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
What is left (the rest is in [ARCHITECTURE.md](ARCHITECTURE.md), "Jellyfin What is left (the rest is in [ARCHITECTURE.md](ARCHITECTURE.md), "Jellyfin
in a session"): in a session"):
- Auto-advance cancel for everyone (the next episode and Up next): today
Cancel is per screen (the designated swapper's Cancel stops the swap; a
seek stops all). The wire has it (`queue` `cancel`, `state.advance`);
the app uses it in step 6.
- Sidecar (external) subtitle streams of Jellyfin items: only embedded - Sidecar (external) subtitle streams of Jellyfin items: only embedded
tracks are mapped to mpv today. tracks are mapped to mpv today.
+1
View File
@@ -16,6 +16,7 @@ export 'src/media_source.dart';
export 'src/sync_engine.dart'; export 'src/sync_engine.dart';
export 'src/updater.dart'; export 'src/updater.dart';
export 'src/watch_session.dart'; export 'src/watch_session.dart';
export 'src/whats_next.dart';
export 'src/jellyfin/client.dart'; export 'src/jellyfin/client.dart';
export 'src/jellyfin/client_info.dart'; export 'src/jellyfin/client_info.dart';
export 'src/jellyfin/http.dart'; export 'src/jellyfin/http.dart';
+4 -2
View File
@@ -9,7 +9,7 @@ abstract class RelayConstants {
static const int protoMax = 16; static const int protoMax = 16;
// Relay (`/r/...`): a member's bytes through the server's buffer. // Relay (`/r/...`): a member's bytes through the server's buffer.
static const int relaySliceBytes = 1 << 20; static const int relaySliceBytes = 256 * 1024;
static const int relayFrameBytes = 64 * 1024; static const int relayFrameBytes = 64 * 1024;
/// Defaults for the buffer; they mirror a viewer's mpv buffer (150 s / /// Defaults for the buffer; they mirror a viewer's mpv buffer (150 s /
/// 256 MiB ahead, 64 MiB back), so whatever a viewer may seek to soon is /// 256 MiB ahead, 64 MiB back), so whatever a viewer may seek to soon is
@@ -92,10 +92,12 @@ abstract class RelayConstants {
/// Up next (`queue`): at most this many items; a title or second line /// Up next (`queue`): at most this many items; a title or second line
/// is cut to [maxQueueTextLength] characters; an item's picture is /// is cut to [maxQueueTextLength] characters; an item's picture is
/// capped like a poster ([maxPosterBytes]). A play request nobody acts on /// capped like a poster ([maxPosterBytes]). A play request nobody acts on
/// ends after [queuePlayTimeoutSecs]. /// ends after [queuePlayTimeoutSecs]. The countdown to the first item at
/// the end of the media (`state.advance`) runs [advanceSecs].
static const int maxQueueItems = 100; static const int maxQueueItems = 100;
static const int maxQueueTextLength = 200; static const int maxQueueTextLength = 200;
static const int queuePlayTimeoutSecs = 30; static const int queuePlayTimeoutSecs = 30;
static const int advanceSecs = 10;
/// Diagnostics sharing (`/l/...`, ARCHITECTURE "Logging"): a member's /// Diagnostics sharing (`/l/...`, ARCHITECTURE "Logging"): a member's
/// batch carries at most [diagBatchBytes] of lines (gzip on the wire, at /// batch carries at most [diagBatchBytes] of lines (gzip on the wire, at
+39 -11
View File
@@ -164,6 +164,45 @@ Map<String, List<NetscapeCookie>> cookiesBySite(Iterable<NetscapeCookie> cookies
return {for (final k in m.keys.toList()..sort()) k: m[k]!}; return {for (final k in m.keys.toList()..sort()) k: m[k]!};
} }
/// [parseCookies], or none for a file without any.
List<NetscapeCookie> parseCookiesOrEmpty(String text) {
try {
return parseCookies(text);
} on FormatException {
return const [];
}
}
/// "Firefox", "Chrome", "Firefox (work)": a browser and its profile.
String browserLabel(CookieBrowser b) {
final name = b.browser.isEmpty ? b.browser : '${b.browser[0].toUpperCase()}${b.browser.substring(1)}';
return b.profile.isEmpty ? name : '$name (${b.profile})';
}
/// The cookies part of a sign-in error (D11): which cookies were used and
/// how many of them are for [site] ([count] -1: unknown; [read]: what
/// reading a browser's gave).
String cookieNoteText(CookieSource? source, String site, int count, {({int count, String? error})? read}) {
switch (source) {
case null:
return 'no cookies were used';
case CookieFile():
if (count < 0) return 'used the cookies file';
return count == 0
? 'used the cookies file, which has none for $site'
: 'used the cookies file ($count for $site: signed out or expired?)';
case CookieBrowser():
final who = "${browserLabel(source)}'s cookies";
final error = read?.error;
if (error != null) return "used $who, but yt-dlp couldn't read them: $error";
final n = read?.count ?? count;
if (n < 0) return 'used $who';
return n == 0
? 'used $who, which have none for $site: sign in to $site there'
: 'used $who ($n for $site: that account may not see it)';
}
}
/// Sites ticked by default after an import: those yt-dlp is usually given /// Sites ticked by default after an import: those yt-dlp is usually given
/// cookies for. /// cookies for.
const suggestedCookieSites = {'bilibili.com', 'youtube.com'}; const suggestedCookieSites = {'bilibili.com', 'youtube.com'};
@@ -233,17 +272,6 @@ class YtDlpCookies {
null => const [], null => const [],
}; };
/// The same as mpv's `ytdl-raw-options` value ('' for none); each value
/// length-quoted (`%N%`) so commas and '=' in paths survive.
String mpvRawOptions([CookieSource? source]) {
String q(String v) => '%${utf8.encode(v).length}%$v';
return switch (source ?? read(count: false)) {
CookieFile(:final path) => 'cookies=${q(path)}',
CookieBrowser b => 'cookies-from-browser=${q(b.spec)}',
null => '',
};
}
/// Keeps [cookies] as the private copy, replacing any browser setting. /// Keeps [cookies] as the private copy, replacing any browser setting.
Future<CookieFile> saveFile(List<NetscapeCookie> cookies) async { Future<CookieFile> saveFile(List<NetscapeCookie> cookies) async {
await Directory(dir).create(recursive: true); await Directory(dir).create(recursive: true);
+27 -4
View File
@@ -95,6 +95,22 @@ final _unreachable = RegExp(
r'network is unreachable|no route to host|name or service not known|getaddrinfo|unable to download webpage', r'network is unreachable|no route to host|name or service not known|getaddrinfo|unable to download webpage',
caseSensitive: false); caseSensitive: false);
/// yt-dlp's [error] (plain words or raw) may be about signing in: worth
/// saying which cookies were used (D11).
bool mayNeedSignIn(String error) {
final t = playErrorText(error);
return t == needsCookiesText || t == noFormatsText;
}
/// [plain] ([playErrorText]) with the cookies [note] (D11), for the two
/// texts about signing in; anything else as is.
String withCookieNote(String plain, String? note) {
if (note == null || note.isEmpty) return plain;
if (plain == needsCookiesText) return 'This site needs cookies ($note) — Settings → Player → yt-dlp → Cookies';
if (plain == noFormatsText) return 'No video found: it may need signing in ($note)';
return plain;
}
/// Why a link or media cannot be played, in plain words, from the raw /// Why a link or media cannot be played, in plain words, from the raw
/// error of yt-dlp, ffmpeg/mpv or the link probe; anything it does not /// error of yt-dlp, ffmpeg/mpv or the link probe; anything it does not
/// know comes back as is (one line, at most 100 characters). /// know comes back as is (one line, at most 100 characters).
@@ -131,6 +147,10 @@ class LinkProbe {
final String? pageTitle; final String? pageTitle;
/// Why yt-dlp could not read the page. /// Why yt-dlp could not read the page.
final String? ytDlpError; final String? ytDlpError;
/// Which cookies that run used ([YtDlp.cookieNote]), when it may be about
/// signing in.
final String? cookies;
/// The HTTP status of the probe's GET; 0 when unknown. /// The HTTP status of the probe's GET; 0 when unknown.
final int status; final int status;
const LinkProbe.file(this.url, this.size) const LinkProbe.file(this.url, this.size)
@@ -138,8 +158,9 @@ class LinkProbe {
info = null, info = null,
pageTitle = null, pageTitle = null,
ytDlpError = null, ytDlpError = null,
cookies = null,
status = 0; status = 0;
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError, this.status = 0}) const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError, this.cookies, this.status = 0})
: isPage = true, : isPage = true,
size = -1; size = -1;
@@ -152,7 +173,7 @@ class LinkProbe {
// A 4xx says more than yt-dlp's words it has no plain text for. // A 4xx says more than yt-dlp's words it has no plain text for.
final known = err != null && _plainTexts.contains(err); final known = err != null && _plainTexts.contains(err);
if (!known && status >= 400 && status < 500) return playErrorText('HTTP Error $status'); if (!known && status >= 400 && status < 500) return playErrorText('HTTP Error $status');
return err; return err == null ? null : withCookieNote(err, cookies);
} }
/// The name to show: yt-dlp's title, the page's, else the host; null /// The name to show: yt-dlp's title, the page's, else the host; null
@@ -219,12 +240,13 @@ Future<LinkProbe> _probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async
} catch (e) { } catch (e) {
getError = e; getError = e;
} }
String? ytErr; String? ytErr, cookies;
if (ytDlp != null) { if (ytDlp != null) {
try { try {
return LinkProbe.page(url, info: await ytDlp.resolve(url.toString()), status: status); return LinkProbe.page(url, info: await ytDlp.resolve(url.toString()), status: status);
} catch (e) { } catch (e) {
ytErr = '$e'; ytErr = '$e';
if (e is YtDlpException) cookies = e.cookies;
} }
} }
if (getError != null) { if (getError != null) {
@@ -238,7 +260,8 @@ Future<LinkProbe> _probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async
body = await _readText(await req.close().timeout(const Duration(seconds: 15))); body = await _readText(await req.close().timeout(const Duration(seconds: 15)));
} catch (_) {} } catch (_) {}
} }
return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr, status: status); return LinkProbe.page(url,
pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr, cookies: cookies, status: status);
} finally { } finally {
if (client == null) c.close(force: true); if (client == null) c.close(force: true);
} }
+37 -10
View File
@@ -836,16 +836,42 @@ class SeekIntent {
} }
} }
/// `swap.restore`: what played before the server restarted, offered back
/// by an app that still holds it: at [position] (seconds), [transport]
/// as it was (`playing`, `held` or `finished`). The server takes it only
/// while it has no media (the first offer wins, later ones are ignored).
class SwapRestore {
final double position;
final String transport;
const SwapRestore({required this.position, this.transport = 'held'});
Map<String, Object?> toJson() => {'position': position, 'transport': transport};
static SwapRestore? fromJson(Object? j) {
if (j is! Map) return null;
final t = j['transport'];
return SwapRestore(
position: finiteDouble(j['position'], 'restore.position'),
transport: t == 'playing' || t == 'finished' ? t as String : 'held');
}
}
class SwapIntent { class SwapIntent {
final HelloMedia media; final HelloMedia media;
/// The Up next item this swap puts up (`queue[].id`); the server takes /// The Up next item this swap puts up (`queue[].id`); the server takes
/// it out of the queue with the swap, and ignores the swap when it is no /// it out of the queue with the swap, and ignores the swap when it is no
/// longer queued (someone else put it up first). /// longer queued (someone else put it up first).
final String? queueId; final String? queueId;
const SwapIntent({required this.media, this.queueId}); /// Puts back what played before a server restart ([SwapRestore]).
final SwapRestore? restore;
const SwapIntent({required this.media, this.queueId, this.restore});
Map<String, Object?> toJson() => Map<String, Object?> toJson() => {
{'t': 'swap', 'media': media.toJson(), if (queueId != null) 'queueId': queueId}; 't': 'swap',
'media': media.toJson(),
if (queueId != null) 'queueId': queueId,
if (restore != null) 'restore': restore!.toJson(),
};
factory SwapIntent.fromJson(Map<String, Object?> json) { factory SwapIntent.fromJson(Map<String, Object?> json) {
if (json['t'] != 'swap') throw const ProtocolException('not a swap intent'); if (json['t'] != 'swap') throw const ProtocolException('not a swap intent');
@@ -854,7 +880,8 @@ class SwapIntent {
final queueId = json['queueId']; final queueId = json['queueId'];
return SwapIntent( return SwapIntent(
media: HelloMedia.fromJson(Map<String, Object?>.from(media)), media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
queueId: queueId is String && queueId.isNotEmpty ? queueId : null); queueId: queueId is String && queueId.isNotEmpty ? queueId : null,
restore: SwapRestore.fromJson(json['restore']));
} }
} }
@@ -1147,11 +1174,11 @@ class HoldInfo {
} }
} }
/// `state.advance` (reserved, step 6): the shared countdown to Up next's /// `state.advance`: the shared countdown to Up next's first item when the
/// first item when the media finishes. At [at] + [secs] (server clock) the /// media finishes. At [at] + [secs] (server clock) the server sets
/// server sets `queuePlay` to [id]; `{"t":"queue","op":"cancel"}` clears /// `queuePlay` to [id] (holding while its performer or its relay's source
/// it for everyone. The server parses and broadcasts it but never starts /// is `gone`); `{"t":"queue","op":"cancel"}` and a seek clear it for
/// one yet. /// everyone (see `SyncEngine.whatsNext`).
class QueueAdvance { class QueueAdvance {
final String id; final String id;
final DateTime at; final DateTime at;
@@ -1211,7 +1238,7 @@ class StateBroadcast {
final String? queuePlay; final String? queuePlay;
/// Why it is held ([HoldInfo]); null unless `held` with media up. /// Why it is held ([HoldInfo]); null unless `held` with media up.
final HoldInfo? hold; final HoldInfo? hold;
/// The shared countdown to Up next (reserved, [QueueAdvance]). /// The shared countdown to Up next's first item ([QueueAdvance]); null when none runs.
final QueueAdvance? advance; final QueueAdvance? advance;
const StateBroadcast({ const StateBroadcast({
required this.eventId, required this.eventId,
+25 -17
View File
@@ -2,9 +2,9 @@ import 'dart:typed_data';
import 'constants.dart'; import 'constants.dart';
import 'errors.dart'; import 'errors.dart';
import 'presence.dart';
import 'protocol.dart'; import 'protocol.dart';
import 'sync_engine.dart'; import 'sync_engine.dart';
import 'whats_next.dart';
// Up next: what plays after the current media, held by the server for // Up next: what plays after the current media, held by the server for
// everyone (see PROTOCOL.md "Queue"). Wire types, and [EngineQueue], the // everyone (see PROTOCOL.md "Queue"). Wire types, and [EngineQueue], the
@@ -342,14 +342,15 @@ typedef QueueRefusal = ({ErrCode code, String message});
/// The `queue` messages applied to the engine's [SyncEngine.queue], with /// The `queue` messages applied to the engine's [SyncEngine.queue], with
/// their feed lines (`queue-add`, `queue-remove`, `queue-skip`, /// their feed lines (`queue-add`, `queue-remove`, `queue-skip`,
/// `advance-cancel`). Each returns whether the queue changed; /// `advance-cancel`), then what's next ([SyncEngine.whatsNext]). Each
/// [applyQueueAdd] a refusal for the sender instead (null when added). /// returns whether the queue changed; [applyQueueAdd] a refusal for the
/// sender instead (null when added).
extension EngineQueueOps on SyncEngine { extension EngineQueueOps on SyncEngine {
/// [item] is already cleaned by the server (see `cleanQueueAdd`). An /// [item] is already cleaned by the server (see `cleanQueueAdd`). An
/// [QueueItem.auto] item must continue the current media /// [QueueItem.auto] item must continue the current media
/// ([QueueItem.forMedia] = [SyncEngine.mediaId]) and come from who put /// ([QueueItem.forMedia] = [SyncEngine.mediaId]) and come from who put
/// it up ([SyncEngine.swapBy]) or, when that member is not `in`, from the /// it up or, when that member is not `in`, from the first member who is
/// first member who is. /// ([mayAddContinuation]); it adds no feed line (it is no news).
QueueRefusal? applyQueueAdd(String token, QueueItem item, QueueRefusal? applyQueueAdd(String token, QueueItem item,
{bool next = false, String? privateUrl, Uint8List? thumb}) { {bool next = false, String? privateUrl, Uint8List? thumb}) {
final member = members[token]; final member = members[token];
@@ -358,24 +359,27 @@ extension EngineQueueOps on SyncEngine {
if (media == null || item.forMedia != mediaId) { if (media == null || item.forMedia != mediaId) {
return (code: ErrCode.bad, message: 'bad message: an auto item must name the current media'); return (code: ErrCode.bad, message: 'bad message: an auto item must name the current media');
} }
final swapper = members[swapBy]; final views = [for (final m in members.values) m.view(inGate: false)];
final firstIn = members.values.where((m) => m.presence == Presence.watching).firstOrNull; if (!mayAddContinuation(member.id, members[swapBy]?.id, views)) {
final allowed = token == swapBy ||
((swapper == null || swapper.presence != Presence.watching) && identical(firstIn, member));
if (!allowed) {
return (code: ErrCode.forbidden, message: 'only who put this media up adds what continues it'); return (code: ErrCode.forbidden, message: 'only who put this media up adds what continues it');
} }
} }
final added = queue.add(item.copyWith(by: member.id, byName: member.name), final added = queue.add(item.copyWith(by: member.id, byName: member.name),
next: next, privateUrl: privateUrl, thumb: thumb); next: next, privateUrl: privateUrl, thumb: thumb);
if (added == null) return (code: ErrCode.full, message: 'Up next is full (${RelayConstants.maxQueueItems} items)'); if (added == null) return (code: ErrCode.full, message: 'Up next is full (${RelayConstants.maxQueueItems} items)');
feed.add(FeedMessage( if (!added.auto) {
actor: member.name, actorId: member.id, action: 'queue-add', target: added.title, at: now)); feed.add(FeedMessage(
actor: member.name, actorId: member.id, action: 'queue-add', target: added.title, at: now));
}
whatsNext();
return null; return null;
} }
bool applyQueueMove(String token, String id, int to) => bool applyQueueMove(String token, String id, int to) {
members.containsKey(token) && queue.move(id, to); if (!members.containsKey(token) || !queue.move(id, to)) return false;
whatsNext();
return true;
}
/// [why] set: skipped because it cannot play (`queue-skip`, with the /// [why] set: skipped because it cannot play (`queue-skip`, with the
/// reason), else removed by hand (`queue-remove`). /// reason), else removed by hand (`queue-remove`).
@@ -391,6 +395,7 @@ extension EngineQueueOps on SyncEngine {
target: gone.title, target: gone.title,
reason: why, reason: why,
at: now)); at: now));
whatsNext();
return true; return true;
} }
@@ -399,17 +404,20 @@ extension EngineQueueOps on SyncEngine {
if (!members.containsKey(token)) return false; if (!members.containsKey(token)) return false;
final target = id.isEmpty ? (queue.isEmpty ? null : queue.items.first.id) : id; final target = id.isEmpty ? (queue.isEmpty ? null : queue.items.first.id) : id;
if (target == null || queue.play == target) return false; if (target == null || queue.play == target) return false;
return queue.requestPlay(target, now); if (!queue.requestPlay(target, now)) return false;
whatsNext();
return true;
} }
/// `cancel`: drops the continuation ([EngineQueue.auto]) and the /// `cancel`: drops the continuation ([EngineQueue.auto]) and the
/// countdown ([SyncEngine.advance]) for everyone (feed `advance-cancel`). /// countdown ([SyncEngine.advance]) for everyone (feed `advance-cancel`);
/// no countdown starts again for this media.
bool applyQueueCancel(String token) { bool applyQueueCancel(String token) {
final member = members[token]; final member = members[token];
if (member == null) return false; if (member == null) return false;
final dropped = queue.dropAuto(); final dropped = queue.dropAuto();
final hadAdvance = advance != null; final hadAdvance = advance != null;
advance = null; cancelAdvance();
if (dropped == null && !hadAdvance) return false; if (dropped == null && !hadAdvance) return false;
feed.add(FeedMessage( feed.add(FeedMessage(
actor: member.name, actorId: member.id, action: 'advance-cancel', target: dropped?.title, at: now)); actor: member.name, actorId: member.id, action: 'advance-cancel', target: dropped?.title, at: now));
+116 -5
View File
@@ -7,6 +7,7 @@ import 'constants.dart';
import 'presence.dart'; import 'presence.dart';
import 'protocol.dart'; import 'protocol.dart';
import 'queue.dart'; import 'queue.dart';
import 'whats_next.dart';
typedef SyncClock = DateTime Function(); typedef SyncClock = DateTime Function();
@@ -245,9 +246,19 @@ class SyncEngine {
/// Up next ([EngineQueue]): in `state.queue`, edited by `queue` /// Up next ([EngineQueue]): in `state.queue`, edited by `queue`
/// messages, taken from by swaps that name an item. /// messages, taken from by swaps that name an item.
final EngineQueue queue = EngineQueue(); final EngineQueue queue = EngineQueue();
/// The shared countdown to Up next (`state.advance`); reserved: only /// The shared countdown to Up next's first item at the end of the media
/// `cancel` touches it today. /// (`state.advance`, see [whatsNext]).
QueueAdvance? advance; QueueAdvance? advance;
/// The relay of Up next item `queueId` waits for its source, who is
/// `gone` (set by the server: the engine does not hold relays). The
/// countdown holds while it does.
bool Function(String queueId)? itemSourceGone;
// The mediaId whose countdown was cancelled (`cancel`): it does not
// start again for that media.
int _advanceOffFor = -1;
// The item last asked to play for the current media: not counted down
// again.
String? _advancedId;
/// A member went `gone` (its socket closed or fell silent): the server /// A member went `gone` (its socket closed or fell silent): the server
/// closes the socket and marks a relay it serves. /// closes the socket and marks a relay it serves.
void Function(EngineMember member)? onGone; void Function(EngineMember member)? onGone;
@@ -316,12 +327,97 @@ class SyncEngine {
at: now); at: now);
/// Heartbeat (1 s): silence, joining timeouts, gone notices, grace, /// Heartbeat (1 s): silence, joining timeouts, gone notices, grace,
/// a stale play request, end of media. /// a stale play request, end of media, what's next.
void tick() { void tick() {
sweepSilent(); sweepSilent();
sweepPresence(); sweepPresence();
queue.tick(now); queue.tick(now);
evaluate(); evaluate();
whatsNext();
}
/// What's next (PROTOCOL.md "Queue"), on every tick and queue change:
/// - a play request (`queuePlay`) nobody here can serve is skipped
/// (feed `queue-skip`, actor `server`, with why: [queuePerformer]);
/// - with nothing up, the first item is asked to play at once;
/// - at the end of the media with an item first, the countdown
/// ([advance], [RelayConstants.advanceSecs]) starts (not again for a
/// media whose countdown was cancelled, nor for the item it already
/// asked for); it follows the first item when that changes, keeping
/// its end; it ends with the end of `finished` (a seek), an empty
/// queue, `cancel` or a swap. At its end the first item is asked to
/// play (`queuePlay`) and the countdown is over, unless nobody can
/// put it up (skipped) or its performer is not around or its relay's
/// source is `gone` ([itemSourceGone]): then it holds until they are
/// back.
/// Returns whether anything changed.
bool whatsNext() {
var changed = false;
final views = [for (final m in members.values) m.view(inGate: false)];
final play = queue.play;
if (play != null) {
final item = queue.item(play);
final why = item == null ? null : queuePerformer(item, views).why;
if (item != null && why != null) {
_skipQueued(item, why);
changed = true;
}
}
final first = queue.items.firstOrNull;
if (media == null) {
if (first != null && queue.play == null) changed = _due(first, views) || changed;
return changed;
}
var a = advance;
if (a != null && (transport != Transport.finished || first == null || queue.play != null)) {
advance = a = null;
changed = true;
}
if (transport != Transport.finished || first == null || queue.play != null) return changed;
if (a == null) {
if (_advanceOffFor == mediaId || _advancedId == first.id) return changed;
advance = a = QueueAdvance(id: first.id, at: now, secs: RelayConstants.advanceSecs);
changed = true;
} else if (a.id != first.id) {
advance = a = QueueAdvance(id: first.id, at: a.at, secs: a.secs);
changed = true;
}
if (now.isBefore(a.at.add(Duration(seconds: a.secs)))) return changed;
return _due(first, views) || changed;
}
/// [item] should play now: asked for (`queuePlay`), skipped when nobody
/// can put it up, or held. Returns whether anything changed.
bool _due(QueueItem item, List<MemberView> views) {
final perf = queuePerformer(item, views);
final why = perf.why;
if (why != null) {
_skipQueued(item, why);
return true;
}
if (perf.id == null || (itemSourceGone?.call(item.id) ?? false)) return false;
queue.requestPlay(item.id, now);
advance = null;
_advancedId = item.id;
return true;
}
void _skipQueued(QueueItem item, String why) {
queue.remove(item.id);
// The countdown goes on with the next item (asked for on the next
// tick when its time is up).
final a = advance, first = queue.items.firstOrNull;
if (a != null && a.id == item.id) {
advance = first == null ? null : QueueAdvance(id: first.id, at: a.at, secs: a.secs);
}
feed.add(FeedMessage(
actor: 'server', actorId: 'server', action: 'queue-skip', target: item.title, reason: why, at: now));
}
/// `cancel` for the current media: no countdown for it any more.
void cancelAdvance() {
advance = null;
_advanceOffFor = mediaId;
} }
/// Joining: loads in the background until its reports hold the media /// Joining: loads in the background until its reports hold the media
@@ -617,6 +713,8 @@ class SyncEngine {
_seekSeq = seq; _seekSeq = seq;
_seekEventId = eventId; _seekEventId = eventId;
feed.add(_line('seek', actor: member)); feed.add(_line('seek', actor: member));
// A seek stops the countdown to Up next.
advance = null;
// Seeking back from the end: held, and resumes once the gate passes. // Seeking back from the end: held, and resumes once the gate passes.
if (transport == Transport.finished) { if (transport == Transport.finished) {
transport = Transport.held; transport = Transport.held;
@@ -627,11 +725,15 @@ class SyncEngine {
/// Returns false (no state change) for media with non-finite numbers. /// Returns false (no state change) for media with non-finite numbers.
/// [queueId]: the Up next item this swap puts up; it leaves the queue /// [queueId]: the Up next item this swap puts up; it leaves the queue
/// (any other swap drops the continuation item, [EngineQueue.auto]). /// (any other swap drops the continuation item, [EngineQueue.auto]).
bool applySwap(String actorToken, HelloMedia next, {String? queueId}) { /// [restore]: what played before a restart, put back where it was (the
/// server takes it only while nothing is up; see `swap.restore`): held
/// at its position (the gate starts it again), or finished.
bool applySwap(String actorToken, HelloMedia next, {String? queueId, SwapRestore? restore}) {
if (!next.duration.isFinite || next.size < 0) return false; if (!next.duration.isFinite || next.size < 0) return false;
final actor = members[actorToken]; final actor = members[actorToken];
queue.swapped(queueId); queue.swapped(queueId);
advance = null; advance = null;
_advancedId = null;
mediaId++; mediaId++;
media = next; media = next;
swapBy = actorToken; swapBy = actorToken;
@@ -648,7 +750,15 @@ class SyncEngine {
member.inSet = true; member.inSet = true;
member.buffered = 0; member.buffered = 0;
} }
feed.add(_line('swap', actor: actor)); if (restore != null) {
final d = next.duration;
position = restore.position < 0 ? 0 : (d > 0 && restore.position > d ? d : restore.position);
if (restore.transport == 'finished' && d > 0) {
position = d;
transport = Transport.finished;
}
}
feed.add(_line('swap', actor: actor, reason: restore == null ? '' : 'restore'));
return true; return true;
} }
@@ -715,6 +825,7 @@ class SyncEngine {
eventId++; eventId++;
transport = Transport.finished; transport = Transport.finished;
feed.add(_line('finished')); feed.add(_line('finished'));
whatsNext();
return; return;
} }
final blocked = final blocked =
+63 -65
View File
@@ -1,9 +1,9 @@
import 'dart:math' as math; import 'dart:math' as math;
// What one viewer actually watched of one opened item, and the rule that // What one viewer actually watched of one opened item, and the rule that
// turns it into "watched", "barely looked" or "stopped part way". Pure, so // turns it into "watched", "barely looked" or "stopped part way" (D3).
// any tracker (Jellyfin today) applies the same rule. See ARCHITECTURE.md // Pure, so every tracker (the device history, Jellyfin, AniList later)
// "Watched rule". // applies the same rule. See ARCHITECTURE.md "Watch tracking".
/// What a [WatchSession] amounts to when it ends. /// What a [WatchSession] amounts to when it ends.
enum WatchOutcome { enum WatchOutcome {
@@ -14,7 +14,8 @@ enum WatchOutcome {
/// whatever happened, with no resume point. /// whatever happened, with no resume point.
keepPlayed, keepPlayed,
/// Barely watched (a peek, a screenshot): leave the item as it was. /// Barely watched (a peek, a screenshot visit, skipping around): leave
/// the item as it was.
peek, peek,
/// Part way: resume from [WatchVerdict.resumeAt]. /// Part way: resume from [WatchVerdict.resumeAt].
@@ -28,108 +29,105 @@ class WatchVerdict {
final double? resumeAt; final double? resumeAt;
const WatchVerdict(this.outcome, {this.resumeAt}); const WatchVerdict(this.outcome, {this.resumeAt});
/// It counts as watched (played now, or played before).
bool get watched => outcome == WatchOutcome.played || outcome == WatchOutcome.keepPlayed;
@override @override
String toString() => 'WatchVerdict(${outcome.name}${resumeAt == null ? '' : ', $resumeAt'})'; String toString() => 'WatchVerdict(${outcome.name}${resumeAt == null ? '' : ', $resumeAt'})';
} }
/// One opening of one item on this client. Fed the player position about /// One opening of one item on this app. Fed about once a second
/// once a second ([sample]); only normal playback counts as watched: the /// ([sample]) with the player's position, whether the media plays here
/// stretch between two samples while playing, when the position moved /// (the server's transport is playing and this player plays it) and
/// forward no faster than [maxRate] (plus [slack]) and the samples are at /// whether this viewer is In (present: `in`, from the presence the server
/// most [maxGap] apart. Seeks, skips and pauses add nothing. [covered] is the union of the watched stretches, /// reports for it). The basis is [timeIn]: wall time the media played
/// so rewatching a part does not count twice; [watched] is the plain sum. /// while this viewer was In. Time sat out (Away), joining, gone, paused or
/// held never counts, nor does a jump: skipping around adds only the time
/// actually played. Two samples further apart than [maxGap] add nothing
/// (the app stalled). A resume carries the earlier watch: when what played
/// here starts at about the resume point ([resumeBefore], 60 s either
/// way) and nothing before it played, the time up to it counts as watched
/// before ([credited]).
class WatchSession { class WatchSession {
/// Played before this opening (Jellyfin `UserData.Played`). /// Played before this opening (Jellyfin `UserData.Played`, the device's
final bool playedBefore; /// watch history); set once known (Jellyfin answers after the open).
bool playedBefore;
/// The resume point before this opening, seconds (0 = none). /// The resume point before this opening, seconds (0 = none).
final double resumeBefore; double resumeBefore;
final double maxRate;
final double slack;
final double maxGap; final double maxGap;
WatchSession( WatchSession({this.playedBefore = false, this.resumeBefore = 0, this.maxGap = 10});
{this.playedBefore = false, this.resumeBefore = 0, this.maxRate = 2, this.slack = 1.5, this.maxGap = 10});
DateTime? startedAt; DateTime? startedAt;
DateTime? endedAt; DateTime? endedAt;
double? firstPosition; double? firstPosition;
double? lastPosition; double? lastPosition;
/// The furthest position seen, reached by playing or by a jump. /// The furthest position seen while In, reached by playing or by a jump.
double furthest = 0; double furthest = 0;
/// Seconds of normal playback, rewatches included. /// Seconds the media played while this viewer was In.
double watched = 0; double timeIn = 0;
final _spans = <(double, double)>[]; /// The lowest position it played from while In; null before any.
({double pos, bool playing, DateTime at})? _prev; double? playedFrom;
/// Seconds of the item covered by normal playback (rewatches once). /// [timeIn] plus the time before the resume point when this opening
double get covered => _spans.fold(0.0, (s, e) => s + e.$2 - e.$1); /// continued from it.
double get credited {
/// The watched stretches, sorted and merged. final from = playedFrom;
List<(double, double)> get spans => List.unmodifiable(_spans); final resumed = resumeBefore > 0 && from != null && (from - resumeBefore).abs() <= 60;
return timeIn + (resumed ? resumeBefore : 0);
void sample(double position, {required bool playing, required DateTime at}) {
final prev = _prev;
_prev = (pos: position, playing: playing, at: at);
startedAt ??= at;
endedAt = at;
firstPosition ??= position;
lastPosition = position;
furthest = math.max(furthest, position);
if (prev == null || !prev.playing) return;
final dt = at.difference(prev.at).inMilliseconds / 1000;
final dp = position - prev.pos;
if (dt <= 0 || dt > maxGap || dp <= 0 || dp > dt * maxRate + slack) return;
watched += dp;
_add(prev.pos, position);
} }
void _add(double a, double b) { ({DateTime at, double pos, bool counting})? _prev;
final out = <(double, double)>[];
var (lo, hi) = (a, b); void sample(double position, {required bool playing, required bool present, required DateTime at}) {
for (final (s, e) in _spans) { final prev = _prev;
if (e < lo || s > hi) { _prev = (at: at, pos: position, counting: playing && present);
out.add((s, e)); startedAt ??= at;
} else { endedAt = at;
lo = math.min(lo, s); if (present) {
hi = math.max(hi, e); firstPosition ??= position;
} lastPosition = position;
furthest = math.max(furthest, position);
} }
out if (prev == null || !prev.counting) return;
..add((lo, hi)) final dt = at.difference(prev.at).inMilliseconds / 1000;
..sort((x, y) => x.$1.compareTo(y.$1)); if (dt <= 0 || dt > maxGap) return;
_spans timeIn += dt;
..clear() final from = playedFrom;
..addAll(out); playedFrom = from == null ? prev.pos : math.min(from, prev.pos);
} }
/// The rule, for an item [runtime] seconds long whose ending starts at /// The rule, for an item [runtime] seconds long whose ending starts at
/// [endingStart] (an Outro segment or ED chapter; null = the real end): /// [endingStart] (an Outro segment or ED chapter; null = the real end):
/// - played before: [WatchOutcome.keepPlayed]; /// - played before: [WatchOutcome.keepPlayed];
/// - [covered] >= 70 % of the runtime, or the ending was reached /// - [credited] time In >= 70 % of the runtime, or the ending was
/// ([furthest]) with [covered] >= 40 %: [WatchOutcome.played]. The /// reached ([furthest]) with it >= 40 %: [WatchOutcome.played]. The
/// ending is the earlier of [endingStart] and the last 15 % (at most /// ending is the earlier of [endingStart] and the last 15 % (at most
/// 4 min): someone who watched that far and stops is done with it; /// 4 min): someone who watched that far and stops is done with it.
/// - [covered] under max(2 min, 5 %) (at most a quarter of a short /// Any watch Jellyfin's own rule counts (a position past 90 %, reached
/// by playing) counts here too, also with the opening and ending
/// skipped;
/// - [credited] under max(2 min, 5 %) (at most a quarter of a short
/// item): [WatchOutcome.peek]; /// item): [WatchOutcome.peek];
/// - else [WatchOutcome.partial] at [lastPosition]. /// - else [WatchOutcome.partial] at [lastPosition].
/// An unknown runtime (0) never makes it played. /// An unknown runtime (0) never makes it played.
WatchVerdict verdict({required double runtime, double? endingStart}) { WatchVerdict verdict({required double runtime, double? endingStart}) {
if (playedBefore) return const WatchVerdict(WatchOutcome.keepPlayed); if (playedBefore) return const WatchVerdict(WatchOutcome.keepPlayed);
final c = covered; final t = credited;
if (runtime > 0) { if (runtime > 0) {
final tail = runtime - math.min(240.0, 0.15 * runtime); final tail = runtime - math.min(240.0, 0.15 * runtime);
final end = endingStart != null && endingStart > 0 && endingStart < tail ? endingStart : tail; final end = endingStart != null && endingStart > 0 && endingStart < tail ? endingStart : tail;
if (c >= 0.7 * runtime || (furthest >= end && c >= 0.4 * runtime)) { if (t >= 0.7 * runtime || (furthest >= end && t >= 0.4 * runtime)) {
return const WatchVerdict(WatchOutcome.played); return const WatchVerdict(WatchOutcome.played);
} }
} }
final peekBelow = runtime > 0 ? math.min(math.max(120.0, 0.05 * runtime), 0.25 * runtime) : 120.0; final peekBelow = runtime > 0 ? math.min(math.max(120.0, 0.05 * runtime), 0.25 * runtime) : 120.0;
if (c < peekBelow) return const WatchVerdict(WatchOutcome.peek); if (t < peekBelow) return const WatchVerdict(WatchOutcome.peek);
return WatchVerdict(WatchOutcome.partial, resumeAt: lastPosition ?? 0); return WatchVerdict(WatchOutcome.partial, resumeAt: lastPosition ?? 0);
} }
} }
+79
View File
@@ -0,0 +1,79 @@
import 'presence.dart';
import 'protocol.dart';
import 'queue.dart';
// What's next, the rules every side reads the same way (PROTOCOL.md
// "Queue", ARCHITECTURE.md "What's next"): who puts an Up next item up.
// Pure and only on what `state` carries, so the server (which holds the
// countdown and skips what nobody can play) and every app agree without
// a wire message.
/// Who puts an Up next item up: [id] a member id; null with [why] when
/// nobody here can (the server skips the item), null without [why] when
/// nobody is around right now (it waits).
typedef QueuePerformer = ({String? id, String? why});
/// Connected and watching (`in` or `joining`): can do things for the
/// party now (put an Up next item up).
bool isPresentMember(MemberView m) => m.presence == Presence.watching || m.presence == Presence.joining;
/// - A link passed on by its adder, or the adder's file: only the adder
/// (their app has the file, the cookies). Gone (disconnected, in its
/// grace): it waits for them; left for good: skipped.
/// - Jellyfin: the adder when it plays that server with its own account,
/// else the lowest member id that does (`jf[serverId] == true`,
/// present). Nobody: skipped.
/// - A link everyone opens, or one the server fetches: the adder when
/// present, else the lowest present member id; nobody present: it waits.
QueuePerformer queuePerformer(QueueItem item, List<MemberView> members) {
MemberView? adder;
for (final m in members) {
if (m.id == item.by) adder = m;
}
if (item.how.adderOnly) {
if (adder == null) return (id: null, why: '${item.byName.isEmpty ? 'who added it' : item.byName} left');
if (adder.presence == Presence.gone) return (id: null, why: null);
return (id: adder.id, why: null);
}
final ref = item.media.jellyfin;
if (item.how == QueueHow.jellyfin && ref != null) {
bool can(MemberView m) => isPresentMember(m) && m.jf[ref.serverId] == true;
if (adder != null && can(adder)) return (id: adder.id, why: null);
final id = lowestMemberId(members.where(can));
return id == null ? (id: null, why: 'nobody here has that Jellyfin server') : (id: id, why: null);
}
if (adder != null && isPresentMember(adder)) return (id: adder.id, why: null);
return (id: lowestMemberId(members.where(isPresentMember)), why: null);
}
/// The lowest member id of [members] ("m2" before "m10"), or null.
String? lowestMemberId(Iterable<MemberView> members) {
final ids = [for (final m in members) m.id]..sort(compareMemberIds);
return ids.isEmpty ? null : ids.first;
}
/// "m2" before "m10": numeric suffix order, then plain text.
int compareMemberIds(String a, String b) {
final na = int.tryParse(a.replaceFirst(RegExp(r'^\D+'), ''));
final nb = int.tryParse(b.replaceFirst(RegExp(r'^\D+'), ''));
if (na != null && nb != null && na != nb) return na.compareTo(nb);
return a.compareTo(b);
}
/// Who may add the continuation ([QueueItem.auto]) of the current media:
/// who put it up ([swapBy], a member id), or, when that member is not
/// `in`, the first member (in [members] order, the join order) who is.
bool mayAddContinuation(String memberId, String? swapBy, List<MemberView> members) {
if (memberId.isEmpty) return false;
if (memberId == swapBy) return true;
final swapper = members.where((m) => m.id == swapBy).firstOrNull;
if (swapper != null && swapper.presence == Presence.watching) return false;
return members.where((m) => m.presence == Presence.watching).firstOrNull?.id == memberId;
}
/// Whole seconds left of [advance] at [now] (server clock), at least 0.
int advanceSecondsLeft(QueueAdvance advance, DateTime now) {
final end = advance.at.add(Duration(seconds: advance.secs));
final ms = end.difference(now).inMilliseconds;
return ms <= 0 ? 0 : (ms / 1000).ceil();
}
+173 -4
View File
@@ -8,7 +8,9 @@ import 'package:crypto/crypto.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'cookies.dart'; import 'cookies.dart';
import 'link_probe.dart' show mayNeedSignIn;
import 'logging.dart'; import 'logging.dart';
import 'relay.dart' show mpvEdl;
// yt-dlp: turns a web page (YouTube, Twitter/X, ...) into media URLs. The // yt-dlp: turns a web page (YouTube, Twitter/X, ...) into media URLs. The
// app and the server each keep their own copy, the official standalone // app and the server each keep their own copy, the official standalone
@@ -19,7 +21,11 @@ final _log = createLogger('yt-dlp');
class YtDlpException implements Exception { class YtDlpException implements Exception {
final String message; final String message;
const YtDlpException(this.message);
/// Which cookies the run used and what they hold for the site, when the
/// failure may be about signing in ([YtDlp.cookieNote], D11).
final String? cookies;
const YtDlpException(this.message, {this.cookies});
@override @override
String toString() => message; String toString() => message;
} }
@@ -121,6 +127,10 @@ class YtDlpInfo {
/// Every relayable format by id, to find [tracks] again after a /// Every relayable format by id, to find [tracks] again after a
/// re-resolve. /// re-resolve.
final Map<String, YtDlpFormat> formats; final Map<String, YtDlpFormat> formats;
/// What a player opens for this page ([playFormat]); null when yt-dlp
/// picked nothing a player can open.
final YtDlpPlay? play;
const YtDlpInfo({ const YtDlpInfo({
required this.title, required this.title,
this.uploader = '', this.uploader = '',
@@ -131,6 +141,7 @@ class YtDlpInfo {
this.isLive = false, this.isLive = false,
this.tracks = const [], this.tracks = const [],
this.formats = const {}, this.formats = const {},
this.play,
}); });
/// One line for the log: extractor, duration, live, and each picked /// One line for the log: extractor, duration, live, and each picked
@@ -163,6 +174,7 @@ class YtDlpInfo {
duration: j['duration'] is num ? (j['duration'] as num).toDouble() : null, duration: j['duration'] is num ? (j['duration'] as num).toDouble() : null,
thumbnail: str('thumbnail').isEmpty ? null : str('thumbnail'), thumbnail: str('thumbnail').isEmpty ? null : str('thumbnail'),
isLive: isLive, isLive: isLive,
play: YtDlpPlay.parse(j),
tracks: isLive ? const [] : pickTracks(formats), tracks: isLive ? const [] : pickTracks(formats),
formats: { formats: {
for (final f in formats) for (final f in formats)
@@ -341,6 +353,100 @@ Map<String, String> parseSha256Sums(String text) => {
m.group(2)!: m.group(1)!.toLowerCase(), m.group(2)!: m.group(1)!.toLowerCase(),
}; };
/// The formats a player opens for a page: the best video up to 1080p with
/// the best audio, else the best single file (yt-dlp's `-f`).
const playFormat = 'bv*[height<=1080]+ba/b';
/// The subtitles a player gets: every language but Bilibili's danmaku.
const playSubLangs = 'all,-danmaku';
/// A page's subtitle track for a player (`requested_subtitles`).
class YtDlpSubtitle {
final String lang;
final String name;
final String url;
final String ext;
const YtDlpSubtitle(this.lang, this.url, {this.name = '', this.ext = ''});
}
/// What a player opens for a page, as yt-dlp resolved it ([playFormat]):
/// one file, or a video and an audio stream played together, with the
/// headers its CDN wants (the first format's `http_headers`, plus its
/// `cookies` as a Cookie header) and the subtitles ([playSubLangs]).
/// URLs are bound to the resolving machine (its address, its cookies).
class YtDlpPlay {
final List<String> urls;
final Map<String, String> headers;
final List<YtDlpSubtitle> subtitles;
const YtDlpPlay(this.urls, {this.headers = const {}, this.subtitles = const []});
/// The URL mpv opens: the file, or an EDL of the streams.
String get mpvUrl => urls.length == 1 ? urls.single : mpvEdl(urls);
/// From `yt-dlp -J`: its `requested_formats`, else the top-level format;
/// null when none of them is something a player can open.
static YtDlpPlay? parse(Map<String, Object?> j) {
final picked = j['requested_formats'] is List
? [for (final f in j['requested_formats'] as List) if (f is Map) Map<String, Object?>.from(f)]
: [if (j['url'] is String) j];
if (picked.isEmpty) return null;
final urls = <String>[];
for (final f in picked) {
final url = _playUrl(f);
if (url == null) return null;
urls.add(url);
}
final first = picked.first;
final headers = <String, String>{
if (first['http_headers'] is Map)
for (final e in (first['http_headers'] as Map).entries)
if (e.key is String && e.value is String) e.key as String: e.value as String,
};
final cookie = cookieHeaderOf(first['cookies']);
if (cookie.isNotEmpty) headers['Cookie'] = cookie;
final subs = <YtDlpSubtitle>[];
final requested = j['requested_subtitles'];
if (requested is Map) {
for (final e in requested.entries) {
final lang = '${e.key}', v = e.value;
if (lang == 'danmaku' || v is! Map || v['url'] is! String) continue;
final ext = v['ext'] is String ? v['ext'] as String : '';
if (ext == 'xml' || ext == 'json') continue;
subs.add(YtDlpSubtitle(lang, v['url'] as String,
name: v['name'] is String ? v['name'] as String : '', ext: ext));
}
}
return YtDlpPlay(urls, headers: headers, subtitles: subs);
}
static String? _playUrl(Map<String, Object?> f) {
final protocol = f['protocol'] is String ? f['protocol'] as String : 'https';
if (protocol.startsWith('http_dash')) {
final manifest = f['manifest_url'];
return manifest is String && manifest.startsWith('http') ? manifest : null;
}
final url = f['url'];
if (url is! String || !(url.startsWith('http://') || url.startsWith('https://'))) return null;
return const {'http', 'https', 'm3u8', 'm3u8_native'}.contains(protocol) ? url : null;
}
}
/// A Cookie header from yt-dlp's `cookies` field (`name=value; Domain=...;
/// Path=/; name2=value2; ...`): the name=value pairs, attributes dropped.
String cookieHeaderOf(Object? cookies) {
if (cookies is! String || cookies.trim().isEmpty) return '';
const attributes = {'domain', 'path', 'expires', 'max-age', 'secure', 'httponly', 'samesite', 'comment', 'version'};
final pairs = <String>[];
for (final part in cookies.split(';')) {
final t = part.trim();
final i = t.indexOf('=');
final name = (i < 0 ? t : t.substring(0, i)).trim();
if (i <= 0 || attributes.contains(name.toLowerCase())) continue;
pairs.add('$name=${t.substring(i + 1).trim()}');
}
return pairs.join('; ');
}
/// The last `ERROR:` line of yt-dlp's stderr without its prefixes /// The last `ERROR:` line of yt-dlp's stderr without its prefixes
/// (`ERROR: [twitter] 123: No video` -> `No video`), else the last line. /// (`ERROR: [twitter] 123: No video` -> `No video`), else the last line.
String ytDlpErrorText(String stderr) { String ytDlpErrorText(String stderr) {
@@ -602,7 +708,11 @@ class YtDlp {
final e = await err; final e = await err;
_log.warning('resolve ${logUrl(url)}: exit $code after ${sw.elapsedMilliseconds} ms; stderr tail:\n' _log.warning('resolve ${logUrl(url)}: exit $code after ${sw.elapsedMilliseconds} ms; stderr tail:\n'
'${_tail(e)}'); '${_tail(e)}');
throw YtDlpException(ytDlpErrorText(e)); final text = ytDlpErrorText(e);
// A site that hides the video from guests: say which cookies went.
final note = mayNeedSignIn(text) ? await cookieNote(url) : null;
if (note != null) _log.info('resolve ${logUrl(url)}: $note');
throw YtDlpException(text, cookies: note);
} }
try { try {
final j = jsonDecode(await out); final j = jsonDecode(await out);
@@ -616,6 +726,59 @@ class YtDlp {
} }
} }
/// Which cookies a run for [url] uses and what they hold for its site
/// ([cookieSite]), in plain words (D11): none; the private file and how
/// many of its cookies are for the site; a browser, and how many cookies
/// for the site yt-dlp could read from it (one run of yt-dlp that writes
/// the browser's cookies to a private file in [dir], counted and deleted
/// at once; values are never logged).
Future<String> cookieNote(String url) async {
final site = cookieSite(Uri.tryParse(url)?.host ?? '');
final source = cookies.read(count: false);
switch (source) {
case null:
return 'no cookies were used';
case CookieFile(:final path):
try {
final n = parseCookies(await File(path).readAsString()).where((c) => cookieSite(c.host) == site).length;
return cookieNoteText(source, site, n);
} catch (_) {
return cookieNoteText(source, site, -1);
}
case CookieBrowser():
return cookieNoteText(source, site, -1, read: await _browserCookies(source, site));
}
}
/// How many cookies for [site] yt-dlp reads from [browser], or why it
/// cannot.
Future<({int count, String? error})> _browserCookies(CookieBrowser browser, String site) async {
final exe = await ensure();
if (exe == null) return (count: -1, error: lastError ?? 'yt-dlp is not available');
final jar = File(p.join(dir, '.cookie-check-$pid.txt'));
try {
// Private before yt-dlp writes into it (it keeps the mode).
await jar.writeAsString('# Netscape HTTP Cookie File\n');
if (!Platform.isWindows) await Process.run('chmod', ['600', jar.path]);
// No URL: yt-dlp loads the browser's cookies, saves the jar and stops.
final r = await Process.run(
exe, ['--ignore-config', '--cookies-from-browser', browser.spec, '--cookies', jar.path])
.timeout(const Duration(seconds: 30));
final cookies = parseCookiesOrEmpty(await jar.readAsString());
final err = '${r.stderr}';
if (cookies.isEmpty && err.contains('ERROR:') && !err.contains('provide at least one URL')) {
return (count: -1, error: ytDlpErrorText(err));
}
return (count: cookies.where((c) => cookieSite(c.host) == site).length, error: null);
} on TimeoutException {
return (count: -1, error: 'yt-dlp took longer than 30 s reading them');
} catch (e) {
return (count: -1, error: '$e');
} finally {
if (await jar.exists()) await jar.delete().catchError((_) => jar);
}
}
/// [args] for the log: the cookie source named, not its path or spec. /// [args] for the log: the cookie source named, not its path or spec.
static String _argsForLog(List<String> args) { static String _argsForLog(List<String> args) {
final out = <String>[]; final out = <String>[];
@@ -640,8 +803,14 @@ class YtDlp {
} }
/// The command line [resolve] runs. /// The command line [resolve] runs.
List<String> resolveArgs(String url) => List<String> resolveArgs(String url) => [
['-J', '--no-playlist', '--no-warnings', '--ignore-config', ...cookies.args(), '--', url]; '-J', '--no-playlist', '--no-warnings', '--ignore-config',
// What a player opens ([YtDlpPlay]): yt-dlp's own pick at most
// 1080p, and the page's subtitles but Bilibili's danmaku (an XML
// comment feed, not subtitles).
'-f', playFormat, '--write-subs', '--sub-langs', playSubLangs,
...cookies.args(), '--', url,
];
Future<void> close() => _changes.close(); Future<void> close() => _changes.close();
} }
+1 -1
View File
@@ -1,6 +1,6 @@
name: sync_relay_core name: sync_relay_core
description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter. description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter.
version: 1.2.0 version: 1.3.0
environment: environment:
sdk: ^3.0.0 sdk: ^3.0.0
dependencies: dependencies:
+73 -6
View File
@@ -30,26 +30,93 @@ void main() {
expect(again.map((c) => c.toLine()), cookies.map((c) => c.toLine())); expect(again.map((c) => c.toLine()), cookies.map((c) => c.toLine()));
}); });
test('kept sites become a private file yt-dlp and mpv are pointed at', () async { test('kept sites become a private file yt-dlp is pointed at', () async {
final dir = await Directory.systemTemp.createTemp('cookies_test'); final dir = await Directory.systemTemp.createTemp('cookies_test');
addTearDown(() => dir.delete(recursive: true)); addTearDown(() => dir.delete(recursive: true));
final src = File('${dir.path}/export.json')..writeAsStringSync(_json); final src = File('${dir.path}/export.json')..writeAsStringSync(_json);
final y = YtDlp('${dir.path}/yt-dlp, a=b'); final y = YtDlp('${dir.path}/yt-dlp, a=b');
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '--', 'U']); expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '-f', playFormat, '--write-subs', '--sub-langs', playSubLangs, '--', 'U']);
final kept = await y.cookies.import(src.path, sites: {'bilibili.com'}); final kept = await y.cookies.import(src.path, sites: {'bilibili.com'});
expect([kept.count, ...kept.sites], [1, 'bilibili.com']); expect([kept.count, ...kept.sites], [1, 'bilibili.com']);
final path = y.cookies.filePath; final path = y.cookies.filePath;
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '--cookies', path, '--', 'U']); expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '-f', playFormat, '--write-subs', '--sub-langs', playSubLangs, '--cookies', path, '--', 'U']);
expect(y.cookies.mpvRawOptions(), 'cookies=%${path.length}%$path');
if (!Platform.isWindows) expect(File(path).statSync().modeString(), 'rw-------'); if (!Platform.isWindows) expect(File(path).statSync().modeString(), 'rw-------');
await y.cookies.saveBrowser('firefox', 'work'); await y.cookies.saveBrowser('firefox', 'work');
expect(y.resolveArgs('U').sublist(4, 6), ['--cookies-from-browser', 'firefox:work']); expect(y.resolveArgs('U').sublist(9, 11), ['--cookies-from-browser', 'firefox:work']);
expect(File(path).existsSync(), isFalse); expect(File(path).existsSync(), isFalse);
expect(y.cookies.mpvRawOptions(), 'cookies-from-browser=%12%firefox:work');
await y.cookies.remove(); await y.cookies.remove();
expect(y.cookies.read(), isNull); expect(y.cookies.read(), isNull);
}); });
// D11: a sign-in error says which cookies were used and what they hold
// for the site.
group('cookie notes', () {
test('the texts', () {
expect(cookieNoteText(null, 'bilibili.com', -1), 'no cookies were used');
const file = CookieFile('/x', 3, ['youtube.com']);
expect(cookieNoteText(file, 'bilibili.com', 0), 'used the cookies file, which has none for bilibili.com');
expect(cookieNoteText(file, 'bilibili.com', 4),
'used the cookies file (4 for bilibili.com: signed out or expired?)');
const ff = CookieBrowser('firefox', 'work');
expect(cookieNoteText(ff, 'bilibili.com', -1, read: (count: 0, error: null)),
"used Firefox (work)'s cookies, which have none for bilibili.com: sign in to bilibili.com there");
expect(cookieNoteText(ff, 'bilibili.com', -1, read: (count: 12, error: null)),
"used Firefox (work)'s cookies (12 for bilibili.com: that account may not see it)");
expect(cookieNoteText(const CookieBrowser('chrome'), 'x.com', -1, read: (count: -1, error: 'locked')),
"used Chrome's cookies, but yt-dlp couldn't read them: locked");
expect(withCookieNote(noFormatsText, 'no cookies were used'),
'No video found: it may need signing in (no cookies were used)');
expect(withCookieNote(needsCookiesText, 'x'), 'This site needs cookies (x) — Settings → Player → yt-dlp → Cookies');
expect(withCookieNote(notFoundText, 'x'), notFoundText);
expect(mayNeedSignIn('No video formats found!'), isTrue);
expect(mayNeedSignIn('HTTP Error 404'), isFalse);
});
test("a failed resolve names the file's cookies for the site; a browser's are counted by yt-dlp",
() async {
final dir = await Directory.systemTemp.createTemp('cookie_note');
addTearDown(() => dir.delete(recursive: true));
// A stand-in yt-dlp: a version, a resolve that finds no formats, and
// the browser read (no URL) that writes the jar it is given.
final fake = File('${dir.path}/yt-dlp');
await fake.writeAsString(r'''#!/bin/sh
case "$1" in --version) echo 2099.01.01; exit 0;; esac
jar=""; prev=""
for a in "$@"; do [ "$prev" = "--cookies" ] && jar="$a"; prev="$a"; done
case "$*" in *-J*) echo "ERROR: [BiliBili] BV1: No video formats found!" >&2; exit 1;; esac
printf '# Netscape HTTP Cookie File\n.bilibili.com\tTRUE\t/\tTRUE\t2000000000\tSESSDATA\tx\n.bilibili.com\tTRUE\t/\tFALSE\t2000000000\tbuvid3\ty\n.other.org\tTRUE\t/\tFALSE\t0\ta\tb\n' > "$jar"
echo "Extracted 3 cookies from firefox" >&2
echo "yt-dlp: error: You must provide at least one URL." >&2; exit 2
''');
await Process.run('chmod', ['+x', fake.path]);
final y = YtDlp(dir.path, updateEvery: const Duration(days: 9999));
await File('${dir.path}/last-check').writeAsString(DateTime.now().toIso8601String());
const page = 'https://www.bilibili.com/video/BV1';
Future<YtDlpException> fail() async {
try {
await y.resolve(page);
} on YtDlpException catch (e) {
return e;
}
throw StateError('resolved');
}
expect((await fail()).cookies, 'no cookies were used');
await y.cookies.saveFile(parseCookies('.youtube.com\tTRUE\t/\tFALSE\t0\tSID\tv\n'));
expect((await fail()).cookies, 'used the cookies file, which has none for bilibili.com');
await y.cookies.saveBrowser('firefox');
final e = await fail();
expect(e.message, contains('No video formats found'));
expect(e.cookies, "used Firefox's cookies (2 for bilibili.com: that account may not see it)");
expect(dir.listSync().map((f) => f.path).where((p) => p.contains('cookie-check')), isEmpty,
reason: 'the browser jar is deleted at once');
// The probe carries it into the plain words.
expect(LinkProbe.page(Uri.parse(page), ytDlpError: e.message, cookies: e.cookies).problem,
"No video found: it may need signing in (used Firefox's cookies (2 for bilibili.com: that account may not see it))");
}, skip: Platform.isWindows ? 'the stand-in yt-dlp is a shell script' : false);
});
} }
+1 -1
View File
@@ -316,7 +316,7 @@ void main() {
expect(r.copyWith(sourceGone: false).toJson().containsKey('sourceGoneSince'), isFalse); expect(r.copyWith(sourceGone: false).toJson().containsKey('sourceGoneSince'), isFalse);
}); });
test('state carries hold and the reserved advance', () { test('state carries hold and advance', () {
final at = DateTime.utc(2026); final at = DateTime.utc(2026);
final s = StateBroadcast( final s = StateBroadcast(
eventId: 1, eventId: 1,
+129
View File
@@ -70,6 +70,7 @@ void main() {
}); });
test('skip asks for the first item; a play request times out', () { test('skip asks for the first item; a play request times out', () {
engine.applySwap('a', const HelloMedia(name: 'up', size: 0, duration: 0, url: 'https://x/up'));
expect(engine.applyQueuePlay('a', ''), isFalse, reason: 'nothing queued'); expect(engine.applyQueuePlay('a', ''), isFalse, reason: 'nothing queued');
engine.applyQueueAdd('a', link('one')); engine.applyQueueAdd('a', link('one'));
expect(engine.applyQueuePlay('a', ''), isTrue); expect(engine.applyQueuePlay('a', ''), isTrue);
@@ -171,4 +172,132 @@ void main() {
expect(engine.media!.name, 'Ep02'); expect(engine.media!.name, 'Ep02');
}); });
}); });
group("what's next (the server's countdown)", () {
QueueItem file(String title, {String key = 'abcdefgh12'}) => QueueItem(
media: HelloMedia(name: title, size: 3, duration: 0), how: QueueHow.file, title: title, key: key);
// Both keep talking (pings), so nobody goes silent.
void after(int secs) {
t = t.add(Duration(seconds: secs));
engine
..touch('a')
..touch('b')
..tick();
}
/// Ep01 (20 s) up by alice, both ready and buffered, played to its end.
void finish() {
engine.applySwap('a', const HelloMedia(name: 'Ep01', size: 0, duration: 20, url: 'https://example.com/1'));
for (final tok in ['a', 'b']) {
engine.applyReady(tok, true);
engine.applyReport(tok, 0, 100, engine.eventId, mediaId: engine.mediaId);
}
expect(engine.transport, Transport.playing);
after(21);
expect(engine.transport, Transport.finished);
}
test('nothing up: the first item is asked for at once', () {
engine.applyQueueAdd('b', link('one'));
expect(engine.queue.play, idOf('one'));
expect(engine.advance, isNull);
});
test('at the end with an item first: 10 s for everyone, then queuePlay', () {
finish();
expect(engine.advance, isNull, reason: 'nothing queued, nothing counts down');
engine.applyQueueAdd('b', link('two'));
final a = engine.advance!;
expect([a.id, a.secs], [idOf('two'), RelayConstants.advanceSecs]);
expect(a.at, t);
expect(engine.broadcast().advance!.id, a.id, reason: 'in every state');
expect(advanceSecondsLeft(a, t.add(const Duration(milliseconds: 2500))), 8);
after(5);
expect(engine.queue.play, isNull);
// A new first item takes the countdown over, its end unchanged.
engine.applyQueueAdd('a', link('first'), next: true);
expect([engine.advance!.id, engine.advance!.at], [idOf('first'), a.at]);
after(5);
expect(engine.queue.play, idOf('first'));
expect(engine.advance, isNull, reason: 'over once it asked');
after(1);
expect(engine.advance, isNull, reason: 'not counted down again while the request waits');
});
test('a seek stops it; cancel stops it for this media and drops the continuation', () {
engine.applyQueueAdd('b', link('two'));
finish();
expect(engine.advance, isNotNull);
engine.applySeek('b', 5);
expect(engine.advance, isNull);
expect(engine.transport, isNot(Transport.finished));
// Played to the end again: a fresh countdown.
after(16);
expect(engine.transport, Transport.finished);
expect(engine.advance, isNotNull);
engine.applyQueueAdd('a', link('Ep02').copyWith(auto: true, forMedia: engine.mediaId));
engine.feed.drain();
expect(engine.applyQueueCancel('b'), isTrue);
expect(engine.advance, isNull);
expect(titles(), ['two']);
expect(engine.feed.drain().single.action, 'advance-cancel');
after(30);
expect((engine.advance, engine.queue.play), (null, null), reason: 'cancelled for this media');
});
test("holds while its performer is gone, plays once they're back", () {
engine.applyQueueAdd('b', file('bobs'));
finish();
engine.applyGone('b');
after(12);
expect(engine.queue.play, isNull, reason: 'held: only bob has the file');
expect(engine.advance, isNotNull);
engine.applyTakeover('b', seq: 1);
after(1);
expect(engine.queue.play, idOf('bobs'));
});
test("holds while the item's relay source is gone", () {
var sourceGone = true;
engine.itemSourceGone = (_) => sourceGone;
engine.applyQueueAdd('b', link('two'));
finish();
after(12);
expect(engine.queue.play, isNull);
sourceGone = false;
after(1);
expect(engine.queue.play, idOf('two'));
});
test('what nobody here can put up is skipped by the server', () {
engine.applyQueueAdd('b', file('bobs'));
engine.applyQueueAdd('a', link('next'));
finish();
engine.applyLeave('b', reason: 'bye');
engine.feed.drain();
after(11);
expect(titles(), ['next']);
final skip = engine.feed.drain().firstWhere((f) => f.action == 'queue-skip');
expect([skip.actorId, skip.target, skip.reason], ['server', 'bobs', 'bob left']);
expect(engine.advance!.id, idOf('next'), reason: 'the countdown goes on with the next one');
after(1);
expect(engine.queue.play, idOf('next'));
// A play request for a Jellyfin item nobody here can open: skipped too.
engine.applyQueueAdd('a', QueueItem(
media: const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i')),
how: QueueHow.jellyfin,
title: 'ep'));
engine.applyQueuePlay('a', idOf('ep'));
expect(titles().contains('ep'), isFalse);
});
test('the continuation adds no feed line', () {
engine.applySwap('a', const HelloMedia(name: 'Ep01', size: 0, duration: 0, url: 'https://example.com/1'));
engine.feed.drain();
engine.applyQueueAdd('a', link('Ep02').copyWith(auto: true, forMedia: engine.mediaId));
expect(engine.feed.drain().where((f) => f.action == 'queue-add'), isEmpty);
});
});
} }
+16
View File
@@ -388,6 +388,22 @@ void main() {
group('swap', () { group('swap', () {
const ep2 = HelloMedia(name: 'Ep02.mkv', size: 9, duration: 90.0); const ep2 = HelloMedia(name: 'Ep02.mkv', size: 9, duration: 90.0);
test('a restore (after a restart) puts it back at its position, held; finished stays finished', () {
join('a', 'alice');
engine.applySwap('a', ep2, restore: const SwapRestore(position: 42.5, transport: 'playing'));
expect((engine.positionAt(), engine.transport), (42.5, Transport.held));
expect(engine.feed.drain().last.reason, 'restore');
engine.applyReady('a', true);
buffer('a', 4.0);
expect(engine.transport, Transport.playing, reason: 'the gate starts it again');
engine.applySwap('a', ep2, restore: const SwapRestore(position: 90, transport: 'finished'));
expect((engine.positionAt(), engine.transport), (90.0, Transport.finished));
final rt = SwapIntent.fromJson(
const SwapIntent(media: ep2, restore: SwapRestore(position: 3, transport: 'playing')).toJson());
expect((rt.restore!.position, rt.restore!.transport), (3.0, 'playing'));
expect(SwapIntent.fromJson(const SwapIntent(media: ep2).toJson()).restore, isNull);
});
test('swap bumps mediaId, holds, keeps ready, fresh members in set', () { test('swap bumps mediaId, holds, keeps ready, fresh members in set', () {
join('a', 'alice'); join('a', 'alice');
engine.applyReady('a', true); engine.applyReady('a', true);
+127 -78
View File
@@ -1,102 +1,151 @@
// restructure: rewrite in step 6 (D3: the watched rule moves from position coverage to time present)
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
/// Plays [from]..[to] at normal speed, one sample a second, starting at final _t0 = DateTime(2026, 1, 1);
/// wall second [t]; returns the wall second after.
int play(WatchSession w, int t, double from, double to) { /// One sample a second for [secs] seconds from wall second [t], the
var pos = from; /// position moving with the wall clock from [from] while [playing];
w.sample(pos, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t))); /// returns the wall second after.
while (pos < to) { int run(WatchSession w, int t, double from, int secs, {bool playing = true, bool present = true}) {
t++; for (var i = 0; i <= secs; i++) {
pos = pos + 1 > to ? to : pos + 1; w.sample(from + (playing ? i : 0), playing: playing, present: present, at: _t0.add(Duration(seconds: t + i)));
w.sample(pos, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
} }
return t + 1; return t + secs + 1;
} }
void main() { void main() {
const ep = 1440.0; // 24 min const ep = 1440.0; // 24 min
const ed = 1350.0; // the ending starts 1.5 min before the end const ed = 1350.0; // the ending starts 1.5 min before the end
test('normal playback counts; seeks, skips and pauses do not', () { group('time In', () {
final w = WatchSession(); test('counts the time it played while In; pauses, holds and jumps add nothing', () {
var t = play(w, 0, 0, 60); final w = WatchSession();
// A jump ahead between two samples: not watched. var t = run(w, 0, 0, 60);
w.sample(600, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t))); // Held (the server waits for someone): the clock runs, nothing counts.
t = play(w, t + 1, 600, 630); t = run(w, t, 60, 30, playing: false);
// Paused: the position does not move, nothing counts. // A jump ahead: only the time actually played after it.
w.sample(630, playing: false, at: DateTime(2026, 1, 1).add(Duration(seconds: t + 30))); t = run(w, t, 600, 30);
expect(w.covered, closeTo(90, 1)); expect(w.timeIn, closeTo(90, 2));
expect(w.spans, hasLength(2)); expect(w.furthest, 630);
expect(w.firstPosition, 0); expect(w.firstPosition, 0);
expect(w.lastPosition, 630); });
expect(w.furthest, 630);
test('sat out (Away) or joining: not counted, though the media plays on (P7)', () {
final w = WatchSession();
var t = run(w, 0, 0, 120); // in for two minutes
t = run(w, t, 120, 1200, present: false); // sat out for the rest
expect(w.timeIn, closeTo(120, 2));
expect(w.verdict(runtime: ep).outcome, WatchOutcome.partial);
expect(w.furthest, closeTo(120, 1), reason: 'positions while away are not this viewer\'s');
});
test('a stall longer than the gap adds nothing', () {
final w = WatchSession();
w.sample(0, playing: true, present: true, at: _t0);
w.sample(30, playing: true, present: true, at: _t0.add(const Duration(seconds: 30)));
expect(w.timeIn, 0);
});
test('drift corrections and small seeks while watching do not cost watched time (P9)', () {
final w = WatchSession();
var pos = 0.0;
for (var i = 0; i <= 1200; i++) {
// Every 7 s the player is corrected half a second back or ahead.
if (i % 7 == 0) pos += i % 14 == 0 ? 0.5 : -0.5;
w.sample(pos, playing: true, present: true, at: _t0.add(Duration(seconds: i)));
pos += 1;
}
expect(w.timeIn, closeTo(1200, 1));
expect(w.verdict(runtime: ep).outcome, WatchOutcome.played);
});
}); });
test('rewatching a part counts once in coverage, twice in watched time', () { group('verdict (D3)', () {
final w = WatchSession(); test('played: 70 % of the runtime In, or the ending (or the last 15 %) reached after 40 %', () {
var t = play(w, 0, 0, 300); final full = WatchSession();
t = play(w, t, 100, 200); // went back for a moment run(full, 0, 0, 1200); // 83 %
expect(w.covered, closeTo(300, 1)); expect(full.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
expect(w.watched, closeTo(400, 1));
});
test('played: 70 % covered, or the ending (or the last 15 %) reached after 40 %', () { // The opening and the ending skipped, stopped at the ED: Jellyfin's
final full = WatchSession(); // own rule (past 90 %) would count it too.
play(full, 0, 0, 1200); // 83 % final skipped = WatchSession();
expect(full.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played); var t = run(skipped, 0, 0, 90);
t = run(skipped, t, 180, 1170); // to the ED start
expect(skipped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
expect(skipped.verdict(runtime: ep).outcome, WatchOutcome.played);
final skipped = WatchSession(); final half = WatchSession();
final t = play(skipped, 0, 0, 800); // 56 %, then skipped to the ED t = run(half, 0, 0, 800); // 56 %, then a skip to the ED
play(skipped, t, ed, ed + 5); run(half, t, ed, 5);
expect(skipped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played); expect(half.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
// Without a known ending the last 15 % (at most 4 min) counts. });
expect(skipped.verdict(runtime: ep).outcome, WatchOutcome.played);
// Watched with the opening skipped, stopped 3 min before the end. test('skipping around or a screenshot visit never counts', () {
final early = WatchSession(); // Through the whole episode in 30 s jumps, 2 s at each stop.
final u = play(early, 0, 0, 90); final hops = WatchSession();
play(early, u, 180, ep - 180); var t = 0;
expect(early.verdict(runtime: ep).outcome, WatchOutcome.played); for (var p = 0.0; p < ep; p += 30) {
t = run(hops, t, p, 2);
}
expect(hops.furthest, greaterThan(ed));
expect(hops.verdict(runtime: ep, endingStart: ed).watched, isFalse);
final jumped = WatchSession(); final shot = WatchSession();
play(jumped, 0, 0, 400); // 28 %, then jumped to the end run(shot, 0, 700, 20);
jumped.sample(ep, playing: false, at: DateTime(2026, 1, 2)); expect(shot.verdict(runtime: ep).outcome, WatchOutcome.peek);
expect(jumped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.partial);
});
test('played before: stays played however little was watched', () { // A quarter watched, then a jump to the end: part way, not played.
final w = WatchSession(playedBefore: true); final jumped = WatchSession();
play(w, 0, 700, 730); // checking a moment t = run(jumped, 0, 0, 400);
expect(w.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.keepPlayed); run(jumped, t, ep - 5, 5);
}); expect(jumped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.partial);
});
test('a peek leaves nothing; part way resumes at the last position', () { test('played before: stays played however little was watched', () {
final peek = WatchSession(resumeBefore: 300); final w = WatchSession(playedBefore: true);
play(peek, 0, 900, 990); // 90 s run(w, 0, 700, 30);
expect(peek.verdict(runtime: ep).outcome, WatchOutcome.peek); expect(w.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.keepPlayed);
expect(w.verdict(runtime: ep).watched, isTrue);
});
final movie = WatchSession(); test('a resume continues the earlier watch', () {
play(movie, 0, 0, 300); // 5 min of 2 h: under 5 % final resumed = WatchSession(resumeBefore: 600);
expect(movie.verdict(runtime: 7200).outcome, WatchOutcome.peek); run(resumed, 0, 590, 800); // from the resume point to the end
expect(resumed.credited, closeTo(1400, 2));
expect(resumed.verdict(runtime: ep).outcome, WatchOutcome.played);
final part = WatchSession(); // Played from the start again: the earlier part is not counted twice.
final t = play(part, 0, 0, 500); final again = WatchSession(resumeBefore: 600);
part.sample(450, playing: false, at: DateTime(2026, 1, 1).add(Duration(seconds: t))); run(again, 0, 0, 300);
final v = part.verdict(runtime: ep); expect(again.credited, closeTo(300, 2));
expect(v.outcome, WatchOutcome.partial); });
expect(v.resumeAt, 450);
// A 3 min clip: half of it is not a peek. test('a peek leaves nothing; part way resumes at the last position', () {
final clip = WatchSession(); final peek = WatchSession(resumeBefore: 300);
play(clip, 0, 0, 90); run(peek, 0, 900, 90);
expect(clip.verdict(runtime: 180).outcome, WatchOutcome.partial); expect(peek.verdict(runtime: ep).outcome, WatchOutcome.peek);
});
test('an unknown runtime never makes it played', () { final movie = WatchSession();
final w = WatchSession(); run(movie, 0, 0, 300); // 5 min of 2 h: under 5 %
play(w, 0, 0, 1000); expect(movie.verdict(runtime: 7200).outcome, WatchOutcome.peek);
expect(w.verdict(runtime: 0).outcome, WatchOutcome.partial);
final part = WatchSession();
final t = run(part, 0, 0, 500);
part.sample(450, playing: false, present: true, at: _t0.add(Duration(seconds: t)));
final v = part.verdict(runtime: ep);
expect(v.outcome, WatchOutcome.partial);
expect(v.resumeAt, 450);
// A 3 min clip: half of it is not a peek.
final clip = WatchSession();
run(clip, 0, 0, 90);
expect(clip.verdict(runtime: 180).outcome, WatchOutcome.partial);
});
test('an unknown runtime never makes it played', () {
final w = WatchSession();
run(w, 0, 0, 1000);
expect(w.verdict(runtime: 0).outcome, WatchOutcome.partial);
});
}); });
} }
+82
View File
@@ -0,0 +1,82 @@
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
MemberView member(String id,
{Presence presence = Presence.watching, Map<String, bool> jf = const {}, String name = ''}) =>
MemberView(
id: id, name: name.isEmpty ? id : name, ready: false, inSet: true, buffered: 0, presence: presence, jf: jf);
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
id: 'q1',
media: how == QueueHow.jellyfin
? const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i'))
: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
how: how,
title: 'x',
by: by,
byName: byName,
);
void main() {
group('queuePerformer: who puts an item up (the server and every app read it the same)', () {
test('a link passed on or a file: only its adder; gone waits for it, left means skipped', () {
for (final how in [QueueHow.through, QueueHow.file]) {
expect(queuePerformer(item(how), [member('m1'), member('m2', presence: Presence.away)]).id, 'm2',
reason: 'sitting out, it comes back to put its own item up');
final waits = queuePerformer(item(how), [member('m1'), member('m2', presence: Presence.gone)]);
expect((waits.id, waits.why), (null, null), reason: 'a gone adder may come back within its grace');
final left = queuePerformer(item(how), [member('m1'), member('m3')]);
expect((left.id, left.why), (null, 'Bob left'));
}
});
test('Jellyfin: the adder with access, else the lowest member with access, else skipped', () {
final access = {'S': true};
expect(queuePerformer(item(QueueHow.jellyfin), [member('m1', jf: access), member('m2', jf: access)]).id, 'm2');
expect(
queuePerformer(item(QueueHow.jellyfin), [member('m10', jf: access), member('m3', jf: access), member('m2')])
.id,
'm3');
expect(
queuePerformer(
item(QueueHow.jellyfin), [member('m3', jf: access, presence: Presence.away), member('m2')])
.why,
'nobody here has that Jellyfin server');
});
test('a link everyone opens or the server fetches: the adder, else the lowest present member', () {
for (final how in [QueueHow.direct, QueueHow.server]) {
expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2');
expect(
queuePerformer(item(how), [member('m4'), member('m2', presence: Presence.gone), member('m3')]).id, 'm3');
final nobody = queuePerformer(item(how), [member('m2', presence: Presence.away)]);
expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip');
}
});
test('member ids order by number: m2 before m10', () {
expect(compareMemberIds('m9', 'm10'), lessThan(0));
expect(lowestMemberId([member('m10'), member('m9')]), 'm9');
expect(lowestMemberId(const []), isNull);
});
});
test('the continuation: who put the media up, else the first member in', () {
final members = [member('m1', presence: Presence.away), member('m2'), member('m3')];
expect(mayAddContinuation('m1', 'm1', members), isTrue, reason: 'the swapper itself');
expect(mayAddContinuation('m2', 'm1', members), isTrue, reason: 'the swapper is away: the first in');
expect(mayAddContinuation('m3', 'm1', members), isFalse);
expect(mayAddContinuation('m2', 'm3', members), isFalse, reason: 'the swapper is in');
expect(mayAddContinuation('m2', null, members), isTrue, reason: 'the media the server started with');
expect(mayAddContinuation('', 'm1', members), isFalse);
});
test('seconds left of a countdown, on the server clock', () {
final at = DateTime.utc(2026, 1, 1);
final a = QueueAdvance(id: 'q1', at: at, secs: 10);
expect(advanceSecondsLeft(a, at), 10);
expect(advanceSecondsLeft(a, at.add(const Duration(milliseconds: 100))), 10);
expect(advanceSecondsLeft(a, at.add(const Duration(seconds: 9, milliseconds: 1))), 1);
expect(advanceSecondsLeft(a, at.add(const Duration(seconds: 12))), 0);
});
}
+42
View File
@@ -261,4 +261,46 @@ void main() {
expect(pick(['avc1.640033', 'vp09.00.40.08', 'av01.0.08M.08']), 'v2'); expect(pick(['avc1.640033', 'vp09.00.40.08', 'av01.0.08M.08']), 'v2');
expect(pick(['avc1.640033', 'vp09.00.40.08']), 'v1'); expect(pick(['avc1.640033', 'vp09.00.40.08']), 'v1');
}); });
test('what a player opens: yt-dlp\'s pick, an EDL for video + audio, headers with the cookies, subtitles but danmaku', () {
final split = YtDlpInfo.parse({
'title': 'Page',
'formats': <Object>[],
'requested_formats': [
{
'format_id': 'v', 'url': 'https://cdn/v.m4s', 'protocol': 'https',
'http_headers': {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA'},
'cookies': 'SESSDATA=abc; Domain=.bilibili.com; Path=/; Secure; Expires=1999999999; buvid3=xyz; Domain=.bilibili.com',
},
{'format_id': 'a', 'url': 'https://cdn/a.m4s', 'protocol': 'https', 'http_headers': {'Referer': 'other'}},
],
'requested_subtitles': {
'zh-CN': {'url': 'https://subs/zh.vtt', 'ext': 'vtt', 'name': 'Chinese'},
'danmaku': {'url': 'https://comment/1.xml', 'ext': 'xml'},
},
}).play!;
expect(split.urls, ['https://cdn/v.m4s', 'https://cdn/a.m4s']);
expect(split.mpvUrl, mpvEdl(['https://cdn/v.m4s', 'https://cdn/a.m4s']));
expect(split.headers, {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA', 'Cookie': 'SESSDATA=abc; buvid3=xyz'});
expect([for (final s in split.subtitles) '${s.lang} ${s.name} ${s.url}'], ['zh-CN Chinese https://subs/zh.vtt']);
final single = YtDlpInfo.parse({
'title': 'Tweet', 'url': 'https://video.twimg.com/v.mp4', 'protocol': 'https', 'ext': 'mp4',
'http_headers': {'User-Agent': 'UA'},
}).play!;
expect(single.mpvUrl, 'https://video.twimg.com/v.mp4');
expect(single.headers, {'User-Agent': 'UA'});
final hls = YtDlpInfo.parse({
'title': 'Live', 'is_live': true,
'requested_formats': [{'url': 'https://x/live.m3u8', 'protocol': 'm3u8_native'}],
}).play!;
expect(hls.mpvUrl, 'https://x/live.m3u8', reason: 'a live stream plays, it is only not relayed');
expect(YtDlpInfo.parse({'title': 'DASH', 'requested_formats': [{'url': 'https://x/f', 'protocol': 'http_dash_segments', 'manifest_url': 'https://x/m.mpd'}]}).play?.mpvUrl,
'https://x/m.mpd');
expect(YtDlpInfo.parse({'title': 'none', 'formats': <Object>[]}).play, isNull);
expect(YtDlpInfo.parse({'title': 'rtmp', 'url': 'rtmp://x/y', 'protocol': 'rtmp'}).play, isNull);
expect(cookieHeaderOf(null), '');
expect(cookieHeaderOf('a=1; Path=/; HttpOnly; b=2=3'), 'a=1; b=2=3');
});
} }
+101 -38
View File
@@ -112,14 +112,41 @@ class ChannelRelayFeed extends RelayFeed {
class _Pull { class _Pull {
final int id, start, end; final int id, start, end;
int pos; int pos;
final buf = BytesBuilder(copy: false); // The slice being filled: its chunks so far (readers take them as they
// come, see [RelayBuffer.read]).
final parts = <Uint8List>[];
int filled = 0;
_Pull(this.id, this.start, this.end) : pos = start; _Pull(this.id, this.start, this.end) : pos = start;
/// Bytes of the slice being filled from [from] (offset in the slice).
Uint8List? partFrom(int from) {
if (from >= filled) return null;
final out = BytesBuilder(copy: false);
var at = 0;
for (final p in parts) {
final end = at + p.length;
if (end > from) out.add(at >= from ? p : Uint8List.sublistView(p, from - at));
at = end;
}
return out.takeBytes();
}
Uint8List takeSlice() {
final out = BytesBuilder(copy: false);
for (final p in parts) {
out.add(p);
}
parts.clear();
filled = 0;
return out.takeBytes();
}
} }
class _Viewer { class _Viewer {
int pos = 0; int pos = 0;
int open = 0; // requests in flight int open = 0; // requests in flight
int newest = 0; // sequence of its newest request int newest = 0; // sequence of its newest request
(int slice, int req)? blocking; // where its newest request waits
DateTime lastActive = DateTime.now(); DateTime lastActive = DateTime.now();
} }
@@ -134,9 +161,11 @@ class _Viewer {
/// slowest and [ahead] past the furthest viewer. Evicted by distance from /// slowest and [ahead] past the furthest viewer. Evicted by distance from
/// the viewers, never LRU. /// the viewers, never LRU.
/// - Filled only while someone watches: a viewer blocked on a slice comes /// - Filled only while someone watches: a viewer blocked on a slice comes
/// first (oldest wait first); then read-ahead. /// first (oldest wait first); then read-ahead. A blocked viewer reads the
/// slice being filled as its bytes arrive, not once it is whole.
/// - Only a viewer's newest request moves its position or asks the feed /// - Only a viewer's newest request moves its position or asks the feed
/// for anything; older ones still get bytes once they are buffered. /// for anything; older ones still get bytes once they are buffered, and
/// stop holding the feed once superseded (a seek elsewhere).
/// Everything lives in memory: dropping the buffer is the cleanup. /// Everything lives in memory: dropping the buffer is the cleanup.
class RelayBuffer implements TrackBuffer { class RelayBuffer implements TrackBuffer {
/// Set by the relay when a media's tracks share the budget. /// Set by the relay when a media's tracks share the budget.
@@ -166,6 +195,8 @@ class RelayBuffer implements TrackBuffer {
final _slices = <int, Uint8List>{}; final _slices = <int, Uint8List>{};
final _waiters = <int, List<Completer<void>>>{}; final _waiters = <int, List<Completer<void>>>{};
final _blocked = <int, int>{}; // slice -> wait start (us) final _blocked = <int, int>{}; // slice -> wait start (us)
// The driving requests waiting on each blocked slice.
final _blockedBy = <int, Set<int>>{};
final _viewers = <String, _Viewer>{}; final _viewers = <String, _Viewer>{};
@override @override
FeedClock clock = FeedClock(); FeedClock clock = FeedClock();
@@ -256,18 +287,19 @@ class RelayBuffer implements TrackBuffer {
_endPull(pl); _endPull(pl);
return; return;
} }
pl.buf.add(Uint8List.sublistView(data, off, off + take)); pl.parts.add(Uint8List.sublistView(data, off, off + take));
pl.filled += take;
off += take; off += take;
pl.pos += take; pl.pos += take;
if (pl.pos == _sliceEnd(s) + 1) { final done = pl.pos == _sliceEnd(s) + 1;
_slices[s] = pl.buf.takeBytes(); if (done) _slices[s] = pl.takeSlice();
for (final c in _waiters.remove(s) ?? const <Completer<void>>[]) { // Whole, or more of it: whoever waits on it reads on.
c.complete(); for (final c in _waiters.remove(s) ?? const <Completer<void>>[]) {
} c.complete();
if (_shouldStop(pl, s)) { }
_endPull(pl); if (done && _shouldStop(pl, s)) {
return; _endPull(pl);
} return;
} }
} }
} }
@@ -405,25 +437,54 @@ class RelayBuffer implements TrackBuffer {
await _kick.future.timeout(const Duration(seconds: 5), onTimeout: () {}); await _kick.future.timeout(const Duration(seconds: 5), onTimeout: () {});
} }
Future<bool> _waitSlice(int s, bool drives) async { /// The bytes of slice [s] from [from] (offset in the slice) as soon as
/// there are any: the whole slice's rest, or what the pull filling it has
/// so far. Null when they did not come within [waitLimit]. [drives]: the
/// request is its viewer's newest ([driving] says while it still is), so
/// it may move the feed.
Future<Uint8List?> _waitBytes(_Viewer v, int s, int from, int req, bool Function() driving) async {
final deadline = DateTime.now().add(waitLimit); final deadline = DateTime.now().add(waitLimit);
while (!_slices.containsKey(s)) { try {
if (_disposed || DateTime.now().isAfter(deadline)) return false; while (true) {
if (drives) { final whole = _slices[s];
_blocked.putIfAbsent(s, () => DateTime.now().microsecondsSinceEpoch); if (whole != null) return from == 0 ? whole : Uint8List.sublistView(whole, from);
// A pull nobody waits on gives way at once; one serving another
// blocked viewer hands over at its next slice boundary.
final pl = _pull; final pl = _pull;
final at = pl == null ? -1 : pl.pos ~/ slice; if (pl != null && pl.pos ~/ slice == s && pl.start <= s * slice) {
if (pl != null && at != s && !_blocked.containsKey(at)) _endPull(pl); final part = pl.partFrom(from);
_wake(); if (part != null) return part;
}
if (_disposed || DateTime.now().isAfter(deadline)) return null;
if (driving()) {
_blocked.putIfAbsent(s, () => DateTime.now().microsecondsSinceEpoch);
_blockedBy.putIfAbsent(s, () => {}).add(req);
v.blocking = (s, req);
// A pull nobody waits on gives way at once; one serving another
// blocked viewer hands over at its next slice boundary.
final at = pl == null ? -1 : pl.pos ~/ slice;
if (pl != null && at != s && !_blocked.containsKey(at)) _endPull(pl);
_wake();
} else {
_unblock(s, req);
}
final c = Completer<void>();
_waiters.putIfAbsent(s, () => []).add(c);
await c.future.timeout(const Duration(seconds: 2), onTimeout: () {});
} }
final c = Completer<void>(); } finally {
_waiters.putIfAbsent(s, () => []).add(c); _unblock(s, req);
await c.future.timeout(const Duration(seconds: 2), onTimeout: () {}); }
}
// [req] no longer waits on slice [s]; the slice stops counting as
// blocked once nobody driving does.
void _unblock(int s, int req) {
final by = _blockedBy[s];
if (by == null || !by.remove(req)) return;
if (by.isEmpty) {
_blockedBy.remove(s);
_blocked.remove(s);
_wake();
} }
_blocked.remove(s);
return true;
} }
// ---------------------------------------------------------------- viewers // ---------------------------------------------------------------- viewers
@@ -444,6 +505,10 @@ class RelayBuffer implements TrackBuffer {
final tail = start >= size - pin; final tail = start >= size - pin;
final me = ++_reqSeq; final me = ++_reqSeq;
if (!tail) { if (!tail) {
// A seek: the request it supersedes stops holding the feed at once.
final was = v.blocking;
if (was != null) _unblock(was.$1, was.$2);
v.blocking = null;
v.newest = me; v.newest = me;
v.pos = start; v.pos = start;
} }
@@ -454,16 +519,14 @@ class RelayBuffer implements TrackBuffer {
var off = start; var off = start;
while (off <= end) { while (off <= end) {
final s = off ~/ slice; final s = off ~/ slice;
if (!await _waitSlice(s, tail || v.newest == me)) { final data = await _waitBytes(v, s, off - s * slice, me, () => tail || v.newest == me);
throw StateError('relay: bytes at $off did not arrive'); if (data == null) throw StateError('relay: bytes at $off did not arrive');
} final n = min(data.length, end - off + 1);
final data = _slices[s]; if (n <= 0) continue;
if (data == null) continue; // evicted between wake and read yield n == data.length ? data : Uint8List.sublistView(data, 0, n);
final from = off - s * slice, to = min(data.length, end - s * slice + 1); bytesToViewers += n;
yield Uint8List.sublistView(data, from, to); onSent?.call(n);
bytesToViewers += to - from; off += n;
onSent?.call(to - from);
off += to - from;
v.lastActive = DateTime.now(); v.lastActive = DateTime.now();
if (!tail && v.newest == me) v.pos = off; if (!tail && v.newest == me) v.pos = off;
_wake(); _wake();
+23 -5
View File
@@ -50,7 +50,17 @@ class ServerHub {
engine = engine ?? SyncEngine() { engine = engine ?? SyncEngine() {
this.engine this.engine
..onGone = _memberGone ..onGone = _memberGone
..onLeft = _memberLeft; ..onLeft = _memberLeft
..itemSourceGone = _warmSourceGone;
}
/// The warm relay of Up next item [queueId] waits for a member source
/// that is `gone`: the countdown holds (see `SyncEngine.whatsNext`).
bool _warmSourceGone(String queueId) {
final w = warm;
if (w == null || w.queueId != queueId || w.fetch || w.sourceToken == null) return false;
final src = engine.members[w.sourceToken];
return src == null || src.presence == Presence.gone;
} }
static String _randomId() { static String _randomId() {
@@ -698,8 +708,16 @@ void mountSyncRoute(
final swap = SwapIntent.fromJson(decoded); final swap = SwapIntent.fromJson(decoded);
var parsed = swap.media; var parsed = swap.media;
final queueId = swap.queueId; final queueId = swap.queueId;
final restore = swap.restore;
// What played before a restart, offered back: the first
// offer wins (see `swap.restore`).
if (restore != null && hub.engine.media != null) {
_hubLog.info('restore by ${hub.who(token)} ignored: media is up already');
return;
}
_hubLog.info('swap by ${hub.who(token)}: "${parsed.name}" ${_swapKind(parsed)}' _hubLog.info('swap by ${hub.who(token)}: "${parsed.name}" ${_swapKind(parsed)}'
'${queueId == null ? '' : ' (Up next $queueId)'}'); '${queueId == null ? '' : ' (Up next $queueId)'}'
'${restore == null ? '' : ' (restored at ${restore.position.toStringAsFixed(1)} s, ${restore.transport})'}');
if (queueId != null) { if (queueId != null) {
// Already put up by someone else: nothing to do. // Already put up by someone else: nothing to do.
if (!hub.engine.queue.has(queueId)) return; if (!hub.engine.queue.has(queueId)) return;
@@ -737,7 +755,7 @@ void mountSyncRoute(
: promoted != null : promoted != null
? hub.promotedRelay(promoted, queueId!) ? hub.promotedRelay(promoted, queueId!)
: hub.relayFor(token, const MediaRelay())); : hub.relayFor(token, const MediaRelay()));
if (media == null || !hub.engine.applySwap(token, media, queueId: queueId)) { if (media == null || !hub.engine.applySwap(token, media, queueId: queueId, restore: restore)) {
promoted?.dispose(); promoted?.dispose();
wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap'); wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap');
return; return;
@@ -785,7 +803,7 @@ void mountSyncRoute(
duration: parsed.duration, duration: parsed.duration,
ref: parsed.ref, ref: parsed.ref,
relay: promoted != null ? hub.promotedRelay(promoted, queueId!) : hub.relayFor(token, relayReq)); relay: promoted != null ? hub.promotedRelay(promoted, queueId!) : hub.relayFor(token, relayReq));
if (!hub.engine.applySwap(token, media, queueId: queueId)) { if (!hub.engine.applySwap(token, media, queueId: queueId, restore: restore)) {
promoted?.dispose(); promoted?.dispose();
wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap'); wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap');
return; return;
@@ -827,7 +845,7 @@ void mountSyncRoute(
wsErr(ws, ErrCode.bad, 'swap needs a url or the hosted file', forType: 'swap'); wsErr(ws, ErrCode.bad, 'swap needs a url or the hosted file', forType: 'swap');
return; return;
} }
if (!hub.engine.applySwap(token, intent.media, queueId: queueId)) { if (!hub.engine.applySwap(token, intent.media, queueId: queueId, restore: restore)) {
wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap'); wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap');
return; return;
} }
+1 -1
View File
@@ -1,7 +1,7 @@
name: sync_relay_server name: sync_relay_server
description: Pure-Dart sync + media server. Embeddable, no Flutter. description: Pure-Dart sync + media server. Embeddable, no Flutter.
publish_to: none publish_to: none
version: 1.2.0 version: 1.3.0
environment: environment:
sdk: ^3.0.0 sdk: ^3.0.0
dependencies: dependencies:
+106
View File
@@ -141,4 +141,110 @@ void main() {
expect(back.queue.single.by, aliceId, reason: 'the same member: its items are its own again'); expect(back.queue.single.by, aliceId, reason: 'the same member: its items are its own again');
expect(back.members.where((m) => m.name == 'alice'), hasLength(1)); expect(back.members.where((m) => m.name == 'alice'), hasLength(1));
}); });
test('after a restart, the first offer of what played wins; later ones are ignored', () async {
final a = await _Peer.join(server.boundPort, 'alice');
final b = await _Peer.join(server.boundPort, 'bob');
const media = HelloMedia(name: 'v', size: 0, duration: 600, url: 'https://example.com/v.mp4');
a.send(const SwapIntent(media: media, restore: SwapRestore(position: 120.5, transport: 'playing')).toJson());
final back = await b.state((s) => s.media != null);
expect((back.position, back.transport), (120.5, 'held'));
final line = await b.until((m) => m['t'] == 'feed' && m['action'] == 'swap');
expect(line['reason'], 'restore');
b.send(const SwapIntent(
media: HelloMedia(name: 'w', size: 0, duration: 0, url: 'https://example.com/w.mp4'),
restore: SwapRestore(position: 3))
.toJson());
a.msgs.clear();
final later = await a.state((s) => true);
expect((later.mediaId, later.media?['name'], later.position), (back.mediaId, 'v', 120.5));
});
group('the countdown (state.advance), on a fake clock', () {
late DateTime t;
late SyncServer clocked;
setUp(() async {
t = DateTime.utc(2026, 1, 1);
clocked = SyncServer(const AppConfig(port: 0, secret: 's', name: 't'), clock: () => t);
await clocked.start();
});
tearDown(() => clocked.stop());
/// Moves the clock on; everyone keeps talking; one heartbeat.
void step(int secs) {
t = t.add(Duration(seconds: secs));
final engine = clocked.hub.engine;
for (final token in engine.members.keys) {
engine.touch(token);
}
clocked.hub.heartbeat();
}
/// Alice and bob in; a 20 s video up, both ready with a full buffer,
/// then played to its end; [queue] what bob adds first.
Future<(_Peer, _Peer)> playToEnd(List<QueueItem> queue) async {
final a = await _Peer.join(clocked.boundPort, 'alice');
final b = await _Peer.join(clocked.boundPort, 'bob');
a.send(const SwapIntent(media: HelloMedia(name: 'Ep01', size: 0, duration: 20, url: 'https://example.com/1.mp4'))
.toJson());
final up = await b.state((s) => s.media != null);
for (final p in [a, b]) {
p.send(const ReadyIntent(ready: true).toJson());
p.send(ReportMessage(position: 0, buffered: 100, eventId: up.eventId + 1, mediaId: up.mediaId).toJson());
}
await b.state((s) => s.transport == 'playing');
for (final q in queue) {
b.send(QueueIntent.add(q).toJson());
}
await b.state((s) => s.queue.length == queue.length);
step(21);
return (a, b);
}
const next = QueueItem(
media: HelloMedia(name: 'n', size: 0, duration: 0, url: 'https://example.com/n.mp4'),
how: QueueHow.direct,
title: 'Next');
test('everyone sees the same countdown; at its end the first item is asked for', () async {
final (a, b) = await playToEnd([next]);
final s = await a.state((s) => s.advance != null);
expect(s.transport, 'finished');
expect([s.advance!.id, s.advance!.secs], [s.queue.first.id, RelayConstants.advanceSecs]);
expect((await b.state((s) => s.advance != null)).advance!.at, s.advance!.at);
step(5);
expect((await a.state((s) => true)).queuePlay, isNull);
step(5);
final due = await a.state((s) => s.queuePlay != null);
expect(due.queuePlay, s.queue.first.id);
expect(due.advance, isNull);
});
test('cancel is shared: the countdown stops for everyone', () async {
final (a, b) = await playToEnd([next]);
await a.state((s) => s.advance != null);
b.send(const QueueIntent.cancel().toJson());
await a.state((s) => s.advance == null);
final line = await a.until((m) => m['t'] == 'feed' && m['action'] == 'advance-cancel');
expect(line['actor'], 'bob');
step(15);
expect(clocked.hub.engine.queue.play, isNull);
});
test('an item whose adder left is skipped by the server, with why', () async {
final (a, b) = await playToEnd([
const QueueItem(
media: HelloMedia(name: 'f.mkv', size: 10, duration: 0), how: QueueHow.file, title: 'Bobs', key: 'k1234567890'),
]);
await a.state((s) => s.advance != null);
b.send(const ByeMessage().toJson());
await a.until((m) => m['t'] == 'feed' && m['action'] == 'leave');
step(10);
final skip = await a.until((m) => m['t'] == 'feed' && m['action'] == 'queue-skip');
expect([skip['actorId'], skip['target'], skip['reason']], ['server', 'Bobs', 'bob left']);
expect((await a.state((s) => true)).queue, isEmpty);
});
});
} }
+245
View File
@@ -0,0 +1,245 @@
## 2.0.1
- fix: flutter 3.38.x crash
- fix(linux): flutter 3.38.x h/w render
## 2.0.0
- feat: flutter 3.38.x support
- **BREAKING CHANGE**: remove `screen_brightness` and `volume_controller` dependencies
## 1.3.1
- fix(windows): notify `VideoOutput.Resize` on platform thread
- fix(linux): notify `VideoOutput.Resize` on platform thread
- fix(android): `VideoController` implementation
- fix(android): remove deprecated API usage
## 1.3.0
- **REFACTOR**: screen_brightness -> screen_brightness_platform_interface.
- **REFACTOR**(android): simplify AndroidVideoController implementation.
- **REFACTOR**(android): VideoOutput SurfaceTextureEntry -> SurfaceProducer migration.
- **REFACTOR**: use setProperty API in NativeVideoController.
- **FIX**: improve responsiveness of showing controls on mobile.
- **FIX**: bump web to 1.1.0.
- **FIX**: cast to JSObject.
- **FIX**: not call super.didChangeAppLifecycleState(state);.
- **FIX**: not call super.didChangeAppLifecycleState(state);.
- **FIX**: wakelock print.
- **FIX**: subtitles not shifting on controls show/hide.
- **FIX**: set width/height from VideoParams in NativeVideoController.
- **FIX**: seek inside onPointerMove.
- **FIX**(windows): automatic IDXGIAdapter selection on windows 10 or greater.
- **FIX**(android): waitUntilFirstFrameRenderedCompleter.
- **FIX**(android): --hwdec=auto-safe as default.
- **FIX**: dispose ValueNotifier(s) in PlatformVideoController.
- **FIX**: fullscreen.
- **FIX**: long press video speed reset issue.
- **FIX**: wrong value in brightness builder callback.
- **FIX**: Use a Scaffold as the outermost widget on fullscreen video pages.
- **FEAT**: upgrade volume_controller dependency and refactor related code.
- **FEAT**: seek on double tap custom duration support.
## 1.2.5
- fix: subtitleView not being updated
- fix: mobile double tap areas hidden but still mounted
- fix: mobile center click is delayed
- feat: desktop video controls mouse hides with controls
- feat: desktop on tap pause and play
- feat: desktop on double tap fullscreen
- fix: windows crash by @Airyzz [#900](https://github.com/media-kit/media-kit/pull/900)
- feat: auto pass changes to fullscreen without having to call .update
## 1.2.4
- fix: web compile error
## 1.2.3
- feat: `VideoState.update` & `VideoViewParameters`
## 1.2.2
- fix: override `setState` & check `mounted` in `MaterialVideoControls` & `MaterialDesktopVideoControls`
## 1.2.1
- fix(android): clear `android.view.Surface` before playback
- feat: `MaterialVideoControlsThemeData.seekBarAlignment`
## 1.2.0
- fix: `MaterialVideoControls` layout
## 1.1.9
- fix: unmount `CircularProgressIndicator` buffering indicator if invisible
- fix: pass all video attributes to fullscreen route
- fix: prevent controls from hiding during seek
- fix: hide last video's frame upon `Player.open`
- fix: `ThemeData.copyWith` override
- fix(android): `hwdec=auto-safe` w/ `enableHardwareAcceleration=true`
- fix(windows): fullscreen for non-primary monitors
- fix(darwin): `mpv_render_context_free` call
- fix(darwin): memory leaks
- fix(ios): fix `disposeMPV`
- feat: `VideoControllerConfiguration.androidAttachSurfaceAfterVideoParameters`
- feat: center the seek-bar within its parent `Container` for improved tap area
- feat: `backdropColor` argument in `MaterialVideoControlsThemeData`
## 1.1.8
- fix: pass all `*VideoControlsTheme`(s) to fullscreen `context`
## 1.1.7
- fix: add `await` to `maybePop` when exiting fullscreen
- fix: `MaterialVideoControls`/`MaterialDesktopVideoControls` seekbar glitch
- fix(android): S/W rendering fallback
- fix(android): create fresh `android.view.Surface` for every video output
## 1.1.6
- fix: programmatic fullscreen API
- fix(android): pause upon entering fullscreen
- fix(android): `waitUntilFirstFrameRenderedNotify` in `FlutterFragmentActivity`
## 1.1.5
- fix(android): `waitUntilFirstFrameRenderedNotify` fallback & release-mode
## 1.1.4
- feat: `Video` `resumeUponEnteringForegroundMode`
- feat(android): `waitUntilFirstFrameRenderedNotify` implementation
## 1.1.3
- feat(android): `VideoControllerConfiguration.scale`
- fix(android): use `hwdec=auto`
- fix(android): `SurfaceTexture.setDefaultBufferSize` & render race
## 1.1.2
- fix(windows): memory leak in `GetVideoWidth`/`GetVideoHeight`
- fix(linux): `GThread*` leak in S/W render & `video_output_get_(width|height)`
- fix(linux): H/W support for multiple videos
- build(darwin): bump `mpv` headers to `0.36.0`
- build(darwin): use symlinks for `FRAMEWORK_SEARCH_PATHS`, `media_kit_libs_*** >= 1.1.0`
- fix(darwin): remove black screen when switching videos ([#332](https://github.com/media-kit/media-kit/issues/332))
- feat: `Video`: expose `onEnterFullscreen` & `onExitFullscreen`
- feat: feat: `visibleOnMount` `MaterialVideoControls`/`MaterialDesktopVideoControls`
- fix: display `bufferingIndicatorBuilder` even if controls are hidden
## 1.1.1
- chore: `try`/`catch` native calls to hide stray logs
- fix: `MaterialDesktopVideoControls`: do not add `onTapUp` callback if `toggleFullscreenOnDoublePress` is disabled
## 1.1.0
- feat: `SubtitleView`, `SubtitleViewConfiguration`
- feat: `shiftSubtitlesOnControlsVisibilityChange` in `MaterialVideoControls` & `MaterialDesktopVideoControls`
- feat: apply rotation from metadata to video output
- feat: improve wakelock behavior
- feat: `pauseUponEnteringBackgroundMode`
- fix: `bufferingIndicatorBuilder` padding in `MaterialVideoControls` & `MaterialDesktopVideoControls`
- fix(windows): maintain aspect ratio in s/w rendering pixel-buffer size clamping
- fix(linux): maintain aspect ratio in s/w rendering pixel-buffer size clamping
- perf(android): use `hwdec=mediacodec` w/ `enableHardwareAcceleration`
- deps: migrate [`package:wakelock_plus`](https://pub.dev/packages/wakelock_plus)
## 1.0.2
- fix(video/macos): fix fullscreen support
## 1.0.1
- fix: synchronize `VideoController` constructor
- fix: `fullscreen` video controls theme data not being applied
## 1.0.0
- feat: web support
- feat: fullscreen API
- feat: acquire wakelock
- feat: support for AGP 8.0
- feat: pre-built video controls
- feat: `controls` argument in `Video` widget
- feat: `AdaptiveVideoControls`, `MaterialVideoControls`, `MaterialDesktopVideoControls` & `NoVideoControls`
## 0.0.12
- fix(android): improve `Texture` resize handling
## 0.0.11
- fix(android): improve `Texture` resize handling
## 0.0.10
- feat: `VideoControllerConfiguration`
- feat: `VideoController.waitUntilFirstFrameRendered`
- refactor: clean-up package structure
- refactor: remove `VideoController.dispose`
- refactor: `VideoController.create` -> `VideoController` constructor
- fix(android): add `av1` to `hwdec-codecs`
- fix(android): use `--vo=gpu` + `--hwdec=mediacodec-copy` /w `enableHardwareAcceleration`
## 0.0.9
- fix(android): revert to `--vo=mediacodec_embed` in `enableHardwareAcceleration`
## 0.0.8
- fix(android): subtitle rendering
- fix(android): video rendering inside emulators (#149)
- fix(android): video rendering with `enableHardwareAcceleration: false`
## 0.0.7
- fix(linux): VAAPI hardware acceleration
- perf(windows): `VideoOutput::Resize`: delete texture objects in background
## 0.0.6
- fix(windows): synchronize texture object deletion in on unregister _v.i.z_ `VideoOutput::Resize` or `VideoOutput::~VideoOutput`
## 0.0.5
- Android support
- feat: `VideoController.setSize`
- fix: set `vo` to `libmpv` before creating render context
- refactor: `VideoController.create` takes `Player` reference instead of `handle`
## 0.0.4
- fix: use `mkdir` instead of `.gitkeep`
## 0.0.3
- fix: add `.framework` & `.xcframework` for all libs
## 0.0.2
- macOS support:
- Hardware: MPV_RENDER_API_TYPE_OPENGL + pixel buffer + METAL
- Software: MPV_RENDER_API_TYPE_SW + pixel buffer
- iOS support:
- Hardware: MPV_RENDER_API_TYPE_OPENGL + pixel buffer
- Software: MPV_RENDER_API_TYPE_SW + pixel buffer
- fix(windows): use `TextureRegistrar::UnregisterTexture` release callback to free texture resources
- fix(windows): synchronize texture unregister & release on frame dimensions change
- feat: `aspectRatio` parameter for `Video` widget
## 0.0.1
- Initial release
- Windows support:
- Hardware: MPV_RENDER_API_TYPE_OPENGL + ANGLE + DirectX 11
- Software: MPV_RENDER_API_TYPE_SW + pixel buffer
- GNU/Linux support:
- Hardware: MPV_RENDER_API_TYPE_OPENGL + GDK/GL
- Software: MPV_RENDER_API_TYPE_SW + pixel buffer
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 & onwards Hitesh Kumar Saini <saini123hitesh@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11
View File
@@ -0,0 +1,11 @@
# [package:media_kit_video](https://github.com/media-kit/media-kit)
[![](https://img.shields.io/discord/1079685977523617792?color=33cd57&label=Discord&logo=discord&logoColor=discord)](https://discord.gg/h7qf2R9n57) [![Github Actions](https://github.com/media-kit/media-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/media-kit/media-kit/actions/workflows/ci.yml)
Native implementation for video playback in [package:media_kit](https://pub.dev/packages/media_kit).
## License
Copyright © 2021 & onwards, Hitesh Kumar Saini <<saini123hitesh@gmail.com>>
This project & the work under this repository is governed by MIT license that can be found in the [LICENSE](./LICENSE) file.
+8
View File
@@ -0,0 +1,8 @@
include: package:flutter_lints/flutter.yaml
analyzer:
errors:
dangling_library_doc_comments: ignore
unintended_html_in_doc_comment: ignore
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+38
View File
@@ -0,0 +1,38 @@
group 'com.alexmercerind.media_kit_video'
version '1.0'
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
}
}
rootProject.allprojects {
repositories {
google()
mavenCentral()
}
}
apply plugin: 'com.android.library'
android {
namespace 'com.alexmercerind.media_kit_video'
compileSdkVersion 36
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
defaultConfig {
minSdkVersion 16
consumerProguardFiles 'proguard-rules.pro'
}
}
@@ -0,0 +1,3 @@
-keepclassmembers class io.flutter.embedding.engine.FlutterEngine {
private io.flutter.embedding.engine.FlutterJNI flutterJNI;
}
+1
View File
@@ -0,0 +1 @@
rootProject.name = 'media_kit_video'
@@ -0,0 +1 @@
<manifest package="com.alexmercerind.media_kit_video"></manifest>
@@ -0,0 +1,84 @@
/**
* This file is a part of media_kit (https://github.com/media-kit/media-kit).
* <p>
* Copyright © 2021 & onwards, Hitesh Kumar Saini <saini123hitesh@gmail.com>.
* All rights reserved.
* Use of this source code is governed by MIT license that can be found in the LICENSE file.
*/
package com.alexmercerind.media_kit_video;
import androidx.annotation.NonNull;
import java.util.HashMap;
import io.flutter.embedding.engine.plugins.FlutterPlugin;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
/**
* MediaKitVideoPlugin
*/
public class MediaKitVideoPlugin implements FlutterPlugin, MethodCallHandler {
private MethodChannel channel;
private VideoOutputManager videoOutputManager;
@Override
public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) {
channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "com.alexmercerind/media_kit_video");
channel.setMethodCallHandler(this);
videoOutputManager = new VideoOutputManager(flutterPluginBinding.getTextureRegistry());
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
switch (call.method) {
case "VideoOutputManager.Create": {
final long handle = Long.parseLong(call.argument("handle"));
videoOutputManager.create(handle, (id, wid, width, height) -> channel.invokeMethod("VideoOutput.Resize", new HashMap<String, Object>() {{
put("handle", handle);
put("id", id);
put("wid", wid);
put("rect", new HashMap<String, Object>() {{
put("left", 0);
put("top", 0);
put("width", width);
put("height", height);
}});
}}));
result.success(null);
break;
}
case "VideoOutputManager.SetSurfaceSize": {
final long handle = Long.parseLong(call.argument("handle"));
final int width = Integer.parseInt(call.argument("width"));
final int height = Integer.parseInt(call.argument("height"));
videoOutputManager.setSurfaceSize(handle, width, height);
result.success(null);
break;
}
case "VideoOutputManager.Dispose": {
final long handle = Long.parseLong(call.argument("handle"));
videoOutputManager.dispose(handle);
result.success(null);
break;
}
case "Utils.IsEmulator": {
result.success(Utils.isEmulator());
break;
}
default: {
result.notImplemented();
break;
}
}
}
@Override
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
channel.setMethodCallHandler(null);
}
}
@@ -0,0 +1,5 @@
package com.alexmercerind.media_kit_video;
public interface TextureUpdateCallback {
void onTextureUpdate(long id, long wid, int width, int height);
}
Loaded 100 of 221 files, more files were not shown because too many files have changed in this diff. Show more