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>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-16 01:08:54 +03:00
1 parent 8970f2694b
commit 25b19f2d6a
47 files changed
+4066 -1711

No files matched your search

+10 -1
View File
@@ -41,7 +41,8 @@ jobs:
working-directory: packages/server
# 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:
# Not for a "Release v…" commit: release.yml builds it on the same
# (only) Windows runner, which this job would hold up.
@@ -71,3 +72,11 @@ jobs:
- name: Windows build
shell: powershell
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
+5 -1
View File
@@ -533,7 +533,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
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
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
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
+9 -2
View File
@@ -3,15 +3,16 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'app.dart';
import 'src/app/app_graph.dart';
import 'src/files/thumbnails.dart' show thumbnailPlayers;
import 'src/platform/app_log.dart';
import 'src/platform/app_shutdown.dart' show closeAppWith;
import 'src/platform/desktop/desktop_app_dirs.dart';
import 'src/platform/desktop_entry.dart';
import 'src/platform/smoke.dart';
import 'src/screens/home/ui.dart';
import 'src/session/app_events.dart' show UpdateAvailable;
import 'src/state/settings.dart';
@@ -67,7 +68,8 @@ Future<void> main(List<String> args) async {
}
// The library's matches, pins and offsets (device-owned).
unawaited(graph.library.ensureLoaded());
MediaKit.ensureInitialized();
ports.players.init();
thumbnailPlayers = ports.players;
// yt-dlp for web page links: downloaded on first run, kept current.
ports.links.warmUp();
// Linux: the launcher entry and icon Wayland needs (release installs).
@@ -82,6 +84,11 @@ Future<void> main(List<String> args) async {
}
});
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
// Invite).
unawaited(graph.navigation.start());
+2 -2
View File
@@ -88,8 +88,8 @@ class AppGraph {
prefs: () => AppSettings.instance.notifyPrefs,
);
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
final coordinator =
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin);
final coordinator = SessionCoordinator(
notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin, players: p.players);
// Device-owned: the library box; AnimeFlow's runs when signed in.
final matcher = library ??
ShowMatcher(
@@ -121,7 +121,7 @@ class PlayerControlsModel {
/// A seek bar's press or drag; through `player.seek`, the user-seek
/// 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.
void nudge(double secs) => coord.nudge(secs);
@@ -157,7 +157,7 @@ class PlayerControlsModel {
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 ↑ / ↓;
/// flashes the volume readout.
@@ -222,7 +222,9 @@ class ShaderPresetSetting extends StatelessWidget {
final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value;
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) {
RenderMode.software => 'Software rendering: shaders have no effect',
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.
Future<void> applyHwdecLive(PlaybackController playback) async {
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
if (!playback.alive.peek()) return;
final p = playback.current;
if (p == null) return;
try {
final dynamic platform = playback.player.platform;
await platform?.setProperty('hwdec', AppSettings.instance.hwdec.peek());
await p.setProperty('hwdec', AppSettings.instance.hwdec.peek());
} catch (e) {
debugPrint('live hwdec failed: $e');
}
+12 -16
View File
@@ -4,9 +4,11 @@ import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:media_kit/media_kit.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
// users' machines; Windows ships only libmpv). A hidden player per file
// with `vo=null`: mpv still keeps the last decoded frame, so
@@ -23,6 +25,9 @@ class Thumbnail {
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.
Future<Directory> Function() thumbnailCacheRoot = getApplicationCacheDirectory;
@@ -33,7 +38,7 @@ const _timeout = Duration(seconds: 20);
int _running = 0;
final _waiting = <Completer<void>>[];
final _inFlight = <String, Future<Thumbnail?>>{};
final _players = <Player>{};
final _players = <HostedPlayer>{};
bool _closed = false;
/// 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()) {
_players.remove(p);
try {
await p.dispose().timeout(deadline);
await thumbnailPlayers.dispose(p).timeout(deadline);
} catch (_) {}
}
}
@@ -147,19 +152,10 @@ Future<Thumbnail?> _generate(
String out,
bool Function() cancelled,
) async {
final player = Player(
configuration: const PlayerConfiguration(
vo: 'null',
muted: true,
osc: false,
libass: false,
title: 'thumbnail',
bufferSize: 4 << 20,
),
);
final player = await thumbnailPlayers.create(PlayerRole.thumbnail);
_players.add(player);
try {
final mpv = player.platform as NativePlayer;
final mpv = player;
for (final (k, v) in [
('ao', 'null'),
('aid', 'no'),
@@ -176,7 +172,7 @@ Future<Thumbnail?> _generate(
]) {
await mpv.setProperty(k, v);
}
await player.open(Media(path), play: false);
await player.open(path);
final target = File(out);
final deadline = DateTime.now().add(_timeout);
while (DateTime.now().isBefore(deadline)) {
@@ -199,6 +195,6 @@ Future<Thumbnail?> _generate(
return null;
} finally {
// [cancelAllThumbnails] may have taken it already.
if (_players.remove(player)) await player.dispose();
if (_players.remove(player)) await thumbnailPlayers.dispose(player);
}
}
@@ -47,6 +47,8 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
_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
// its own after dispose returns; it must be done before the engine
// 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_shell.dart';
import 'desktop_urls.dart';
import 'media_kit_host.dart';
/// The desktop's [Ports].
Ports desktopPorts() {
@@ -14,5 +15,6 @@ Ports desktopPorts() {
folders: const DesktopFolders(urls),
links: AppYtDlp.instance,
notifications: DesktopNotifications(),
players: MediaKitHost(),
);
}
@@ -0,0 +1,502 @@
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);
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;
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}) =>
// An empty map, not null: media_kit caches headers per URI.
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).
void warmUp();
/// mpv's `ytdl-raw-options` (sub languages, cookies).
Future<String> mpvRawOptions();
/// This device opens web pages itself (the media plan's `resolve`
/// 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.
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 'link_resolver.dart';
import 'player_host.dart';
import 'shell_port.dart';
import 'system_notifications.dart';
import 'url_opener.dart';
@@ -7,6 +8,7 @@ import 'url_opener.dart';
export 'app_dirs.dart';
export 'folder_access.dart';
export 'link_resolver.dart';
export 'player_host.dart';
export 'shell_port.dart';
export 'system_notifications.dart';
export 'url_opener.dart';
@@ -19,11 +21,14 @@ class Ports {
final FolderAccess folders;
final LinkResolver links;
final SystemNotifications notifications;
/// The media players (mpv): created, rendered and disposed here.
final PlayerHost players;
const Ports({
required this.shell,
required this.urls,
required this.folders,
required this.links,
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';
/// 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 hosted server. [probe] remembers what pasted links are for a while,
/// 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
/// update check afterwards).
@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
Future<String> mpvRawOptions() async {
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
try {
final cookies = (await ytDlp).cookies.mpvRawOptions();
return cookies.isEmpty ? subs : '$subs,$cookies';
} catch (_) {
return subs;
}
}
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
final _probes = <String, ({DateTime at, DateTime until, Future<LinkProbe> f})>{};
static const _probeTtl = Duration(minutes: 10);
@@ -550,7 +550,7 @@ class QueueController {
// The server's warm relay of the item, once bytes can flow: what
// everyone without their own copy preloads.
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) {
case QueueHow.jellyfin:
final ref = head.media.jellyfin;
@@ -589,7 +589,7 @@ class QueueController {
/// puts it up (`AppYtDlp.probe`, kept while it stays first), a Jellyfin
/// item resolved with this app's account
/// ([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
/// runs out, dropped when it is no longer first.
void _warm(StateBroadcast? r) {
@@ -617,7 +617,7 @@ class QueueController {
/// 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
/// 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
/// first.
void _feedWarm(StateBroadcast? r) {
@@ -633,7 +633,7 @@ class QueueController {
final pick = _local[head.key]?.pick;
if (pick is FileMedia) {
_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) {
_fedWarm = key;
unawaited(() async {
@@ -642,7 +642,7 @@ class QueueController {
final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
final y = await coord.ytDlp.ytDlp;
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) {
debugPrint('queue: feeding ${head.id}: $e');
}
@@ -656,7 +656,7 @@ class QueueController {
final w = coord.warmJellyfin;
if (w == null || !sameJellyfinItem(w.ref, ref)) return;
_fedWarm = key;
coord.feedWarm(
coord.media.feeder.feedWarm(
head.id, RelayMedia.single(HttpRelayBytes(Uri.parse(w.plan.url), headers: w.plan.headers)));
case QueueHow.direct:
case QueueHow.server:
@@ -710,7 +710,7 @@ class QueueController {
// A refresh opens a link again (a page's stream URLs expire); the
// same Jellyfin stream stays as it is.
_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));
_syncPreload(coord.serverState.peek());
_warmTimer = Timer(ok ? _warmRefresh : _warmRetry, () => _resolveWarm(t, fresh: true));
@@ -730,25 +730,25 @@ class QueueController {
final out = coord.selfAway.peek();
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
final want = out ? null : _preloadWant;
final playback = coord.playback;
final media = coord.media;
if (want == null) {
_preloadKey = '';
_preloadGen++;
if (playback.preloadUrl != null) unawaited(playback.dropPreload());
if (media.preloadUrl != null) unawaited(media.dropPrepared());
return;
}
if (!playback.preloadEnabled) return;
if (!media.preloadEnabled) return;
final key = '${want.url}|${want.headers}';
final fresh = _preloadFresh;
// Asked for already: in flight, there, or failed (tried again with the
// 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;
_preloadKey = key;
_preloadBusy = true;
final gen = ++_preloadGen;
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;
_preloadBusy = false;
_preloadOk = ok;
@@ -0,0 +1,868 @@
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 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();
}
@@ -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;
}
@@ -15,7 +15,9 @@ import '../jellyfin/track_prefs.dart';
import '../library/media_refs.dart';
import '../playback/media_marks.dart';
import '../playback/playback_controller.dart';
import '../platform/desktop/media_kit_host.dart' show MediaKitHost;
import '../platform/ports/link_resolver.dart';
import '../platform/ports/player_host.dart';
import '../playback/yt_dlp_app.dart' show AppYtDlp;
import '../schedule/anilist.dart';
import '../screens/home/picked_media.dart';
@@ -30,6 +32,7 @@ import 'auto_sit_out.dart';
import 'connection.dart';
import 'delivery.dart';
import 'media_art.dart';
import 'media_pipeline.dart';
import 'notifier.dart';
import 'now_playing.dart';
import 'now_playing_info.dart';
@@ -41,6 +44,7 @@ export 'connection.dart' show ConnectCancelled, ConnectParams;
export 'notifier.dart' show ToastItem;
export 'delivery.dart' show stateMediaRelay;
export 'status.dart';
export 'media_pipeline.dart' show MediaPipeline;
final _log = createLogger('session');
@@ -74,32 +78,6 @@ bool unreadyForNewMedia(
required bool ready}) =>
enabled && lastMediaId >= 0 && mediaId != lastMediaId && ready;
/// What this member serves when its own swap is relayed: set at swap time,
/// claimed by the state that names this member as the source.
sealed class _RelayOrigin {
const _RelayOrigin();
}
final class _FileOrigin extends _RelayOrigin {
final String path;
const _FileOrigin(this.path);
}
final class _LinkOrigin extends _RelayOrigin {
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 _RelayOrigin {
final String? link;
const _JellyfinOrigin(this.link);
}
String? stateMediaUrl(StateBroadcast state) {
final media = state.media;
if (media == null) return null;
@@ -153,9 +131,13 @@ class SessionCoordinator {
late final AvatarCache avatars = AvatarCache(connection.client);
/// The current media's poster and banner by `state.media.art` hash.
late final ArtCache art = ArtCache(connection.client);
final PlaybackController playback = PlaybackController();
/// The one way to open media: plan → resolve → open → ready, the
/// players, Up next's prepared one, being a relay source.
final MediaPipeline media;
/// The session player's marks and commands.
PlaybackController get playback => media.playback;
/// Timings of each media change here (see [SwitchTrace]).
final SwitchTrace switchTrace = SwitchTrace();
SwitchTrace get switchTrace => media.trace;
final StateDiff diff = StateDiff();
/// The server's latest state. A drop keeps it (with the player, the
@@ -297,28 +279,15 @@ class SessionCoordinator {
// out, so only a real swap counts as a new video.
int _seenMediaId = -1;
int _mediaId = -1;
// The mediaId whose media the player holds (or is opening); -1 for none.
// A swap stops the previous media.
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 (see
// [_openMedia]).
int _openedFor = -1;
String? _openUrl;
String _wsHost = '';
int _wsPort = 0;
_RelayOrigin? _pendingOrigin;
// This client's last swap, until a state shows it landed (a newer
// mediaId than [after] with this member as `swapBy`): its art goes up then.
// [link]: the web link it came from (a relayed one has no `url`).
({HelloMedia media, int after, String? link, Uint8List? art})? _pendingArt;
AniListClient? _aniList;
// What this member serves for media [$1].
(int, _RelayOrigin)? _activeOrigin;
RelaySource? _relaySource;
int _relaySourceMediaId = -1;
/// This member serves the current media's bytes through the server.
final relaying = signal<bool>(false);
Signal<bool> get relaying => media.feeder.relaying;
/// Names by member id, kept after they leave (a relay source that left).
final memberNames = MemberNames();
// The media this member was told its upload is too slow for.
@@ -331,13 +300,14 @@ class SessionCoordinator {
int _lastEventId = -1;
final UserSeekGate _seekGate = UserSeekGate();
Timer? _seekTimer;
// The media is open here (or opening).
String? get _openUrl => media.openUrl;
// True while a media open is in flight: the player's duration may still
// belong to the previous media then.
bool _opening = false;
bool get _opening => media.opening;
/// Auto sit-out (Settings: "Auto sit-out"): idle or minimised for a
/// while sets this member away, coming back sets it in again.
final AutoSitOut autoSitOut = AutoSitOut(DateTime.now().toUtc());
Map<String, String>? _openHeaders;
// Jellyfin: the resolved ref, the mediaId it belongs to, a generation
// that stale resolutions check, and the progress reporter.
@@ -366,14 +336,17 @@ class SessionCoordinator {
Connection? connection,
JellyfinAccounts? jellyfin,
TrackPrefs? trackPrefs,
LinkResolver? ytDlp}) {
LinkResolver? ytDlp,
PlayerHost? players}) {
final n = notifier ?? Notifier(sfx: sfx);
final links = ytDlp ?? AppYtDlp.instance;
return SessionCoordinator._(
notifier: n,
connection: connection ?? Connection(notify: n.notify),
ytDlp: ytDlp ?? AppYtDlp.instance,
ytDlp: links,
jellyfin: jellyfin ?? JellyfinAccounts.instance,
trackPrefs: trackPrefs ?? TrackPrefs(),
media: MediaPipeline(host: players ?? (MediaKitHost()..init()), links: links),
);
}
@@ -382,10 +355,22 @@ class SessionCoordinator {
required this.connection,
required this.ytDlp,
required this.jellyfin,
required this.trackPrefs}) {
required this.trackPrefs,
required this.media}) {
connection
..onWelcome = _onWelcome
..caps = () => caps;
media
..session = MediaSession(
server: () => ServerAddress(_wsHost, _wsPort, connection.token),
selfId: () => connection.memberId,
// What this app can open itself: pages through the link
// resolver (desktop: always), Jellyfin with its accounts.
caps: () => ClientCaps(source: true, resolve: ytDlp.resolvesLinks, jellyfin: caps.jellyfin),
jellyfin: jellyfin)
..notify = notify
..onError = _setPlayerError
..onOpened = _onOpened;
// The Notifier's view of the session (Status → Notifier, never back).
notifier.session = () {
final r = serverState.peek();
@@ -427,17 +412,16 @@ class SessionCoordinator {
}
/// The session's first player: created here on connect, disposed by
/// [leave]; each new media gets its own ([PlaybackController.openUrl]).
/// Never reused across sessions or media (a GPU-side video freeze sticks
/// to the player).
/// [leave]; each new media gets its own (the [media] pipeline). Never
/// reused across sessions or media (a GPU-side video freeze sticks to
/// the player).
Future<void> initPlayer() async {
if (_playerReady) return;
try {
playback.onYtdlError = _setPlayerError;
playback.onLoadError = _setPlayerError;
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
_bufferSub ??= playback.buffered.subscribe((_) => _onBuffered());
await playback.init();
await media.start();
_playerReady = true;
} catch (e, st) {
debugPrint('player init failed: $e\n$st');
@@ -445,11 +429,13 @@ class SessionCoordinator {
}
}
void _setPlayerError(String raw) {
_log.warning('player cannot open media $_mediaId: $raw');
/// [raw]: why this player cannot open the current media; [cookies]:
/// which cookies a page's resolve used (D11), said with a sign-in error.
void _setPlayerError(String raw, {String? cookies}) {
_log.warning('player cannot open media $_mediaId: $raw${cookies == null ? '' : ' ($cookies)'}');
_playerErrorFor = _mediaId;
_playerErrorDuration = playback.duration.peek();
playerError.value = playErrorText(raw);
playerError.value = withCookieNote(playErrorText(raw), cookies);
}
/// Clears [playerError] once the media opened (a new duration) or the
@@ -500,12 +486,9 @@ class SessionCoordinator {
_playerReady = false;
playerFault.value = '';
try {
await playback.recreate();
final url = _openUrl;
final headers = _openHeaders;
_openUrl = null;
_clearPendingSeek();
if (url != null) await _openMedia(url, headers: headers);
diff.reset();
await media.reload();
} catch (e, st) {
debugPrint('video reload failed: $e\n$st');
playerFault.value = 'video reload failed: $e';
@@ -532,10 +515,7 @@ class SessionCoordinator {
_wsPort = params.port;
diff.reset();
_mediaId = -1;
_playerMediaId = -1;
_openedFor = -1;
settledMediaId.value = -1;
_openUrl = null;
media.reset();
playerError.value = '';
playerFault.value = '';
fetchFailedFor.value = -1;
@@ -571,7 +551,7 @@ class SessionCoordinator {
_startTicker();
try {
final url = welcome.mediaUrl;
if (url != null && url.isNotEmpty) await _openMedia(url);
if (url != null && url.isNotEmpty) await this.media.open(url);
final media = params.media ??
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
@@ -593,50 +573,19 @@ class SessionCoordinator {
_errSub ??= connection.errors.listen((msg) => notify(ServerRefused(_sentenceCase(msg))));
}
/// Opens [url] (with [headers] on every request); false on failure. A
/// new media gets a fresh player (or the preload holding it); the media
/// the player held before (back from sitting out, a reload) keeps it.
Future<bool> _openMedia(String url, {Map<String, String>? headers}) async {
if (url == _openUrl) return true;
_opens++;
_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;
/// A media opened (the pipeline's open step): the server hears at once
/// (a prepared media already has its buffer).
void _onOpened(int mediaId, String url) {
diff.reset();
_opening = true;
final mediaId = _mediaId;
var ok = false;
try {
await _prepareYtDlp(url);
switchTrace.mark(mediaId, SwitchTrace.open);
final promoted = await playback.openUrl(url,
headers: headers,
keepPlayer: keepPlayer,
onFreshPlayer: () => switchTrace.mark(mediaId, SwitchTrace.player));
switchTrace.mark(mediaId, promoted ? SwitchTrace.preloaded : SwitchTrace.opened);
ok = true;
} catch (e) {
debugPrint('open media failed: $e');
_setPlayerError('$e');
} finally {
_opening = false;
}
// Tell the server at once (a preloaded media already has its buffer).
final r = serverState.peek();
if (ok && r != null && r.mediaId == mediaId && url == _openUrl && connection.token.isNotEmpty && _playerReady) {
if (r != null && r.mediaId == mediaId && url == _openUrl && connection.token.isNotEmpty && _playerReady) {
_sendReport(r);
}
return ok;
}
int _opens = 0;
/// How many times this session opened a media in the player (a drop
/// and return must not add one).
int get opens => _opens;
int get opens => media.opens;
// The media whose buffer reached the gate's need was reported at once.
int _bufferReportedFor = -1;
@@ -646,202 +595,16 @@ class SessionCoordinator {
void _onBuffered() {
final r = serverState.peek();
if (r == null || r.transport != 'held' || _bufferReportedFor == r.mediaId) return;
if (_lastAway || _opening || _openUrl == null || _playerMediaId != r.mediaId || !_playerReady) return;
if (_lastAway || _opening || _openUrl == null || media.playerMediaId != r.mediaId || !_playerReady) return;
if (connection.token.isEmpty || playback.bufferedSecsAhead < r.bufferNeed) return;
_bufferReportedFor = r.mediaId;
_sendReport(r);
}
/// 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';
if (u.host == _wsHost && u.port == _wsPort && 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';
}
/// A web link (not this server's): mpv's yt-dlp hook gets the app's
/// managed yt-dlp, downloaded first if needed; a media file name does
/// not wait for that.
Future<void> _prepareYtDlp(String url) async {
final path = await _ytDlpPathFor(url);
if (path != null) await playback.useYtDlp(path);
}
/// The yt-dlp mpv's hook needs for [url] (null: none, not a web link).
Future<String?> _ytDlpPathFor(String url) async {
final uri = Uri.tryParse(url);
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return null;
if (uri.host == _wsHost && uri.port == _wsPort) return null;
try {
final y = await ytDlp.ytDlp;
final ready = y.ensure();
return hasMediaExtension(uri)
? y.path
: await ready.timeout(const Duration(seconds: 90), onTimeout: () => null);
} catch (e) {
debugPrint('yt-dlp not ready: $e');
return null;
}
}
/// 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
/// preloads what comes next only then, so the two never compete, and a
/// preload a swap is about to take is never replaced first.
final settledMediaId = signal(-1);
/// Opens [url] in the hidden preload player ([PlaybackController.preload]);
/// [openUrl] for the same URL and headers then starts from it. True
/// once its first seconds are in.
Future<bool> preloadMedia(String url, {Map<String, String>? headers, bool fresh = false}) async {
if (!_playerReady) return false;
final path = await _ytDlpPathFor(url);
if (!_playerReady) return false;
return playback.preload(url, headers: headers, ytdlPath: path, fresh: fresh);
}
/// The server-fetched media this viewer opens once it is live.
int _awaitLiveFor = -1;
/// 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 _waitForSource(StateBroadcast r, MediaRelay relay) =>
!relay.live && !relay.sourceGone && (relay.fetch || r.members.any((m) => m.id == relay.source));
/// This viewer's `/r/` URL; with tracks, a pieces track's playlist, or
/// an mpv EDL of every track when there are several (a page's separate
/// video and audio). A relay started in Up next ([queueId]) keeps its
/// queue id in the URL, so the preload of its warm relay matches.
String _relayMediaUrlFor(String name, int mediaId,
[List<RelayTrackSpec> tracks = const [], String queueId = '']) =>
relayViewerUrl('http://$_wsHost:$_wsPort/r/default/${connection.token}/${queueId.isEmpty ? mediaId : queueId}', name,
tracks: tracks);
/// This viewer's URL of the warm relay of Up next's first [item]
/// (`queue[0].media.relay`, see ARCHITECTURE "Getting ready"): the same
/// URL the media has once the item goes up, so the preload is promoted.
String warmRelayUrl(QueueItem item, MediaRelay relay) =>
_relayMediaUrlFor(item.media.name.isEmpty ? item.title : item.media.name, -1, relay.tracks, item.id);
// This member feeds the warm relay of Up next item `queueId` (its
// link, file or Jellyfin stream) before the item goes up; the swap
// that puts it up takes the source over.
({String queueId, RelaySource src})? _warmSource;
/// 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 (_warmSource?.queueId == queueId) {
unawaited(media.close());
return;
}
stopWarmSource();
final src = RelaySource(
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${connection.token}/source?q=${Uri.encodeQueryComponent(queueId)}'),
media);
_warmSource = (queueId: queueId, src: src);
unawaited(() async {
try {
await src.start();
} catch (e) {
debugPrint('warm relay source of $queueId failed: $e');
if (identical(_warmSource?.src, src)) _warmSource = null;
return;
}
await src.done;
if (identical(_warmSource?.src, src)) _warmSource = null;
}());
}
void stopWarmSource() {
final w = _warmSource;
_warmSource = null;
if (w != null) unawaited(w.src.close());
}
/// 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.
void _startRelaySource(int mediaId, RelayMedia media,
{bool announce = false, String queueId = '', String name = '', double duration = 0}) {
// Already serving this media (it redials by itself through a drop and
// goes on once this member is back).
if (_relaySource != null && _relaySourceMediaId == mediaId) {
unawaited(media.close());
return;
}
_stopRelaySource();
final warm = _warmSource;
if (queueId.isNotEmpty && warm != null && warm.queueId == queueId) {
_warmSource = null;
unawaited(media.close());
_relaySource = warm.src;
_relaySourceMediaId = mediaId;
relaying.value = true;
if (announce) notify(const RelayStarted());
unawaited(warm.src.done.then((_) {
if (identical(_relaySource, warm.src)) {
_relaySource = null;
relaying.value = false;
}
}));
return;
}
// The same `info` on every dial (name and length included), so the
// server knows a re-dial after a drop as the same media and continues.
final src = RelaySource(
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${connection.token}/source'), media,
name: name, duration: duration);
_relaySource = src;
_relaySourceMediaId = mediaId;
relaying.value = true;
if (announce) notify(const RelayStarted());
unawaited(() async {
try {
await src.start();
} catch (e) {
debugPrint('relay source failed: $e');
if (identical(_relaySource, src)) {
_relaySource = null;
relaying.value = false;
notify(RelayFailed('$e'));
}
return;
}
await src.done;
if (identical(_relaySource, src)) {
_relaySource = null;
relaying.value = false;
}
}());
}
void _stopRelaySource() {
final src = _relaySource;
_relaySource = null;
_relaySourceMediaId = -1;
relaying.value = false;
if (src != null) unawaited(src.close());
}
String _fileUrlFor(String name) {
return 'http://$_wsHost:$_wsPort/m/default/${connection.token}/${Uri.encodeComponent(name)}';
}
/// prepares what comes next only then.
Signal<int> get settledMediaId => media.settledMediaId;
StateBroadcast? _queuedState;
bool _applying = false;
@@ -881,7 +644,7 @@ class SessionCoordinator {
_trackSelfReady(r);
if (_lastAway) {
// A newer media than the one this member serves: that relay ended.
if (_relaySourceMediaId != -1 && _relaySourceMediaId != r.mediaId) _stopRelaySource();
if (media.feeder.mediaId != -1 && media.feeder.mediaId != r.mediaId) media.feeder.stop();
await _unloadForOut();
return;
}
@@ -898,73 +661,21 @@ class SessionCoordinator {
r.mediaId == _jfMediaId &&
sameJellyfinItem(ref, _jfRef) &&
jellyfinPlan.peek() is OwnPlay;
final relay = stateMediaRelay(r);
final mine = relay != null && relay.source.isNotEmpty && relay.source == connection.memberId;
_RelayOrigin? origin;
if (mine) {
final pending = _pendingOrigin;
if (pending != null) {
_pendingOrigin = null;
_activeOrigin = (r.mediaId, pending);
}
origin = _activeOrigin?.$1 == r.mediaId ? _activeOrigin!.$2 : null;
}
if (_relaySourceMediaId != r.mediaId) _stopRelaySource();
if (!same) {
if (same) {
media.plan(r);
} else {
_seekGate.reset();
_clearPendingSeek();
_leaveJellyfinItem();
await _leavePreviousMedia(r.mediaId);
final name = (r.media?['name'] as String?) ?? '';
if (ref != null) {
await _playJellyfin(ref, r,
origin: origin is _JellyfinOrigin ? origin : null,
memberRelayUrl:
relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId, const [], relay.queueId) : null);
} else if (origin is _FileOrigin) {
// The source first: viewers wait on it, not on this player.
_startRelaySource(r.mediaId, RelayMedia.single(FileRelayBytes(File(origin.path))),
announce: true, queueId: relay?.queueId ?? '', name: name, duration: _durationOf(r));
await _openMedia(origin.path);
} else if (origin is _LinkOrigin) {
_startRelaySource(
r.mediaId, LinkRelayMedia(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe),
announce: true, queueId: relay?.queueId ?? '', name: name, duration: _durationOf(r));
await _openMedia(origin.url);
} else if (relay != null && !mine) {
if (_waitForSource(r, relay)) {
// The server is still getting it (yt-dlp takes seconds), or the
// member passing it on has not dialled in yet (its app opens the
// link first): 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 == relay.source).firstOrNull?.name;
_log.info('media ${r.mediaId}: waiting for ${relay.fetch ? 'the server' : who} before opening the relay');
} else {
await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks, relay.queueId));
}
} else {
var url = stateMediaUrl(r);
if ((url == null || url.isEmpty) && connection.token.isNotEmpty) {
final name = (r.media?['name'] as String?) ?? '';
if (name.isNotEmpty) url = _fileUrlFor(name);
}
if (url != null && url.isNotEmpty) {
await _openMedia(url);
} else {
_openUrl = null;
}
}
diff.reset();
final next = await media.switchTo(r);
if (next is SwitchJellyfin) await _playJellyfin(next.source, r);
}
_seekGate.reset();
_clearPendingSeek();
if (_mediaId == r.mediaId) settledMediaId.value = r.mediaId;
} else if (_awaitLiveFor == r.mediaId && stateMediaRelay(r) != null && !_waitForSource(r, stateMediaRelay(r)!)) {
_awaitLiveFor = -1;
final relay = stateMediaRelay(r)!;
await _openMedia(
_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId, relay.tracks, relay.queueId));
if (_mediaId == r.mediaId) media.settled(r.mediaId);
} else {
await media.onState(r);
}
_jellyfinTransport(r);
if (!_playerReady || _openUrl == null) {
@@ -990,6 +701,9 @@ class SessionCoordinator {
];
}
_wasPlayAllowed = allowed;
// A relay without its source: what is buffered plays, nothing seeks
// past it; one seek resumes when the source is back (D6).
actions = media.recover(r, actions, serverPosition: serverPositionAt(r, now));
// The user is seeking (or our seek has not echoed yet), or this is the
// echo of our own seek arriving: the player is where the user put it.
// Later drift corrections within that event still apply.
@@ -1038,38 +752,13 @@ class SessionCoordinator {
/// [joinBack] opens the media as new.
Future<void> _unloadForOut() async {
_wasPlayAllowed = false;
_awaitLiveFor = -1;
if (_mediaId == -1 && _openUrl == null) return;
_mediaId = -1;
_playerMediaId = -1;
_openUrl = null;
_openHeaders = null;
_seekGate.reset();
_clearPendingSeek();
diff.reset();
_leaveJellyfinItem();
if (!_playerReady) return;
try {
await playback.stop();
} catch (_) {}
}
/// A new media [mediaId]: the previous one leaves the player at once, so
/// nobody sees or hears it while the new one resolves, is fetched by the
/// server or opens. A reconnect to the media the player holds keeps it.
Future<void> _leavePreviousMedia(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;
diff.reset();
if (!_playerReady) return;
try {
await playback.stop();
} catch (_) {}
await media.unload();
}
void _onFeed(FeedMessage f) {
@@ -1114,10 +803,13 @@ class SessionCoordinator {
// The buffer counts only while the player holds the server's current
// media: before a swap's media is open it is the previous one's (or
// none), and the start must wait for this member.
final holds = !_lastAway && !_opening && _openUrl != null && _playerMediaId == r.mediaId;
final holds = !_lastAway && !_opening && _openUrl != null && media.playerMediaId == r.mediaId;
_checkPlayerError(r.mediaId);
final error = playerError.peek();
final buffered = holds ? playback.bufferedSecsAhead : 0.0;
// A relay without its source this player cannot follow: nothing
// buffered, so the server waits for the source (D6).
final starved = holds && media.starved(serverPositionAt(r, connection.serverNow()));
final buffered = holds && !starved ? playback.bufferedSecsAhead : 0.0;
if (holds && buffered >= r.bufferNeed) switchTrace.mark(r.mediaId, SwitchTrace.buffered);
connection.sendReport(
position: _lastAway ? 0 : playback.positionSecs,
@@ -1204,7 +896,8 @@ class SessionCoordinator {
final at = _pendingSeekAt;
if (event == null || at == null) return;
final now = DateTime.now().toUtc();
if (event != r.eventId || _seekGate.holdsPlayer(now)) {
// A relay without its source: never asked again (the seek loop).
if (event != r.eventId || _seekGate.holdsPlayer(now) || media.sourceDown) {
_clearPendingSeek();
return;
}
@@ -1234,6 +927,7 @@ class SessionCoordinator {
_seekGate.request(position, now);
_clearPendingSeek();
unawaited(playback.apply([SeekAction(position)]));
media.noteUserSeek(position);
_seekTimer?.cancel();
_seekTimer = Timer(_seekGate.debounce, _flushUserSeek);
}
@@ -1246,9 +940,7 @@ class SessionCoordinator {
// Playing on since the request: send where the player is now, so the
// server's clock and ours agree when the echo comes back.
final own = playback.positionSecs;
if (_playerReady &&
playback.player.state.playing &&
(own - position).abs() < 2.0) {
if (_playerReady && playback.playing && (own - position).abs() < 2.0) {
position = own;
}
connection.sendSeek(position, seq: settled.seq);
@@ -1482,7 +1174,7 @@ class SessionCoordinator {
markInput();
final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) {
_pendingOrigin = _JellyfinOrigin(url);
this.media.expectOrigin(JellyfinOrigin(url));
_swap(media.copyWith(relay: const MediaRelay()), queueId: queueId);
return null;
}
@@ -1511,13 +1203,13 @@ class SessionCoordinator {
if (relayProblem != null) return relayProblem;
final y = await ytDlp.ytDlp;
// Up next's item this app already feeds (its warm relay): read.
final warm = _warmSource;
final fed = queueId != null && warm != null && warm.queueId == queueId && warm.src.tracks.isNotEmpty;
final warm = queueId == null ? const <RelayTrack>[] : this.media.feeder.warmTracks(queueId);
final fed = warm.isNotEmpty;
final check = fed ? null : LinkRelayMedia(uri, ytDlp: y, probe: probe);
var size = 0;
final List<RelayTrackSpec> specs;
try {
final tracks = fed ? warm.src.tracks : await check!.open().timeout(const Duration(seconds: 15));
final tracks = fed ? warm : await check!.open().timeout(const Duration(seconds: 15));
for (final t in tracks) {
// A playlist is read (DRM and live refused) but has no size.
final pieces = t.pieces;
@@ -1533,7 +1225,7 @@ class SessionCoordinator {
} finally {
await check?.close();
}
_pendingOrigin = _LinkOrigin(url, probe, y);
this.media.expectOrigin(LinkOrigin(url, probe, y));
_swap(HelloMedia(name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs), ref: what),
link: url, queueId: queueId);
return null;
@@ -1567,7 +1259,7 @@ class SessionCoordinator {
/// relayed by this member for viewers without an account.
void swapJellyfin(JellyfinMedia media, {String? queueId}) {
markInput();
_pendingOrigin = const _JellyfinOrigin(null);
this.media.expectOrigin(const JellyfinOrigin(null));
_swap(mediaForPick(media).copyWith(relay: const MediaRelay()), queueId: queueId);
}
@@ -1586,7 +1278,7 @@ class SessionCoordinator {
}
final file = File(path);
if (!await file.exists()) return 'file not found — check the path';
_pendingOrigin = _FileOrigin(path);
media.expectOrigin(FileOrigin(path));
_swap(HelloMedia(
name: file.uri.pathSegments.last,
size: await file.length(),
@@ -1642,7 +1334,6 @@ class SessionCoordinator {
_jfMediaId = -1;
_reporter = null;
_lastSnap = null;
_openHeaders = null;
_blockedKey = '';
currentRef.value = null;
jellyfinPlan.value = null;
@@ -1683,7 +1374,7 @@ class SessionCoordinator {
final last = _lastSnap;
return PlayerSnapshot(
position: playback.positionSecs,
paused: !playback.player.state.playing,
paused: !playback.playing,
aid: last?.aid,
sid: last?.sid,
);
@@ -1695,25 +1386,15 @@ class SessionCoordinator {
if (!_playerReady) return null;
return _lastSnap = PlayerSnapshot(
position: playback.positionSecs,
paused: !playback.player.state.playing,
paused: !playback.playing,
aid: ids.aid,
sid: ids.sid,
);
}
/// This member's `/j/` URL for [ref] (the server's Jellyfin login).
String? jellyfinRelayUrl(JellyfinRef ref) => _relayUrlFor(ref);
String? _relayUrlFor(JellyfinRef ref) {
if (connection.token.isEmpty) return null;
final welcome = connection.relayUrl;
if (welcome != null &&
welcome.contains('/${connection.token}/') &&
welcome.endsWith('/${Uri.encodeComponent(ref.itemId)}')) {
return welcome;
}
return relayUrlFor(_wsHost, _wsPort, connection.token, ref.itemId);
}
String? jellyfinRelayUrl(JellyfinRef ref) =>
connection.token.isEmpty ? null : ServerAddress(_wsHost, _wsPort, connection.token).jellyfinUrl(ref);
/// `report.jf`: every linked account that works (so a queued Jellyfin
/// item finds someone to put it up while other media plays), then how
@@ -1729,67 +1410,31 @@ class SessionCoordinator {
return {...linked, ref.serverId: access};
}
/// Resolves [ref] for this viewer and opens it: own account (direct
/// play, progress reports, neighbouring episodes), the relay, or the
/// blocked state. Then carries the audio/subtitle choice over.
Future<void> _playJellyfin(JellyfinRef ref, StateBroadcast r,
{_JellyfinOrigin? origin, String? memberRelayUrl}) async {
/// Resolves [source]'s item for this viewer and opens it (the media
/// pipeline: own account, the server's login, the member passing it on,
/// or blocked), then this item's own work: progress reports and
/// neighbouring episodes with an own account, the audio/subtitle choice
/// carried over.
Future<void> _playJellyfin(JellyfinSource source, StateBroadcast r) async {
final ref = source.ref;
final gen = ++_jfGen;
_jfRef = ref;
_jfMediaId = r.mediaId;
currentRef.value = ref;
// Until it resolves nothing is open: no syncing the previous media.
final prevUrl = _openUrl;
_openUrl = null;
_opening = true;
if (_playerReady) {
try {
await playback.player.pause();
} catch (_) {}
}
JellyfinPlan plan;
try {
plan = _takeWarmJellyfin(ref) ??
await resolveJellyfinRef(jellyfin, ref,
relay: r.relay, relayUrl: _relayUrlFor(ref), memberRelayUrl: memberRelayUrl);
} finally {
if (gen == _jfGen) _opening = false;
}
if (gen != _jfGen) return;
switchTrace.mark(r.mediaId, SwitchTrace.resolved);
// This member put it up: serve it to viewers without an account, with
// its own sign-in, else the pasted link (played directly here too).
if (origin != null) {
final name = (r.media?['name'] as String?) ?? '';
if (plan is OwnPlay) {
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers)),
queueId: stateMediaRelay(r)?.queueId ?? '', name: name, duration: _durationOf(r));
} else if (origin.link != null) {
plan = RelayPlay(origin.link!);
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!))),
name: name, duration: _durationOf(r));
}
}
final plan = await media.openJellyfin(source, r, warm: _takeWarmJellyfin(ref), current: () => gen == _jfGen);
if (plan == null || gen != _jfGen) return;
jellyfinPlan.value = plan;
switch (plan) {
case BlockedPlay():
jellyfinBlocked.value = plan;
_blockedKey = _accountKey(ref.serverId);
try {
await playback.stop();
} catch (_) {}
return;
case RelayPlay(:final url):
case RelayPlay():
jellyfinBlocked.value = null;
// Same relay URL (resumed member): the player still has it.
if (url == prevUrl) {
_openUrl = url;
return;
}
if (!await _openMedia(url) || gen != _jfGen) return;
case OwnPlay(:final url, :final headers):
if (_openUrl == null) return;
case OwnPlay():
jellyfinBlocked.value = null;
if (!await _openMedia(url, headers: headers) || gen != _jfGen) return;
if (_openUrl == null) return;
// Played before and the resume point, fresh (a warmed plan's item
// is older): the watched rule never unmarks a played item.
final known = plan.item?.userData;
@@ -1992,9 +1637,9 @@ class SessionCoordinator {
/// "Try again" on the blocked card (and after linking an account).
void retryJellyfin() {
final r = serverState.peek();
final ref = refOfStateMedia(r?.media);
if (r == null || ref == null) return;
unawaited(_playJellyfin(ref, r));
if (r == null) return;
final source = media.plan(r);
if (source is JellyfinSource) unawaited(_playJellyfin(source, r));
}
/// A welcome's part here (see [WelcomeHandler]), before its state is
@@ -2027,7 +1672,8 @@ class SessionCoordinator {
} else if (!rejoin.sameServer && _mediaId != -1) {
// The same media under a restarted server's numbering: the player
// keeps it.
_mediaId = _playerMediaId = _openedFor = now.mediaId;
_mediaId = now.mediaId;
media.renumber(now.mediaId);
_seenMediaId = now.mediaId;
}
// The intent the server lost: ready (unless the video changed and the
@@ -2060,21 +1706,12 @@ class SessionCoordinator {
/// scratch (a relay this member served for the old one stops).
void _forgetMedia() {
_mediaId = -1;
_playerMediaId = -1;
_openUrl = null;
_openHeaders = null;
_awaitLiveFor = -1;
_wasPlayAllowed = false;
diff.reset();
_clearPendingSeek();
_stopRelaySource();
stopWarmSource();
_activeOrigin = null;
media.forget();
}
/// The media's length from a state (0 unknown).
static double _durationOf(StateBroadcast r) => (r.media?['duration'] as num?)?.toDouble() ?? 0;
/// "Stop reconnecting".
void cancelReconnect() => connection.cancelReconnect();
@@ -2090,7 +1727,7 @@ class SessionCoordinator {
_playerReady = false;
_ticker?.cancel();
_ticker = null;
await playback.disposeAll();
await media.disposeAll();
}
/// Out of the session: with [bye] (Leave, closing the app) the member
@@ -2110,15 +1747,12 @@ class SessionCoordinator {
_seekTimer = null;
_seekGate.reset();
await _stopJellyfin();
_stopRelaySource();
stopWarmSource();
_pendingOrigin = null;
media.reset();
_pendingArt = null;
_ownLink = null;
_activeOrigin = null;
_playerReady = false;
try {
await playback.dispose();
await media.release();
} catch (_) {}
// The last lines go before the token does, then the bye.
await connection.close(bye: bye);
@@ -2139,6 +1773,6 @@ class SessionCoordinator {
_bufferSub?.call();
_aniList?.close();
await connection.dispose();
await playback.dispose();
await media.dispose();
}
}
+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}",
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.
final relay = f.ownPlan ? null : stateMediaRelay(r);
if (relay != null && relay.source != f.selfId) {
@@ -11,6 +11,7 @@ import '../controls/controls_probe.dart';
import '../controls/model/controls_model.dart';
import '../controls/session_controls.dart';
import '../controls/volume.dart';
import '../platform/desktop/media_kit_host.dart' show videoControllerOf;
import '../queue/queue_ui.dart';
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
import '../screens/session/jellyfin_widgets.dart';
@@ -321,7 +322,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
Widget _video(VideoControlsBuilder? controls) => Video(
key: _videoKey,
controller: _coord.playback.video,
controller: videoControllerOf(_coord.playback.video),
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
controls: controls,
);
@@ -653,7 +654,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
],
);
}),
_MiniSeekBar(onSeek: (d) => playback.player.seek(d)),
_MiniSeekBar(onSeek: playback.userSeek),
],
),
),
-21
View File
@@ -1,26 +1,5 @@
import 'package:media_kit/media_kit.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,
/// arrow keys one per press) into one `seek` intent, and tells the
/// coordinator when server seeks must not touch the player:
+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);
}
});
}
+39 -21
View File
@@ -1,30 +1,30 @@
// 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:convert';
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/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/session_media.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/queue/queue_controller.dart' show WarmState;
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/session/switch_trace.dart';
// How long an Up next switch takes, end to end, over real mpv and a real
// sync server: two Jellyfin account holders and a guest (through the
// server's /j login) watch an episode, then ⏭ puts up the next episode,
// then a direct link, then a link passed on "through" alice, then one the
// server fetches (both relayed: the guests preload them from the server's
// warm relay of the item). 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.
// How long an Up next switch takes through the media pipeline, end to
// end, over real mpv and a real sync server: two Jellyfin account holders
// and a guest (through the server's /j login) watch an episode, then ⏭ puts
// up the next episode, then a direct link, then a link passed on "through"
// alice, then one the server fetches (both relayed: the guests prepare
// them from the server's warm relay of the item). Every client prepares
// the next item in a hidden player (`MediaPipeline.prepare`) and the swap
// 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_DELAY_MS and WT_SWITCH_RATE_KBS set the remote's delay and rate.
@@ -202,7 +202,12 @@ void main() {
'Up next: ⏭ to a Jellyfin episode, then to a direct link, timed per client',
timeout: const Timeout(Duration(minutes: 4)),
() 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 file = await _media();
final jelly = _FakeJellyfin(file);
@@ -236,9 +241,15 @@ void main() {
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
await accB.ensureLoaded(validate: false);
SessionCoordinator coord(JellyfinAccounts acc) => SessionCoordinator(
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp)
..playback.preloadEnabled = preload;
final hosts = <SessionCoordinator, MediaKitHost>{};
SessionCoordinator coord(JellyfinAccounts acc) {
final host = MediaKitHost()..init();
final c = SessionCoordinator(
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp, players: host)
..media.preloadEnabled = preload;
hosts[c] = host;
return c;
}
final a = coord(accA), b = coord(accB), c = coord(accC);
final all = {'alice': a, 'guest': b, 'carol': c};
ConnectParams params(String name, [HelloMedia? media]) =>
@@ -252,7 +263,7 @@ void main() {
if (preload) {
for (final x in all.values) {
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();
@@ -269,11 +280,14 @@ void main() {
}
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 volume carried over, the clock moves.
// the volume carried over, the clock moves; the old one retired.
for (final x in all.values) {
expect(x.playback.durationSecs, greaterThan(59));
expect(x.playback.player.state.volume, 0, reason: 'volume kept');
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;
await Future.delayed(const Duration(seconds: 1));
@@ -286,7 +300,7 @@ void main() {
await b.connect(params('guest'));
await c.connect(params('carol'));
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');
expect(await a.queue.add(pickForRef(accA, const JellyfinRef(serverId: _sid, itemId: _ep2, title: 'Second'))!),
@@ -332,14 +346,17 @@ void main() {
// ignore: avoid_print
print(report.join('\n'));
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.
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);
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);
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.
if (preload) {
@@ -348,6 +365,7 @@ void main() {
}
}
} finally {
await logSub.cancel();
for (final x in all.values) {
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:sync_relay_core/core.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;
@@ -42,13 +43,15 @@ void main() {
final server = SyncServer(AppConfig(
port: 0, secret: '', name: 'readahead', mediaPath: media));
await server.start();
final p = PlaybackController();
final host = MediaKitHost();
final m = MediaPipeline(host: host);
final p = m.playback;
try {
await p.init();
expect(p.pinMismatches, isEmpty,
await m.start();
expect(host.pinMismatches, isEmpty,
reason: 'every pinned mpv property took effect');
final token = server.session.issueToken('probe', '');
await p.openUrl(
await m.open(
'http://127.0.0.1:${server.boundPort}/m/default/$token/readahead.mp4');
var best = 0.0;
final end = DateTime.now().add(const Duration(seconds: 30));
@@ -62,7 +65,7 @@ void main() {
expect(best, greaterThan(120),
reason: 'paused player reads ahead about two minutes');
} finally {
await p.dispose();
await m.dispose();
await server.stop();
}
},
+226 -2
View File
@@ -35,15 +35,130 @@ Future<String> _media() async {
}
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);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(const Duration(milliseconds: 200));
await Future.delayed(every);
}
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.
Future<(HttpServer, List<String>)> _origin(String file) async {
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
@@ -267,4 +382,113 @@ void main() {
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: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_presets.dart';
import 'package:starsync/src/state/settings.dart';
@@ -21,12 +22,14 @@ void main() {
support.createSync(recursive: true);
shaderSupportDir = () async => support;
final s = AppSettings.instance;
final p = PlaybackController();
final m = MediaPipeline(host: MediaKitHost());
final p = m.playback;
try {
// The saved choice goes on at player creation.
s.setShaderPreset(ShaderPreset.a);
await p.init();
final dynamic mpv = p.player.platform;
await m.start();
await p.applyShaders();
final mpv = p.player;
final modeA =
await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const []);
expect(shaderListMatches(modeA, await mpv.getProperty('glsl-shaders')), isTrue);
@@ -59,17 +62,16 @@ void main() {
expect(await mpv.getProperty('glsl-shaders'), good.path);
// A recreated player (Reload video) gets the choice again.
await p.recreate();
expect(await (p.player.platform as dynamic).getProperty('glsl-shaders'),
good.path);
await m.reload();
await p.applyShaders();
expect(await p.player.getProperty('glsl-shaders'), good.path);
s.setShaderPreset(ShaderPreset.off);
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');
} finally {
await p.dispose();
await m.dispose();
}
});
}
+1 -1
View File
@@ -12,7 +12,7 @@ const baseline = {
'UpdateController': 8,
'Profile': 8,
'FolderStore': 6,
'AppYtDlp': 4,
'AppYtDlp': 2,
'RecentLinks': 3,
};
@@ -26,8 +26,7 @@ Future<void> _poll(
Future<void> _quiet(SessionCoordinator c) async {
await c.playback.player.setVolume(0);
try {
final dynamic platform = c.playback.player.platform;
await platform?.setProperty('loop-file', 'inf');
await c.playback.player.setProperty('loop-file', 'inf');
} 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 '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/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/session/session_coordinator.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';
Future<void> _poll(
@@ -26,47 +28,46 @@ Future<void> _poll(
void main() {
test(
'reload video: new player, same media, resynced, ready untouched; '
'leave disposes the player',
'reload video: a fresh player from the host, same media, resynced, ready untouched; '
'leave releases every player',
timeout: const Timeout(Duration(minutes: 2)),
() 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();
final c = SessionCoordinator();
final host = MediaKitHost()..init();
final c = SessionCoordinator(players: host);
try {
await c.connect(ConnectParams(
host: '127.0.0.1',
port: server.boundPort,
secret: 'reload',
name: 'solo'));
await c.playback.player.setVolume(0);
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'reload', name: 'solo'));
await c.playback.setVolume(0);
await _poll(() => c.serverState.peek() != null, 'server state');
c.toggleReady();
await _poll(
() => c.playback.player.state.playing && c.playback.positionSecs > 2,
'solo playback past 2 s');
await _poll(() => c.playback.playing && c.playback.positionSecs > 2, 'solo playback past 2 s');
final opens = c.opens;
final old = c.playback.player;
final gen = c.playback.generation.peek();
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.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);
await _poll(() {
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());
return (c.playback.positionSecs - want).abs() < 1.5 &&
c.playback.positionSecs > 2;
return (c.playback.positionSecs - want).abs() < 1.5 && c.playback.positionSecs > 2;
}, 'reloaded player playing at the synced position');
expect(c.selfMember.peek()?.ready, isTrue);
expect(c.serverState.peek()?.transport, 'playing');
await c.leave();
expect(c.playback.alive.peek(), isFalse);
await host.retiring;
expect(host.live, 0, reason: 'leaving releases every player');
} finally {
await c.dispose();
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.
setUpAll(() async {
MediaKit.ensureInitialized();
await coord.playback.init();
await coord.media.start();
});
testWidgets('wheel over the video: readout for about a second', (tester) async {
+91 -27
View File
@@ -2,8 +2,10 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:starsync/src/playback/playback_controller.dart';
import 'package:sync_relay_core/core.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;
const _referer = 'https://www.bilibili.com/';
@@ -22,7 +24,8 @@ Future<(String, String)> _media() async {
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 {
_Cdn(this._server, this._files) {
_server.listen(_serve);
@@ -30,11 +33,27 @@ class _Cdn {
final HttpServer _server;
final Map<String, String> _files;
int ok = 0, forbidden = 0;
final cookies = <String?>[];
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 {
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];
if (file == null || req.headers.value('referer') != _referer) {
forbidden++;
@@ -43,6 +62,7 @@ class _Cdn {
return;
}
ok++;
cookies.add(req.headers.value('cookie'));
final bytes = await File(file).readAsBytes();
var start = 0, end = bytes.length - 1;
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:
/// `requested_formats` with per-format `http_headers`, none at the top.
Future<String> _fakeYtDlp(_Cdn cdn, {required bool headers}) async {
final dir = await Directory('$_tmp/ytdl_headers_fake').create(recursive: true);
/// A stand-in yt-dlp (the managed copy in its own folder) printing what
/// yt-dlp gives for a split-track page: `requested_formats` with
/// per-format `http_headers` and `cookies`, subtitles with Bilibili's
/// 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 json = jsonEncode({
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
'format_id': 'v+a', 'ext': 'mp4',
'requested_formats': [
{'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',
'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'}');
await f.writeAsString("#!/bin/sh\ncat <<'EOF'\n$json\nEOF\n");
final f = File('${dir.path}/yt-dlp');
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]);
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() {
@@ -85,32 +117,39 @@ void main() {
late HttpServer server;
setUpAll(() async {
MediaKit.ensureInitialized();
final (v, a) = await _media();
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
cdn = _Cdn(server, {'v.m4s': v, 'a.m4s': a});
});
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(
"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)),
() async {
final p = PlaybackController();
final host = MediaKitHost()..init();
final m = MediaPipeline(host: host, links: await _fakeYtDlp(cdn, headers: true));
final p = m.playback;
try {
await p.init();
expect(p.pinMismatches, isEmpty, reason: 'stream-lavf-o and the rest took effect');
await p.useYtDlp(await _fakeYtDlp(cdn, headers: true));
await p.openUrl('https://www.bilibili.com/video/BV1fake');
await m.start();
expect(host.pinMismatches, isEmpty, reason: 'stream-lavf-o, ytdl=no and the rest took effect');
expect(await p.player.getProperty('ytdl'), 'no');
expect(await m.open(cdn.page('BV1fake')), isTrue);
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));
}
expect(p.duration.peek(), greaterThan(Duration.zero), reason: 'both tracks opened');
expect(cdn.forbidden, 0, reason: 'every request had the Referer');
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 {
await p.dispose();
await m.dispose();
}
},
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',
timeout: const Timeout(Duration(minutes: 1)),
() async {
final p = PlaybackController();
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: false));
final p = m.playback;
final errors = <String>[];
p.onLoadError = errors.add;
try {
await p.init();
await m.start();
var logs = 0;
final sub = p.player.stream.log.listen((_) => logs++);
await p.useYtDlp(await _fakeYtDlp(cdn, headers: false));
final sub = p.player.logs.listen((_) => logs++);
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 sub.cancel();
// ignore: avoid_print
print('403s: ${cdn.forbidden - before}, log lines: $logs, errors: $errors');
expect(cdn.forbidden - before, lessThan(10), reason: 'no retry loop on 403');
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(logs, lessThan(30), reason: 'warnings and errors only');
} finally {
await p.dispose();
await m.dispose();
}
},
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));
}
}
+246 -128
View File
@@ -9,7 +9,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
|---|---|---|
| `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/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), `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 |
| `deploy` | systemd unit for the dedicated server | |
@@ -132,7 +132,7 @@ only stopping sharing sends guests out.
`AppGraph` (`app/app_graph.dart`) is built once in `main.dart`
(`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`,
`state/connect_prefs.dart`), `YourServer`, `Navigation` and the library's
matches (`ShowMatcher`, "Library", whose `refForPath` it hands the
@@ -264,8 +264,8 @@ Loosely modeled on Syncplay, with a different readiness design.
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
until level, otherwise nothing.
- **User seeks**: the player is a `SyncPlayer` whose public `seek` is the
user's: the session's seek bar (`ChapterSeekBar`, one `seek` per pointer
- **User seeks**: a hosted player's public `seek` is the user's (its
`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
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
@@ -274,8 +274,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
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
position recomputed now, and only while its event is still current and the
user is not seeking.
position recomputed now, and only while its event is still current, the
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
(`silence`) is closed (4015) and its member goes `gone`; the grace, the
`gone` notice, the joiner bound and the joining timeout follow. The
@@ -406,8 +407,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`
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
server is only the clock. A pasted link that
directly (a web page through its own yt-dlp, see "yt-dlp"); the server
is only the clock. A pasted link that
`parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the
ServerId is known (in the link, else `/System/Info/Public`), so its
`api_key` is never shared (`mediaForLink`, host and swap alike).
@@ -452,7 +453,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
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
`demuxer-max-back-bytes`), `libass` on; pinned via runtime property:
`demuxer-readahead-secs=150`, `cache-secs=150` (about two minutes of
@@ -463,21 +465,25 @@ file is ~8), `demuxer-max-back-bytes=67108864`,
(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
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
be opened) before a duration is known is reported once per open
(`onLoadError`), with ffmpeg's HTTP status when this player gets
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
player passes the HTTP errors it gets on to the session player). Since
every 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
the coordinator's `playerError` in plain words (`playErrorText`, core
first one, and once that is disposed the next one created; the host
passes the HTTP errors any player gets on as `httpErrors`). Since every
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 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
needs cookies — Settings → Player → yt-dlp → Cookies", 403 → "Not allowed
(403)", 404/410 → "Not found (404)", "Unsupported URL" → "Not a video
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`
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
@@ -486,8 +492,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
logged and kept in `pinMismatches`. Measured against the `/m` endpoint
(`readahead_e2e_test`): a paused player buffers ~147 s ahead (10 s before).
media_kit has no public `setProperty`/`getProperty`; the code calls them
through a guarded dynamic call on `player.platform`. Hardware decoding comes from Settings (auto, off, custom)
media_kit has no public `setProperty`/`getProperty`; the host calls them
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`)
overrides it. Auto is mpv `auto` on Linux and `auto-copy` on Windows
(`autoHwdec`, also for a blank custom value): media_kit renders through
@@ -518,8 +524,9 @@ turns shaders off (saved) with a toast.
Render mode: media_kit's Linux/Windows video output creates mpv's render
context (OpenGL, else its software renderer) when the `VideoController` is
created, and nothing in its API says which. The player therefore requests
mpv logs at `v` while the controller is created: OpenGL init logs
created, and nothing in its API says which. The host therefore requests
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`
(`RenderMode.gpu`; "suspected software renderer" means llvmpipe,
`softwareGl`); none within 1.5 s of the controller being ready means the
@@ -534,48 +541,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
start also `mpv-version` and `ffmpeg-version`). Why media_kit fell back to
software it only prints on stderr (`media_kit: VideoOutput: ...`), which
the app does not capture. Otherwise the session player logs at `warn`
(`PlaybackController.logLevel`, switched with `mpv_request_log_messages`
on media_kit's handle): media_kit drains mpv's log on the UI thread, one
message at a time, so a chatty level, or ffmpeg logging every retry of a
refused request, could starve the UI. Shader and yt-dlp hook errors are
`error` lines and still arrive. The thumbnail players keep
media_kit's default (`error`) and nothing listens to their log. The hardware path is
the app does not capture. Otherwise a session player logs at `warn`
(`sessionLogLevel`; the host switches levels with
`mpv_request_log_messages` on media_kit's handle, the only raw mpv call
in the app): media_kit drains mpv's log on the UI thread, one message at
a time, so a chatty level, or ffmpeg logging every retry of a refused
request, could starve the UI. Shader errors are `error` lines and still
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
thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change
can silently fall back to software.
Player lifecycle: a `Player` and its `VideoController` live for one
media: created on join, disposed on leave, replaced by `recreate()` for
"Reload video" (button or Ctrl+R; volume kept), and replaced on every
media change, so nothing a player went through (a stuck video output, a
frozen frame) carries over to the next media. `openUrl` promotes the
preload when it holds the new media (see "Getting ready" under "Up
next"); otherwise, when the player already held media, it creates a fresh
one first, set up as on join (pins, hwdec, yt-dlp hook, shaders, log
level, observers) and, with the view up and the render mode known, its
`VideoController` created and ready before the view swaps to it (else the
view creates it and the render-mode probe runs on it). Both go through
one hand-over (`_adopt`): observers move over, the view swaps to the new
output (`generation`), volume (and so mute) and shaders carry over, and
the old player is paused and disposed once the view let go of its
texture (as on leave). A fresh player waits for the previous old one to
be gone, so at most one old player lingers. The first open after join or
Reload video uses the player just created; joining back to the media the
player held keeps it (`_openedFor`); a dropped connection never touches
the player. Cost (`SwitchTrace`
`player`, headless, so without the `VideoController`): 45 to 65 ms per
swap for one app in the process (mpv created and initialised, then
pinned), about 150 ms with three in one process
(`queue_switch_e2e_test` with `WT_SWITCH_PRELOAD=0`), against a cold
switch of about 1.5 s. `openUrl` opens with `play: false`; 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. `leave()`
stops the player (otherwise audio keeps playing). On media change
(`mediaId` bump) the coordinator stops the previous media at once (no old
picture or sound while the new one resolves, is fetched or opens), then opens the new URL
(or promotes the preload holding it) and `StateDiff` seeks to 0, holds
and resets rate.
**Player host** (`PlayerHost`, `platform/ports/player_host.dart`; the
desktop's `MediaKitHost`, one per app in `Ports.players`, tests make
their own): creates players by role (`session`: the full cache, `warn`;
`preload`: the prepared one, 48 MiB ahead, muted, `error`; `thumbnail`:
no outputs, no cache, no log) with every pin set before the caller's
first command (`pinMismatches` lists the read-backs that differ), gives
a player its video output (`HostedVideo`, with the hwdec policy:
`SYNC_RELAY_HWDEC`, else the setting, `auto-copy` on Windows), runs the
render-mode probe and its diagnostics (`renderMode`, also after leaving),
routes mpv's log, and owns the order players go in: `retire` (pause, let
the view drop the texture: two frames and a route pop, then dispose; a
new session player waits for the last one, so at most one old player
lingers), `dispose` (one no view showed) and `disposeAll` (app close:
every live player, each bounded). A `HostedPlayer` is one mpv: state and
streams (position, duration, buffer, volume, completed, width, log),
open, play/pause/stop, `seek` (the user's, see "User seeks") and
`syncSeek`, rate, volume, properties, commands, `pin` (set and read
back), its log level. Nothing else in the app imports media_kit except
the `Video` widget glue (`session_player.dart`, `videoControllerOf`).
**Media pipeline** (`MediaPipeline`, `session/media_pipeline.dart`, owned
by the coordinator): the one way to open media, plan → resolve → open →
ready.
- Plan (`planMedia`, `session/media_plan.dart`, pure; `media_plan_test`
holds the table): from `state.media`, what this member serves (its
`MediaOrigin`: a file or a link it passes on, a Jellyfin item it put
up; set at its swap, claimed by the state that names it the relay's
source), its own capabilities and who is present, where this app reads
the media from (`SourcePlan`): your server's file (`/m`), a link
everyone opens (`DirectLink`; a page is resolved here), another
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
`playback/media_marks.dart`, no widgets; any controls UI reads them):
@@ -716,7 +796,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
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
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
`http_dash_segments`, e.g. X's HLS, generic `.m3u8`/`.mpd`) is relayed as
@@ -757,11 +837,11 @@ How bytes move:
6.6 s unchunked) and even beats mpv reading YouTube directly, since the
buffer reads ahead at full speed.
- The server keeps one buffer per track of a relayed media (one for a
file), a `RelayBuffer` for bytes: 1 MiB slices in
memory, filled by one sequential pull at a time. Kept: the first and
file), a `RelayBuffer` for bytes: 256 KiB slices in
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),
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
distance from the viewers, never LRU. With 2+ viewers the source sends
each byte about once. Several tracks split the ahead and behind budgets
@@ -789,12 +869,21 @@ How bytes move:
opened in 0.02 s from the buffer.
- Filling a byte buffer: a viewer blocked on a slice first (oldest wait
first, handing the source over at slice boundaries), then the pinned
slices, then read-ahead from the slowest viewer. Nothing is fetched while nobody
watches through the relay (a Jellyfin source with only account holders
uploads nothing).
slices, then read-ahead from the slowest viewer. A viewer reads the
slice being filled as its bytes arrive, not once it is whole (mpv's
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
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
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
@@ -834,20 +923,21 @@ How bytes move:
media_kit's mpv gives up on a silent request after 5 s
(`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
server fetch (`_waitForSource`; "waiting for <name> to pass the video
on…" meanwhile), unless the source already left (then they open what
the server has); Up next preloads wait the same way. The putter starts
server fetch (`relayWaitsForSource` in the media plan; "waiting for
<name> to pass the video on…" meanwhile), unless the source already
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
tracks' sizes at once.
- Source away (D6): viewers keep playing from the buffer; a request
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
(`media.relay.sourceGone`, `ServerHub.refreshRelaySource`). The source
(`RelaySource`, kept by the coordinator through the app's own drop)
reconnects its channel with backoff (its token lasts through the
grace; a half-open channel is caught by its 5 s WebSocket ping) and
sends its `info` again, the same on every dial (the coordinator passes
the media's `name` and `duration`): the same as its first (sizes, piece
(`RelaySource`, kept by the media pipeline's `RelayFeeder` through the
app's own drop) reconnects its channel with backoff (its token lasts
through the grace; a half-open channel is caught by its 5 s WebSocket
ping) and sends its `info` again, the same on every dial (the feeder
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
buffer filler pulling what is missing; another one is closed with
4002 and ends the relay (`ServerHub.relayOtherMedia`); a second channel
@@ -860,6 +950,10 @@ How bytes move:
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
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
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
@@ -942,18 +1036,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
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,
so it uses them too. mpv's hook gets the same as `ytdl-raw-options`
(`cookies=%N%<path>` / `cookies-from-browser=%N%<spec>`, length-quoted),
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
so it uses them too. A player gets them through the app's resolve
(the format's cookies as its Cookie header), never on its own. The dedicated
server: `--yt-dlp-cookies <file>` / `ytDlpCookies` converted into its
folder at start; without it the copy and browser setting are removed.
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
--ignore-config` plus the cookies, 25 s timeout: title, uploader, duration, thumbnail,
live or not, and what a relay reads (`pickTracks`), each format with
its URL, `http_headers` and `http_chunk_size`. Only plain http(s)
--ignore-config -f bv*[height<=1080]+ba/b --write-subs --sub-langs
all,-danmaku` plus the cookies, 25 s timeout: title, uploader, duration,
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:
- the best single file (`pickSingleFile`: video and audio not `none`, a
video container, tallest up to 1080p, else the shortest above), unless
@@ -986,18 +1094,19 @@ all three link modes. Nothing is bundled or installed by hand.
under the button) and offers "Put it up anyway" (`LinkMedia.anyway`),
for when the server or a friend with cookies may still open it;
`swapLink` refuses it the same way for any other caller.
- **Everyone fetches it**: the swap sends the page URL. Before opening an
external link every app points mpv's hook at its managed copy
(`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`,
once per player), waiting for the download unless the URL names a media
file. A hook error is a player error (see the mpv settings under "Media"). The hook (mpv
0.41) gives mpv the first requested format's `http_headers` (User-Agent
as `user-agent`; Referer, Cookie, X-Forwarded-For as file-local
`http-header-fields`, which win over the empty list media_kit sets on
load) and its `cookies` (`stream-lavf-o`), for every track of its EDL:
Bilibili's CDN answers 403 without the Referer. The app never opens a
resolved format URL in mpv itself (the putter of "Through me" plays the
page through the hook too); relays read them with yt-dlp's headers.
- **Everyone fetches it**: the swap sends the page URL. Every app
resolves it with its own yt-dlp (the media pipeline's resolve step,
`LinkResolver.probe`, waiting for the download on first use) and opens
what yt-dlp picked (`YtDlpPlay.mpvUrl`) with its headers as mpv's
`http-header-fields` (media_kit's `Media.httpHeaders`), for every
track of the EDL: Bilibili's CDN answers 403 without the Referer. Its
subtitles are added once the media is in (`sub-add <url> auto`). mpv's
own Lua yt-dlp hook is off (`ytdl=no`): one resolve path for every mode,
cookies and headers always the app's, and a libmpv without Lua plays
links the same. A resolve that fails is a player error in plain words,
with the cookies it used. Stream URLs are bound to the resolving
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
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
@@ -1174,8 +1283,9 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
of a link this app puts up (its swap reads it). Per
kind: a Jellyfin item is resolved with this app's account
(`resolveJellyfinRef`: PlaybackInfo and the item) into
`SessionCoordinator.warmJellyfin`, which `_playJellyfin` takes instead
of resolving when the same ref goes up within 35 minutes and the
`SessionCoordinator.warmJellyfin`, which the media pipeline's resolve
(`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
when `state.relayNext` says the server's login passes it on (see
"Jellyfin relay"). A link everyone opens is that link; the app that
@@ -1184,35 +1294,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
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
(`QueueController._feedWarm`, `SessionCoordinator.feedWarm`; a
(`QueueController._feedWarm`, `RelayFeeder.feedWarm`; a
Jellyfin item's from the member who puts it up, with its resolved
stream, when the server asks for one); everyone else, and everyone
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
re-reading the link's sizes (its warm source has them) and that
source carries on as the media's. It
is resolved as soon as it is first there, again every 30 minutes, and
dropped when it stops being first.
**Preload**: once resolved, the stream is opened in a second, hidden
mpv player (`PlaybackController.preload`, through
`SessionCoordinator.preloadMedia`): paused at the start, muted, with
its own video output when the session view is up (media_kit starts a
player with `vid=no`; the output decodes the first frame), the same
pins, hwdec and yt-dlp hook as the session player but a 48 MiB forward
and 8 MiB back cache (`preloadBytes`), mpv log at `error`. One at a
time; it waits until the media playing has started here
(`SessionCoordinator.settledMediaId`: opened, failed or nothing to
**Preload**: once resolved, the stream is opened early by the media
pipeline (`MediaPipeline.prepare`, a page through the same resolve as
any open) in a second, hidden player (the host's `preload` role):
paused at the start, muted, with its own video output when the
session view is up (media_kit starts a player with `vid=no`; the
output decodes the first frame), the same pins and hwdec as the
session player but a 48 MiB forward and 8 MiB back cache
(`preloadAheadBytes`), mpv log at `error`. One at a time; it waits
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
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
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
changes nothing (the usual open follows). When the coordinator opens
the same URL and headers (`openUrl`), the preload is promoted instead
of a fresh player: it becomes the session player through the same
hand-over (see "Player lifecycle" under "Media"), unmuted and with the
full cache pins. Its buffer comes along,
changes nothing (the usual open follows). When the pipeline opens the
same URL and headers, the preload is promoted instead of a fresh
player: it becomes the session player through the same hand-over (see
"Media pipeline" under "Media"), unmuted and with the full cache pins
(`PlayerHost.promote`), its subtitles added. Its buffer comes along,
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
longer the first mpv of the process, so ffmpeg's HTTP status reaches
@@ -1220,15 +1331,15 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
errors themselves are still detected; see "Media"). Off with env
`SYNC_RELAY_PRELOAD=0`. Measured (`queue_switch_e2e_test`, a fake
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
86 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.
Relayed, from the warm relay (same setup, loopback server): a link
passed on 21 to 22 ms, a server-fetched link 35 to 37 ms, against
account holders and a `/j` guest): ⏭ to the server playing 74 to
87 ms for the next episode and 35 ms for a direct link, against about
1.6 to 1.8 s cold; at 300 ms and 12 Mbit/s 52 and 103 ms against about
2.9 s. Relayed, from the warm relay (same setup, loopback server): a
link passed on 36 to 38 ms, a server-fetched link 46 to 53 ms, against
about 1.6 s cold.
`SwitchTrace` (env `SYNC_RELAY_TRACE_SWITCH=1` prints it) times each
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.
The row of the item shows a dot on its picture's corner (a tooltip
says which): hollow waiting, pulsing getting ready (resolving, then
@@ -1369,8 +1480,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
pressing first: reconnecting with Cancel, unstable, refused or replaced
with Retry, connecting, your player's fault or error with Reload, the
server getting the video, the relay's source dropped / left / not
passing it on yet, finished, someone loading, someone who cannot play
server getting the video, everyone held for the relay's source
("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
everyone is fine, and only connection lines while you sit out), each
with a `StatusLevel` (ok and info never colour anything, warn amber,
@@ -1466,8 +1579,12 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
- `FolderAccess` (`DesktopFolders`): folder and file dialogs
(file_picker), the Files scan, folder exists, file stat, showing a
folder in the file manager. Paths for now.
- `LinkResolver` (`AppYtDlp`): the app's yt-dlp, link probes, mpv's raw
options, warm-up.
- `LinkResolver` (`AppYtDlp`): the app's yt-dlp, link probes (what a
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`,
`platform/desktop/desktop_notifications.dart`): `show(title, body, tag,
payload)` and `onClick(payload)`, through flutter_local_notifications'
@@ -1481,8 +1598,7 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
(`AppDirs`, "Desktop UI"), and the copy of the data from before the
rename; resolved in `main` before the composition root exists, so not a
`Ports` field.
Still direct: the player (`PlaybackController`, media_kit, and its
`AppYtDlp.instance.mpvRawOptions`), the updater's restart
Still direct: the updater's restart
(`Process.start`), the session's own file reads for what it plays or
relays (`QueueController`, thumbnails, cookies import), app-private
caches.
@@ -2016,8 +2132,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
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).
- Volume: mpv's `volume-max` is pinned to `PlaybackController.maxVolume`
(150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the
- Volume: mpv's `volume-max` is pinned to 150 per player by the host
(`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`),
clamped. The wheel works anywhere on the full player (`VolumeWheel`
around the player's stack): `WheelSteps` makes a mouse notch one step
@@ -2040,10 +2157,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
(a live one calls its wakeup callback into a dead isolate): the session
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
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
dispose returns, and an output still up when the engine stops blocks the
plugin's teardown (`~VideoOutput` waits on a texture callback that never
+45 -14
View File
@@ -78,6 +78,8 @@ and `e2e_check` (simulated viewers against a live server).
Three suites, run from each package's folder:
- `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
file), the `PresencePolicy` table, queue model, the library's L1
(release and folder names, `groupLocal` over a real anime folder's
@@ -95,7 +97,8 @@ Three suites, run from each package's folder:
controls, settings, schedule), pure app logic (`StateDiff`, the library's L2 matching against a
fake AniList (`show_matcher_test`), `media.ref` producers,
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`)), `singletons_test` (the `.instance` count, see
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;
run it only before a restructure step lands, otherwise only the files
@@ -108,21 +111,34 @@ else is offline: Jellyfin and link hosts are local fakes.
Real-mpv tests: `sync_playback_e2e`, `coord_presence` (drops through a
`FlakyProxy`: a half-open link and return, 10 flaps, `replaced`, `bye`,
a dead or restarted server), `video_reload_e2e`,
a dead or restarted server), `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`,
`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
source, a source that drops and resumes), `jellyfin_e2e`,
`queue_switch_e2e` (times Up next switches against a delayed, rate-capped
fake Jellyfin and link host: an episode, a direct link, a link passed on
and a server-fetched one; `WT_SWITCH_PRELOAD=0` for the cold path,
source, a source that drops and resumes; a seek past what a slow source
has sent, timed; a source that drops while a viewer seeks past
the buffer and another joins: no seek loop, "Waiting for", all resume
(D6)), `jellyfin_e2e`, `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,
reload and end-of-file ones expect `~/Videos/chiyo.mp4`; the others
generate media with ffmpeg into `WT_TEST_TMP` (default: system temp; all
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
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
quietly (`WT_SFX_VOLUME`, default 0.1).
@@ -163,9 +179,9 @@ in [ROADMAP.md](ROADMAP.md)):
| 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`) | |
| 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) |
| 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) |
| 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`) |
| 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) |
| 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` (prepared and promoted through the media pipeline) | step 6: the countdown as server state (`state.advance`) |
| 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
behaviour that step of the restructure ([ROADMAP.md](ROADMAP.md)) changes;
@@ -174,7 +190,20 @@ before.
CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus
`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:
- Never pump media_kit's `Video` in `flutter test`: the runner hangs (kill
@@ -302,8 +331,10 @@ is off), not software rendering.
- `web_socket_channel` 3.x: `channel.ready` errors are unhandled on refused
connections; swallow them and report through the stream's `onError`.
Streams are single-subscription: one listener per socket.
- media_kit has no public `Player.setProperty`; the code reaches mpv through a
dynamic call on `player.platform`. Cache size goes through
- media_kit has no public `Player.setProperty`; the player host
(`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:)`.
- Hot restart with any media_kit `Player` alive (session, thumbnails)
used to SIGSEGV on mpv's `core` thread: each mpv handle's wakeup callback
+29 -24
View File
@@ -35,19 +35,9 @@ Problems are numbered; delete a line when its fix ships.
- D11 Login-only sites: error texts say which cookies were used.
**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).
- 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
check; Windows hwdec `auto-copy`): a Windows user must confirm shaders
stay on and the EGL-surface error is gone.
@@ -56,8 +46,11 @@ Problems are numbered; delete a line when its fix ships.
`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:
orderly close, Linux `_exit` (0 crashes in 41 closes vs 10/10 before),
Windows normal close + TerminateProcess + watchdogs, untested on
Windows; if it still hangs: Event Viewer 1000/1002, a Task Manager dump,
Windows normal close + TerminateProcess + watchdogs; the player host
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.
- 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.
@@ -68,12 +61,6 @@ the automatic continuation and countdown, relay state), the device
(client id, library and folders, match corrections, watch history,
settings, notification prefs, what this app is sourcing) and the
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
@@ -85,7 +72,7 @@ Composition root (in place, ARCHITECTURE "Composition root"): the
remaining `.instance` singletons move into `AppGraph` as the steps touch
them; `singletons_test` holds the count (today: AppSettings 55,
JellyfinAccounts 10, UpdateController 8, Profile 8, FolderStore 6,
AppYtDlp 4, RecentLinks 3).
AppYtDlp 2, RecentLinks 3).
Not in the restructure: P25 (ops).
@@ -126,6 +113,24 @@ your server leaves them there without you, unwarned; a join refused
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
alone.
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).
Left of step 4 (library): a human check of the Files library on the
real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders
with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the
@@ -186,7 +191,8 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
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
libmpv keeps Lua/ytdl_hook (link mode "everyone fetches it"), offline
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`.
@@ -207,10 +213,9 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
step 6.
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.
bundled Windows libmpv plays the app's `edl://` URLs (video + audio
with headers) through media_kit. YouTube: yt-dlp wants a JS runtime
(deno) for full extraction.
- Live streams through the relay (today refused: the sync model needs a
fixed duration and seeking).
- `tool/relay_e2e.dart`: a two-track case (bytes and pieces) over a
+1 -1
View File
@@ -9,7 +9,7 @@ abstract class RelayConstants {
static const int protoMax = 16;
// 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;
/// 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
+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]!};
}
/// [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
/// cookies for.
const suggestedCookieSites = {'bilibili.com', 'youtube.com'};
@@ -233,17 +272,6 @@ class YtDlpCookies {
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.
Future<CookieFile> saveFile(List<NetscapeCookie> cookies) async {
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',
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
/// 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).
@@ -131,6 +147,10 @@ class LinkProbe {
final String? pageTitle;
/// Why yt-dlp could not read the page.
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.
final int status;
const LinkProbe.file(this.url, this.size)
@@ -138,8 +158,9 @@ class LinkProbe {
info = null,
pageTitle = null,
ytDlpError = null,
cookies = null,
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,
size = -1;
@@ -152,7 +173,7 @@ class LinkProbe {
// A 4xx says more than yt-dlp's words it has no plain text for.
final known = err != null && _plainTexts.contains(err);
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
@@ -219,12 +240,13 @@ Future<LinkProbe> _probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async
} catch (e) {
getError = e;
}
String? ytErr;
String? ytErr, cookies;
if (ytDlp != null) {
try {
return LinkProbe.page(url, info: await ytDlp.resolve(url.toString()), status: status);
} catch (e) {
ytErr = '$e';
if (e is YtDlpException) cookies = e.cookies;
}
}
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)));
} 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 {
if (client == null) c.close(force: true);
}
+173 -4
View File
@@ -8,7 +8,9 @@ import 'package:crypto/crypto.dart';
import 'package:path/path.dart' as p;
import 'cookies.dart';
import 'link_probe.dart' show mayNeedSignIn;
import 'logging.dart';
import 'relay.dart' show mpvEdl;
// 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
@@ -19,7 +21,11 @@ final _log = createLogger('yt-dlp');
class YtDlpException implements Exception {
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
String toString() => message;
}
@@ -121,6 +127,10 @@ class YtDlpInfo {
/// Every relayable format by id, to find [tracks] again after a
/// re-resolve.
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({
required this.title,
this.uploader = '',
@@ -131,6 +141,7 @@ class YtDlpInfo {
this.isLive = false,
this.tracks = const [],
this.formats = const {},
this.play,
});
/// 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,
thumbnail: str('thumbnail').isEmpty ? null : str('thumbnail'),
isLive: isLive,
play: YtDlpPlay.parse(j),
tracks: isLive ? const [] : pickTracks(formats),
formats: {
for (final f in formats)
@@ -341,6 +353,100 @@ Map<String, String> parseSha256Sums(String text) => {
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
/// (`ERROR: [twitter] 123: No video` -> `No video`), else the last line.
String ytDlpErrorText(String stderr) {
@@ -602,7 +708,11 @@ class YtDlp {
final e = await err;
_log.warning('resolve ${logUrl(url)}: exit $code after ${sw.elapsedMilliseconds} ms; stderr tail:\n'
'${_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 {
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.
static String _argsForLog(List<String> args) {
final out = <String>[];
@@ -640,8 +803,14 @@ class YtDlp {
}
/// The command line [resolve] runs.
List<String> resolveArgs(String url) =>
['-J', '--no-playlist', '--no-warnings', '--ignore-config', ...cookies.args(), '--', url];
List<String> resolveArgs(String 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();
}
+73 -6
View File
@@ -30,26 +30,93 @@ void main() {
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');
addTearDown(() => dir.delete(recursive: true));
final src = File('${dir.path}/export.json')..writeAsStringSync(_json);
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'});
expect([kept.count, ...kept.sites], [1, 'bilibili.com']);
final path = y.cookies.filePath;
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '--cookies', path, '--', 'U']);
expect(y.cookies.mpvRawOptions(), 'cookies=%${path.length}%$path');
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '-f', playFormat, '--write-subs', '--sub-langs', playSubLangs, '--cookies', path, '--', 'U']);
if (!Platform.isWindows) expect(File(path).statSync().modeString(), 'rw-------');
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(y.cookies.mpvRawOptions(), 'cookies-from-browser=%12%firefox:work');
await y.cookies.remove();
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);
});
}
+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']), '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');
});
}
+87 -24
View File
@@ -112,14 +112,41 @@ class ChannelRelayFeed extends RelayFeed {
class _Pull {
final int id, start, end;
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;
/// 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 {
int pos = 0;
int open = 0; // requests in flight
int newest = 0; // sequence of its newest request
(int slice, int req)? blocking; // where its newest request waits
DateTime lastActive = DateTime.now();
}
@@ -134,9 +161,11 @@ class _Viewer {
/// slowest and [ahead] past the furthest viewer. Evicted by distance from
/// the viewers, never LRU.
/// - 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
/// 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.
class RelayBuffer implements TrackBuffer {
/// 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 _waiters = <int, List<Completer<void>>>{};
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>{};
@override
FeedClock clock = FeedClock();
@@ -256,21 +287,22 @@ class RelayBuffer implements TrackBuffer {
_endPull(pl);
return;
}
pl.buf.add(Uint8List.sublistView(data, off, off + take));
pl.parts.add(Uint8List.sublistView(data, off, off + take));
pl.filled += take;
off += take;
pl.pos += take;
if (pl.pos == _sliceEnd(s) + 1) {
_slices[s] = pl.buf.takeBytes();
final done = pl.pos == _sliceEnd(s) + 1;
if (done) _slices[s] = pl.takeSlice();
// Whole, or more of it: whoever waits on it reads on.
for (final c in _waiters.remove(s) ?? const <Completer<void>>[]) {
c.complete();
}
if (_shouldStop(pl, s)) {
if (done && _shouldStop(pl, s)) {
_endPull(pl);
return;
}
}
}
}
@override
void feedEnd(int id) {
@@ -405,25 +437,54 @@ class RelayBuffer implements TrackBuffer {
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);
while (!_slices.containsKey(s)) {
if (_disposed || DateTime.now().isAfter(deadline)) return false;
if (drives) {
try {
while (true) {
final whole = _slices[s];
if (whole != null) return from == 0 ? whole : Uint8List.sublistView(whole, from);
final pl = _pull;
if (pl != null && pl.pos ~/ slice == s && pl.start <= s * slice) {
final part = pl.partFrom(from);
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 pl = _pull;
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: () {});
}
} finally {
_unblock(s, req);
}
}
// [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);
return true;
_wake();
}
}
// ---------------------------------------------------------------- viewers
@@ -444,6 +505,10 @@ class RelayBuffer implements TrackBuffer {
final tail = start >= size - pin;
final me = ++_reqSeq;
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.pos = start;
}
@@ -454,16 +519,14 @@ class RelayBuffer implements TrackBuffer {
var off = start;
while (off <= end) {
final s = off ~/ slice;
if (!await _waitSlice(s, tail || v.newest == me)) {
throw StateError('relay: bytes at $off did not arrive');
}
final data = _slices[s];
if (data == null) continue; // evicted between wake and read
final from = off - s * slice, to = min(data.length, end - s * slice + 1);
yield Uint8List.sublistView(data, from, to);
bytesToViewers += to - from;
onSent?.call(to - from);
off += to - from;
final data = await _waitBytes(v, s, off - s * slice, me, () => tail || v.newest == me);
if (data == null) throw StateError('relay: bytes at $off did not arrive');
final n = min(data.length, end - off + 1);
if (n <= 0) continue;
yield n == data.length ? data : Uint8List.sublistView(data, 0, n);
bytesToViewers += n;
onSent?.call(n);
off += n;
v.lastActive = DateTime.now();
if (!tail && v.newest == me) v.pos = off;
_wake();
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# The close smoke run of tool/smoke_windows.ps1 on Linux (the same app
# mode, apps/desktop/lib/src/platform/smoke.dart): start the release
# bundle with a short clip, open it, play, close; check it exited within
# LIMIT seconds and left no process. Needs a display (skips without one).
# Usage (after `flutter build linux --release`): tool/smoke_linux.sh [LIMIT]
set -euo pipefail
LIMIT="${1:-45}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
EXE="$ROOT/apps/desktop/build/linux/x64/release/bundle/starsync"
[[ -x "$EXE" ]] || { echo "build first: $EXE missing" >&2; exit 1; }
if [[ -z "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]]; then echo "skipped: no display"; exit 0; fi
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
MEDIA="$WORK/smoke.mp4"
ffmpeg -y -v error -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440 \
-t 5 -c:v libx264 -preset ultrafast -pix_fmt yuv420p -c:a aac "$MEDIA"
OUT="$WORK/smoke.txt"
start=$(date +%s)
STARSYNC_PROFILE=smoke STARSYNC_SMOKE="$MEDIA" STARSYNC_SMOKE_OUT="$OUT" "$EXE" >"$WORK/stdout.txt" 2>&1 &
pid=$!
exited=0
for _ in $(seq $((LIMIT * 10))); do
if ! kill -0 "$pid" 2>/dev/null; then exited=1; break; fi
sleep 0.1
done
secs=$(( $(date +%s) - start ))
steps="$(tr '\n' '|' < "$OUT" 2>/dev/null || true)"
echo "smoke steps: $steps (after $secs s)"
grep -E 'smoke:|close:|mpv: render' "$WORK/stdout.txt" | tail -20 || true
if [[ $exited == 0 ]]; then kill -9 "$pid" 2>/dev/null || true; fi
left="$(pgrep -f "$EXE" || true)"
[[ -n "$left" ]] && kill -9 $left 2>/dev/null || true
grep -qx start "$OUT" 2>/dev/null || { [[ $exited == 1 ]] && { echo "skipped: the app never came up"; exit 0; }; echo "did not come up nor exit" >&2; exit 1; }
grep -q '^skip ' "$OUT" && { echo "skipped: $(grep '^skip ' "$OUT")"; exit 0; }
grep -q '^fail ' "$OUT" && { echo "smoke failed: $(grep '^fail ' "$OUT")" >&2; exit 1; }
grep -q '^opened ' "$OUT" || { echo "the media never opened" >&2; exit 1; }
[[ $exited == 1 ]] || { echo "the app did not close within $LIMIT s (P32)" >&2; exit 1; }
[[ -z "$left" ]] || { echo "a starsync process was left behind (P32)" >&2; exit 1; }
echo "smoke ok: opened and closed in $secs s, no process left"
+70
View File
@@ -0,0 +1,70 @@
# Close smoke run (P32) on the built Windows app: start it with a short
# media file, let it open the file in a player with a video output, play a
# second and close the normal way (`apps/desktop/lib/src/platform/smoke.dart`),
# then check it exited within $Limit seconds and left no process behind.
# A runner that cannot bring up a window or a video output skips (warning).
# Usage (after a release build): powershell -File tool/smoke_windows.ps1 [-Limit 45]
param([int]$Limit = 45)
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $PSScriptRoot
$Exe = Join-Path $Root 'apps/desktop/build/windows/x64/runner/Release/starsync.exe'
if (-not (Test-Path $Exe)) { throw "build first: $Exe missing" }
$Exe = (Resolve-Path $Exe).Path
$Work = Join-Path ([IO.Path]::GetTempPath()) "starsync-smoke-$PID"
New-Item -ItemType Directory -Force $Work | Out-Null
$Out = Join-Path $Work 'smoke.txt'
# Media: a 5 s clip with ffmpeg when the runner has it, else a 5 s WAV
# written here (mpv opens either; headless mpv needs an audio track).
$Media = Join-Path $Work 'smoke.mp4'
$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
if ($ffmpeg) {
& $ffmpeg.Source -y -v error -f lavfi -i 'testsrc2=size=320x180:rate=24' -f lavfi -i 'sine=frequency=440' `
-t 5 -c:v libx264 -preset ultrafast -pix_fmt yuv420p -c:a aac $Media
}
if (-not $ffmpeg -or $LASTEXITCODE -or -not (Test-Path $Media)) {
$Media = Join-Path $Work 'smoke.wav'
$rate = 48000; $n = $rate * 5
$data = New-Object byte[] ($n * 2)
for ($i = 0; $i -lt $n; $i++) {
$v = [int16](3000 * [Math]::Sin(2 * [Math]::PI * 440 * $i / $rate))
$data[2 * $i] = [byte]($v -band 0xFF); $data[2 * $i + 1] = [byte](($v -shr 8) -band 0xFF)
}
$f = [IO.File]::Create($Media); $w = New-Object IO.BinaryWriter($f)
$w.Write([Text.Encoding]::ASCII.GetBytes('RIFF')); $w.Write([int](36 + $data.Length))
$w.Write([Text.Encoding]::ASCII.GetBytes('WAVEfmt ')); $w.Write([int]16); $w.Write([int16]1); $w.Write([int16]1)
$w.Write([int]$rate); $w.Write([int]($rate * 2)); $w.Write([int16]2); $w.Write([int16]16)
$w.Write([Text.Encoding]::ASCII.GetBytes('data')); $w.Write([int]$data.Length); $w.Write($data); $w.Close()
}
# Its own profile: no settings, sign-ins or client id of a real install.
$env:STARSYNC_PROFILE = 'smoke'
$env:STARSYNC_SMOKE = $Media
$env:STARSYNC_SMOKE_OUT = $Out
$clock = [Diagnostics.Stopwatch]::StartNew()
$p = Start-Process -FilePath $Exe -PassThru
$exited = $p.WaitForExit($Limit * 1000)
$secs = [int]$clock.Elapsed.TotalSeconds
$lines = if (Test-Path $Out) { Get-Content $Out } else { @() }
Write-Host "smoke steps: $($lines -join ' | ') (after $secs s)"
$logs = Join-Path $env:APPDATA 'foxyz\Starsync\profiles\smoke\logs'
if (Test-Path $logs) {
Get-ChildItem $logs -Filter 'app-*.log' | Sort-Object LastWriteTime | Select-Object -Last 1 |
ForEach-Object { Select-String -Path $_.FullName -Pattern 'smoke:|close:|mpv: render' | ForEach-Object { $_.Line } }
}
$left = Get-Process -Name starsync -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $Exe }
if (-not $exited) { $p | Stop-Process -Force -ErrorAction SilentlyContinue }
$left | Stop-Process -Force -ErrorAction SilentlyContinue
if (-not ($lines -contains 'start')) {
if ($exited) { Write-Warning "skipped: the app never came up on this runner (exit $($p.ExitCode))"; exit 0 }
Write-Error "the app did not come up and did not exit within $Limit s"; exit 1
}
if ($lines | Where-Object { $_ -like 'skip *' }) { Write-Warning "skipped: $($lines -like 'skip *')"; exit 0 }
if ($lines | Where-Object { $_ -like 'fail *' }) { Write-Error "smoke failed: $($lines -like 'fail *')"; exit 1 }
if (-not ($lines | Where-Object { $_ -like 'opened *' })) { Write-Error 'the media never opened'; exit 1 }
if (-not $exited) { Write-Error "the app did not close within $Limit s (P32)"; exit 1 }
if ($left) { Write-Error "a starsync process was left behind (P32)"; exit 1 }
Write-Host "smoke ok: opened and closed in $secs s, no process left"
Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue