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:
1 parent
8970f2694b
commit
25b19f2d6a
47 files changed
+4066
-1711
No files matched your search
+10
-1
@@ -41,7 +41,8 @@ jobs:
|
|||||||
working-directory: packages/server
|
working-directory: packages/server
|
||||||
|
|
||||||
# The updater's Windows apply path (running exe parked as .old) only
|
# The updater's Windows apply path (running exe parked as .old) only
|
||||||
# means something on real NTFS. Runner notes: see release.yml.
|
# means something on real NTFS, and the close smoke run needs the real
|
||||||
|
# build. Runner notes: see release.yml.
|
||||||
updater-windows:
|
updater-windows:
|
||||||
# Not for a "Release v…" commit: release.yml builds it on the same
|
# Not for a "Release v…" commit: release.yml builds it on the same
|
||||||
# (only) Windows runner, which this job would hold up.
|
# (only) Windows runner, which this job would hold up.
|
||||||
@@ -71,3 +72,11 @@ jobs:
|
|||||||
- name: Windows build
|
- name: Windows build
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1
|
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1
|
||||||
|
# The close smoke run on that build (P32): start, open a short clip in
|
||||||
|
# a player with a video output, close; it must exit within 45 s and
|
||||||
|
# leave no process. A runner that cannot show a window skips with a
|
||||||
|
# warning. Here rather than in a job of its own: one build, one runner.
|
||||||
|
- name: Smoke (start, open, close)
|
||||||
|
shell: powershell
|
||||||
|
timeout-minutes: 3
|
||||||
|
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/smoke_windows.ps1 -Limit 45
|
||||||
@@ -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
|
computer and are used by its yt-dlp only (your own playback, "through
|
||||||
me", and a server you host). Cookies log yt-dlp in as you on those
|
me", and a server you host). Cookies log yt-dlp in as you on those
|
||||||
sites: keep the file private, and for YouTube consider a throwaway
|
sites: keep the file private, and for YouTube consider a throwaway
|
||||||
account (Google may flag accounts used with yt-dlp).
|
account (Google may flag accounts used with yt-dlp). When a page shows
|
||||||
|
no video, the message says which cookies were used: none, your file
|
||||||
|
(and whether it has any for that site), or your browser's (and how many
|
||||||
|
it has for that site), so you know whether to sign in there or add
|
||||||
|
cookies.
|
||||||
YouTube works in all three too, and so do pages and links that only
|
YouTube works in all three too, and so do pages and links that only
|
||||||
offer streaming playlists (HLS `.m3u8`, DASH `.mpd`, the HLS of X videos).
|
offer streaming playlists (HLS `.m3u8`, DASH `.mpd`, the HLS of X videos).
|
||||||
Through me and Server fetches it never show the link to anyone; they can't pass on live
|
Through me and Server fetches it never show the link to anyone; they can't pass on live
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'src/app/app_graph.dart';
|
import 'src/app/app_graph.dart';
|
||||||
|
import 'src/files/thumbnails.dart' show thumbnailPlayers;
|
||||||
import 'src/platform/app_log.dart';
|
import 'src/platform/app_log.dart';
|
||||||
import 'src/platform/app_shutdown.dart' show closeAppWith;
|
import 'src/platform/app_shutdown.dart' show closeAppWith;
|
||||||
import 'src/platform/desktop/desktop_app_dirs.dart';
|
import 'src/platform/desktop/desktop_app_dirs.dart';
|
||||||
import 'src/platform/desktop_entry.dart';
|
import 'src/platform/desktop_entry.dart';
|
||||||
|
import 'src/platform/smoke.dart';
|
||||||
import 'src/screens/home/ui.dart';
|
import 'src/screens/home/ui.dart';
|
||||||
import 'src/session/app_events.dart' show UpdateAvailable;
|
import 'src/session/app_events.dart' show UpdateAvailable;
|
||||||
import 'src/state/settings.dart';
|
import 'src/state/settings.dart';
|
||||||
@@ -67,7 +68,8 @@ Future<void> main(List<String> args) async {
|
|||||||
}
|
}
|
||||||
// The library's matches, pins and offsets (device-owned).
|
// The library's matches, pins and offsets (device-owned).
|
||||||
unawaited(graph.library.ensureLoaded());
|
unawaited(graph.library.ensureLoaded());
|
||||||
MediaKit.ensureInitialized();
|
ports.players.init();
|
||||||
|
thumbnailPlayers = ports.players;
|
||||||
// yt-dlp for web page links: downloaded on first run, kept current.
|
// yt-dlp for web page links: downloaded on first run, kept current.
|
||||||
ports.links.warmUp();
|
ports.links.warmUp();
|
||||||
// Linux: the launcher entry and icon Wayland needs (release installs).
|
// Linux: the launcher entry and icon Wayland needs (release installs).
|
||||||
@@ -82,6 +84,11 @@ Future<void> main(List<String> args) async {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
runApp(AppScope(graph: graph, child: const WatchTogetherApp()));
|
runApp(AppScope(graph: graph, child: const WatchTogetherApp()));
|
||||||
|
// CI's close smoke run: open a file, play, close (see smoke.dart).
|
||||||
|
final smoke = smokeMedia;
|
||||||
|
if (smoke != null) {
|
||||||
|
unawaited(WidgetsBinding.instance.endOfFrame.then((_) => runSmoke(graph, smoke)));
|
||||||
|
}
|
||||||
// Home: your own server, quietly (only this app can reach it until
|
// Home: your own server, quietly (only this app can reach it until
|
||||||
// Invite).
|
// Invite).
|
||||||
unawaited(graph.navigation.start());
|
unawaited(graph.navigation.start());
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ class AppGraph {
|
|||||||
prefs: () => AppSettings.instance.notifyPrefs,
|
prefs: () => AppSettings.instance.notifyPrefs,
|
||||||
);
|
);
|
||||||
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
|
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
|
||||||
final coordinator =
|
final coordinator = SessionCoordinator(
|
||||||
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin);
|
notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin, players: p.players);
|
||||||
// Device-owned: the library box; AnimeFlow's runs when signed in.
|
// Device-owned: the library box; AnimeFlow's runs when signed in.
|
||||||
final matcher = library ??
|
final matcher = library ??
|
||||||
ShowMatcher(
|
ShowMatcher(
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ class PlayerControlsModel {
|
|||||||
|
|
||||||
/// A seek bar's press or drag; through `player.seek`, the user-seek
|
/// A seek bar's press or drag; through `player.seek`, the user-seek
|
||||||
/// path, so everyone follows.
|
/// path, so everyone follows.
|
||||||
void seek(Duration to) => playback.player.seek(to);
|
void seek(Duration to) => playback.userSeek(to);
|
||||||
|
|
||||||
/// Arrow keys: ±[secs], relative to a seek still in flight.
|
/// Arrow keys: ±[secs], relative to a seek still in flight.
|
||||||
void nudge(double secs) => coord.nudge(secs);
|
void nudge(double secs) => coord.nudge(secs);
|
||||||
@@ -157,7 +157,7 @@ class PlayerControlsModel {
|
|||||||
|
|
||||||
void playNextEpisode() => coord.playNextEpisode();
|
void playNextEpisode() => coord.playNextEpisode();
|
||||||
|
|
||||||
void setVolume(double v) => playback.player.setVolume(v.clamp(0.0, vol.maxVolume));
|
void setVolume(double v) => playback.setVolume(v.clamp(0.0, vol.maxVolume));
|
||||||
|
|
||||||
/// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓;
|
/// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓;
|
||||||
/// flashes the volume readout.
|
/// flashes the volume readout.
|
||||||
|
|||||||
@@ -222,7 +222,9 @@ class ShaderPresetSetting extends StatelessWidget {
|
|||||||
final preset = s.shaderPreset.value;
|
final preset = s.shaderPreset.value;
|
||||||
final quality = s.shaderQuality.value;
|
final quality = s.shaderQuality.value;
|
||||||
final hasCustom = s.customShaders.value.isNotEmpty;
|
final hasCustom = s.customShaders.value.isNotEmpty;
|
||||||
final mode = (renderMode ?? PlaybackController.lastRenderMode).value;
|
final mode = (renderMode ??
|
||||||
|
context.getInheritedWidgetOfExactType<AppScope>()?.graph.ports.players.renderMode)
|
||||||
|
?.value;
|
||||||
final warning = switch (mode) {
|
final warning = switch (mode) {
|
||||||
RenderMode.software => 'Software rendering: shaders have no effect',
|
RenderMode.software => 'Software rendering: shaders have no effect',
|
||||||
RenderMode.softwareGl => 'OpenGL on the CPU: shaders will be slow',
|
RenderMode.softwareGl => 'OpenGL on the CPU: shaders will be slow',
|
||||||
@@ -267,10 +269,10 @@ class ShaderPresetSetting extends StatelessWidget {
|
|||||||
/// on the fly). `SYNC_RELAY_HWDEC` wins, as at player start.
|
/// on the fly). `SYNC_RELAY_HWDEC` wins, as at player start.
|
||||||
Future<void> applyHwdecLive(PlaybackController playback) async {
|
Future<void> applyHwdecLive(PlaybackController playback) async {
|
||||||
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
|
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
|
||||||
if (!playback.alive.peek()) return;
|
final p = playback.current;
|
||||||
|
if (p == null) return;
|
||||||
try {
|
try {
|
||||||
final dynamic platform = playback.player.platform;
|
await p.setProperty('hwdec', AppSettings.instance.hwdec.peek());
|
||||||
await platform?.setProperty('hwdec', AppSettings.instance.hwdec.peek());
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('live hwdec failed: $e');
|
debugPrint('live hwdec failed: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../platform/desktop/media_kit_host.dart' show MediaKitHost;
|
||||||
|
import '../platform/ports/player_host.dart';
|
||||||
|
|
||||||
// Poster frames for the Files source, made by mpv itself (no ffmpeg on
|
// Poster frames for the Files source, made by mpv itself (no ffmpeg on
|
||||||
// users' machines; Windows ships only libmpv). A hidden player per file
|
// users' machines; Windows ships only libmpv). A hidden player per file
|
||||||
// with `vo=null`: mpv still keeps the last decoded frame, so
|
// with `vo=null`: mpv still keeps the last decoded frame, so
|
||||||
@@ -23,6 +25,9 @@ class Thumbnail {
|
|||||||
|
|
||||||
const thumbnailWidth = 480;
|
const thumbnailWidth = 480;
|
||||||
|
|
||||||
|
/// The players thumbnails are made with (the app's host, set in `main`).
|
||||||
|
PlayerHost thumbnailPlayers = MediaKitHost()..init();
|
||||||
|
|
||||||
/// Where thumbnails are cached; swapped in tests.
|
/// Where thumbnails are cached; swapped in tests.
|
||||||
Future<Directory> Function() thumbnailCacheRoot = getApplicationCacheDirectory;
|
Future<Directory> Function() thumbnailCacheRoot = getApplicationCacheDirectory;
|
||||||
|
|
||||||
@@ -33,7 +38,7 @@ const _timeout = Duration(seconds: 20);
|
|||||||
int _running = 0;
|
int _running = 0;
|
||||||
final _waiting = <Completer<void>>[];
|
final _waiting = <Completer<void>>[];
|
||||||
final _inFlight = <String, Future<Thumbnail?>>{};
|
final _inFlight = <String, Future<Thumbnail?>>{};
|
||||||
final _players = <Player>{};
|
final _players = <HostedPlayer>{};
|
||||||
bool _closed = false;
|
bool _closed = false;
|
||||||
|
|
||||||
/// App close: no new thumbnails, running ones stop at their next check
|
/// App close: no new thumbnails, running ones stop at their next check
|
||||||
@@ -49,7 +54,7 @@ Future<void> cancelAllThumbnails({
|
|||||||
for (final p in _players.toList()) {
|
for (final p in _players.toList()) {
|
||||||
_players.remove(p);
|
_players.remove(p);
|
||||||
try {
|
try {
|
||||||
await p.dispose().timeout(deadline);
|
await thumbnailPlayers.dispose(p).timeout(deadline);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,19 +152,10 @@ Future<Thumbnail?> _generate(
|
|||||||
String out,
|
String out,
|
||||||
bool Function() cancelled,
|
bool Function() cancelled,
|
||||||
) async {
|
) async {
|
||||||
final player = Player(
|
final player = await thumbnailPlayers.create(PlayerRole.thumbnail);
|
||||||
configuration: const PlayerConfiguration(
|
|
||||||
vo: 'null',
|
|
||||||
muted: true,
|
|
||||||
osc: false,
|
|
||||||
libass: false,
|
|
||||||
title: 'thumbnail',
|
|
||||||
bufferSize: 4 << 20,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_players.add(player);
|
_players.add(player);
|
||||||
try {
|
try {
|
||||||
final mpv = player.platform as NativePlayer;
|
final mpv = player;
|
||||||
for (final (k, v) in [
|
for (final (k, v) in [
|
||||||
('ao', 'null'),
|
('ao', 'null'),
|
||||||
('aid', 'no'),
|
('aid', 'no'),
|
||||||
@@ -176,7 +172,7 @@ Future<Thumbnail?> _generate(
|
|||||||
]) {
|
]) {
|
||||||
await mpv.setProperty(k, v);
|
await mpv.setProperty(k, v);
|
||||||
}
|
}
|
||||||
await player.open(Media(path), play: false);
|
await player.open(path);
|
||||||
final target = File(out);
|
final target = File(out);
|
||||||
final deadline = DateTime.now().add(_timeout);
|
final deadline = DateTime.now().add(_timeout);
|
||||||
while (DateTime.now().isBefore(deadline)) {
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
@@ -199,6 +195,6 @@ Future<Thumbnail?> _generate(
|
|||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
// [cancelAllThumbnails] may have taken it already.
|
// [cancelAllThumbnails] may have taken it already.
|
||||||
if (_players.remove(player)) await player.dispose();
|
if (_players.remove(player)) await thumbnailPlayers.dispose(player);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,8 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
|
|||||||
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
||||||
_step('sounds', graph.sfx.dispose(), const Duration(milliseconds: 500)),
|
_step('sounds', graph.sfx.dispose(), const Duration(milliseconds: 500)),
|
||||||
]);
|
]);
|
||||||
|
// The host owns the disposal order: anything still alive goes now.
|
||||||
|
await _step('all players', graph.ports.players.disposeAll(), const Duration(milliseconds: 1500));
|
||||||
// Windows: media_kit_video frees a player's video output on a thread of
|
// Windows: media_kit_video frees a player's video output on a thread of
|
||||||
// its own after dispose returns; it must be done before the engine
|
// its own after dispose returns; it must be done before the engine
|
||||||
// stops, or the plugin's teardown waits on it for good.
|
// stops, or the plugin's teardown waits on it for good.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'desktop_folders.dart';
|
|||||||
import 'desktop_notifications.dart';
|
import 'desktop_notifications.dart';
|
||||||
import 'desktop_shell.dart';
|
import 'desktop_shell.dart';
|
||||||
import 'desktop_urls.dart';
|
import 'desktop_urls.dart';
|
||||||
|
import 'media_kit_host.dart';
|
||||||
|
|
||||||
/// The desktop's [Ports].
|
/// The desktop's [Ports].
|
||||||
Ports desktopPorts() {
|
Ports desktopPorts() {
|
||||||
@@ -14,5 +15,6 @@ Ports desktopPorts() {
|
|||||||
folders: const DesktopFolders(urls),
|
folders: const DesktopFolders(urls),
|
||||||
links: AppYtDlp.instance,
|
links: AppYtDlp.instance,
|
||||||
notifications: DesktopNotifications(),
|
notifications: DesktopNotifications(),
|
||||||
|
players: MediaKitHost(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,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).
|
/// Gets it ready in the background (download on first run, updates).
|
||||||
void warmUp();
|
void warmUp();
|
||||||
|
|
||||||
/// mpv's `ytdl-raw-options` (sub languages, cookies).
|
/// This device opens web pages itself (the media plan's `resolve`
|
||||||
Future<String> mpvRawOptions();
|
/// capability). Desktop: always (yt-dlp is downloaded when first
|
||||||
|
/// needed); a device without it gets pages passed on by someone else.
|
||||||
|
bool get resolvesLinks;
|
||||||
|
|
||||||
/// What [url] is, remembered for a while ([keep]); [fresh] asks again.
|
/// What [url] is, remembered for a while ([keep]); [fresh] asks again.
|
||||||
Future<LinkProbe> probe(String url, {Duration keep, bool fresh});
|
Future<LinkProbe> probe(String url, {Duration keep, bool fresh});
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:signals/signals.dart';
|
||||||
|
|
||||||
|
// The media players as the app sees them: mpv instances, their video
|
||||||
|
// outputs, how the video is rendered, mpv's log and the order players are
|
||||||
|
// torn down in. Desktop: media_kit over libmpv
|
||||||
|
// (`platform/desktop/media_kit_host.dart`); nothing else in the app
|
||||||
|
// touches media_kit except the `Video` widget glue.
|
||||||
|
|
||||||
|
/// How the video output renders (shaders need [gpu]).
|
||||||
|
enum RenderMode {
|
||||||
|
unknown,
|
||||||
|
|
||||||
|
/// OpenGL on a real GPU: shaders work.
|
||||||
|
gpu,
|
||||||
|
|
||||||
|
/// OpenGL on a software rasterizer (llvmpipe): shaders crawl.
|
||||||
|
softwareGl,
|
||||||
|
|
||||||
|
/// The video output's own software fallback: shaders are ignored.
|
||||||
|
software,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a player is for: its cache, log level and log routing.
|
||||||
|
enum PlayerRole {
|
||||||
|
/// The session's player (and a fresh one per media): full cache, mpv log
|
||||||
|
/// at `warn` into the app log.
|
||||||
|
session,
|
||||||
|
|
||||||
|
/// The hidden player holding what plays next: small cache, muted, log
|
||||||
|
/// at `error`.
|
||||||
|
preload,
|
||||||
|
|
||||||
|
/// A poster frame for Files: no audio or video output, no cache, no log.
|
||||||
|
thumbnail,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line of mpv's log.
|
||||||
|
class PlayerLogLine {
|
||||||
|
final String prefix;
|
||||||
|
final String level;
|
||||||
|
final String text;
|
||||||
|
const PlayerLogLine(this.prefix, this.level, this.text);
|
||||||
|
|
||||||
|
bool get isError => level == 'error' || level == 'fatal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A snapshot of a player's state.
|
||||||
|
class HostedPlayerState {
|
||||||
|
final Duration position;
|
||||||
|
final Duration duration;
|
||||||
|
|
||||||
|
/// End of the cached range (mpv `demuxer-cache-time`).
|
||||||
|
final Duration buffer;
|
||||||
|
final double volume;
|
||||||
|
final bool playing;
|
||||||
|
|
||||||
|
/// At the end of the file.
|
||||||
|
final bool completed;
|
||||||
|
final int? width;
|
||||||
|
final int? height;
|
||||||
|
const HostedPlayerState({
|
||||||
|
this.position = Duration.zero,
|
||||||
|
this.duration = Duration.zero,
|
||||||
|
this.buffer = Duration.zero,
|
||||||
|
this.volume = 100,
|
||||||
|
this.playing = false,
|
||||||
|
this.completed = false,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One mpv player. Each media gets its own (see ARCHITECTURE "Player
|
||||||
|
/// lifecycle"); [PlayerHost.retire] / [PlayerHost.disposeAll] end it.
|
||||||
|
abstract interface class HostedPlayer {
|
||||||
|
PlayerRole get role;
|
||||||
|
HostedPlayerState get state;
|
||||||
|
Stream<Duration> get positionStream;
|
||||||
|
Stream<Duration> get durationStream;
|
||||||
|
Stream<Duration> get bufferStream;
|
||||||
|
Stream<double> get volumeStream;
|
||||||
|
Stream<bool> get completedStream;
|
||||||
|
Stream<int?> get widthStream;
|
||||||
|
|
||||||
|
/// mpv's log lines at the player's level.
|
||||||
|
Stream<PlayerLogLine> get logs;
|
||||||
|
|
||||||
|
/// The player is gone (disposed or being disposed): commands do nothing.
|
||||||
|
bool get disposed;
|
||||||
|
|
||||||
|
/// Seeks from the video widget's own controls land here instead of in
|
||||||
|
/// mpv when set ([seek] goes through it too); [syncSeek] always moves
|
||||||
|
/// mpv.
|
||||||
|
void Function(Duration position)? onUserSeek;
|
||||||
|
|
||||||
|
/// Opens [url] (with [headers] on every request), paused unless [play].
|
||||||
|
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false});
|
||||||
|
Future<void> play();
|
||||||
|
Future<void> pause();
|
||||||
|
Future<void> stop();
|
||||||
|
|
||||||
|
/// A user seek: through [onUserSeek] when set, else [syncSeek].
|
||||||
|
Future<void> seek(Duration position);
|
||||||
|
|
||||||
|
/// Moves mpv (sync corrections, scrub preview).
|
||||||
|
Future<void> syncSeek(Duration position);
|
||||||
|
Future<void> setRate(double rate);
|
||||||
|
Future<void> setVolume(double volume);
|
||||||
|
Future<void> setProperty(String name, String value);
|
||||||
|
Future<String> getProperty(String name);
|
||||||
|
Future<void> observeProperty(String name, Future<void> Function(String value) onChange);
|
||||||
|
Future<void> command(List<String> args);
|
||||||
|
|
||||||
|
/// Sets [name] and reads it back: null when mpv took [value], else
|
||||||
|
/// `name: value -> got` (mpv ignores invalid values silently).
|
||||||
|
Future<String?> pin(String name, String value);
|
||||||
|
|
||||||
|
/// mpv's log level for this player (`warn`, `v`, ...).
|
||||||
|
void setLogLevel(String level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A player's video output (a texture the `Video` widget shows).
|
||||||
|
abstract interface class HostedVideo {
|
||||||
|
HostedPlayer get player;
|
||||||
|
|
||||||
|
/// Completes once the output is up (its texture registered).
|
||||||
|
Future<void> get ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates, owns and disposes the app's players. One per app (the
|
||||||
|
/// composition root's `Ports`); tests make their own.
|
||||||
|
abstract interface class PlayerHost {
|
||||||
|
/// Once at start, before any player.
|
||||||
|
void init();
|
||||||
|
|
||||||
|
/// A new player for [role], set up before it is returned to the caller's
|
||||||
|
/// first await: cache, pins ([pinMismatches]), hwdec, log level.
|
||||||
|
Future<HostedPlayer> create(PlayerRole role, {bool muted = false});
|
||||||
|
|
||||||
|
/// [player] (a preload) becomes a session player: unmuted, the full
|
||||||
|
/// cache pins, the session's log level.
|
||||||
|
Future<void> promote(HostedPlayer player);
|
||||||
|
|
||||||
|
/// [player]'s video output, with the hwdec policy. [probe]: listen to
|
||||||
|
/// mpv's render init to learn [renderMode] (the first output of an app
|
||||||
|
/// start).
|
||||||
|
HostedVideo video(HostedPlayer player, {bool probe = false});
|
||||||
|
|
||||||
|
/// How video renders, as last learned (kept after leaving).
|
||||||
|
ReadonlySignal<RenderMode> get renderMode;
|
||||||
|
|
||||||
|
/// mpv `hwdec` for new players: env `SYNC_RELAY_HWDEC` wins over the
|
||||||
|
/// setting (Windows' auto is `auto-copy`).
|
||||||
|
String? get hwdec;
|
||||||
|
|
||||||
|
/// Pins that did not take on the last session player created.
|
||||||
|
List<String> get pinMismatches;
|
||||||
|
|
||||||
|
/// ffmpeg's HTTP errors (`HTTP error 403 Forbidden`), from whichever
|
||||||
|
/// player ffmpeg logs through (one per process).
|
||||||
|
Stream<String> get httpErrors;
|
||||||
|
|
||||||
|
/// `mpv-version` and `ffmpeg-version` of [player], for diagnostics.
|
||||||
|
Future<String?> versions(HostedPlayer player);
|
||||||
|
|
||||||
|
/// Ends [player] once the view let go of its texture (two frames and a
|
||||||
|
/// route pop); at most one retiring player lingers, a new [create] of a
|
||||||
|
/// session player waits for it ([retiring]).
|
||||||
|
Future<void> retire(HostedPlayer player);
|
||||||
|
|
||||||
|
/// The last retire, until done.
|
||||||
|
Future<void> get retiring;
|
||||||
|
|
||||||
|
/// Disposes [player] at once (no view showed it).
|
||||||
|
Future<void> dispose(HostedPlayer player);
|
||||||
|
|
||||||
|
/// App close: every live player goes, each bounded by [each], before
|
||||||
|
/// the engine does (a live mpv calls back into a dead isolate).
|
||||||
|
Future<void> disposeAll({Duration each});
|
||||||
|
|
||||||
|
/// Live players, for tests and the close.
|
||||||
|
int get live;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [log] says the media could not be opened: before a [duration] is known,
|
||||||
|
/// mpv's fatal "nothing to play" or a demuxer error.
|
||||||
|
bool mpvLoadFailed(PlayerLogLine log, Duration duration) {
|
||||||
|
return duration == Duration.zero &&
|
||||||
|
((log.prefix == 'cplayer' && log.level == 'fatal') || (log.prefix == 'demux' && log.level == 'error'));
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'folder_access.dart';
|
import 'folder_access.dart';
|
||||||
import 'link_resolver.dart';
|
import 'link_resolver.dart';
|
||||||
|
import 'player_host.dart';
|
||||||
import 'shell_port.dart';
|
import 'shell_port.dart';
|
||||||
import 'system_notifications.dart';
|
import 'system_notifications.dart';
|
||||||
import 'url_opener.dart';
|
import 'url_opener.dart';
|
||||||
@@ -7,6 +8,7 @@ import 'url_opener.dart';
|
|||||||
export 'app_dirs.dart';
|
export 'app_dirs.dart';
|
||||||
export 'folder_access.dart';
|
export 'folder_access.dart';
|
||||||
export 'link_resolver.dart';
|
export 'link_resolver.dart';
|
||||||
|
export 'player_host.dart';
|
||||||
export 'shell_port.dart';
|
export 'shell_port.dart';
|
||||||
export 'system_notifications.dart';
|
export 'system_notifications.dart';
|
||||||
export 'url_opener.dart';
|
export 'url_opener.dart';
|
||||||
@@ -19,11 +21,14 @@ class Ports {
|
|||||||
final FolderAccess folders;
|
final FolderAccess folders;
|
||||||
final LinkResolver links;
|
final LinkResolver links;
|
||||||
final SystemNotifications notifications;
|
final SystemNotifications notifications;
|
||||||
|
/// The media players (mpv): created, rendered and disposed here.
|
||||||
|
final PlayerHost players;
|
||||||
const Ports({
|
const Ports({
|
||||||
required this.shell,
|
required this.shell,
|
||||||
required this.urls,
|
required this.urls,
|
||||||
required this.folders,
|
required this.folders,
|
||||||
required this.links,
|
required this.links,
|
||||||
required this.notifications,
|
required this.notifications,
|
||||||
|
required this.players,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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
@@ -6,15 +6,6 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
|
|
||||||
import '../platform/ports/link_resolver.dart';
|
import '../platform/ports/link_resolver.dart';
|
||||||
|
|
||||||
/// mpv's `ytdl-format` for links every player opens itself.
|
|
||||||
const ytdlFormat = 'bv*[height<=1080]+ba/b';
|
|
||||||
|
|
||||||
/// yt-dlp's `--sub-langs` for mpv's hook (its default is `all`), without
|
|
||||||
/// Bilibili's danmaku: an XML comment feed served raw-deflate, which
|
|
||||||
/// ffmpeg fails to inflate on every read (a warning each time) while the
|
|
||||||
/// video waits on it.
|
|
||||||
const ytdlSubLangs = 'all,-danmaku';
|
|
||||||
|
|
||||||
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
|
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
|
||||||
/// the hosted server. [probe] remembers what pasted links are for a while,
|
/// the hosted server. [probe] remembers what pasted links are for a while,
|
||||||
/// so the Link preview and the swap ask once.
|
/// so the Link preview and the swap ask once.
|
||||||
@@ -36,20 +27,10 @@ class AppYtDlp implements LinkResolver {
|
|||||||
/// Gets yt-dlp ready in the background (download on first run, a daily
|
/// Gets yt-dlp ready in the background (download on first run, a daily
|
||||||
/// update check afterwards).
|
/// update check afterwards).
|
||||||
@override
|
@override
|
||||||
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
bool get resolvesLinks => true;
|
||||||
|
|
||||||
/// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
|
|
||||||
/// yt-dlp folder (see [YtDlpCookies]).
|
|
||||||
@override
|
@override
|
||||||
Future<String> mpvRawOptions() async {
|
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
||||||
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
|
|
||||||
try {
|
|
||||||
final cookies = (await ytDlp).cookies.mpvRawOptions();
|
|
||||||
return cookies.isEmpty ? subs : '$subs,$cookies';
|
|
||||||
} catch (_) {
|
|
||||||
return subs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final _probes = <String, ({DateTime at, DateTime until, Future<LinkProbe> f})>{};
|
final _probes = <String, ({DateTime at, DateTime until, Future<LinkProbe> f})>{};
|
||||||
static const _probeTtl = Duration(minutes: 10);
|
static const _probeTtl = Duration(minutes: 10);
|
||||||
|
|||||||
@@ -550,7 +550,7 @@ class QueueController {
|
|||||||
// The server's warm relay of the item, once bytes can flow: what
|
// The server's warm relay of the item, once bytes can flow: what
|
||||||
// everyone without their own copy preloads.
|
// everyone without their own copy preloads.
|
||||||
final relay = head.media.relay;
|
final relay = head.media.relay;
|
||||||
final warmUrl = relay != null && relay.live ? coord.warmRelayUrl(head, relay) : null;
|
final warmUrl = relay != null && relay.live ? coord.media.warmRelayUrl(head, relay) : null;
|
||||||
switch (head.how) {
|
switch (head.how) {
|
||||||
case QueueHow.jellyfin:
|
case QueueHow.jellyfin:
|
||||||
final ref = head.media.jellyfin;
|
final ref = head.media.jellyfin;
|
||||||
@@ -589,7 +589,7 @@ class QueueController {
|
|||||||
/// puts it up (`AppYtDlp.probe`, kept while it stays first), a Jellyfin
|
/// puts it up (`AppYtDlp.probe`, kept while it stays first), a Jellyfin
|
||||||
/// item resolved with this app's account
|
/// item resolved with this app's account
|
||||||
/// ([SessionCoordinator.warmJellyfin]), and then opened in the hidden
|
/// ([SessionCoordinator.warmJellyfin]), and then opened in the hidden
|
||||||
/// preload player ([SessionCoordinator.preloadMedia]) once the media
|
/// preload player ([MediaPipeline.prepare]) once the media
|
||||||
/// playing has started. Done as soon as it is first, again before that
|
/// playing has started. Done as soon as it is first, again before that
|
||||||
/// runs out, dropped when it is no longer first.
|
/// runs out, dropped when it is no longer first.
|
||||||
void _warm(StateBroadcast? r) {
|
void _warm(StateBroadcast? r) {
|
||||||
@@ -617,7 +617,7 @@ class QueueController {
|
|||||||
/// Feeds the server's warm relay of Up next's first item from here when
|
/// Feeds the server's warm relay of Up next's first item from here when
|
||||||
/// it names this member as its source (a link passed on, a file), or a
|
/// it names this member as its source (a link passed on, a file), or a
|
||||||
/// Jellyfin item this app puts up with its own account when the server
|
/// Jellyfin item this app puts up with its own account when the server
|
||||||
/// asks for a source; see [SessionCoordinator.feedWarm]. The server
|
/// asks for a source; see [RelayFeeder.feedWarm]. The server
|
||||||
/// drops the relay (and with it the source) once the item is no longer
|
/// drops the relay (and with it the source) once the item is no longer
|
||||||
/// first.
|
/// first.
|
||||||
void _feedWarm(StateBroadcast? r) {
|
void _feedWarm(StateBroadcast? r) {
|
||||||
@@ -633,7 +633,7 @@ class QueueController {
|
|||||||
final pick = _local[head.key]?.pick;
|
final pick = _local[head.key]?.pick;
|
||||||
if (pick is FileMedia) {
|
if (pick is FileMedia) {
|
||||||
_fedWarm = key;
|
_fedWarm = key;
|
||||||
coord.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
|
coord.media.feeder.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
|
||||||
} else if (pick is LinkMedia) {
|
} else if (pick is LinkMedia) {
|
||||||
_fedWarm = key;
|
_fedWarm = key;
|
||||||
unawaited(() async {
|
unawaited(() async {
|
||||||
@@ -642,7 +642,7 @@ class QueueController {
|
|||||||
final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
|
final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
|
||||||
final y = await coord.ytDlp.ytDlp;
|
final y = await coord.ytDlp.ytDlp;
|
||||||
if (_fedWarm != key) return;
|
if (_fedWarm != key) return;
|
||||||
coord.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe));
|
coord.media.feeder.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('queue: feeding ${head.id}: $e');
|
debugPrint('queue: feeding ${head.id}: $e');
|
||||||
}
|
}
|
||||||
@@ -656,7 +656,7 @@ class QueueController {
|
|||||||
final w = coord.warmJellyfin;
|
final w = coord.warmJellyfin;
|
||||||
if (w == null || !sameJellyfinItem(w.ref, ref)) return;
|
if (w == null || !sameJellyfinItem(w.ref, ref)) return;
|
||||||
_fedWarm = key;
|
_fedWarm = key;
|
||||||
coord.feedWarm(
|
coord.media.feeder.feedWarm(
|
||||||
head.id, RelayMedia.single(HttpRelayBytes(Uri.parse(w.plan.url), headers: w.plan.headers)));
|
head.id, RelayMedia.single(HttpRelayBytes(Uri.parse(w.plan.url), headers: w.plan.headers)));
|
||||||
case QueueHow.direct:
|
case QueueHow.direct:
|
||||||
case QueueHow.server:
|
case QueueHow.server:
|
||||||
@@ -710,7 +710,7 @@ class QueueController {
|
|||||||
// A refresh opens a link again (a page's stream URLs expire); the
|
// A refresh opens a link again (a page's stream URLs expire); the
|
||||||
// same Jellyfin stream stays as it is.
|
// same Jellyfin stream stays as it is.
|
||||||
_preloadFresh = fresh && ok && t.ref == null && open != null;
|
_preloadFresh = fresh && ok && t.ref == null && open != null;
|
||||||
final waits = ok && open != null && coord.playback.preloadEnabled;
|
final waits = ok && open != null && coord.media.preloadEnabled;
|
||||||
warm.value = (id: t.id, state: !ok ? WarmState.failed : (waits ? WarmState.warming : WarmState.ready));
|
warm.value = (id: t.id, state: !ok ? WarmState.failed : (waits ? WarmState.warming : WarmState.ready));
|
||||||
_syncPreload(coord.serverState.peek());
|
_syncPreload(coord.serverState.peek());
|
||||||
_warmTimer = Timer(ok ? _warmRefresh : _warmRetry, () => _resolveWarm(t, fresh: true));
|
_warmTimer = Timer(ok ? _warmRefresh : _warmRetry, () => _resolveWarm(t, fresh: true));
|
||||||
@@ -730,25 +730,25 @@ class QueueController {
|
|||||||
final out = coord.selfAway.peek();
|
final out = coord.selfAway.peek();
|
||||||
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
|
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
|
||||||
final want = out ? null : _preloadWant;
|
final want = out ? null : _preloadWant;
|
||||||
final playback = coord.playback;
|
final media = coord.media;
|
||||||
if (want == null) {
|
if (want == null) {
|
||||||
_preloadKey = '';
|
_preloadKey = '';
|
||||||
_preloadGen++;
|
_preloadGen++;
|
||||||
if (playback.preloadUrl != null) unawaited(playback.dropPreload());
|
if (media.preloadUrl != null) unawaited(media.dropPrepared());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!playback.preloadEnabled) return;
|
if (!media.preloadEnabled) return;
|
||||||
final key = '${want.url}|${want.headers}';
|
final key = '${want.url}|${want.headers}';
|
||||||
final fresh = _preloadFresh;
|
final fresh = _preloadFresh;
|
||||||
// Asked for already: in flight, there, or failed (tried again with the
|
// Asked for already: in flight, there, or failed (tried again with the
|
||||||
// next refresh). One that went away (Reload video) is asked again.
|
// next refresh). One that went away (Reload video) is asked again.
|
||||||
if (!fresh && key == _preloadKey && (_preloadBusy || !_preloadOk || playback.preloadUrl == want.url)) return;
|
if (!fresh && key == _preloadKey && (_preloadBusy || !_preloadOk || media.preloadUrl == want.url)) return;
|
||||||
_preloadFresh = false;
|
_preloadFresh = false;
|
||||||
_preloadKey = key;
|
_preloadKey = key;
|
||||||
_preloadBusy = true;
|
_preloadBusy = true;
|
||||||
final gen = ++_preloadGen;
|
final gen = ++_preloadGen;
|
||||||
final id = warm.peek()?.id;
|
final id = warm.peek()?.id;
|
||||||
unawaited(coord.preloadMedia(want.url, headers: want.headers, fresh: fresh).then((ok) {
|
unawaited(coord.media.prepare(want.url, headers: want.headers, fresh: fresh).then((ok) {
|
||||||
if (gen != _preloadGen) return;
|
if (gen != _preloadGen) return;
|
||||||
_preloadBusy = false;
|
_preloadBusy = false;
|
||||||
_preloadOk = ok;
|
_preloadOk = ok;
|
||||||
|
|||||||
@@ -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 '../library/media_refs.dart';
|
||||||
import '../playback/media_marks.dart';
|
import '../playback/media_marks.dart';
|
||||||
import '../playback/playback_controller.dart';
|
import '../playback/playback_controller.dart';
|
||||||
|
import '../platform/desktop/media_kit_host.dart' show MediaKitHost;
|
||||||
import '../platform/ports/link_resolver.dart';
|
import '../platform/ports/link_resolver.dart';
|
||||||
|
import '../platform/ports/player_host.dart';
|
||||||
import '../playback/yt_dlp_app.dart' show AppYtDlp;
|
import '../playback/yt_dlp_app.dart' show AppYtDlp;
|
||||||
import '../schedule/anilist.dart';
|
import '../schedule/anilist.dart';
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
@@ -30,6 +32,7 @@ import 'auto_sit_out.dart';
|
|||||||
import 'connection.dart';
|
import 'connection.dart';
|
||||||
import 'delivery.dart';
|
import 'delivery.dart';
|
||||||
import 'media_art.dart';
|
import 'media_art.dart';
|
||||||
|
import 'media_pipeline.dart';
|
||||||
import 'notifier.dart';
|
import 'notifier.dart';
|
||||||
import 'now_playing.dart';
|
import 'now_playing.dart';
|
||||||
import 'now_playing_info.dart';
|
import 'now_playing_info.dart';
|
||||||
@@ -41,6 +44,7 @@ export 'connection.dart' show ConnectCancelled, ConnectParams;
|
|||||||
export 'notifier.dart' show ToastItem;
|
export 'notifier.dart' show ToastItem;
|
||||||
export 'delivery.dart' show stateMediaRelay;
|
export 'delivery.dart' show stateMediaRelay;
|
||||||
export 'status.dart';
|
export 'status.dart';
|
||||||
|
export 'media_pipeline.dart' show MediaPipeline;
|
||||||
|
|
||||||
final _log = createLogger('session');
|
final _log = createLogger('session');
|
||||||
|
|
||||||
@@ -74,32 +78,6 @@ bool unreadyForNewMedia(
|
|||||||
required bool ready}) =>
|
required bool ready}) =>
|
||||||
enabled && lastMediaId >= 0 && mediaId != lastMediaId && 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) {
|
String? stateMediaUrl(StateBroadcast state) {
|
||||||
final media = state.media;
|
final media = state.media;
|
||||||
if (media == null) return null;
|
if (media == null) return null;
|
||||||
@@ -153,9 +131,13 @@ class SessionCoordinator {
|
|||||||
late final AvatarCache avatars = AvatarCache(connection.client);
|
late final AvatarCache avatars = AvatarCache(connection.client);
|
||||||
/// The current media's poster and banner by `state.media.art` hash.
|
/// The current media's poster and banner by `state.media.art` hash.
|
||||||
late final ArtCache art = ArtCache(connection.client);
|
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]).
|
/// Timings of each media change here (see [SwitchTrace]).
|
||||||
final SwitchTrace switchTrace = SwitchTrace();
|
SwitchTrace get switchTrace => media.trace;
|
||||||
final StateDiff diff = StateDiff();
|
final StateDiff diff = StateDiff();
|
||||||
|
|
||||||
/// The server's latest state. A drop keeps it (with the player, the
|
/// 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.
|
// out, so only a real swap counts as a new video.
|
||||||
int _seenMediaId = -1;
|
int _seenMediaId = -1;
|
||||||
int _mediaId = -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 = '';
|
String _wsHost = '';
|
||||||
int _wsPort = 0;
|
int _wsPort = 0;
|
||||||
_RelayOrigin? _pendingOrigin;
|
|
||||||
// This client's last swap, until a state shows it landed (a newer
|
// 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.
|
// 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`).
|
// [link]: the web link it came from (a relayed one has no `url`).
|
||||||
({HelloMedia media, int after, String? link, Uint8List? art})? _pendingArt;
|
({HelloMedia media, int after, String? link, Uint8List? art})? _pendingArt;
|
||||||
AniListClient? _aniList;
|
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.
|
/// 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).
|
/// Names by member id, kept after they leave (a relay source that left).
|
||||||
final memberNames = MemberNames();
|
final memberNames = MemberNames();
|
||||||
// The media this member was told its upload is too slow for.
|
// The media this member was told its upload is too slow for.
|
||||||
@@ -331,13 +300,14 @@ class SessionCoordinator {
|
|||||||
int _lastEventId = -1;
|
int _lastEventId = -1;
|
||||||
final UserSeekGate _seekGate = UserSeekGate();
|
final UserSeekGate _seekGate = UserSeekGate();
|
||||||
Timer? _seekTimer;
|
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
|
// True while a media open is in flight: the player's duration may still
|
||||||
// belong to the previous media then.
|
// 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
|
/// Auto sit-out (Settings: "Auto sit-out"): idle or minimised for a
|
||||||
/// while sets this member away, coming back sets it in again.
|
/// while sets this member away, coming back sets it in again.
|
||||||
final AutoSitOut autoSitOut = AutoSitOut(DateTime.now().toUtc());
|
final AutoSitOut autoSitOut = AutoSitOut(DateTime.now().toUtc());
|
||||||
Map<String, String>? _openHeaders;
|
|
||||||
|
|
||||||
// Jellyfin: the resolved ref, the mediaId it belongs to, a generation
|
// Jellyfin: the resolved ref, the mediaId it belongs to, a generation
|
||||||
// that stale resolutions check, and the progress reporter.
|
// that stale resolutions check, and the progress reporter.
|
||||||
@@ -366,14 +336,17 @@ class SessionCoordinator {
|
|||||||
Connection? connection,
|
Connection? connection,
|
||||||
JellyfinAccounts? jellyfin,
|
JellyfinAccounts? jellyfin,
|
||||||
TrackPrefs? trackPrefs,
|
TrackPrefs? trackPrefs,
|
||||||
LinkResolver? ytDlp}) {
|
LinkResolver? ytDlp,
|
||||||
|
PlayerHost? players}) {
|
||||||
final n = notifier ?? Notifier(sfx: sfx);
|
final n = notifier ?? Notifier(sfx: sfx);
|
||||||
|
final links = ytDlp ?? AppYtDlp.instance;
|
||||||
return SessionCoordinator._(
|
return SessionCoordinator._(
|
||||||
notifier: n,
|
notifier: n,
|
||||||
connection: connection ?? Connection(notify: n.notify),
|
connection: connection ?? Connection(notify: n.notify),
|
||||||
ytDlp: ytDlp ?? AppYtDlp.instance,
|
ytDlp: links,
|
||||||
jellyfin: jellyfin ?? JellyfinAccounts.instance,
|
jellyfin: jellyfin ?? JellyfinAccounts.instance,
|
||||||
trackPrefs: trackPrefs ?? TrackPrefs(),
|
trackPrefs: trackPrefs ?? TrackPrefs(),
|
||||||
|
media: MediaPipeline(host: players ?? (MediaKitHost()..init()), links: links),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,10 +355,22 @@ class SessionCoordinator {
|
|||||||
required this.connection,
|
required this.connection,
|
||||||
required this.ytDlp,
|
required this.ytDlp,
|
||||||
required this.jellyfin,
|
required this.jellyfin,
|
||||||
required this.trackPrefs}) {
|
required this.trackPrefs,
|
||||||
|
required this.media}) {
|
||||||
connection
|
connection
|
||||||
..onWelcome = _onWelcome
|
..onWelcome = _onWelcome
|
||||||
..caps = () => caps;
|
..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).
|
// The Notifier's view of the session (Status → Notifier, never back).
|
||||||
notifier.session = () {
|
notifier.session = () {
|
||||||
final r = serverState.peek();
|
final r = serverState.peek();
|
||||||
@@ -427,17 +412,16 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The session's first player: created here on connect, disposed by
|
/// The session's first player: created here on connect, disposed by
|
||||||
/// [leave]; each new media gets its own ([PlaybackController.openUrl]).
|
/// [leave]; each new media gets its own (the [media] pipeline). Never
|
||||||
/// Never reused across sessions or media (a GPU-side video freeze sticks
|
/// reused across sessions or media (a GPU-side video freeze sticks to
|
||||||
/// to the player).
|
/// the player).
|
||||||
Future<void> initPlayer() async {
|
Future<void> initPlayer() async {
|
||||||
if (_playerReady) return;
|
if (_playerReady) return;
|
||||||
try {
|
try {
|
||||||
playback.onYtdlError = _setPlayerError;
|
|
||||||
playback.onLoadError = _setPlayerError;
|
playback.onLoadError = _setPlayerError;
|
||||||
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
|
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
|
||||||
_bufferSub ??= playback.buffered.subscribe((_) => _onBuffered());
|
_bufferSub ??= playback.buffered.subscribe((_) => _onBuffered());
|
||||||
await playback.init();
|
await media.start();
|
||||||
_playerReady = true;
|
_playerReady = true;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('player init failed: $e\n$st');
|
debugPrint('player init failed: $e\n$st');
|
||||||
@@ -445,11 +429,13 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setPlayerError(String raw) {
|
/// [raw]: why this player cannot open the current media; [cookies]:
|
||||||
_log.warning('player cannot open media $_mediaId: $raw');
|
/// 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;
|
_playerErrorFor = _mediaId;
|
||||||
_playerErrorDuration = playback.duration.peek();
|
_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
|
/// Clears [playerError] once the media opened (a new duration) or the
|
||||||
@@ -500,12 +486,9 @@ class SessionCoordinator {
|
|||||||
_playerReady = false;
|
_playerReady = false;
|
||||||
playerFault.value = '';
|
playerFault.value = '';
|
||||||
try {
|
try {
|
||||||
await playback.recreate();
|
|
||||||
final url = _openUrl;
|
|
||||||
final headers = _openHeaders;
|
|
||||||
_openUrl = null;
|
|
||||||
_clearPendingSeek();
|
_clearPendingSeek();
|
||||||
if (url != null) await _openMedia(url, headers: headers);
|
diff.reset();
|
||||||
|
await media.reload();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('video reload failed: $e\n$st');
|
debugPrint('video reload failed: $e\n$st');
|
||||||
playerFault.value = 'video reload failed: $e';
|
playerFault.value = 'video reload failed: $e';
|
||||||
@@ -532,10 +515,7 @@ class SessionCoordinator {
|
|||||||
_wsPort = params.port;
|
_wsPort = params.port;
|
||||||
diff.reset();
|
diff.reset();
|
||||||
_mediaId = -1;
|
_mediaId = -1;
|
||||||
_playerMediaId = -1;
|
media.reset();
|
||||||
_openedFor = -1;
|
|
||||||
settledMediaId.value = -1;
|
|
||||||
_openUrl = null;
|
|
||||||
playerError.value = '';
|
playerError.value = '';
|
||||||
playerFault.value = '';
|
playerFault.value = '';
|
||||||
fetchFailedFor.value = -1;
|
fetchFailedFor.value = -1;
|
||||||
@@ -571,7 +551,7 @@ class SessionCoordinator {
|
|||||||
_startTicker();
|
_startTicker();
|
||||||
try {
|
try {
|
||||||
final url = welcome.mediaUrl;
|
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 ??
|
final media = params.media ??
|
||||||
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
|
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
|
||||||
if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
|
if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
|
||||||
@@ -593,50 +573,19 @@ class SessionCoordinator {
|
|||||||
_errSub ??= connection.errors.listen((msg) => notify(ServerRefused(_sentenceCase(msg))));
|
_errSub ??= connection.errors.listen((msg) => notify(ServerRefused(_sentenceCase(msg))));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens [url] (with [headers] on every request); false on failure. A
|
/// A media opened (the pipeline's open step): the server hears at once
|
||||||
/// new media gets a fresh player (or the preload holding it); the media
|
/// (a prepared media already has its buffer).
|
||||||
/// the player held before (back from sitting out, a reload) keeps it.
|
void _onOpened(int mediaId, String url) {
|
||||||
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;
|
|
||||||
diff.reset();
|
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();
|
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);
|
_sendReport(r);
|
||||||
}
|
}
|
||||||
return ok;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int _opens = 0;
|
|
||||||
|
|
||||||
/// How many times this session opened a media in the player (a drop
|
/// How many times this session opened a media in the player (a drop
|
||||||
/// and return must not add one).
|
/// 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.
|
// The media whose buffer reached the gate's need was reported at once.
|
||||||
int _bufferReportedFor = -1;
|
int _bufferReportedFor = -1;
|
||||||
@@ -646,202 +595,16 @@ class SessionCoordinator {
|
|||||||
void _onBuffered() {
|
void _onBuffered() {
|
||||||
final r = serverState.peek();
|
final r = serverState.peek();
|
||||||
if (r == null || r.transport != 'held' || _bufferReportedFor == r.mediaId) return;
|
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;
|
if (connection.token.isEmpty || playback.bufferedSecsAhead < r.bufferNeed) return;
|
||||||
_bufferReportedFor = r.mediaId;
|
_bufferReportedFor = r.mediaId;
|
||||||
_sendReport(r);
|
_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
|
/// 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
|
/// 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
|
/// prepares what comes next only then.
|
||||||
/// preload a swap is about to take is never replaced first.
|
Signal<int> get settledMediaId => media.settledMediaId;
|
||||||
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)}';
|
|
||||||
}
|
|
||||||
|
|
||||||
StateBroadcast? _queuedState;
|
StateBroadcast? _queuedState;
|
||||||
bool _applying = false;
|
bool _applying = false;
|
||||||
@@ -881,7 +644,7 @@ class SessionCoordinator {
|
|||||||
_trackSelfReady(r);
|
_trackSelfReady(r);
|
||||||
if (_lastAway) {
|
if (_lastAway) {
|
||||||
// A newer media than the one this member serves: that relay ended.
|
// 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();
|
await _unloadForOut();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -898,73 +661,21 @@ class SessionCoordinator {
|
|||||||
r.mediaId == _jfMediaId &&
|
r.mediaId == _jfMediaId &&
|
||||||
sameJellyfinItem(ref, _jfRef) &&
|
sameJellyfinItem(ref, _jfRef) &&
|
||||||
jellyfinPlan.peek() is OwnPlay;
|
jellyfinPlan.peek() is OwnPlay;
|
||||||
final relay = stateMediaRelay(r);
|
if (same) {
|
||||||
final mine = relay != null && relay.source.isNotEmpty && relay.source == connection.memberId;
|
media.plan(r);
|
||||||
_RelayOrigin? origin;
|
} else {
|
||||||
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) {
|
|
||||||
_seekGate.reset();
|
_seekGate.reset();
|
||||||
_clearPendingSeek();
|
_clearPendingSeek();
|
||||||
_leaveJellyfinItem();
|
_leaveJellyfinItem();
|
||||||
await _leavePreviousMedia(r.mediaId);
|
diff.reset();
|
||||||
final name = (r.media?['name'] as String?) ?? '';
|
final next = await media.switchTo(r);
|
||||||
if (ref != null) {
|
if (next is SwitchJellyfin) await _playJellyfin(next.source, r);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_seekGate.reset();
|
_seekGate.reset();
|
||||||
_clearPendingSeek();
|
_clearPendingSeek();
|
||||||
if (_mediaId == r.mediaId) settledMediaId.value = r.mediaId;
|
if (_mediaId == r.mediaId) media.settled(r.mediaId);
|
||||||
} else if (_awaitLiveFor == r.mediaId && stateMediaRelay(r) != null && !_waitForSource(r, stateMediaRelay(r)!)) {
|
} else {
|
||||||
_awaitLiveFor = -1;
|
await media.onState(r);
|
||||||
final relay = stateMediaRelay(r)!;
|
|
||||||
await _openMedia(
|
|
||||||
_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId, relay.tracks, relay.queueId));
|
|
||||||
}
|
}
|
||||||
_jellyfinTransport(r);
|
_jellyfinTransport(r);
|
||||||
if (!_playerReady || _openUrl == null) {
|
if (!_playerReady || _openUrl == null) {
|
||||||
@@ -990,6 +701,9 @@ class SessionCoordinator {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
_wasPlayAllowed = allowed;
|
_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
|
// 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.
|
// echo of our own seek arriving: the player is where the user put it.
|
||||||
// Later drift corrections within that event still apply.
|
// Later drift corrections within that event still apply.
|
||||||
@@ -1038,38 +752,13 @@ class SessionCoordinator {
|
|||||||
/// [joinBack] opens the media as new.
|
/// [joinBack] opens the media as new.
|
||||||
Future<void> _unloadForOut() async {
|
Future<void> _unloadForOut() async {
|
||||||
_wasPlayAllowed = false;
|
_wasPlayAllowed = false;
|
||||||
_awaitLiveFor = -1;
|
|
||||||
if (_mediaId == -1 && _openUrl == null) return;
|
if (_mediaId == -1 && _openUrl == null) return;
|
||||||
_mediaId = -1;
|
_mediaId = -1;
|
||||||
_playerMediaId = -1;
|
|
||||||
_openUrl = null;
|
|
||||||
_openHeaders = null;
|
|
||||||
_seekGate.reset();
|
_seekGate.reset();
|
||||||
_clearPendingSeek();
|
_clearPendingSeek();
|
||||||
diff.reset();
|
diff.reset();
|
||||||
_leaveJellyfinItem();
|
_leaveJellyfinItem();
|
||||||
if (!_playerReady) return;
|
await media.unload();
|
||||||
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 (_) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onFeed(FeedMessage f) {
|
void _onFeed(FeedMessage f) {
|
||||||
@@ -1114,10 +803,13 @@ class SessionCoordinator {
|
|||||||
// The buffer counts only while the player holds the server's current
|
// 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
|
// media: before a swap's media is open it is the previous one's (or
|
||||||
// none), and the start must wait for this member.
|
// 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);
|
_checkPlayerError(r.mediaId);
|
||||||
final error = playerError.peek();
|
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);
|
if (holds && buffered >= r.bufferNeed) switchTrace.mark(r.mediaId, SwitchTrace.buffered);
|
||||||
connection.sendReport(
|
connection.sendReport(
|
||||||
position: _lastAway ? 0 : playback.positionSecs,
|
position: _lastAway ? 0 : playback.positionSecs,
|
||||||
@@ -1204,7 +896,8 @@ class SessionCoordinator {
|
|||||||
final at = _pendingSeekAt;
|
final at = _pendingSeekAt;
|
||||||
if (event == null || at == null) return;
|
if (event == null || at == null) return;
|
||||||
final now = DateTime.now().toUtc();
|
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();
|
_clearPendingSeek();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1234,6 +927,7 @@ class SessionCoordinator {
|
|||||||
_seekGate.request(position, now);
|
_seekGate.request(position, now);
|
||||||
_clearPendingSeek();
|
_clearPendingSeek();
|
||||||
unawaited(playback.apply([SeekAction(position)]));
|
unawaited(playback.apply([SeekAction(position)]));
|
||||||
|
media.noteUserSeek(position);
|
||||||
_seekTimer?.cancel();
|
_seekTimer?.cancel();
|
||||||
_seekTimer = Timer(_seekGate.debounce, _flushUserSeek);
|
_seekTimer = Timer(_seekGate.debounce, _flushUserSeek);
|
||||||
}
|
}
|
||||||
@@ -1246,9 +940,7 @@ class SessionCoordinator {
|
|||||||
// Playing on since the request: send where the player is now, so the
|
// Playing on since the request: send where the player is now, so the
|
||||||
// server's clock and ours agree when the echo comes back.
|
// server's clock and ours agree when the echo comes back.
|
||||||
final own = playback.positionSecs;
|
final own = playback.positionSecs;
|
||||||
if (_playerReady &&
|
if (_playerReady && playback.playing && (own - position).abs() < 2.0) {
|
||||||
playback.player.state.playing &&
|
|
||||||
(own - position).abs() < 2.0) {
|
|
||||||
position = own;
|
position = own;
|
||||||
}
|
}
|
||||||
connection.sendSeek(position, seq: settled.seq);
|
connection.sendSeek(position, seq: settled.seq);
|
||||||
@@ -1482,7 +1174,7 @@ class SessionCoordinator {
|
|||||||
markInput();
|
markInput();
|
||||||
final media = await mediaForLink(jellyfin, url);
|
final media = await mediaForLink(jellyfin, url);
|
||||||
if (media.jellyfin != null) {
|
if (media.jellyfin != null) {
|
||||||
_pendingOrigin = _JellyfinOrigin(url);
|
this.media.expectOrigin(JellyfinOrigin(url));
|
||||||
_swap(media.copyWith(relay: const MediaRelay()), queueId: queueId);
|
_swap(media.copyWith(relay: const MediaRelay()), queueId: queueId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1511,13 +1203,13 @@ class SessionCoordinator {
|
|||||||
if (relayProblem != null) return relayProblem;
|
if (relayProblem != null) return relayProblem;
|
||||||
final y = await ytDlp.ytDlp;
|
final y = await ytDlp.ytDlp;
|
||||||
// Up next's item this app already feeds (its warm relay): read.
|
// Up next's item this app already feeds (its warm relay): read.
|
||||||
final warm = _warmSource;
|
final warm = queueId == null ? const <RelayTrack>[] : this.media.feeder.warmTracks(queueId);
|
||||||
final fed = queueId != null && warm != null && warm.queueId == queueId && warm.src.tracks.isNotEmpty;
|
final fed = warm.isNotEmpty;
|
||||||
final check = fed ? null : LinkRelayMedia(uri, ytDlp: y, probe: probe);
|
final check = fed ? null : LinkRelayMedia(uri, ytDlp: y, probe: probe);
|
||||||
var size = 0;
|
var size = 0;
|
||||||
final List<RelayTrackSpec> specs;
|
final List<RelayTrackSpec> specs;
|
||||||
try {
|
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) {
|
for (final t in tracks) {
|
||||||
// A playlist is read (DRM and live refused) but has no size.
|
// A playlist is read (DRM and live refused) but has no size.
|
||||||
final pieces = t.pieces;
|
final pieces = t.pieces;
|
||||||
@@ -1533,7 +1225,7 @@ class SessionCoordinator {
|
|||||||
} finally {
|
} finally {
|
||||||
await check?.close();
|
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),
|
_swap(HelloMedia(name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs), ref: what),
|
||||||
link: url, queueId: queueId);
|
link: url, queueId: queueId);
|
||||||
return null;
|
return null;
|
||||||
@@ -1567,7 +1259,7 @@ class SessionCoordinator {
|
|||||||
/// relayed by this member for viewers without an account.
|
/// relayed by this member for viewers without an account.
|
||||||
void swapJellyfin(JellyfinMedia media, {String? queueId}) {
|
void swapJellyfin(JellyfinMedia media, {String? queueId}) {
|
||||||
markInput();
|
markInput();
|
||||||
_pendingOrigin = const _JellyfinOrigin(null);
|
this.media.expectOrigin(const JellyfinOrigin(null));
|
||||||
_swap(mediaForPick(media).copyWith(relay: const MediaRelay()), queueId: queueId);
|
_swap(mediaForPick(media).copyWith(relay: const MediaRelay()), queueId: queueId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1586,7 +1278,7 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
if (!await file.exists()) return 'file not found — check the path';
|
if (!await file.exists()) return 'file not found — check the path';
|
||||||
_pendingOrigin = _FileOrigin(path);
|
media.expectOrigin(FileOrigin(path));
|
||||||
_swap(HelloMedia(
|
_swap(HelloMedia(
|
||||||
name: file.uri.pathSegments.last,
|
name: file.uri.pathSegments.last,
|
||||||
size: await file.length(),
|
size: await file.length(),
|
||||||
@@ -1642,7 +1334,6 @@ class SessionCoordinator {
|
|||||||
_jfMediaId = -1;
|
_jfMediaId = -1;
|
||||||
_reporter = null;
|
_reporter = null;
|
||||||
_lastSnap = null;
|
_lastSnap = null;
|
||||||
_openHeaders = null;
|
|
||||||
_blockedKey = '';
|
_blockedKey = '';
|
||||||
currentRef.value = null;
|
currentRef.value = null;
|
||||||
jellyfinPlan.value = null;
|
jellyfinPlan.value = null;
|
||||||
@@ -1683,7 +1374,7 @@ class SessionCoordinator {
|
|||||||
final last = _lastSnap;
|
final last = _lastSnap;
|
||||||
return PlayerSnapshot(
|
return PlayerSnapshot(
|
||||||
position: playback.positionSecs,
|
position: playback.positionSecs,
|
||||||
paused: !playback.player.state.playing,
|
paused: !playback.playing,
|
||||||
aid: last?.aid,
|
aid: last?.aid,
|
||||||
sid: last?.sid,
|
sid: last?.sid,
|
||||||
);
|
);
|
||||||
@@ -1695,25 +1386,15 @@ class SessionCoordinator {
|
|||||||
if (!_playerReady) return null;
|
if (!_playerReady) return null;
|
||||||
return _lastSnap = PlayerSnapshot(
|
return _lastSnap = PlayerSnapshot(
|
||||||
position: playback.positionSecs,
|
position: playback.positionSecs,
|
||||||
paused: !playback.player.state.playing,
|
paused: !playback.playing,
|
||||||
aid: ids.aid,
|
aid: ids.aid,
|
||||||
sid: ids.sid,
|
sid: ids.sid,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This member's `/j/` URL for [ref] (the server's Jellyfin login).
|
/// This member's `/j/` URL for [ref] (the server's Jellyfin login).
|
||||||
String? jellyfinRelayUrl(JellyfinRef ref) => _relayUrlFor(ref);
|
String? jellyfinRelayUrl(JellyfinRef ref) =>
|
||||||
|
connection.token.isEmpty ? null : ServerAddress(_wsHost, _wsPort, connection.token).jellyfinUrl(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `report.jf`: every linked account that works (so a queued Jellyfin
|
/// `report.jf`: every linked account that works (so a queued Jellyfin
|
||||||
/// item finds someone to put it up while other media plays), then how
|
/// item finds someone to put it up while other media plays), then how
|
||||||
@@ -1729,67 +1410,31 @@ class SessionCoordinator {
|
|||||||
return {...linked, ref.serverId: access};
|
return {...linked, ref.serverId: access};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves [ref] for this viewer and opens it: own account (direct
|
/// Resolves [source]'s item for this viewer and opens it (the media
|
||||||
/// play, progress reports, neighbouring episodes), the relay, or the
|
/// pipeline: own account, the server's login, the member passing it on,
|
||||||
/// blocked state. Then carries the audio/subtitle choice over.
|
/// or blocked), then this item's own work: progress reports and
|
||||||
Future<void> _playJellyfin(JellyfinRef ref, StateBroadcast r,
|
/// neighbouring episodes with an own account, the audio/subtitle choice
|
||||||
{_JellyfinOrigin? origin, String? memberRelayUrl}) async {
|
/// carried over.
|
||||||
|
Future<void> _playJellyfin(JellyfinSource source, StateBroadcast r) async {
|
||||||
|
final ref = source.ref;
|
||||||
final gen = ++_jfGen;
|
final gen = ++_jfGen;
|
||||||
_jfRef = ref;
|
_jfRef = ref;
|
||||||
_jfMediaId = r.mediaId;
|
_jfMediaId = r.mediaId;
|
||||||
currentRef.value = ref;
|
currentRef.value = ref;
|
||||||
// Until it resolves nothing is open: no syncing the previous media.
|
final plan = await media.openJellyfin(source, r, warm: _takeWarmJellyfin(ref), current: () => gen == _jfGen);
|
||||||
final prevUrl = _openUrl;
|
if (plan == null || gen != _jfGen) return;
|
||||||
_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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jellyfinPlan.value = plan;
|
jellyfinPlan.value = plan;
|
||||||
switch (plan) {
|
switch (plan) {
|
||||||
case BlockedPlay():
|
case BlockedPlay():
|
||||||
jellyfinBlocked.value = plan;
|
jellyfinBlocked.value = plan;
|
||||||
_blockedKey = _accountKey(ref.serverId);
|
_blockedKey = _accountKey(ref.serverId);
|
||||||
try {
|
|
||||||
await playback.stop();
|
|
||||||
} catch (_) {}
|
|
||||||
return;
|
return;
|
||||||
case RelayPlay(:final url):
|
case RelayPlay():
|
||||||
jellyfinBlocked.value = null;
|
jellyfinBlocked.value = null;
|
||||||
// Same relay URL (resumed member): the player still has it.
|
if (_openUrl == null) return;
|
||||||
if (url == prevUrl) {
|
case OwnPlay():
|
||||||
_openUrl = url;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!await _openMedia(url) || gen != _jfGen) return;
|
|
||||||
case OwnPlay(:final url, :final headers):
|
|
||||||
jellyfinBlocked.value = null;
|
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
|
// Played before and the resume point, fresh (a warmed plan's item
|
||||||
// is older): the watched rule never unmarks a played item.
|
// is older): the watched rule never unmarks a played item.
|
||||||
final known = plan.item?.userData;
|
final known = plan.item?.userData;
|
||||||
@@ -1992,9 +1637,9 @@ class SessionCoordinator {
|
|||||||
/// "Try again" on the blocked card (and after linking an account).
|
/// "Try again" on the blocked card (and after linking an account).
|
||||||
void retryJellyfin() {
|
void retryJellyfin() {
|
||||||
final r = serverState.peek();
|
final r = serverState.peek();
|
||||||
final ref = refOfStateMedia(r?.media);
|
if (r == null) return;
|
||||||
if (r == null || ref == null) return;
|
final source = media.plan(r);
|
||||||
unawaited(_playJellyfin(ref, r));
|
if (source is JellyfinSource) unawaited(_playJellyfin(source, r));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A welcome's part here (see [WelcomeHandler]), before its state is
|
/// A welcome's part here (see [WelcomeHandler]), before its state is
|
||||||
@@ -2027,7 +1672,8 @@ class SessionCoordinator {
|
|||||||
} else if (!rejoin.sameServer && _mediaId != -1) {
|
} else if (!rejoin.sameServer && _mediaId != -1) {
|
||||||
// The same media under a restarted server's numbering: the player
|
// The same media under a restarted server's numbering: the player
|
||||||
// keeps it.
|
// keeps it.
|
||||||
_mediaId = _playerMediaId = _openedFor = now.mediaId;
|
_mediaId = now.mediaId;
|
||||||
|
media.renumber(now.mediaId);
|
||||||
_seenMediaId = now.mediaId;
|
_seenMediaId = now.mediaId;
|
||||||
}
|
}
|
||||||
// The intent the server lost: ready (unless the video changed and the
|
// 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).
|
/// scratch (a relay this member served for the old one stops).
|
||||||
void _forgetMedia() {
|
void _forgetMedia() {
|
||||||
_mediaId = -1;
|
_mediaId = -1;
|
||||||
_playerMediaId = -1;
|
|
||||||
_openUrl = null;
|
|
||||||
_openHeaders = null;
|
|
||||||
_awaitLiveFor = -1;
|
|
||||||
_wasPlayAllowed = false;
|
_wasPlayAllowed = false;
|
||||||
diff.reset();
|
diff.reset();
|
||||||
_clearPendingSeek();
|
_clearPendingSeek();
|
||||||
_stopRelaySource();
|
media.forget();
|
||||||
stopWarmSource();
|
|
||||||
_activeOrigin = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The media's length from a state (0 unknown).
|
|
||||||
static double _durationOf(StateBroadcast r) => (r.media?['duration'] as num?)?.toDouble() ?? 0;
|
|
||||||
|
|
||||||
/// "Stop reconnecting".
|
/// "Stop reconnecting".
|
||||||
void cancelReconnect() => connection.cancelReconnect();
|
void cancelReconnect() => connection.cancelReconnect();
|
||||||
|
|
||||||
@@ -2090,7 +1727,7 @@ class SessionCoordinator {
|
|||||||
_playerReady = false;
|
_playerReady = false;
|
||||||
_ticker?.cancel();
|
_ticker?.cancel();
|
||||||
_ticker = null;
|
_ticker = null;
|
||||||
await playback.disposeAll();
|
await media.disposeAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Out of the session: with [bye] (Leave, closing the app) the member
|
/// Out of the session: with [bye] (Leave, closing the app) the member
|
||||||
@@ -2110,15 +1747,12 @@ class SessionCoordinator {
|
|||||||
_seekTimer = null;
|
_seekTimer = null;
|
||||||
_seekGate.reset();
|
_seekGate.reset();
|
||||||
await _stopJellyfin();
|
await _stopJellyfin();
|
||||||
_stopRelaySource();
|
media.reset();
|
||||||
stopWarmSource();
|
|
||||||
_pendingOrigin = null;
|
|
||||||
_pendingArt = null;
|
_pendingArt = null;
|
||||||
_ownLink = null;
|
_ownLink = null;
|
||||||
_activeOrigin = null;
|
|
||||||
_playerReady = false;
|
_playerReady = false;
|
||||||
try {
|
try {
|
||||||
await playback.dispose();
|
await media.release();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
// The last lines go before the token does, then the bye.
|
// The last lines go before the token does, then the bye.
|
||||||
await connection.close(bye: bye);
|
await connection.close(bye: bye);
|
||||||
@@ -2139,6 +1773,6 @@ class SessionCoordinator {
|
|||||||
_bufferSub?.call();
|
_bufferSub?.call();
|
||||||
_aniList?.close();
|
_aniList?.close();
|
||||||
await connection.dispose();
|
await connection.dispose();
|
||||||
await playback.dispose();
|
await media.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,6 +210,20 @@ StatusLine? _line(StatusFacts f, StateBroadcast? r, MemberView? self, SourceTrou
|
|||||||
return StatusLine(StatusKind.problem, "Can't play this: ${f.playerError}",
|
return StatusLine(StatusKind.problem, "Can't play this: ${f.playerError}",
|
||||||
level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this");
|
level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this");
|
||||||
}
|
}
|
||||||
|
// The server holds everyone for the relay's source (D6): a viewer's
|
||||||
|
// buffer ran dry while it is gone.
|
||||||
|
if (r.transport == 'held' && r.hold?.reason == HoldReason.source) {
|
||||||
|
final relayed = stateMediaRelay(r);
|
||||||
|
final id = relayed?.source ?? '';
|
||||||
|
final name = source?.name ??
|
||||||
|
r.members.where((m) => m.id == id).firstOrNull?.name ??
|
||||||
|
f.names?[id] ??
|
||||||
|
'the member passing it on';
|
||||||
|
return StatusLine(StatusKind.waiting, 'Waiting for $name',
|
||||||
|
detail: "$name's connection dropped; what the server had has played",
|
||||||
|
level: StatusLevel.warn,
|
||||||
|
short: 'Waiting for $name');
|
||||||
|
}
|
||||||
// The relay: what everyone reads comes through the server.
|
// The relay: what everyone reads comes through the server.
|
||||||
final relay = f.ownPlan ? null : stateMediaRelay(r);
|
final relay = f.ownPlan ? null : stateMediaRelay(r);
|
||||||
if (relay != null && relay.source != f.selfId) {
|
if (relay != null && relay.source != f.selfId) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../controls/controls_probe.dart';
|
|||||||
import '../controls/model/controls_model.dart';
|
import '../controls/model/controls_model.dart';
|
||||||
import '../controls/session_controls.dart';
|
import '../controls/session_controls.dart';
|
||||||
import '../controls/volume.dart';
|
import '../controls/volume.dart';
|
||||||
|
import '../platform/desktop/media_kit_host.dart' show videoControllerOf;
|
||||||
import '../queue/queue_ui.dart';
|
import '../queue/queue_ui.dart';
|
||||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||||
import '../screens/session/jellyfin_widgets.dart';
|
import '../screens/session/jellyfin_widgets.dart';
|
||||||
@@ -321,7 +322,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
|
|
||||||
Widget _video(VideoControlsBuilder? controls) => Video(
|
Widget _video(VideoControlsBuilder? controls) => Video(
|
||||||
key: _videoKey,
|
key: _videoKey,
|
||||||
controller: _coord.playback.video,
|
controller: videoControllerOf(_coord.playback.video),
|
||||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||||
controls: controls,
|
controls: controls,
|
||||||
);
|
);
|
||||||
@@ -653,7 +654,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
_MiniSeekBar(onSeek: (d) => playback.player.seek(d)),
|
_MiniSeekBar(onSeek: playback.userSeek),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,26 +1,5 @@
|
|||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
/// A [Player] whose public [seek] belongs to the user. media_kit's seek bar
|
|
||||||
/// (and anything else in the video controls) calls `player.seek`; with
|
|
||||||
/// [onUserSeek] set those calls become explicit events for the sync layer
|
|
||||||
/// instead of moving mpv behind its back. Sync-driven seeks use [syncSeek].
|
|
||||||
class SyncPlayer extends Player {
|
|
||||||
SyncPlayer({super.configuration});
|
|
||||||
|
|
||||||
void Function(Duration position)? onUserSeek;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> seek(Duration duration) async {
|
|
||||||
final hook = onUserSeek;
|
|
||||||
if (hook == null) return super.seek(duration);
|
|
||||||
hook(duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seek mpv directly (preview while scrubbing, server corrections).
|
|
||||||
Future<void> syncSeek(Duration duration) => super.seek(duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Turns a burst of user seeks (a seek bar drag fires one per pointer move,
|
/// Turns a burst of user seeks (a seek bar drag fires one per pointer move,
|
||||||
/// arrow keys one per press) into one `seek` intent, and tells the
|
/// arrow keys one per press) into one `seek` intent, and tells the
|
||||||
/// coordinator when server seeks must not touch the player:
|
/// coordinator when server seeks must not touch the player:
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:sync_relay_server/server.dart';
|
import 'package:sync_relay_server/server.dart';
|
||||||
import 'package:starsync/src/jellyfin/accounts.dart';
|
import 'package:starsync/src/jellyfin/accounts.dart';
|
||||||
import 'package:starsync/src/jellyfin/session_media.dart';
|
import 'package:starsync/src/jellyfin/session_media.dart';
|
||||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||||
|
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
|
||||||
import 'package:starsync/src/playback/yt_dlp_app.dart';
|
import 'package:starsync/src/playback/yt_dlp_app.dart';
|
||||||
import 'package:starsync/src/queue/queue_controller.dart' show WarmState;
|
import 'package:starsync/src/queue/queue_controller.dart' show WarmState;
|
||||||
import 'package:starsync/src/screens/home/picked_media.dart';
|
import 'package:starsync/src/screens/home/picked_media.dart';
|
||||||
import 'package:starsync/src/session/session_coordinator.dart';
|
import 'package:starsync/src/session/session_coordinator.dart';
|
||||||
import 'package:starsync/src/session/switch_trace.dart';
|
import 'package:starsync/src/session/switch_trace.dart';
|
||||||
|
|
||||||
// How long an Up next switch takes, end to end, over real mpv and a real
|
// How long an Up next switch takes through the media pipeline, end to
|
||||||
// sync server: two Jellyfin account holders and a guest (through the
|
// end, over real mpv and a real sync server: two Jellyfin account holders
|
||||||
// server's /j login) watch an episode, then ⏭ puts up the next episode,
|
// and a guest (through the server's /j login) watch an episode, then ⏭ puts
|
||||||
// then a direct link, then a link passed on "through" alice, then one the
|
// up the next episode, then a direct link, then a link passed on "through"
|
||||||
// server fetches (both relayed: the guests preload them from the server's
|
// alice, then one the server fetches (both relayed: the guests prepare
|
||||||
// warm relay of the item). The fake Jellyfin and the link's host answer with a
|
// them from the server's warm relay of the item). Every client prepares
|
||||||
// delay and a capped rate, like a server elsewhere. Prints each client's
|
// the next item in a hidden player (`MediaPipeline.prepare`) and the swap
|
||||||
// phases (SwitchTrace) and the wall time from ⏭ to the server playing.
|
// promotes it. The fake Jellyfin and the link's host answer with a delay
|
||||||
|
// and a capped rate, like a server elsewhere. Prints each client's phases
|
||||||
|
// (SwitchTrace) and the wall time from ⏭ to the server playing.
|
||||||
//
|
//
|
||||||
// WT_SWITCH_PRELOAD=0 turns the preload off (the cold path, for comparison);
|
// WT_SWITCH_PRELOAD=0 turns the preload off (the cold path, for comparison);
|
||||||
// WT_SWITCH_DELAY_MS and WT_SWITCH_RATE_KBS set the remote's delay and rate.
|
// WT_SWITCH_DELAY_MS and WT_SWITCH_RATE_KBS set the remote's delay and rate.
|
||||||
@@ -202,7 +202,12 @@ void main() {
|
|||||||
'Up next: ⏭ to a Jellyfin episode, then to a direct link, timed per client',
|
'Up next: ⏭ to a Jellyfin episode, then to a direct link, timed per client',
|
||||||
timeout: const Timeout(Duration(minutes: 4)),
|
timeout: const Timeout(Duration(minutes: 4)),
|
||||||
() async {
|
() async {
|
||||||
MediaKit.ensureInitialized();
|
// mpv's warnings, as the host writes them to the app log.
|
||||||
|
final mpvLines = <String>[];
|
||||||
|
// (The root's records, the app's logging not being hierarchical.)
|
||||||
|
final logSub = createLogger('mpv').onRecord.listen((r) {
|
||||||
|
if (r.loggerName == 'mpv') mpvLines.add(r.message);
|
||||||
|
});
|
||||||
final preload = Platform.environment['WT_SWITCH_PRELOAD'] != '0';
|
final preload = Platform.environment['WT_SWITCH_PRELOAD'] != '0';
|
||||||
final file = await _media();
|
final file = await _media();
|
||||||
final jelly = _FakeJellyfin(file);
|
final jelly = _FakeJellyfin(file);
|
||||||
@@ -236,9 +241,15 @@ void main() {
|
|||||||
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
|
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
|
||||||
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
|
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||||
await accB.ensureLoaded(validate: false);
|
await accB.ensureLoaded(validate: false);
|
||||||
SessionCoordinator coord(JellyfinAccounts acc) => SessionCoordinator(
|
final hosts = <SessionCoordinator, MediaKitHost>{};
|
||||||
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp)
|
SessionCoordinator coord(JellyfinAccounts acc) {
|
||||||
..playback.preloadEnabled = preload;
|
final host = MediaKitHost()..init();
|
||||||
|
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 a = coord(accA), b = coord(accB), c = coord(accC);
|
||||||
final all = {'alice': a, 'guest': b, 'carol': c};
|
final all = {'alice': a, 'guest': b, 'carol': c};
|
||||||
ConnectParams params(String name, [HelloMedia? media]) =>
|
ConnectParams params(String name, [HelloMedia? media]) =>
|
||||||
@@ -252,7 +263,7 @@ void main() {
|
|||||||
if (preload) {
|
if (preload) {
|
||||||
for (final x in all.values) {
|
for (final x in all.values) {
|
||||||
expect(x.queue.warm.peek()?.state, WarmState.ready, reason: 'ready on every client before ⏭');
|
expect(x.queue.warm.peek()?.state, WarmState.ready, reason: 'ready on every client before ⏭');
|
||||||
expect(x.playback.preloadUrl, isNotNull, reason: 'preloaded on every client');
|
expect(x.media.preloadUrl, isNotNull, reason: 'preloaded on every client');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final clock = Stopwatch()..start();
|
final clock = Stopwatch()..start();
|
||||||
@@ -269,11 +280,14 @@ void main() {
|
|||||||
}
|
}
|
||||||
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all play $what');
|
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all play $what');
|
||||||
// The promoted player is the session player: marks, tracks and
|
// The promoted player is the session player: marks, tracks and
|
||||||
// the volume carried over, the clock moves.
|
// the volume carried over, the clock moves; the old one retired.
|
||||||
for (final x in all.values) {
|
for (final x in all.values) {
|
||||||
expect(x.playback.durationSecs, greaterThan(59));
|
expect(x.playback.durationSecs, greaterThan(59));
|
||||||
expect(x.playback.player.state.volume, 0, reason: 'volume kept');
|
expect(x.playback.player.state.volume, 0, reason: 'volume kept');
|
||||||
await _poll(() => x.playback.tracks.peek().isNotEmpty, 'tracks listed');
|
await _poll(() => x.playback.tracks.peek().isNotEmpty, 'tracks listed');
|
||||||
|
final host = hosts[x]!;
|
||||||
|
await host.retiring;
|
||||||
|
expect(host.live, lessThanOrEqualTo(2), reason: 'the session player and at most the next prepared one');
|
||||||
}
|
}
|
||||||
final at = a.playback.positionSecs;
|
final at = a.playback.positionSecs;
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
@@ -286,7 +300,7 @@ void main() {
|
|||||||
await b.connect(params('guest'));
|
await b.connect(params('guest'));
|
||||||
await c.connect(params('carol'));
|
await c.connect(params('carol'));
|
||||||
for (final x in all.values) {
|
for (final x in all.values) {
|
||||||
await x.playback.player.setVolume(0);
|
await x.playback.setVolume(0);
|
||||||
}
|
}
|
||||||
await _poll(() => all.values.every((x) => x.playback.durationSecs > 59), 'all opened episode 1');
|
await _poll(() => all.values.every((x) => x.playback.durationSecs > 59), 'all opened episode 1');
|
||||||
expect(await a.queue.add(pickForRef(accA, const JellyfinRef(serverId: _sid, itemId: _ep2, title: 'Second'))!),
|
expect(await a.queue.add(pickForRef(accA, const JellyfinRef(serverId: _sid, itemId: _ep2, title: 'Second'))!),
|
||||||
@@ -332,14 +346,17 @@ void main() {
|
|||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print(report.join('\n'));
|
print(report.join('\n'));
|
||||||
expect(serverErrors, isEmpty);
|
expect(serverErrors, isEmpty);
|
||||||
|
// No command reached a player still opening, and the
|
||||||
|
// cache is in RAM on every player (no "Failed to create file cache").
|
||||||
|
expect(mpvLines.where((l) => l.contains('error running command') || l.contains('file cache')), isEmpty);
|
||||||
|
|
||||||
// Nothing queued: no preload. Queued and taken off again: dropped.
|
// Nothing queued: no preload. Queued and taken off again: dropped.
|
||||||
if (preload) {
|
if (preload) {
|
||||||
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'nothing preloaded');
|
await _poll(() => all.values.every((x) => x.media.preloadUrl == null), 'nothing preloaded');
|
||||||
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
|
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
|
||||||
await _poll(() => all.values.every((x) => x.playback.preloadUrl == linkUrl), 'preloaded again');
|
await _poll(() => all.values.every((x) => x.media.preloadUrl == linkUrl), 'preloaded again');
|
||||||
a.queue.remove(a.serverState.peek()!.queue.first.id);
|
a.queue.remove(a.serverState.peek()!.queue.first.id);
|
||||||
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'dropped when taken off');
|
await _poll(() => all.values.every((x) => x.media.preloadUrl == null), 'dropped when taken off');
|
||||||
}
|
}
|
||||||
// Preloaded everywhere: every client starts from its preload.
|
// Preloaded everywhere: every client starts from its preload.
|
||||||
if (preload) {
|
if (preload) {
|
||||||
@@ -348,6 +365,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
await logSub.cancel();
|
||||||
for (final x in all.values) {
|
for (final x in all.values) {
|
||||||
await x.dispose();
|
await x.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:sync_relay_server/server.dart';
|
import 'package:sync_relay_server/server.dart';
|
||||||
import 'package:starsync/src/playback/playback_controller.dart';
|
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
|
||||||
|
import 'package:starsync/src/session/media_pipeline.dart';
|
||||||
|
|
||||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||||
|
|
||||||
@@ -42,13 +43,15 @@ void main() {
|
|||||||
final server = SyncServer(AppConfig(
|
final server = SyncServer(AppConfig(
|
||||||
port: 0, secret: '', name: 'readahead', mediaPath: media));
|
port: 0, secret: '', name: 'readahead', mediaPath: media));
|
||||||
await server.start();
|
await server.start();
|
||||||
final p = PlaybackController();
|
final host = MediaKitHost();
|
||||||
|
final m = MediaPipeline(host: host);
|
||||||
|
final p = m.playback;
|
||||||
try {
|
try {
|
||||||
await p.init();
|
await m.start();
|
||||||
expect(p.pinMismatches, isEmpty,
|
expect(host.pinMismatches, isEmpty,
|
||||||
reason: 'every pinned mpv property took effect');
|
reason: 'every pinned mpv property took effect');
|
||||||
final token = server.session.issueToken('probe', '');
|
final token = server.session.issueToken('probe', '');
|
||||||
await p.openUrl(
|
await m.open(
|
||||||
'http://127.0.0.1:${server.boundPort}/m/default/$token/readahead.mp4');
|
'http://127.0.0.1:${server.boundPort}/m/default/$token/readahead.mp4');
|
||||||
var best = 0.0;
|
var best = 0.0;
|
||||||
final end = DateTime.now().add(const Duration(seconds: 30));
|
final end = DateTime.now().add(const Duration(seconds: 30));
|
||||||
@@ -62,7 +65,7 @@ void main() {
|
|||||||
expect(best, greaterThan(120),
|
expect(best, greaterThan(120),
|
||||||
reason: 'paused player reads ahead about two minutes');
|
reason: 'paused player reads ahead about two minutes');
|
||||||
} finally {
|
} finally {
|
||||||
await p.dispose();
|
await m.dispose();
|
||||||
await server.stop();
|
await server.stop();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,15 +35,130 @@ Future<String> _media() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _poll(FutureOr<bool> Function() cond, String what,
|
Future<void> _poll(FutureOr<bool> Function() cond, String what,
|
||||||
{Duration timeout = const Duration(seconds: 30)}) async {
|
{Duration timeout = const Duration(seconds: 30), Duration every = const Duration(milliseconds: 200)}) async {
|
||||||
final deadline = DateTime.now().add(timeout);
|
final deadline = DateTime.now().add(timeout);
|
||||||
while (DateTime.now().isBefore(deadline)) {
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
if (await cond()) return;
|
if (await cond()) return;
|
||||||
await Future.delayed(const Duration(milliseconds: 200));
|
await Future.delayed(every);
|
||||||
}
|
}
|
||||||
fail('timed out waiting for $what');
|
fail('timed out waiting for $what');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 60 s at about 3 Mbit/s: long enough that a slow source's relay holds
|
||||||
|
/// only part of it. An mp4 with its index first: headless players have no
|
||||||
|
/// video track selected (vid=no), and mkv's cues list only the video, so
|
||||||
|
/// an mkv would be seeked by scanning from its start (an app's player,
|
||||||
|
/// with its video output, uses the cues).
|
||||||
|
Future<String> _longMedia() async {
|
||||||
|
final path = '$_tmp/relay_e2e_60s.mp4';
|
||||||
|
if (await File(path).exists()) return path;
|
||||||
|
final r = await Process.run('ffmpeg', [
|
||||||
|
'-y', '-v', 'error',
|
||||||
|
'-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=24',
|
||||||
|
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
|
||||||
|
'-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-b:v', '3M', '-minrate', '3M', '-maxrate', '3M',
|
||||||
|
'-bufsize', '2M', '-g', '24', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', path,
|
||||||
|
]);
|
||||||
|
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file read at most [bytesPerSec] (a source's uplink).
|
||||||
|
class _SlowFile implements RelayBytes {
|
||||||
|
final File file;
|
||||||
|
final int bytesPerSec;
|
||||||
|
_SlowFile(this.file, this.bytesPerSec);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> size() => file.length();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<int>> read(int start, int end) async* {
|
||||||
|
const chunk = 32 * 1024;
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
var sent = 0;
|
||||||
|
await for (final c in file.openRead(start, end + 1)) {
|
||||||
|
for (var o = 0; o < c.length; o += chunk) {
|
||||||
|
final part = c.sublist(o, o + chunk > c.length ? c.length : o + chunk);
|
||||||
|
sent += part.length;
|
||||||
|
final due = sent * 1000 ~/ bytesPerSec - sw.elapsedMilliseconds;
|
||||||
|
if (due > 0) await Future<void>.delayed(Duration(milliseconds: due));
|
||||||
|
yield part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A member that is only a source: it sits out (out of the gate), puts
|
||||||
|
/// [file] up relayed and feeds it at [bytesPerSec]. [drop] goes away
|
||||||
|
/// without a `bye` (the member `gone`, the relay kept); [back] returns as
|
||||||
|
/// the same member and feeds again.
|
||||||
|
class _BareSource {
|
||||||
|
final int port;
|
||||||
|
final File file;
|
||||||
|
final String name;
|
||||||
|
static const _client = 'bareSourceClient01';
|
||||||
|
var _seq = 0;
|
||||||
|
WebSocket? ws;
|
||||||
|
String token = '';
|
||||||
|
RelaySource? src;
|
||||||
|
Timer? _alive;
|
||||||
|
_BareSource(this.port, this.file) : name = 'slow.mp4';
|
||||||
|
|
||||||
|
Future<void> join() async {
|
||||||
|
final w = ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||||
|
final welcome = Completer<Map>();
|
||||||
|
w.listen((f) {
|
||||||
|
final j = jsonDecode('$f') as Map;
|
||||||
|
if (j['t'] == 'welcome' && !welcome.isCompleted) welcome.complete(j);
|
||||||
|
}, onError: (_) {});
|
||||||
|
w.add(jsonEncode(HelloMessage(
|
||||||
|
secret: 'pw', user: const HelloUser(name: 'bob'), client: _client, seq: ++_seq)
|
||||||
|
.toJson()));
|
||||||
|
token = (await welcome.future.timeout(const Duration(seconds: 5)))['token'] as String;
|
||||||
|
w.add(jsonEncode(const PresenceIntent(state: Presence.away).toJson()));
|
||||||
|
// Not silent, like an app (a silent member goes gone).
|
||||||
|
_alive?.cancel();
|
||||||
|
_alive = Timer.periodic(const Duration(seconds: 1),
|
||||||
|
(_) => w.add(jsonEncode({'t': 'report', 'position': 0, 'buffered': 0, 'eventId': 0})));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> swap() async {
|
||||||
|
ws!.add(jsonEncode({
|
||||||
|
't': 'swap',
|
||||||
|
'media': {'name': name, 'size': await file.length(), 'duration': 0, 'relay': <String, Object?>{}},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> feed(int bytesPerSec) async {
|
||||||
|
final s = src = RelaySource(Uri.parse('ws://127.0.0.1:$port/r/default/$token/source'),
|
||||||
|
RelayMedia.single(_SlowFile(file, bytesPerSec)),
|
||||||
|
name: name);
|
||||||
|
await s.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gone without a word: the channel and the socket close, no `bye`.
|
||||||
|
Future<void> drop() async {
|
||||||
|
_alive?.cancel();
|
||||||
|
await src?.close();
|
||||||
|
await ws?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> back(int bytesPerSec) async {
|
||||||
|
await join();
|
||||||
|
await feed(bytesPerSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() async {
|
||||||
|
_alive?.cancel();
|
||||||
|
await src?.close();
|
||||||
|
await ws?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Serves one file with ranges; [requests] counts hits.
|
/// Serves one file with ranges; [requests] counts hits.
|
||||||
Future<(HttpServer, List<String>)> _origin(String file) async {
|
Future<(HttpServer, List<String>)> _origin(String file) async {
|
||||||
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
@@ -267,4 +382,113 @@ void main() {
|
|||||||
await proxy.close();
|
await proxy.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A seek into a part the relay has not buffered yet. The source's
|
||||||
|
// uplink is about 3x the media's bitrate; the time from the seek to the
|
||||||
|
// player playing there again is printed and loosely bounded.
|
||||||
|
test('a seek past what a slow source has sent plays within a few seconds',
|
||||||
|
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||||
|
final file = File(await _longMedia());
|
||||||
|
final alice = await member('alice');
|
||||||
|
final bob = _BareSource(server.boundPort, file);
|
||||||
|
try {
|
||||||
|
await bob.join();
|
||||||
|
await bob.swap();
|
||||||
|
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
|
||||||
|
await bob.feed(1200 * 1024);
|
||||||
|
await _poll(() => alice.playback.durationSecs > 59, 'alice opened it');
|
||||||
|
alice.toggleReady();
|
||||||
|
await _poll(() => alice.playback.playing && alice.playback.positionSecs > 2, 'playing');
|
||||||
|
final took = <int>[];
|
||||||
|
for (final to in [40.0, 52.0]) {
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
alice.seekTo(to);
|
||||||
|
await _poll(() => alice.playback.playing && alice.playback.positionSecs > to + 0.5, 'played at $to',
|
||||||
|
every: const Duration(milliseconds: 20));
|
||||||
|
took.add(sw.elapsedMilliseconds);
|
||||||
|
}
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('relay seek: seek to an unbuffered part until playing there: ${took.join(', ')} ms');
|
||||||
|
for (final t in took) {
|
||||||
|
expect(t, lessThan(5000), reason: "a seek into what the source has not sent yet (4 s of buffer at about 3x the bitrate)");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await bob.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// D6, the viewer's side: the source goes, a seek lands past what
|
||||||
|
// the server got from it, and the buffer runs dry.
|
||||||
|
test('the source drops: what is buffered plays, a seek past it waits for the source, no seek loop, it resumes (D6)',
|
||||||
|
timeout: const Timeout(Duration(minutes: 3)), () async {
|
||||||
|
final file = File(await _longMedia());
|
||||||
|
final alice = await member('alice');
|
||||||
|
final carol = await member('carol');
|
||||||
|
final bob = _BareSource(server.boundPort, file);
|
||||||
|
final errors = <String>[];
|
||||||
|
final subs = <StreamSubscription<Object?>>[];
|
||||||
|
try {
|
||||||
|
await bob.join();
|
||||||
|
await bob.swap();
|
||||||
|
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
|
||||||
|
// Just above the bitrate: the relay holds only a little ahead.
|
||||||
|
await bob.feed(560 * 1024);
|
||||||
|
await _poll(() => alice.playback.durationSecs > 59 && carol.playback.durationSecs > 59, 'both opened it');
|
||||||
|
for (final x in [alice, carol]) {
|
||||||
|
subs.add(x.playback.player.logs.listen((l) {
|
||||||
|
if (l.text.contains('error running command')) errors.add(l.text);
|
||||||
|
}));
|
||||||
|
x.toggleReady();
|
||||||
|
}
|
||||||
|
await _poll(() => alice.playback.playing && alice.playback.positionSecs > 4, 'playing');
|
||||||
|
|
||||||
|
await bob.drop();
|
||||||
|
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.sourceGone ?? false, 'source gone');
|
||||||
|
expect(alice.status.peek().line?.text, startsWith("bob's connection dropped"));
|
||||||
|
final seeks = [alice.playback.seeksSent, carol.playback.seeksSent];
|
||||||
|
// Carol skips to where the source never got to.
|
||||||
|
carol.seekTo(50);
|
||||||
|
await _poll(() => alice.serverState.peek()!.hold?.reason == HoldReason.source, 'held for the source',
|
||||||
|
timeout: const Duration(seconds: 60));
|
||||||
|
expect(alice.status.peek().line?.text, 'Waiting for bob');
|
||||||
|
expect(carol.status.peek().line?.text, 'Waiting for bob');
|
||||||
|
// Held for a while: no seek loop into bytes nobody sends.
|
||||||
|
await Future.delayed(const Duration(seconds: 12));
|
||||||
|
final sent = [alice.playback.seeksSent - seeks[0], carol.playback.seeksSent - seeks[1]];
|
||||||
|
expect(sent.every((n) => n <= 1), isTrue, reason: 'one try each, no loop: $sent');
|
||||||
|
expect(alice.serverState.peek()!.transport, 'held');
|
||||||
|
expect([alice.playerError.peek(), carol.playerError.peek()], everyElement(isEmpty), reason: 'not a broken player');
|
||||||
|
// Someone joins meanwhile: it opens what the server has and waits too.
|
||||||
|
final dave = await member('dave');
|
||||||
|
subs.add(dave.playback.player.logs.listen((l) {
|
||||||
|
if (l.text.contains('error running command')) errors.add(l.text);
|
||||||
|
}));
|
||||||
|
await _poll(() => dave.status.peek().line?.text == 'Waiting for bob', 'dave waits for bob');
|
||||||
|
dave.toggleReady();
|
||||||
|
|
||||||
|
await bob.back(4 * 1024 * 1024);
|
||||||
|
await _poll(() {
|
||||||
|
final relay = stateMediaRelay(alice.serverState.peek()!);
|
||||||
|
return relay != null && relay.live && !relay.sourceGone;
|
||||||
|
}, 'source back');
|
||||||
|
await _poll(
|
||||||
|
() =>
|
||||||
|
alice.serverState.peek()!.transport == 'playing' &&
|
||||||
|
alice.playback.playing &&
|
||||||
|
alice.playback.positionSecs > 50.5 &&
|
||||||
|
carol.playback.positionSecs > 50.5 &&
|
||||||
|
dave.playback.positionSecs > 50.5,
|
||||||
|
'all playing at 50 again',
|
||||||
|
timeout: const Duration(seconds: 40));
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('D6: seeks while the source was gone $sent, held back '
|
||||||
|
'${alice.media.heldBackSeeks} + ${carol.media.heldBackSeeks}');
|
||||||
|
expect(errors, isEmpty, reason: 'no command reached a player without its media');
|
||||||
|
} finally {
|
||||||
|
for (final s in subs) {
|
||||||
|
await s.cancel();
|
||||||
|
}
|
||||||
|
await bob.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,8 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:starsync/src/playback/playback_controller.dart';
|
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
|
||||||
|
import 'package:starsync/src/session/media_pipeline.dart';
|
||||||
import 'package:starsync/src/shaders/shader_assets.dart';
|
import 'package:starsync/src/shaders/shader_assets.dart';
|
||||||
import 'package:starsync/src/shaders/shader_presets.dart';
|
import 'package:starsync/src/shaders/shader_presets.dart';
|
||||||
import 'package:starsync/src/state/settings.dart';
|
import 'package:starsync/src/state/settings.dart';
|
||||||
@@ -21,12 +22,14 @@ void main() {
|
|||||||
support.createSync(recursive: true);
|
support.createSync(recursive: true);
|
||||||
shaderSupportDir = () async => support;
|
shaderSupportDir = () async => support;
|
||||||
final s = AppSettings.instance;
|
final s = AppSettings.instance;
|
||||||
final p = PlaybackController();
|
final m = MediaPipeline(host: MediaKitHost());
|
||||||
|
final p = m.playback;
|
||||||
try {
|
try {
|
||||||
// The saved choice goes on at player creation.
|
// The saved choice goes on at player creation.
|
||||||
s.setShaderPreset(ShaderPreset.a);
|
s.setShaderPreset(ShaderPreset.a);
|
||||||
await p.init();
|
await m.start();
|
||||||
final dynamic mpv = p.player.platform;
|
await p.applyShaders();
|
||||||
|
final mpv = p.player;
|
||||||
final modeA =
|
final modeA =
|
||||||
await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const []);
|
await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const []);
|
||||||
expect(shaderListMatches(modeA, await mpv.getProperty('glsl-shaders')), isTrue);
|
expect(shaderListMatches(modeA, await mpv.getProperty('glsl-shaders')), isTrue);
|
||||||
@@ -59,17 +62,16 @@ void main() {
|
|||||||
expect(await mpv.getProperty('glsl-shaders'), good.path);
|
expect(await mpv.getProperty('glsl-shaders'), good.path);
|
||||||
|
|
||||||
// A recreated player (Reload video) gets the choice again.
|
// A recreated player (Reload video) gets the choice again.
|
||||||
await p.recreate();
|
await m.reload();
|
||||||
expect(await (p.player.platform as dynamic).getProperty('glsl-shaders'),
|
await p.applyShaders();
|
||||||
good.path);
|
expect(await p.player.getProperty('glsl-shaders'), good.path);
|
||||||
|
|
||||||
s.setShaderPreset(ShaderPreset.off);
|
s.setShaderPreset(ShaderPreset.off);
|
||||||
await p.applyShaders(announce: true);
|
await p.applyShaders(announce: true);
|
||||||
expect(await (p.player.platform as dynamic).getProperty('glsl-shaders'),
|
expect(await p.player.getProperty('glsl-shaders'), '');
|
||||||
'');
|
|
||||||
expect(p.shaderNotice.value?.text, 'Shaders: Off');
|
expect(p.shaderNotice.value?.text, 'Shaders: Off');
|
||||||
} finally {
|
} finally {
|
||||||
await p.dispose();
|
await m.dispose();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ const baseline = {
|
|||||||
'UpdateController': 8,
|
'UpdateController': 8,
|
||||||
'Profile': 8,
|
'Profile': 8,
|
||||||
'FolderStore': 6,
|
'FolderStore': 6,
|
||||||
'AppYtDlp': 4,
|
'AppYtDlp': 2,
|
||||||
'RecentLinks': 3,
|
'RecentLinks': 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ Future<void> _poll(
|
|||||||
Future<void> _quiet(SessionCoordinator c) async {
|
Future<void> _quiet(SessionCoordinator c) async {
|
||||||
await c.playback.player.setVolume(0);
|
await c.playback.player.setVolume(0);
|
||||||
try {
|
try {
|
||||||
final dynamic platform = c.playback.player.platform;
|
await c.playback.player.setProperty('loop-file', 'inf');
|
||||||
await platform?.setProperty('loop-file', 'inf');
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
// restructure: rewrite in step 6 (Media pipeline + player host): the
|
|
||||||
// fresh player per video.
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:sync_relay_server/server.dart';
|
import 'package:sync_relay_server/server.dart';
|
||||||
|
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
|
||||||
import 'package:starsync/src/session/session_coordinator.dart';
|
import 'package:starsync/src/session/session_coordinator.dart';
|
||||||
import 'package:starsync/src/sync/state_diff.dart';
|
import 'package:starsync/src/sync/state_diff.dart';
|
||||||
|
|
||||||
|
// "Reload video" through the media pipeline: a fresh player from the
|
||||||
|
// host, the old one retired, the same media opened again and resynced,
|
||||||
|
// the ready flag untouched; leaving releases every player the host made.
|
||||||
|
|
||||||
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4';
|
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4';
|
||||||
|
|
||||||
Future<void> _poll(
|
Future<void> _poll(
|
||||||
@@ -26,47 +28,46 @@ Future<void> _poll(
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test(
|
test(
|
||||||
'reload video: new player, same media, resynced, ready untouched; '
|
'reload video: a fresh player from the host, same media, resynced, ready untouched; '
|
||||||
'leave disposes the player',
|
'leave releases every player',
|
||||||
timeout: const Timeout(Duration(minutes: 2)),
|
timeout: const Timeout(Duration(minutes: 2)),
|
||||||
() async {
|
() async {
|
||||||
MediaKit.ensureInitialized();
|
final server = SyncServer(const AppConfig(port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
|
||||||
final server = SyncServer(const AppConfig(
|
|
||||||
port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
|
|
||||||
await server.start();
|
await server.start();
|
||||||
final c = SessionCoordinator();
|
final host = MediaKitHost()..init();
|
||||||
|
final c = SessionCoordinator(players: host);
|
||||||
try {
|
try {
|
||||||
await c.connect(ConnectParams(
|
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'reload', name: 'solo'));
|
||||||
host: '127.0.0.1',
|
await c.playback.setVolume(0);
|
||||||
port: server.boundPort,
|
|
||||||
secret: 'reload',
|
|
||||||
name: 'solo'));
|
|
||||||
await c.playback.player.setVolume(0);
|
|
||||||
await _poll(() => c.serverState.peek() != null, 'server state');
|
await _poll(() => c.serverState.peek() != null, 'server state');
|
||||||
c.toggleReady();
|
c.toggleReady();
|
||||||
await _poll(
|
await _poll(() => c.playback.playing && c.playback.positionSecs > 2, 'solo playback past 2 s');
|
||||||
() => c.playback.player.state.playing && c.playback.positionSecs > 2,
|
final opens = c.opens;
|
||||||
'solo playback past 2 s');
|
|
||||||
|
|
||||||
final old = c.playback.player;
|
final old = c.playback.player;
|
||||||
final gen = c.playback.generation.peek();
|
final gen = c.playback.generation.peek();
|
||||||
await c.reloadVideo();
|
await c.reloadVideo();
|
||||||
expect(identical(c.playback.player, old), isFalse);
|
expect(identical(c.playback.player, old), isFalse, reason: 'a fresh player');
|
||||||
|
expect(old.disposed, isTrue, reason: 'the old one retired');
|
||||||
|
expect(host.live, 1, reason: 'only the new one is alive');
|
||||||
expect(c.playback.generation.peek(), gen + 1);
|
expect(c.playback.generation.peek(), gen + 1);
|
||||||
|
expect(c.opens, opens + 1, reason: 'the same media opened again');
|
||||||
|
expect(c.playback.volume.peek(), 0, reason: 'the volume carried over');
|
||||||
expect(c.selfMember.peek()?.ready, isTrue);
|
expect(c.selfMember.peek()?.ready, isTrue);
|
||||||
|
|
||||||
await _poll(() {
|
await _poll(() {
|
||||||
final r = c.serverState.peek();
|
final r = c.serverState.peek();
|
||||||
if (r == null || !c.playback.player.state.playing) return false;
|
if (r == null || !c.playback.playing) return false;
|
||||||
final want = serverPositionAt(r, DateTime.now().toUtc());
|
final want = serverPositionAt(r, DateTime.now().toUtc());
|
||||||
return (c.playback.positionSecs - want).abs() < 1.5 &&
|
return (c.playback.positionSecs - want).abs() < 1.5 && c.playback.positionSecs > 2;
|
||||||
c.playback.positionSecs > 2;
|
|
||||||
}, 'reloaded player playing at the synced position');
|
}, 'reloaded player playing at the synced position');
|
||||||
expect(c.selfMember.peek()?.ready, isTrue);
|
expect(c.selfMember.peek()?.ready, isTrue);
|
||||||
expect(c.serverState.peek()?.transport, 'playing');
|
expect(c.serverState.peek()?.transport, 'playing');
|
||||||
|
|
||||||
await c.leave();
|
await c.leave();
|
||||||
expect(c.playback.alive.peek(), isFalse);
|
expect(c.playback.alive.peek(), isFalse);
|
||||||
|
await host.retiring;
|
||||||
|
expect(host.live, 0, reason: 'leaving releases every player');
|
||||||
} finally {
|
} finally {
|
||||||
await c.dispose();
|
await c.dispose();
|
||||||
await server.stop();
|
await server.stop();
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ void main() {
|
|||||||
// dispose would wait on them for ever; the test process ending ends it.
|
// dispose would wait on them for ever; the test process ending ends it.
|
||||||
setUpAll(() async {
|
setUpAll(() async {
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
await coord.playback.init();
|
await coord.media.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('wheel over the video: readout for about a second', (tester) async {
|
testWidgets('wheel over the video: readout for about a second', (tester) async {
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:starsync/src/playback/playback_controller.dart';
|
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
|
||||||
|
import 'package:starsync/src/playback/yt_dlp_app.dart';
|
||||||
|
import 'package:starsync/src/session/media_pipeline.dart';
|
||||||
|
|
||||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||||
const _referer = 'https://www.bilibili.com/';
|
const _referer = 'https://www.bilibili.com/';
|
||||||
@@ -22,7 +24,8 @@ Future<(String, String)> _media() async {
|
|||||||
return (v, a);
|
return (v, a);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A CDN like Bilibili's: 403 without the page's Referer.
|
/// A CDN like Bilibili's: 403 without the page's Referer. `/video/...` is
|
||||||
|
/// the page itself (HTML: the app's probe sends it to yt-dlp).
|
||||||
class _Cdn {
|
class _Cdn {
|
||||||
_Cdn(this._server, this._files) {
|
_Cdn(this._server, this._files) {
|
||||||
_server.listen(_serve);
|
_server.listen(_serve);
|
||||||
@@ -30,11 +33,27 @@ class _Cdn {
|
|||||||
final HttpServer _server;
|
final HttpServer _server;
|
||||||
final Map<String, String> _files;
|
final Map<String, String> _files;
|
||||||
int ok = 0, forbidden = 0;
|
int ok = 0, forbidden = 0;
|
||||||
|
final cookies = <String?>[];
|
||||||
|
|
||||||
String url(String name) => 'http://127.0.0.1:${_server.port}/$name';
|
String url(String name) => 'http://127.0.0.1:${_server.port}/$name';
|
||||||
|
|
||||||
|
String page(String id) => 'http://127.0.0.1:${_server.port}/video/$id';
|
||||||
|
int subtitles = 0;
|
||||||
|
|
||||||
Future<void> _serve(HttpRequest req) async {
|
Future<void> _serve(HttpRequest req) async {
|
||||||
final res = req.response;
|
final res = req.response;
|
||||||
|
if (req.uri.pathSegments.firstOrNull == 'video') {
|
||||||
|
res.headers.contentType = ContentType.html;
|
||||||
|
res.write('<html><title>Fake page</title></html>');
|
||||||
|
await res.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.uri.pathSegments.lastOrNull == 'en.vtt') {
|
||||||
|
subtitles++;
|
||||||
|
res.write('WEBVTT\n\n00:00.000 --> 00:05.000\nHello\n');
|
||||||
|
await res.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
final file = _files[req.uri.pathSegments.lastOrNull];
|
final file = _files[req.uri.pathSegments.lastOrNull];
|
||||||
if (file == null || req.headers.value('referer') != _referer) {
|
if (file == null || req.headers.value('referer') != _referer) {
|
||||||
forbidden++;
|
forbidden++;
|
||||||
@@ -43,6 +62,7 @@ class _Cdn {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ok++;
|
ok++;
|
||||||
|
cookies.add(req.headers.value('cookie'));
|
||||||
final bytes = await File(file).readAsBytes();
|
final bytes = await File(file).readAsBytes();
|
||||||
var start = 0, end = bytes.length - 1;
|
var start = 0, end = bytes.length - 1;
|
||||||
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
|
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
|
||||||
@@ -59,25 +79,37 @@ class _Cdn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A stand-in yt-dlp printing what yt-dlp gives for a split-track page:
|
/// A stand-in yt-dlp (the managed copy in its own folder) printing what
|
||||||
/// `requested_formats` with per-format `http_headers`, none at the top.
|
/// yt-dlp gives for a split-track page: `requested_formats` with
|
||||||
Future<String> _fakeYtDlp(_Cdn cdn, {required bool headers}) async {
|
/// per-format `http_headers` and `cookies`, subtitles with Bilibili's
|
||||||
final dir = await Directory('$_tmp/ytdl_headers_fake').create(recursive: true);
|
/// danmaku among them. [fail]: it finds no formats instead.
|
||||||
|
Future<AppYtDlp> _fakeYtDlp(_Cdn cdn, {required bool headers, bool fail = false}) async {
|
||||||
|
final dir = Directory('$_tmp/ytdl_direct_${headers ? 'h' : 'n'}${fail ? 'f' : ''}');
|
||||||
|
if (dir.existsSync()) dir.deleteSync(recursive: true);
|
||||||
|
dir.createSync(recursive: true);
|
||||||
final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{};
|
final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{};
|
||||||
final json = jsonEncode({
|
final json = jsonEncode({
|
||||||
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
|
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
|
||||||
'format_id': 'v+a', 'ext': 'mp4',
|
'format_id': 'v+a', 'ext': 'mp4',
|
||||||
'requested_formats': [
|
'requested_formats': [
|
||||||
{'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4',
|
{'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4',
|
||||||
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h},
|
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h,
|
||||||
|
'cookies': 'SESSDATA=abc; Domain=.127.0.0.1; Path=/'},
|
||||||
{'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a',
|
{'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a',
|
||||||
'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h},
|
'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h},
|
||||||
],
|
],
|
||||||
|
'requested_subtitles': {
|
||||||
|
'en': {'url': cdn.url('en.vtt'), 'ext': 'vtt', 'name': 'English'},
|
||||||
|
'danmaku': {'url': cdn.url('danmaku.xml'), 'ext': 'xml'},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
final f = File('${dir.path}/yt-dlp-${headers ? 'h' : 'n'}');
|
final f = File('${dir.path}/yt-dlp');
|
||||||
await f.writeAsString("#!/bin/sh\ncat <<'EOF'\n$json\nEOF\n");
|
await f.writeAsString('#!/bin/sh\n'
|
||||||
|
'case "\$1" in --version) echo 2099.01.01; exit 0;; esac\n'
|
||||||
|
'${fail ? 'echo "ERROR: [fake] BV1: No video formats found!" >&2; exit 1\n' : "cat <<'EOF'\n$json\nEOF\n"}');
|
||||||
await Process.run('chmod', ['+x', f.path]);
|
await Process.run('chmod', ['+x', f.path]);
|
||||||
return f.path;
|
await File('${dir.path}/last-check').writeAsString(DateTime.now().toIso8601String());
|
||||||
|
return AppYtDlp(YtDlp(dir.path, updateEvery: const Duration(days: 9999)));
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -85,32 +117,39 @@ void main() {
|
|||||||
late HttpServer server;
|
late HttpServer server;
|
||||||
|
|
||||||
setUpAll(() async {
|
setUpAll(() async {
|
||||||
MediaKit.ensureInitialized();
|
|
||||||
final (v, a) = await _media();
|
final (v, a) = await _media();
|
||||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
cdn = _Cdn(server, {'v.m4s': v, 'a.m4s': a});
|
cdn = _Cdn(server, {'v.m4s': v, 'a.m4s': a});
|
||||||
});
|
});
|
||||||
tearDownAll(() => server.close(force: true));
|
tearDownAll(() => server.close(force: true));
|
||||||
|
|
||||||
|
// A page "everyone fetches" opens through the app's own resolve: its
|
||||||
|
// stream URLs (an EDL of video + audio) with the CDN's headers; mpv's
|
||||||
|
// yt-dlp hook is off.
|
||||||
test(
|
test(
|
||||||
"mpv's yt-dlp hook sends each format's Referer to the CDN",
|
"a page plays from yt-dlp's stream URLs with each CDN's Referer and the cookies; subtitles but danmaku",
|
||||||
timeout: const Timeout(Duration(minutes: 1)),
|
timeout: const Timeout(Duration(minutes: 1)),
|
||||||
() async {
|
() async {
|
||||||
final p = PlaybackController();
|
final host = MediaKitHost()..init();
|
||||||
|
final m = MediaPipeline(host: host, links: await _fakeYtDlp(cdn, headers: true));
|
||||||
|
final p = m.playback;
|
||||||
try {
|
try {
|
||||||
await p.init();
|
await m.start();
|
||||||
expect(p.pinMismatches, isEmpty, reason: 'stream-lavf-o and the rest took effect');
|
expect(host.pinMismatches, isEmpty, reason: 'stream-lavf-o, ytdl=no and the rest took effect');
|
||||||
await p.useYtDlp(await _fakeYtDlp(cdn, headers: true));
|
expect(await p.player.getProperty('ytdl'), 'no');
|
||||||
await p.openUrl('https://www.bilibili.com/video/BV1fake');
|
expect(await m.open(cdn.page('BV1fake')), isTrue);
|
||||||
final end = DateTime.now().add(const Duration(seconds: 20));
|
final end = DateTime.now().add(const Duration(seconds: 20));
|
||||||
while (DateTime.now().isBefore(end) && p.duration.peek() == Duration.zero) {
|
while (DateTime.now().isBefore(end) && (p.duration.peek() == Duration.zero || cdn.subtitles == 0)) {
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
}
|
}
|
||||||
expect(p.duration.peek(), greaterThan(Duration.zero), reason: 'both tracks opened');
|
expect(p.duration.peek(), greaterThan(Duration.zero), reason: 'both tracks opened');
|
||||||
expect(cdn.forbidden, 0, reason: 'every request had the Referer');
|
expect(cdn.forbidden, 0, reason: 'every request had the Referer');
|
||||||
expect(cdn.ok, greaterThanOrEqualTo(2));
|
expect(cdn.ok, greaterThanOrEqualTo(2));
|
||||||
|
expect(cdn.cookies, everyElement('SESSDATA=abc'));
|
||||||
|
expect(cdn.subtitles, 1, reason: 'the page\'s subtitles are added, danmaku is not');
|
||||||
|
await _until(() => p.tracks.peek().any((t) => t.isExternal), 'the subtitle track listed');
|
||||||
} finally {
|
} finally {
|
||||||
await p.dispose();
|
await m.dispose();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
|
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
|
||||||
@@ -120,29 +159,54 @@ void main() {
|
|||||||
'a 403 is a load error, not a reconnect loop',
|
'a 403 is a load error, not a reconnect loop',
|
||||||
timeout: const Timeout(Duration(minutes: 1)),
|
timeout: const Timeout(Duration(minutes: 1)),
|
||||||
() async {
|
() async {
|
||||||
final p = PlaybackController();
|
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: false));
|
||||||
|
final p = m.playback;
|
||||||
final errors = <String>[];
|
final errors = <String>[];
|
||||||
p.onLoadError = errors.add;
|
p.onLoadError = errors.add;
|
||||||
try {
|
try {
|
||||||
await p.init();
|
await m.start();
|
||||||
var logs = 0;
|
var logs = 0;
|
||||||
final sub = p.player.stream.log.listen((_) => logs++);
|
final sub = p.player.logs.listen((_) => logs++);
|
||||||
await p.useYtDlp(await _fakeYtDlp(cdn, headers: false));
|
|
||||||
final before = cdn.forbidden;
|
final before = cdn.forbidden;
|
||||||
await p.openUrl('https://www.bilibili.com/video/BV2fake');
|
await m.open(cdn.page('BV2fake'));
|
||||||
await Future.delayed(const Duration(seconds: 8));
|
await Future.delayed(const Duration(seconds: 8));
|
||||||
await sub.cancel();
|
await sub.cancel();
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('403s: ${cdn.forbidden - before}, log lines: $logs, errors: $errors');
|
print('403s: ${cdn.forbidden - before}, log lines: $logs, errors: $errors');
|
||||||
expect(cdn.forbidden - before, lessThan(10), reason: 'no retry loop on 403');
|
expect(cdn.forbidden - before, lessThan(10), reason: 'no retry loop on 403');
|
||||||
expect(errors, hasLength(1));
|
expect(errors, hasLength(1));
|
||||||
// The status only when ffmpeg logs through this mpv (see _onLog).
|
// The status only when ffmpeg logs through this mpv (see the host).
|
||||||
expect(errors.single, anyOf(contains('403'), contains('could not be opened')));
|
expect(errors.single, anyOf(contains('403'), contains('could not be opened')));
|
||||||
expect(logs, lessThan(30), reason: 'warnings and errors only');
|
expect(logs, lessThan(30), reason: 'warnings and errors only');
|
||||||
} finally {
|
} finally {
|
||||||
await p.dispose();
|
await m.dispose();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
|
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// D11: yt-dlp finds no video; the error names the cookies it used.
|
||||||
|
test('a page yt-dlp finds no video on: the error says which cookies were used', () async {
|
||||||
|
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: true, fail: true));
|
||||||
|
final got = <(String, String?)>[];
|
||||||
|
m.onError = (raw, {cookies}) => got.add((raw, cookies));
|
||||||
|
try {
|
||||||
|
await m.start();
|
||||||
|
expect(await m.open(cdn.page('BV3fake')), isFalse);
|
||||||
|
expect(got.single.$1, contains('No video formats found'));
|
||||||
|
expect(got.single.$2, 'no cookies were used');
|
||||||
|
expect(withCookieNote(playErrorText(got.single.$1), got.single.$2),
|
||||||
|
'No video found: it may need signing in (no cookies were used)');
|
||||||
|
} finally {
|
||||||
|
await m.dispose();
|
||||||
|
}
|
||||||
|
}, skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _until(bool Function() cond, String what) async {
|
||||||
|
final end = DateTime.now().add(const Duration(seconds: 10));
|
||||||
|
while (!cond()) {
|
||||||
|
if (DateTime.now().isAfter(end)) fail('timed out waiting for $what');
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+246
-128
@@ -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/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 |
|
| `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 |
|
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
|
||||||
| `deploy` | systemd unit for the dedicated server | |
|
| `deploy` | systemd unit for the dedicated server | |
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ only stopping sharing sends guests out.
|
|||||||
|
|
||||||
`AppGraph` (`app/app_graph.dart`) is built once in `main.dart`
|
`AppGraph` (`app/app_graph.dart`) is built once in `main.dart`
|
||||||
(`AppGraph.build`) and constructs and disposes the app's systems: the
|
(`AppGraph.build`) and constructs and disposes the app's systems: the
|
||||||
`Ports`, the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the
|
`Ports` (the player host among them, handed to the coordinator), the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the
|
||||||
`SessionCoordinator`, the connect form and recents (`ConnectStore`,
|
`SessionCoordinator`, the connect form and recents (`ConnectStore`,
|
||||||
`state/connect_prefs.dart`), `YourServer`, `Navigation` and the library's
|
`state/connect_prefs.dart`), `YourServer`, `Navigation` and the library's
|
||||||
matches (`ShowMatcher`, "Library", whose `refForPath` it hands the
|
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
|
pause after a newer play. Between events: drift
|
||||||
over 5 s for more than 2 s seeks, ahead by more than 1.5 s sets rate 0.95
|
over 5 s for more than 2 s seeks, ahead by more than 1.5 s sets rate 0.95
|
||||||
until level, otherwise nothing.
|
until level, otherwise nothing.
|
||||||
- **User seeks**: the player is a `SyncPlayer` whose public `seek` is the
|
- **User seeks**: a hosted player's public `seek` is the user's (its
|
||||||
user's: the session's seek bar (`ChapterSeekBar`, one `seek` per pointer
|
`syncSeek` moves mpv): the session's seek bar (`ChapterSeekBar`, one `seek` per pointer
|
||||||
move while dragging and one on release, like media_kit's), the arrow
|
move while dragging and one on release, like media_kit's), the arrow
|
||||||
keys, skips, chapter jumps and `seekTo` all become explicit events. The player moves at
|
keys, skips, chapter jumps and `seekTo` all become explicit events. The player moves at
|
||||||
once (scrub preview); `UserSeekGate` sends one `seek` intent with a fresh
|
once (scrub preview); `UserSeekGate` sends one `seek` intent with a fresh
|
||||||
@@ -274,8 +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
|
most), server seeks never touch the player; the echo of our own seek is not
|
||||||
applied either (the player is already there). A server seek that has not
|
applied either (the player is already there). A server seek that has not
|
||||||
landed after 1.5 s is re-applied once (`_checkSeekLanded`), to the server
|
landed after 1.5 s is re-applied once (`_checkSeekLanded`), to the server
|
||||||
position recomputed now, and only while its event is still current and the
|
position recomputed now, and only while its event is still current, the
|
||||||
user is not seeking.
|
user is not seeking and the relay it reads has its source ("Viewer
|
||||||
|
recovery" under "Media pipeline").
|
||||||
- **Liveness**: WS ping every 5 s; a socket without any frame for 15 s
|
- **Liveness**: WS ping every 5 s; a socket without any frame for 15 s
|
||||||
(`silence`) is closed (4015) and its member goes `gone`; the grace, the
|
(`silence`) is closed (4015) and its member goes `gone`; the grace, the
|
||||||
`gone` notice, the joiner bound and the joining timeout follow. The
|
`gone` notice, the joiner bound and the joining timeout follow. The
|
||||||
@@ -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`
|
- **Link**: the media field holds an http(s) URL. It travels as `media.url`
|
||||||
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
|
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
|
||||||
directly (a web page through mpv's yt-dlp hook, see "yt-dlp"); the
|
directly (a web page through its own yt-dlp, see "yt-dlp"); the server
|
||||||
server is only the clock. A pasted link that
|
is only the clock. A pasted link that
|
||||||
`parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the
|
`parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the
|
||||||
ServerId is known (in the link, else `/System/Info/Public`), so its
|
ServerId is known (in the link, else `/System/Info/Public`), so its
|
||||||
`api_key` is never shared (`mediaForLink`, host and swap alike).
|
`api_key` is never shared (`mediaForLink`, host and swap alike).
|
||||||
@@ -452,7 +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
|
outage of a few seconds is ridden out from mpv's cache with no error. Longer
|
||||||
outages exhaust the cache and rely on reconnect (not chaos-tested yet).
|
outages exhaust the cache and rely on reconnect (not chaos-tested yet).
|
||||||
|
|
||||||
mpv settings (`PlaybackController`): `Player` with `bufferSize` 256 MiB
|
mpv settings (the player host, `platform/desktop/media_kit_host.dart`,
|
||||||
|
set on every player before its first open): `Player` with `bufferSize` 256 MiB
|
||||||
(media_kit's `bufferSize` sets both `demuxer-max-bytes` and
|
(media_kit's `bufferSize` sets both `demuxer-max-bytes` and
|
||||||
`demuxer-max-back-bytes`), `libass` on; pinned via runtime property:
|
`demuxer-max-back-bytes`), `libass` on; pinned via runtime property:
|
||||||
`demuxer-readahead-secs=150`, `cache-secs=150` (about two minutes of
|
`demuxer-readahead-secs=150`, `cache-secs=150` (about two minutes of
|
||||||
@@ -463,21 +465,25 @@ file is ~8), `demuxer-max-back-bytes=67108864`,
|
|||||||
(comma form; this mpv build rejects colon-joined): a dropped connection
|
(comma form; this mpv build rejects colon-joined): a dropped connection
|
||||||
or a 5xx is retried, at most 3 times per read and 4 s apart at most; a
|
or a 5xx is retried, at most 3 times per read and 4 s apart at most; a
|
||||||
4xx (a CDN refusing a request) fails at once instead of being retried
|
4xx (a CDN refusing a request) fails at once instead of being retried
|
||||||
(older ffmpeg ignores `reconnect_max_retries`). A load that fails (mpv's
|
(older ffmpeg ignores `reconnect_max_retries`), `ytdl=no` (pages are
|
||||||
|
resolved by the app, never by mpv's Lua hook). A load that fails (mpv's
|
||||||
fatal "No video or audio streams selected", or an EDL track that cannot
|
fatal "No video or audio streams selected", or an EDL track that cannot
|
||||||
be opened) before a duration is known is reported once per open
|
be opened) before a duration is known is reported once per open
|
||||||
(`onLoadError`), with ffmpeg's HTTP status when this player gets
|
(`onLoadError`), with ffmpeg's HTTP status when this player gets
|
||||||
ffmpeg's log (ffmpeg logs through one mpv instance per process: the
|
ffmpeg's log (ffmpeg logs through one mpv instance per process: the
|
||||||
first one, and once that is disposed the next one created; the preload
|
first one, and once that is disposed the next one created; the host
|
||||||
player passes the HTTP errors it gets on to the session player). Since
|
passes the HTTP errors any player gets on as `httpErrors`). Since every
|
||||||
every media gets its own player, the session player is often not that
|
media gets its own player, the session player is often not that
|
||||||
instance: the status is a bonus, load errors are detected either way. That, a yt-dlp hook error and a failed `openUrl` become
|
instance: the status is a bonus, load errors are detected either way.
|
||||||
the coordinator's `playerError` in plain words (`playErrorText`, core
|
That, a page's resolve error (yt-dlp's words and the cookies it used,
|
||||||
|
D11, see "yt-dlp") and a failed open become the coordinator's
|
||||||
|
`playerError` in plain words (`playErrorText`, core
|
||||||
`link_probe.dart`: 412/precondition/login/cookies/sign in → "This site
|
`link_probe.dart`: 412/precondition/login/cookies/sign in → "This site
|
||||||
needs cookies — Settings → Player → yt-dlp → Cookies", 403 → "Not allowed
|
needs cookies — Settings → Player → yt-dlp → Cookies", 403 → "Not allowed
|
||||||
(403)", 404/410 → "Not found (404)", "Unsupported URL" → "Not a video
|
(403)", 404/410 → "Not found (404)", "Unsupported URL" → "Not a video
|
||||||
page yt-dlp knows", network errors → "Couldn't reach the site", anything
|
page yt-dlp knows", network errors → "Couldn't reach the site", anything
|
||||||
else as is). It rides in `report.error` (see "Player error" in the sync
|
else as is; `withCookieNote` adds the cookies to the two sign-in texts).
|
||||||
|
It rides in `report.error` (see "Player error" in the sync
|
||||||
model) until the media opens (a new duration) or the server's `mediaId`
|
model) until the media opens (a new duration) or the server's `mediaId`
|
||||||
moves on; the pill shows "Can't play this: ..." with Retry (Reload
|
moves on; the pill shows "Can't play this: ..." with Retry (Reload
|
||||||
video). The cache is in RAM: `cache-on-disk=no` is pinned (media_kit
|
video). The cache is in RAM: `cache-on-disk=no` is pinned (media_kit
|
||||||
@@ -486,8 +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
|
ignores invalid values silently, so every pin is read back and a mismatch is
|
||||||
logged and kept in `pinMismatches`. Measured against the `/m` endpoint
|
logged and kept in `pinMismatches`. Measured against the `/m` endpoint
|
||||||
(`readahead_e2e_test`): a paused player buffers ~147 s ahead (10 s before).
|
(`readahead_e2e_test`): a paused player buffers ~147 s ahead (10 s before).
|
||||||
media_kit has no public `setProperty`/`getProperty`; the code calls them
|
media_kit has no public `setProperty`/`getProperty`; the host calls them
|
||||||
through a guarded dynamic call on `player.platform`. Hardware decoding comes from Settings (auto, off, custom)
|
through a guarded dynamic call on `player.platform` (`HostedPlayer`). Hardware decoding comes from Settings (auto, off, custom)
|
||||||
and is passed to the `VideoController`; env `SYNC_RELAY_HWDEC` (e.g. `no`)
|
and is passed to the `VideoController`; env `SYNC_RELAY_HWDEC` (e.g. `no`)
|
||||||
overrides it. Auto is mpv `auto` on Linux and `auto-copy` on Windows
|
overrides it. Auto is mpv `auto` on Linux and `auto-copy` on Windows
|
||||||
(`autoHwdec`, also for a blank custom value): media_kit renders through
|
(`autoHwdec`, also for a blank custom value): media_kit renders through
|
||||||
@@ -518,8 +524,9 @@ turns shaders off (saved) with a toast.
|
|||||||
|
|
||||||
Render mode: media_kit's Linux/Windows video output creates mpv's render
|
Render mode: media_kit's Linux/Windows video output creates mpv's render
|
||||||
context (OpenGL, else its software renderer) when the `VideoController` is
|
context (OpenGL, else its software renderer) when the `VideoController` is
|
||||||
created, and nothing in its API says which. The player therefore requests
|
created, and nothing in its API says which. The host therefore requests
|
||||||
mpv logs at `v` while the controller is created: OpenGL init logs
|
mpv logs at `v` while the first output of an app start is created
|
||||||
|
(`PlayerHost.video(probe: true)`): OpenGL init logs
|
||||||
`GL_VERSION` under `libmpv_render`
|
`GL_VERSION` under `libmpv_render`
|
||||||
(`RenderMode.gpu`; "suspected software renderer" means llvmpipe,
|
(`RenderMode.gpu`; "suspected software renderer" means llvmpipe,
|
||||||
`softwareGl`); none within 1.5 s of the controller being ready means the
|
`softwareGl`); none within 1.5 s of the controller being ready means the
|
||||||
@@ -534,48 +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
|
frame, `hwdec-current` and the `hwdec` asked for (the first one per app
|
||||||
start also `mpv-version` and `ffmpeg-version`). Why media_kit fell back to
|
start also `mpv-version` and `ffmpeg-version`). Why media_kit fell back to
|
||||||
software it only prints on stderr (`media_kit: VideoOutput: ...`), which
|
software it only prints on stderr (`media_kit: VideoOutput: ...`), which
|
||||||
the app does not capture. Otherwise the session player logs at `warn`
|
the app does not capture. Otherwise a session player logs at `warn`
|
||||||
(`PlaybackController.logLevel`, switched with `mpv_request_log_messages`
|
(`sessionLogLevel`; the host switches levels with
|
||||||
on media_kit's handle): media_kit drains mpv's log on the UI thread, one
|
`mpv_request_log_messages` on media_kit's handle, the only raw mpv call
|
||||||
message at a time, so a chatty level, or ffmpeg logging every retry of a
|
in the app): media_kit drains mpv's log on the UI thread, one message at
|
||||||
refused request, could starve the UI. Shader and yt-dlp hook errors are
|
a time, so a chatty level, or ffmpeg logging every retry of a refused
|
||||||
`error` lines and still arrive. The thumbnail players keep
|
request, could starve the UI. Shader errors are `error` lines and still
|
||||||
media_kit's default (`error`) and nothing listens to their log. The hardware path is
|
arrive. The host writes every session and preload player's warnings and
|
||||||
|
errors to the app log (`mpv`, a few per kind at a time, `preload `
|
||||||
|
before the prepared player's). Thumbnail players log nothing. The hardware path is
|
||||||
fragile: media_kit borrows whatever EGL context GDK left current on the main
|
fragile: media_kit borrows whatever EGL context GDK left current on the main
|
||||||
thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change
|
thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change
|
||||||
can silently fall back to software.
|
can silently fall back to software.
|
||||||
|
|
||||||
Player lifecycle: a `Player` and its `VideoController` live for one
|
**Player host** (`PlayerHost`, `platform/ports/player_host.dart`; the
|
||||||
media: created on join, disposed on leave, replaced by `recreate()` for
|
desktop's `MediaKitHost`, one per app in `Ports.players`, tests make
|
||||||
"Reload video" (button or Ctrl+R; volume kept), and replaced on every
|
their own): creates players by role (`session`: the full cache, `warn`;
|
||||||
media change, so nothing a player went through (a stuck video output, a
|
`preload`: the prepared one, 48 MiB ahead, muted, `error`; `thumbnail`:
|
||||||
frozen frame) carries over to the next media. `openUrl` promotes the
|
no outputs, no cache, no log) with every pin set before the caller's
|
||||||
preload when it holds the new media (see "Getting ready" under "Up
|
first command (`pinMismatches` lists the read-backs that differ), gives
|
||||||
next"); otherwise, when the player already held media, it creates a fresh
|
a player its video output (`HostedVideo`, with the hwdec policy:
|
||||||
one first, set up as on join (pins, hwdec, yt-dlp hook, shaders, log
|
`SYNC_RELAY_HWDEC`, else the setting, `auto-copy` on Windows), runs the
|
||||||
level, observers) and, with the view up and the render mode known, its
|
render-mode probe and its diagnostics (`renderMode`, also after leaving),
|
||||||
`VideoController` created and ready before the view swaps to it (else the
|
routes mpv's log, and owns the order players go in: `retire` (pause, let
|
||||||
view creates it and the render-mode probe runs on it). Both go through
|
the view drop the texture: two frames and a route pop, then dispose; a
|
||||||
one hand-over (`_adopt`): observers move over, the view swaps to the new
|
new session player waits for the last one, so at most one old player
|
||||||
output (`generation`), volume (and so mute) and shaders carry over, and
|
lingers), `dispose` (one no view showed) and `disposeAll` (app close:
|
||||||
the old player is paused and disposed once the view let go of its
|
every live player, each bounded). A `HostedPlayer` is one mpv: state and
|
||||||
texture (as on leave). A fresh player waits for the previous old one to
|
streams (position, duration, buffer, volume, completed, width, log),
|
||||||
be gone, so at most one old player lingers. The first open after join or
|
open, play/pause/stop, `seek` (the user's, see "User seeks") and
|
||||||
Reload video uses the player just created; joining back to the media the
|
`syncSeek`, rate, volume, properties, commands, `pin` (set and read
|
||||||
player held keeps it (`_openedFor`); a dropped connection never touches
|
back), its log level. Nothing else in the app imports media_kit except
|
||||||
the player. Cost (`SwitchTrace`
|
the `Video` widget glue (`session_player.dart`, `videoControllerOf`).
|
||||||
`player`, headless, so without the `VideoController`): 45 to 65 ms per
|
|
||||||
swap for one app in the process (mpv created and initialised, then
|
**Media pipeline** (`MediaPipeline`, `session/media_pipeline.dart`, owned
|
||||||
pinned), about 150 ms with three in one process
|
by the coordinator): the one way to open media, plan → resolve → open →
|
||||||
(`queue_switch_e2e_test` with `WT_SWITCH_PRELOAD=0`), against a cold
|
ready.
|
||||||
switch of about 1.5 s. `openUrl` opens with `play: false`; play/pause/seek/rate come only from
|
- Plan (`planMedia`, `session/media_plan.dart`, pure; `media_plan_test`
|
||||||
`StateDiff` actions, the play gate (`localPlayAllowed`: self in-set and
|
holds the table): from `state.media`, what this member serves (its
|
||||||
ready) or the user's own seeks (see Sync model). A server seek within 0.5 s of the current position is skipped. `leave()`
|
`MediaOrigin`: a file or a link it passes on, a Jellyfin item it put
|
||||||
stops the player (otherwise audio keeps playing). On media change
|
up; set at its swap, claimed by the state that names it the relay's
|
||||||
(`mediaId` bump) the coordinator stops the previous media at once (no old
|
source), its own capabilities and who is present, where this app reads
|
||||||
picture or sound while the new one resolves, is fetched or opens), then opens the new URL
|
the media from (`SourcePlan`): your server's file (`/m`), a link
|
||||||
(or promotes the preload holding it) and `StateDiff` seeks to 0, holds
|
everyone opens (`DirectLink`; a page is resolved here), another
|
||||||
and resets rate.
|
member's relay or the server's fetch (`/r`, `waiting` until it is fed:
|
||||||
|
`relayWaitsForSource`), its own file or link (it plays its own copy and
|
||||||
|
feeds the relay), a Jellyfin item (`JellyfinSource`: own account, the
|
||||||
|
server's login `/j`, the member passing it on `/r`), or `Unresolvable`
|
||||||
|
(a page on a device that cannot resolve: `LinkResolver.resolvesLinks`
|
||||||
|
false; who can is read from `members[].caps.resolve`; desktop always
|
||||||
|
can, so this is Android's plug-in point).
|
||||||
|
- Resolve: a Jellyfin item through this app's account (or the plan
|
||||||
|
resolved ahead for Up next, `warmJellyfin`), else the server's login,
|
||||||
|
else the member passing it on, else blocked (`openJellyfin`); a web
|
||||||
|
page through the app's own yt-dlp (`LinkResolver.probe`: yt-dlp's
|
||||||
|
stream URLs, an EDL of video and audio, the CDN's headers with its
|
||||||
|
cookies, the subtitles; see "yt-dlp"), failing in plain words with the
|
||||||
|
cookies that were used (`ResolveFailure`, D11); a relay not fed yet is
|
||||||
|
opened once `relay.live`.
|
||||||
|
- Open: a player per media through the host. The prepared player
|
||||||
|
(Up next's, see "Getting ready") is promoted when it holds the same URL
|
||||||
|
and headers; else, when the session player already held media, a fresh
|
||||||
|
one takes over (pins, volume, shaders; with the view up and the render
|
||||||
|
mode known, its video output ready before the view swaps to it, else
|
||||||
|
the view makes it and the probe runs on it); the same media again (a
|
||||||
|
reconnect, joining back) keeps the player. Every hand-over is
|
||||||
|
`PlaybackController.attach`: log, marks and track observers move over,
|
||||||
|
the view swaps to the new output (`generation`), volume and shaders
|
||||||
|
carry over, the old player is retired. The first open after join or
|
||||||
|
Reload video uses the player just created; a dropped connection never
|
||||||
|
touches the player. "Reload video" (button or Ctrl+R) retires the
|
||||||
|
player, starts a fresh one and opens the media again (a page resolved
|
||||||
|
afresh: its stream URLs may have expired). Cost (`SwitchTrace` `player`,
|
||||||
|
headless, without the video output): 45 to 65 ms per swap for one app
|
||||||
|
in the process, about 150 ms with three in one process
|
||||||
|
(`queue_switch_e2e_test` with `WT_SWITCH_PRELOAD=0`), against a cold
|
||||||
|
switch of about 1.6 s.
|
||||||
|
- Ready: the coordinator reports at once after an open (`onOpened`) and
|
||||||
|
when the buffer reaches the gate's need while held, not on the next
|
||||||
|
tick.
|
||||||
|
- Commands: play/pause/seek/rate come only from `StateDiff` actions, the
|
||||||
|
play gate (`localPlayAllowed`: self in-set and ready) or the user's
|
||||||
|
own seeks (see Sync model); a server seek within 0.5 s of the current
|
||||||
|
position is skipped. They go through `PlaybackController.apply`: while
|
||||||
|
a switch is under way (no player, or its media has no duration yet)
|
||||||
|
they wait, the latest seek, transport and rate apply once to the new
|
||||||
|
player when its media is in; mpv never gets a seek it cannot run
|
||||||
|
(media_kit logged "error running command seek" / `event:5` for each).
|
||||||
|
The volume is the controller's (`setVolume`), on every next player.
|
||||||
|
Opens are paused (`play: false`). On media change (`mediaId` bump) the
|
||||||
|
previous media stops at once (no old picture or sound while the new
|
||||||
|
one resolves, is fetched or opens) and `StateDiff` seeks to 0, holds
|
||||||
|
and resets rate. Leaving stops and releases the players.
|
||||||
|
- Being a relay source (`RelayFeeder`, `session/relay_feeder.dart`): the
|
||||||
|
current media's channel and Up next's warm relay (see "Relay"), with
|
||||||
|
the step-1 re-dial (a source rides through this app's own drop). A
|
||||||
|
Jellyfin item this member put up is passed on only once someone
|
||||||
|
present needs it (`jellyfinRelayNeeded`: the server's login does not
|
||||||
|
cover it and a present member does not play that server with its own
|
||||||
|
account; one whose `caps` say Jellyfin but whose `jf` is not reported
|
||||||
|
yet is asked again with the next state), so a session where everyone
|
||||||
|
has an account opens no relay channel.
|
||||||
|
- Viewer recovery (D6): while the relay this app reads has no source
|
||||||
|
(`sourceGone`, or not live with the source away), it plays what is
|
||||||
|
buffered. A seek past that (a server seek, a drift correction) is
|
||||||
|
tried once; more wait, the seek check does not re-apply, and the
|
||||||
|
report says nothing is buffered once this player cannot be where the
|
||||||
|
server is (`starved`), so the server holds everyone with the reason
|
||||||
|
`source` ("Waiting for Bob"; see "App events and platform ports").
|
||||||
|
When the source is back, one seek to the server position resumes the
|
||||||
|
player, and a player that never loaded meanwhile (it opened while the
|
||||||
|
source was gone and its first bytes never came) opens again in a fresh
|
||||||
|
player. Measured (`relay_e2e`, a source just above the bitrate that
|
||||||
|
drops, a seek past what the server got, a member joining meanwhile):
|
||||||
|
at most one seek per viewer while it is gone, nobody's player broken,
|
||||||
|
all play from the seek once it is back.
|
||||||
|
|
||||||
Chapters, segments, tracks (`PlaybackController` signals, values from
|
Chapters, segments, tracks (`PlaybackController` signals, values from
|
||||||
`playback/media_marks.dart`, no widgets; any controls UI reads them):
|
`playback/media_marks.dart`, no widgets; any controls UI reads them):
|
||||||
@@ -716,7 +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
|
(it writes it to a playlist and `loadlist`s it) and mpv keeps both
|
||||||
streams in sync across seeks; one URL keeps the app's "one media URL"
|
streams in sync across seeks; one URL keeps the app's "one media URL"
|
||||||
path (sync, swap, reopen) as is. The source member plays the page
|
path (sync, swap, reopen) as is. The source member plays the page
|
||||||
itself (yt-dlp hook) like any other link.
|
itself (its own resolve, see "yt-dlp") like any other link.
|
||||||
|
|
||||||
A page with only segmented streams (HLS `m3u8_native`, DASH
|
A page with only segmented streams (HLS `m3u8_native`, DASH
|
||||||
`http_dash_segments`, e.g. X's HLS, generic `.m3u8`/`.mpd`) is relayed as
|
`http_dash_segments`, e.g. X's HLS, generic `.m3u8`/`.mpd`) is relayed as
|
||||||
@@ -757,11 +837,11 @@ How bytes move:
|
|||||||
6.6 s unchunked) and even beats mpv reading YouTube directly, since the
|
6.6 s unchunked) and even beats mpv reading YouTube directly, since the
|
||||||
buffer reads ahead at full speed.
|
buffer reads ahead at full speed.
|
||||||
- The server keeps one buffer per track of a relayed media (one for a
|
- The server keeps one buffer per track of a relayed media (one for a
|
||||||
file), a `RelayBuffer` for bytes: 1 MiB slices in
|
file), a `RelayBuffer` for bytes: 256 KiB slices in
|
||||||
memory, filled by one sequential pull at a time. Kept: the first and
|
memory (`relaySliceBytes`), filled by one sequential pull at a time. Kept: the first and
|
||||||
last 2 MiB (container indexes; mpv reads both on open and on seeks),
|
last 2 MiB (container indexes; mpv reads both on open and on seeks),
|
||||||
64 MiB behind the slowest viewer and 256 MiB past the furthest, the
|
64 MiB behind the slowest viewer and 256 MiB past the furthest, the
|
||||||
same as each viewer's mpv buffer (`PlaybackController`), so a skip
|
same as each viewer's mpv buffer (the player host's pins), so a skip
|
||||||
inside the viewers' buffer window is served from memory. Evicted by
|
inside the viewers' buffer window is served from memory. Evicted by
|
||||||
distance from the viewers, never LRU. With 2+ viewers the source sends
|
distance from the viewers, never LRU. With 2+ viewers the source sends
|
||||||
each byte about once. Several tracks split the ahead and behind budgets
|
each byte about once. Several tracks split the ahead and behind budgets
|
||||||
@@ -789,12 +869,21 @@ How bytes move:
|
|||||||
opened in 0.02 s from the buffer.
|
opened in 0.02 s from the buffer.
|
||||||
- Filling a byte buffer: a viewer blocked on a slice first (oldest wait
|
- Filling a byte buffer: a viewer blocked on a slice first (oldest wait
|
||||||
first, handing the source over at slice boundaries), then the pinned
|
first, handing the source over at slice boundaries), then the pinned
|
||||||
slices, then read-ahead from the slowest viewer. Nothing is fetched while nobody
|
slices, then read-ahead from the slowest viewer. A viewer reads the
|
||||||
watches through the relay (a Jellyfin source with only account holders
|
slice being filled as its bytes arrive, not once it is whole (mpv's
|
||||||
uploads nothing).
|
demuxer, blocked in a read, takes a seek only once that read returns).
|
||||||
|
Nothing is fetched while nobody watches through the relay (a Jellyfin
|
||||||
|
source with only account holders uploads nothing).
|
||||||
- mpv leaves superseded requests open but unread after a seek: only a
|
- mpv leaves superseded requests open but unread after a seek: only a
|
||||||
viewer's newest request moves its position or asks the source for
|
viewer's newest request moves its position or asks the source for
|
||||||
anything; older ones still get bytes that are already buffered.
|
anything; older ones still get bytes that are already buffered, and a
|
||||||
|
request a newer one superseded stops holding the pull at once, so a
|
||||||
|
seek's pull starts without waiting for the old slice to finish.
|
||||||
|
Measured (`relay_e2e` "a seek past what a slow source has sent", a
|
||||||
|
source at about 3x a 3 Mbit/s mp4, the gate's 4 s of buffer): a seek
|
||||||
|
into what the source has not sent plays there after 2.8 to 2.9 s (4.0
|
||||||
|
to 4.3 s with 1 MiB slices served whole and the superseded request
|
||||||
|
holding the pull).
|
||||||
- Flow control on the source (`RelayWindow`): one in-flight budget per
|
- Flow control on the source (`RelayWindow`): one in-flight budget per
|
||||||
connection, 2 x delivery rate x min RTT (rate max-filtered over 2 s),
|
connection, 2 x delivery rate x min RTT (rate max-filtered over 2 s),
|
||||||
clamped 256 KiB to 8 MiB; the server acks bytes as they arrive. A
|
clamped 256 KiB to 8 MiB; the server acks bytes as they arrive. A
|
||||||
@@ -834,20 +923,21 @@ How bytes move:
|
|||||||
media_kit's mpv gives up on a silent request after 5 s
|
media_kit's mpv gives up on a silent request after 5 s
|
||||||
(`network-timeout`) with "could not be opened". So viewers open a
|
(`network-timeout`) with "could not be opened". So viewers open a
|
||||||
member relay's `/r/` URL only once `relay.live` is set, as for a
|
member relay's `/r/` URL only once `relay.live` is set, as for a
|
||||||
server fetch (`_waitForSource`; "waiting for <name> to pass the video
|
server fetch (`relayWaitsForSource` in the media plan; "waiting for
|
||||||
on…" meanwhile), unless the source already left (then they open what
|
<name> to pass the video on…" meanwhile), unless the source already
|
||||||
the server has); Up next preloads wait the same way. The putter starts
|
left (then they open what the server has); Up next preloads wait the
|
||||||
|
same way. The putter starts
|
||||||
its source before opening its own player, and the source reads its
|
its source before opening its own player, and the source reads its
|
||||||
tracks' sizes at once.
|
tracks' sizes at once.
|
||||||
- Source away (D6): viewers keep playing from the buffer; a request
|
- Source away (D6): viewers keep playing from the buffer; a request
|
||||||
waits up to 60 s for missing bytes. The source member going `gone`
|
waits up to 60 s for missing bytes. The source member going `gone`
|
||||||
does not end the relay: it lives through the source's grace
|
does not end the relay: it lives through the source's grace
|
||||||
(`media.relay.sourceGone`, `ServerHub.refreshRelaySource`). The source
|
(`media.relay.sourceGone`, `ServerHub.refreshRelaySource`). The source
|
||||||
(`RelaySource`, kept by the coordinator through the app's own drop)
|
(`RelaySource`, kept by the media pipeline's `RelayFeeder` through the
|
||||||
reconnects its channel with backoff (its token lasts through the
|
app's own drop) reconnects its channel with backoff (its token lasts
|
||||||
grace; a half-open channel is caught by its 5 s WebSocket ping) and
|
through the grace; a half-open channel is caught by its 5 s WebSocket
|
||||||
sends its `info` again, the same on every dial (the coordinator passes
|
ping) and sends its `info` again, the same on every dial (the feeder
|
||||||
the media's `name` and `duration`): the same as its first (sizes, piece
|
passes the media's `name` and `duration`): the same as its first (sizes, piece
|
||||||
shapes, kinds, and name and duration when given) carries on, the
|
shapes, kinds, and name and duration when given) carries on, the
|
||||||
buffer filler pulling what is missing; another one is closed with
|
buffer filler pulling what is missing; another one is closed with
|
||||||
4002 and ends the relay (`ServerHub.relayOtherMedia`); a second channel
|
4002 and ends the relay (`ServerHub.relayOtherMedia`); a second channel
|
||||||
@@ -860,6 +950,10 @@ How bytes move:
|
|||||||
a viewer opening then opens the buffer instead of waiting) and "<name>
|
a viewer opening then opens the buffer instead of waiting) and "<name>
|
||||||
left — playing what the server has" once it left the server; the source
|
left — playing what the server has" once it left the server; the source
|
||||||
gone for 5 s (`sourceDropNotice`) or leaving is also a toast, once each.
|
gone for 5 s (`sourceDropNotice`) or leaving is also a toast, once each.
|
||||||
|
A viewer's buffer running dry holds everyone (`state.hold.reason`
|
||||||
|
`source`: "Waiting for <name>"); the viewer's side, never seeking past
|
||||||
|
what it has and resuming when the source is back, is "Viewer recovery"
|
||||||
|
under "Media pipeline".
|
||||||
- Stats (`relay_stats.dart`, `ServerHub.sampleRates` on the 1 s
|
- Stats (`relay_stats.dart`, `ServerHub.sampleRates` on the 1 s
|
||||||
heartbeat; wire in [PROTOCOL.md](PROTOCOL.md)): the source's rate is
|
heartbeat; wire in [PROTOCOL.md](PROTOCOL.md)): the source's rate is
|
||||||
its bytes over the time a pull is in flight (`FeedClock`, one per media
|
its bytes over the time a pull is in flight (`FeedClock`, one per media
|
||||||
@@ -942,18 +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
|
→ Player → yt-dlp → Cookies (a browser and profile, or a file picked or
|
||||||
dropped, kept whole: every site in it; "Remove cookies"; the shell's
|
dropped, kept whole: every site in it; "Remove cookies"; the shell's
|
||||||
own drop target is off on Settings so only this box takes the file); your server shares the folder,
|
own drop target is off on Settings so only this box takes the file); your server shares the folder,
|
||||||
so it uses them too. mpv's hook gets the same as `ytdl-raw-options`
|
so it uses them too. A player gets them through the app's resolve
|
||||||
(`cookies=%N%<path>` / `cookies-from-browser=%N%<spec>`, length-quoted),
|
(the format's cookies as its Cookie header), never on its own. The dedicated
|
||||||
re-set before each external link when it changed, after
|
|
||||||
`sub-langs=all,-danmaku` (Bilibili's danmaku XML is served raw-deflate:
|
|
||||||
ffmpeg warns on every read and the video waits on the track). The dedicated
|
|
||||||
server: `--yt-dlp-cookies <file>` / `ytDlpCookies` converted into its
|
server: `--yt-dlp-cookies <file>` / `ytDlpCookies` converted into its
|
||||||
folder at start; without it the copy and browser setting are removed.
|
folder at start; without it the copy and browser setting are removed.
|
||||||
Values are never logged and never leave the machine.
|
Values are never logged and never leave the machine.
|
||||||
|
A resolve that fails in a way that may be about signing in (cookies,
|
||||||
|
log in, "No video formats found") says which cookies it used (D11,
|
||||||
|
`YtDlp.cookieNote`, `YtDlpException.cookies`, `LinkProbe.cookies`,
|
||||||
|
`withCookieNote`): none; the file and how many of its cookies are for
|
||||||
|
the site (`cookieSite`); a browser and how many cookies for the site
|
||||||
|
yt-dlp reads from it (one run without a URL, `--cookies-from-browser
|
||||||
|
<spec> --cookies <private file>`, which writes the browser's jar to a
|
||||||
|
0600 file in the yt-dlp folder, counted and deleted at once), or that
|
||||||
|
it could not read them. "No video found: it may need signing in (used
|
||||||
|
Firefox's cookies, which have none for bilibili.com: sign in to
|
||||||
|
bilibili.com there)".
|
||||||
- **Resolve** (`YtDlp.resolve`, `resolveArgs`): `yt-dlp -J --no-playlist --no-warnings
|
- **Resolve** (`YtDlp.resolve`, `resolveArgs`): `yt-dlp -J --no-playlist --no-warnings
|
||||||
--ignore-config` plus the cookies, 25 s timeout: title, uploader, duration, thumbnail,
|
--ignore-config -f bv*[height<=1080]+ba/b --write-subs --sub-langs
|
||||||
live or not, and what a relay reads (`pickTracks`), each format with
|
all,-danmaku` plus the cookies, 25 s timeout: title, uploader, duration,
|
||||||
its URL, `http_headers` and `http_chunk_size`. Only plain http(s)
|
thumbnail, live or not; what a player opens (`YtDlpPlay`: yt-dlp's
|
||||||
|
`requested_formats`, else the top-level format; http(s), HLS, or a DASH
|
||||||
|
manifest; one URL, or an mpv EDL of the video and the audio; the first
|
||||||
|
format's `http_headers` plus its `cookies` as a Cookie header; the
|
||||||
|
requested subtitles but Bilibili's danmaku, an XML comment feed); and
|
||||||
|
what a relay reads (`pickTracks`), each format with its URL,
|
||||||
|
`http_headers` and `http_chunk_size`. For a relay only plain http(s)
|
||||||
formats without DRM count:
|
formats without DRM count:
|
||||||
- the best single file (`pickSingleFile`: video and audio not `none`, a
|
- the best single file (`pickSingleFile`: video and audio not `none`, a
|
||||||
video container, tallest up to 1080p, else the shortest above), unless
|
video container, tallest up to 1080p, else the shortest above), unless
|
||||||
@@ -986,18 +1094,19 @@ all three link modes. Nothing is bundled or installed by hand.
|
|||||||
under the button) and offers "Put it up anyway" (`LinkMedia.anyway`),
|
under the button) and offers "Put it up anyway" (`LinkMedia.anyway`),
|
||||||
for when the server or a friend with cookies may still open it;
|
for when the server or a friend with cookies may still open it;
|
||||||
`swapLink` refuses it the same way for any other caller.
|
`swapLink` refuses it the same way for any other caller.
|
||||||
- **Everyone fetches it**: the swap sends the page URL. Before opening an
|
- **Everyone fetches it**: the swap sends the page URL. Every app
|
||||||
external link every app points mpv's hook at its managed copy
|
resolves it with its own yt-dlp (the media pipeline's resolve step,
|
||||||
(`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`,
|
`LinkResolver.probe`, waiting for the download on first use) and opens
|
||||||
once per player), waiting for the download unless the URL names a media
|
what yt-dlp picked (`YtDlpPlay.mpvUrl`) with its headers as mpv's
|
||||||
file. A hook error is a player error (see the mpv settings under "Media"). The hook (mpv
|
`http-header-fields` (media_kit's `Media.httpHeaders`), for every
|
||||||
0.41) gives mpv the first requested format's `http_headers` (User-Agent
|
track of the EDL: Bilibili's CDN answers 403 without the Referer. Its
|
||||||
as `user-agent`; Referer, Cookie, X-Forwarded-For as file-local
|
subtitles are added once the media is in (`sub-add <url> auto`). mpv's
|
||||||
`http-header-fields`, which win over the empty list media_kit sets on
|
own Lua yt-dlp hook is off (`ytdl=no`): one resolve path for every mode,
|
||||||
load) and its `cookies` (`stream-lavf-o`), for every track of its EDL:
|
cookies and headers always the app's, and a libmpv without Lua plays
|
||||||
Bilibili's CDN answers 403 without the Referer. The app never opens a
|
links the same. A resolve that fails is a player error in plain words,
|
||||||
resolved format URL in mpv itself (the putter of "Through me" plays the
|
with the cookies it used. Stream URLs are bound to the resolving
|
||||||
page through the hook too); relays read them with yt-dlp's headers.
|
machine, so each app resolves its own; a page is resolved again at
|
||||||
|
Reload video and for Up next's 30-minute refresh (they expire).
|
||||||
- **Through me**: the putter's probe must give tracks, and a pieces
|
- **Through me**: the putter's probe must give tracks, and a pieces
|
||||||
track's playlist must be readable, else the swap is refused ("Live
|
track's playlist must be readable, else the swap is refused ("Live
|
||||||
streams can't be passed on", DRM, or nothing a relay can read) with a
|
streams can't be passed on", DRM, or nothing a relay can read) with a
|
||||||
@@ -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
|
of a link this app puts up (its swap reads it). Per
|
||||||
kind: a Jellyfin item is resolved with this app's account
|
kind: a Jellyfin item is resolved with this app's account
|
||||||
(`resolveJellyfinRef`: PlaybackInfo and the item) into
|
(`resolveJellyfinRef`: PlaybackInfo and the item) into
|
||||||
`SessionCoordinator.warmJellyfin`, which `_playJellyfin` takes instead
|
`SessionCoordinator.warmJellyfin`, which the media pipeline's resolve
|
||||||
of resolving when the same ref goes up within 35 minutes and the
|
(`openJellyfin`) takes instead of asking again when the same ref goes
|
||||||
|
up within 35 minutes and the
|
||||||
account is unchanged; without an account it is the server's `/j` URL
|
account is unchanged; without an account it is the server's `/j` URL
|
||||||
when `state.relayNext` says the server's login passes it on (see
|
when `state.relayNext` says the server's login passes it on (see
|
||||||
"Jellyfin relay"). A link everyone opens is that link; the app that
|
"Jellyfin relay"). A link everyone opens is that link; the app that
|
||||||
@@ -1184,35 +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
|
read it from there). A link passed on "through me" and a file on
|
||||||
their adder's app (the link, the path; not the host's own file, which
|
their adder's app (the link, the path; not the host's own file, which
|
||||||
plays from `/m`), which also feeds the server's warm relay of it
|
plays from `/m`), which also feeds the server's warm relay of it
|
||||||
(`QueueController._feedWarm`, `SessionCoordinator.feedWarm`; a
|
(`QueueController._feedWarm`, `RelayFeeder.feedWarm`; a
|
||||||
Jellyfin item's from the member who puts it up, with its resolved
|
Jellyfin item's from the member who puts it up, with its resolved
|
||||||
stream, when the server asks for one); everyone else, and everyone
|
stream, when the server asks for one); everyone else, and everyone
|
||||||
for a server-fetched link, preloads that warm relay's URL
|
for a server-fetched link, preloads that warm relay's URL
|
||||||
(`SessionCoordinator.warmRelayUrl`) once `queue[0].media.relay.live`
|
(`MediaPipeline.warmRelayUrl`) once `queue[0].media.relay.live`
|
||||||
says bytes can flow (see "Relay"). At the swap the adder's app skips
|
says bytes can flow (see "Relay"). At the swap the adder's app skips
|
||||||
re-reading the link's sizes (its warm source has them) and that
|
re-reading the link's sizes (its warm source has them) and that
|
||||||
source carries on as the media's. It
|
source carries on as the media's. It
|
||||||
is resolved as soon as it is first there, again every 30 minutes, and
|
is resolved as soon as it is first there, again every 30 minutes, and
|
||||||
dropped when it stops being first.
|
dropped when it stops being first.
|
||||||
**Preload**: once resolved, the stream is opened in a second, hidden
|
**Preload**: once resolved, the stream is opened early by the media
|
||||||
mpv player (`PlaybackController.preload`, through
|
pipeline (`MediaPipeline.prepare`, a page through the same resolve as
|
||||||
`SessionCoordinator.preloadMedia`): paused at the start, muted, with
|
any open) in a second, hidden player (the host's `preload` role):
|
||||||
its own video output when the session view is up (media_kit starts a
|
paused at the start, muted, with its own video output when the
|
||||||
player with `vid=no`; the output decodes the first frame), the same
|
session view is up (media_kit starts a player with `vid=no`; the
|
||||||
pins, hwdec and yt-dlp hook as the session player but a 48 MiB forward
|
output decodes the first frame), the same pins and hwdec as the
|
||||||
and 8 MiB back cache (`preloadBytes`), mpv log at `error`. One at a
|
session player but a 48 MiB forward and 8 MiB back cache
|
||||||
time; it waits until the media playing has started here
|
(`preloadAheadBytes`), mpv log at `error`. One at a time; it waits
|
||||||
(`SessionCoordinator.settledMediaId`: opened, failed or nothing to
|
until the media playing has started here
|
||||||
|
(`MediaPipeline.settledMediaId`: opened, failed or nothing to
|
||||||
open), so it never competes with that open and a preload a swap is
|
open), so it never competes with that open and a preload a swap is
|
||||||
about to take is never replaced first; a removed, reordered or replaced
|
about to take is never replaced first; a removed, reordered or replaced
|
||||||
first item swaps or drops it then, a manual swap to something else
|
first item swaps or drops it then, a manual swap to something else
|
||||||
keeps it (still next). A link is opened again at the 30-minute refresh
|
keeps it (still next). A link is opened again at the 30-minute refresh
|
||||||
(stream URLs expire); a failed preload is not retried until then and
|
(stream URLs expire); a failed preload is not retried until then and
|
||||||
changes nothing (the usual open follows). When the coordinator opens
|
changes nothing (the usual open follows). When the pipeline opens the
|
||||||
the same URL and headers (`openUrl`), the preload is promoted instead
|
same URL and headers, the preload is promoted instead of a fresh
|
||||||
of a fresh player: it becomes the session player through the same
|
player: it becomes the session player through the same hand-over (see
|
||||||
hand-over (see "Player lifecycle" under "Media"), unmuted and with the
|
"Media pipeline" under "Media"), unmuted and with the full cache pins
|
||||||
full cache pins. Its buffer comes along,
|
(`PlayerHost.promote`), its subtitles added. Its buffer comes along,
|
||||||
so the report sent right after the open already passes the gate: the
|
so the report sent right after the open already passes the gate: the
|
||||||
start no longer waits for anyone's open. A promoted player is no
|
start no longer waits for anyone's open. A promoted player is no
|
||||||
longer the first mpv of the process, so ffmpeg's HTTP status reaches
|
longer the first mpv of the process, so ffmpeg's HTTP status reaches
|
||||||
@@ -1220,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
|
errors themselves are still detected; see "Media"). Off with env
|
||||||
`SYNC_RELAY_PRELOAD=0`. Measured (`queue_switch_e2e_test`, a fake
|
`SYNC_RELAY_PRELOAD=0`. Measured (`queue_switch_e2e_test`, a fake
|
||||||
Jellyfin and link host answering after 120 ms at 24 Mbit/s, two
|
Jellyfin and link host answering after 120 ms at 24 Mbit/s, two
|
||||||
account holders and a `/j` guest): ⏭ to the server playing 59 to
|
account holders and a `/j` guest): ⏭ to the server playing 74 to
|
||||||
86 ms for the next episode and 35 ms for a direct link, against about
|
87 ms for the next episode and 35 ms for a direct link, against about
|
||||||
1.5 s cold; at 300 ms and 12 Mbit/s 52 and 103 ms against about 2.9 s.
|
1.6 to 1.8 s cold; at 300 ms and 12 Mbit/s 52 and 103 ms against about
|
||||||
Relayed, from the warm relay (same setup, loopback server): a link
|
2.9 s. Relayed, from the warm relay (same setup, loopback server): a
|
||||||
passed on 21 to 22 ms, a server-fetched link 35 to 37 ms, against
|
link passed on 36 to 38 ms, a server-fetched link 46 to 53 ms, against
|
||||||
about 1.6 s cold.
|
about 1.6 s cold.
|
||||||
`SwitchTrace` (env `SYNC_RELAY_TRACE_SWITCH=1` prints it) times each
|
`SwitchTrace` (env `SYNC_RELAY_TRACE_SWITCH=1` prints it) times each
|
||||||
phase of a media change: state, resolved, open, player (a fresh
|
phase of a media change: state, resolved, open, player (a fresh
|
||||||
player is up; see "Player lifecycle" under "Media"), opened or
|
player is up; see "Media pipeline" under "Media"), opened or
|
||||||
preloaded, buffered, playing.
|
preloaded, buffered, playing.
|
||||||
The row of the item shows a dot on its picture's corner (a tooltip
|
The row of the item shows a dot on its picture's corner (a tooltip
|
||||||
says which): hollow waiting, pulsing getting ready (resolving, then
|
says which): hollow waiting, pulsing getting ready (resolving, then
|
||||||
@@ -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
|
that moves by the minute. `Status.line` is the player's pill (most
|
||||||
pressing first: reconnecting with Cancel, unstable, refused or replaced
|
pressing first: reconnecting with Cancel, unstable, refused or replaced
|
||||||
with Retry, connecting, your player's fault or error with Reload, the
|
with Retry, connecting, your player's fault or error with Reload, the
|
||||||
server getting the video, the relay's source dropped / left / not
|
server getting the video, everyone held for the relay's source
|
||||||
passing it on yet, finished, someone loading, someone who cannot play
|
("Waiting for <name>": `state.hold.reason` `source`, a viewer's buffer
|
||||||
|
ran dry while it is gone; for everyone, own-account viewers too), the
|
||||||
|
relay's source dropped / left / not passing it on yet, finished, someone loading, someone who cannot play
|
||||||
it, you not ready, who everyone waits for; nothing while it plays and
|
it, you not ready, who everyone waits for; nothing while it plays and
|
||||||
everyone is fine, and only connection lines while you sit out), each
|
everyone is fine, and only connection lines while you sit out), each
|
||||||
with a `StatusLevel` (ok and info never colour anything, warn amber,
|
with a `StatusLevel` (ok and info never colour anything, warn amber,
|
||||||
@@ -1466,8 +1579,12 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
|
|||||||
- `FolderAccess` (`DesktopFolders`): folder and file dialogs
|
- `FolderAccess` (`DesktopFolders`): folder and file dialogs
|
||||||
(file_picker), the Files scan, folder exists, file stat, showing a
|
(file_picker), the Files scan, folder exists, file stat, showing a
|
||||||
folder in the file manager. Paths for now.
|
folder in the file manager. Paths for now.
|
||||||
- `LinkResolver` (`AppYtDlp`): the app's yt-dlp, link probes, mpv's raw
|
- `LinkResolver` (`AppYtDlp`): the app's yt-dlp, link probes (what a
|
||||||
options, warm-up.
|
page plays, see "yt-dlp"), whether this device resolves pages
|
||||||
|
(`resolvesLinks`, the media plan's capability), warm-up.
|
||||||
|
- `PlayerHost` (`MediaKitHost`): the media players ("Player host" under
|
||||||
|
"Media"); Files' thumbnails make theirs with it too
|
||||||
|
(`thumbnailPlayers`, set in `main`).
|
||||||
- `SystemNotifications` (`DesktopNotifications`,
|
- `SystemNotifications` (`DesktopNotifications`,
|
||||||
`platform/desktop/desktop_notifications.dart`): `show(title, body, tag,
|
`platform/desktop/desktop_notifications.dart`): `show(title, body, tag,
|
||||||
payload)` and `onClick(payload)`, through flutter_local_notifications'
|
payload)` and `onClick(payload)`, through flutter_local_notifications'
|
||||||
@@ -1481,8 +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
|
(`AppDirs`, "Desktop UI"), and the copy of the data from before the
|
||||||
rename; resolved in `main` before the composition root exists, so not a
|
rename; resolved in `main` before the composition root exists, so not a
|
||||||
`Ports` field.
|
`Ports` field.
|
||||||
Still direct: the player (`PlaybackController`, media_kit, and its
|
Still direct: the updater's restart
|
||||||
`AppYtDlp.instance.mpvRawOptions`), the updater's restart
|
|
||||||
(`Process.start`), the session's own file reads for what it plays or
|
(`Process.start`), the session's own file reads for what it plays or
|
||||||
relays (`QueueController`, thumbnails, cookies import), app-private
|
relays (`QueueController`, thumbnails, cookies import), app-private
|
||||||
caches.
|
caches.
|
||||||
@@ -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
|
`shown`) or the mouse is on it (which holds the controls up too), and
|
||||||
Skip OP / Skip 1:30 also for 5 s when an offer of a new kind appears
|
Skip OP / Skip 1:30 also for 5 s when an offer of a new kind appears
|
||||||
(Skip ED and Next episode never without the controls).
|
(Skip ED and Next episode never without the controls).
|
||||||
- Volume: mpv's `volume-max` is pinned to `PlaybackController.maxVolume`
|
- Volume: mpv's `volume-max` is pinned to 150 per player by the host
|
||||||
(150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the
|
(`PlaybackController.maxVolume`); the volume is the controller's and
|
||||||
|
goes to every next player; `VolumeBar` runs 0..150 with a tick at 100 and the
|
||||||
part above in amber; the wheel and ↑ / ↓ step 5 % (`volumeStep`),
|
part above in amber; the wheel and ↑ / ↓ step 5 % (`volumeStep`),
|
||||||
clamped. The wheel works anywhere on the full player (`VolumeWheel`
|
clamped. The wheel works anywhere on the full player (`VolumeWheel`
|
||||||
around the player's stack): `WheelSteps` makes a mouse notch one step
|
around the player's stack): `WheelSteps` makes a mouse notch one step
|
||||||
@@ -2040,10 +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
|
window hides at once. Then every mpv player goes, before the engine does
|
||||||
(a live one calls its wakeup callback into a dead isolate): the session
|
(a live one calls its wakeup callback into a dead isolate): the session
|
||||||
player, the preload and the player still retiring
|
player, the preload and the player still retiring
|
||||||
(`PlaybackController.disposeAll`, 1.5 s each, via
|
(`MediaPipeline.disposeAll` → `PlayerHost.disposeAll`, 1.5 s each, via
|
||||||
`SessionCoordinator.releasePlayers`, which keeps the position for the
|
`SessionCoordinator.releasePlayers`, which keeps the position for the
|
||||||
Jellyfin stop report), the Files thumbnail jobs (`cancelAllThumbnails`)
|
Jellyfin stop report), the Files thumbnail jobs (`cancelAllThumbnails`)
|
||||||
and the sound engine (500 ms), side by side; on Windows 300 ms more,
|
and the sound engine (500 ms), side by side, then anything the host
|
||||||
|
still holds (the host owns the disposal order); on Windows 300 ms more,
|
||||||
since media_kit_video frees a video output on a thread of its own after
|
since media_kit_video frees a video output on a thread of its own after
|
||||||
dispose returns, and an output still up when the engine stops blocks the
|
dispose returns, and an output still up when the engine stops blocks the
|
||||||
plugin's teardown (`~VideoOutput` waits on a texture callback that never
|
plugin's teardown (`~VideoOutput` waits on a texture callback that never
|
||||||
|
|||||||
+45
-14
@@ -78,6 +78,8 @@ and `e2e_check` (simulated viewers against a live server).
|
|||||||
Three suites, run from each package's folder:
|
Three suites, run from each package's folder:
|
||||||
|
|
||||||
- `cd packages/core && dart test` (a few seconds): protocol round-trips,
|
- `cd packages/core && dart test` (a few seconds): protocol round-trips,
|
||||||
|
what a player opens for a page (`YtDlpPlay`) and the cookie notes of a
|
||||||
|
failed resolve against a stand-in yt-dlp (`cookies_test`, D11),
|
||||||
the sync engine (clock, gate, presence and grace, correction, end of
|
the sync engine (clock, gate, presence and grace, correction, end of
|
||||||
file), the `PresencePolicy` table, queue model, the library's L1
|
file), the `PresencePolicy` table, queue model, the library's L1
|
||||||
(release and folder names, `groupLocal` over a real anime folder's
|
(release and folder names, `groupLocal` over a real anime folder's
|
||||||
@@ -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
|
controls, settings, schedule), pure app logic (`StateDiff`, the library's L2 matching against a
|
||||||
fake AniList (`show_matcher_test`), `media.ref` producers,
|
fake AniList (`show_matcher_test`), `media.ref` producers,
|
||||||
shader list, sound engine with a fake backend, invite addresses, the
|
shader list, sound engine with a fake backend, invite addresses, the
|
||||||
version gate), `singletons_test` (the `.instance` count, see
|
version gate, the media plan's table with the Jellyfin relay rule and
|
||||||
|
the "Waiting for" status (`media_plan_test`)), `singletons_test` (the `.instance` count, see
|
||||||
ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e`
|
ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e`
|
||||||
tests plus `coord_presence`. The whole suite takes a couple of minutes;
|
tests plus `coord_presence`. The whole suite takes a couple of minutes;
|
||||||
run it only before a restructure step lands, otherwise only the files
|
run it only before a restructure step lands, otherwise only the files
|
||||||
@@ -108,21 +111,34 @@ else is offline: Jellyfin and link hosts are local fakes.
|
|||||||
|
|
||||||
Real-mpv tests: `sync_playback_e2e`, `coord_presence` (drops through a
|
Real-mpv tests: `sync_playback_e2e`, `coord_presence` (drops through a
|
||||||
`FlakyProxy`: a half-open link and return, 10 flaps, `replaced`, `bye`,
|
`FlakyProxy`: a half-open link and return, 10 flaps, `replaced`, `bye`,
|
||||||
a dead or restarted server), `video_reload_e2e`,
|
a dead or restarted server), `video_reload_e2e` (a fresh player from the
|
||||||
|
host, the old one retired, leave releases them all),
|
||||||
`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `chapters_mpv_e2e`,
|
`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `chapters_mpv_e2e`,
|
||||||
`ytdl_headers_e2e`, `relay_e2e` (a dedicated server with no file of its
|
`ytdl_headers_e2e` (a page through the app's own resolve against a
|
||||||
|
stand-in yt-dlp and a Referer-checking CDN: the EDL, the headers and
|
||||||
|
cookies, subtitles without danmaku, a 403, the D11 error),
|
||||||
|
`media_switch_e2e` (commands during a switch wait and apply once, no mpv
|
||||||
|
errors), `relay_e2e` (a dedicated server with no file of its
|
||||||
own: a guest's file, links passed on or fetched by the server, a late
|
own: a guest's file, links passed on or fetched by the server, a late
|
||||||
source, a source that drops and resumes), `jellyfin_e2e`,
|
source, a source that drops and resumes; a seek past what a slow source
|
||||||
`queue_switch_e2e` (times Up next switches against a delayed, rate-capped
|
has sent, timed; a source that drops while a viewer seeks past
|
||||||
fake Jellyfin and link host: an episode, a direct link, a link passed on
|
the buffer and another joins: no seek loop, "Waiting for", all resume
|
||||||
and a server-fetched one; `WT_SWITCH_PRELOAD=0` for the cold path,
|
(D6)), `jellyfin_e2e`, `queue_switch_e2e` (times Up next switches
|
||||||
|
through the media pipeline against a delayed, rate-capped fake Jellyfin
|
||||||
|
and link host: an episode, a direct link, a link passed on and a
|
||||||
|
server-fetched one, each prepared and promoted; no mpv "error running
|
||||||
|
command" or "file cache" lines; `WT_SWITCH_PRELOAD=0` for the cold path,
|
||||||
`WT_SWITCH_DELAY_MS` / `WT_SWITCH_RATE_KBS` for the link). The sync,
|
`WT_SWITCH_DELAY_MS` / `WT_SWITCH_RATE_KBS` for the link). The sync,
|
||||||
reload and end-of-file ones expect `~/Videos/chiyo.mp4`; the others
|
reload and end-of-file ones expect `~/Videos/chiyo.mp4`; the others
|
||||||
generate media with ffmpeg into `WT_TEST_TMP` (default: system temp; all
|
generate media with ffmpeg into `WT_TEST_TMP` (default: system temp; all
|
||||||
desktop tests that write files use it). Headless mpv (no video output
|
desktop tests that write files use it). Headless mpv (no video output
|
||||||
attached) won't open video-only files, so test media needs an audio
|
attached) won't open video-only files, so test media needs an audio
|
||||||
track. Tests always log media_kit "S/W rendering" because they have no
|
track. Tests always log media_kit "S/W rendering" because they have no
|
||||||
display; that says nothing about the app. `sound_device_e2e` opens the
|
display; that says nothing about the app. A headless player has no video
|
||||||
|
track selected (`vid=no`), and an mkv's cues list only the video, so
|
||||||
|
seek tests over a relay or HTTP use an mp4 with its index first
|
||||||
|
(`+faststart`); setting `vid=auto` on a headless player crashes mpv when
|
||||||
|
it is disposed. `sound_device_e2e` opens the
|
||||||
real audio device (flutter_soloud) and plays every sound of every preset
|
real audio device (flutter_soloud) and plays every sound of every preset
|
||||||
quietly (`WT_SFX_VOLUME`, default 0.1).
|
quietly (`WT_SFX_VOLUME`, default 0.1).
|
||||||
|
|
||||||
@@ -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`) | |
|
| 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) | |
|
| Join mid-playback | core `sync_engine_test` "D2: joiners" (never changes the transport while playing, at most 15 s while held, joining ends on error or timeout), `presence_test` (the table); server `relay_test` "a late joiner gets its own /r/ URL"; desktop `coord_presence` (the returning member plays on in sync) | |
|
||||||
| Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (the host sat out through `presence` on your shared server), `notice_table_test` (the feed actions one-to-one, matched by id; sitting out gets only chat, nudges, "starting now", Up next and the chosen joins and leaves; alone no social sounds), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out) | step 6: not tracked as watched (P7) |
|
| Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (the host sat out through `presence` on your shared server), `notice_table_test` (the feed actions one-to-one, matched by id; sitting out gets only chat, nudges, "starting now", Up next and the chosen joins and leaves; alone no social sounds), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out) | 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) |
|
| Swap, also while someone is Away or gone | core `sync_engine_test` "swap" (a gone member stays out of the gate, comes back joining), "away" (a swap leaves it away), `queue_test` (a swap takes its item, drops the `auto` one); server `queue_test` (a gone adder's item waits), `relay_test` (a swap drops the buffer); desktop `sync_playback_e2e`, `media_switch_e2e` (no command reaches a player mid-switch) | |
|
||||||
| Up next advance | core `queue_test` (skip, play request timeout, `auto` and `cancel`); server `queue_test`, `relay_test` "warm relay"; desktop `jellyfin_session_test` (auto-advance swapper, end card countdown), `queue_switch_e2e` | step 5: preparing it through the media pipeline; step 6: the countdown as server state (`state.advance`) |
|
| Up next advance | core `queue_test` (skip, play request timeout, `auto` and `cancel`); 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`) | step 5: the Windows close smoke job (P32) |
|
| Close the app cleanly, incl. `bye` | server `lifecycle_test` (a close keeps the member `gone`, `bye` leaves at once, server stop closes sockets); desktop `server_isolate_test` (stop frees the port; Stop sharing tells guests with 4016 and keeps you and Up next; a friend's server and back keeps yours), `coord_presence` (Stop sharing and your server's stop release their ports; leave sends `bye` and others see "left" at once, leaving for a restart keeps the member `gone`), `video_reload_e2e` (leave releases every player); the close smoke run on the built app (`tool/smoke_windows.ps1` in CI, `tool/smoke_linux.sh` by hand) | |
|
||||||
|
|
||||||
A test file whose first line is `// restructure: rewrite in step N` pins
|
A test file whose first line is `// restructure: rewrite in step N` pins
|
||||||
behaviour that step of the restructure ([ROADMAP.md](ROADMAP.md)) changes;
|
behaviour that step of the restructure ([ROADMAP.md](ROADMAP.md)) changes;
|
||||||
@@ -174,7 +190,20 @@ before.
|
|||||||
|
|
||||||
CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus
|
CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus
|
||||||
`dart compile exe` of the dedicated server (fails if Flutter creeps into
|
`dart compile exe` of the dedicated server (fails if Flutter creeps into
|
||||||
`packages/server`). Desktop tests are not in CI.
|
`packages/server`). Desktop tests are not in CI. The Windows job runs the
|
||||||
|
updater tests, the release build and the close smoke run on that build
|
||||||
|
(`tool/smoke_windows.ps1`, P32): the app starts with
|
||||||
|
`STARSYNC_SMOKE=<clip>` (a 5 s clip from ffmpeg, else a WAV the script
|
||||||
|
writes) under its own profile (`STARSYNC_PROFILE=smoke`), opens the clip
|
||||||
|
in a player with a video output through the media pipeline
|
||||||
|
(`platform/smoke.dart`), plays a second and closes the normal way; the
|
||||||
|
steps go to `STARSYNC_SMOKE_OUT` (`start`, `opened <ms> render=<mode>`,
|
||||||
|
`closing`, or `skip` / `fail` with why). It fails when the app does not
|
||||||
|
exit within 45 s or leaves a `starsync` process, skips with a warning
|
||||||
|
when the runner cannot bring the app or its video output up, and prints
|
||||||
|
the `smoke:`, `close:` and `mpv: render` lines of the app log.
|
||||||
|
`tool/smoke_linux.sh` is the same on Linux (needs a display; after
|
||||||
|
`flutter build linux --release`).
|
||||||
|
|
||||||
Traps:
|
Traps:
|
||||||
- Never pump media_kit's `Video` in `flutter test`: the runner hangs (kill
|
- Never pump media_kit's `Video` in `flutter test`: the runner hangs (kill
|
||||||
@@ -302,8 +331,10 @@ is off), not software rendering.
|
|||||||
- `web_socket_channel` 3.x: `channel.ready` errors are unhandled on refused
|
- `web_socket_channel` 3.x: `channel.ready` errors are unhandled on refused
|
||||||
connections; swallow them and report through the stream's `onError`.
|
connections; swallow them and report through the stream's `onError`.
|
||||||
Streams are single-subscription: one listener per socket.
|
Streams are single-subscription: one listener per socket.
|
||||||
- media_kit has no public `Player.setProperty`; the code reaches mpv through a
|
- media_kit has no public `Player.setProperty`; the player host
|
||||||
dynamic call on `player.platform`. Cache size goes through
|
(`MediaKitHost`) reaches mpv through a dynamic call on
|
||||||
|
`player.platform`, and nothing outside it imports media_kit except the
|
||||||
|
`Video` widget glue. Cache size goes through
|
||||||
`PlayerConfiguration(bufferSize:)`.
|
`PlayerConfiguration(bufferSize:)`.
|
||||||
- Hot restart with any media_kit `Player` alive (session, thumbnails)
|
- Hot restart with any media_kit `Player` alive (session, thumbnails)
|
||||||
used to SIGSEGV on mpv's `core` thread: each mpv handle's wakeup callback
|
used to SIGSEGV on mpv's `core` thread: each mpv handle's wakeup callback
|
||||||
|
|||||||
+29
-24
@@ -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.
|
- D11 Login-only sites: error texts say which cookies were used.
|
||||||
|
|
||||||
**Problems:**
|
**Problems:**
|
||||||
- P2 Viewers of a relay whose source is gone: mpv loops on failed seeks
|
|
||||||
past what the server buffered (viewer-side recovery, step 5).
|
|
||||||
- P7 Sat-out people get tracked as watched on Jellyfin.
|
- P7 Sat-out people get tracked as watched on Jellyfin.
|
||||||
- P9 The watched rule (position coverage) misses real watches (D3).
|
- P9 The watched rule (position coverage) misses real watches (D3).
|
||||||
- P15 Commands reach a player mid-switch (`seek 90.0`, `event:5` errors).
|
|
||||||
- P17 Preload, warm relay and the fresh player per video are layered on
|
|
||||||
the player instead of being one media pipeline.
|
|
||||||
- P18 Bilibili "no video formats" with browser cookies set; the hint
|
|
||||||
wording misleads (D11).
|
|
||||||
- P25 ssh to Gitea blocked through the proxy (pushes go over https).
|
- P25 ssh to Gitea blocked through the proxy (pushes go over https).
|
||||||
- P28 Log noise: "Failed to create file cache"; an idle relay channel for
|
|
||||||
Jellyfin items everyone has an account for.
|
|
||||||
- P29 Relay start delay after a seek into an unbuffered part.
|
|
||||||
- P30 Shaders don't work on Windows. Fix in 1.2.1-dev.3 (list read-back
|
- P30 Shaders don't work on Windows. Fix in 1.2.1-dev.3 (list read-back
|
||||||
check; Windows hwdec `auto-copy`): a Windows user must confirm shaders
|
check; Windows hwdec `auto-copy`): a Windows user must confirm shaders
|
||||||
stay on and the EGL-surface error is gone.
|
stay on and the EGL-surface error is gone.
|
||||||
@@ -56,8 +46,11 @@ Problems are numbered; delete a line when its fix ships.
|
|||||||
`watch-together 2>&1 | grep VideoOutput` from a terminal if software.
|
`watch-together 2>&1 | grep VideoOutput` from a terminal if software.
|
||||||
- P32 The app freezes on exit, mostly reported on Windows. 1.2.1-dev.3:
|
- P32 The app freezes on exit, mostly reported on Windows. 1.2.1-dev.3:
|
||||||
orderly close, Linux `_exit` (0 crashes in 41 closes vs 10/10 before),
|
orderly close, Linux `_exit` (0 crashes in 41 closes vs 10/10 before),
|
||||||
Windows normal close + TerminateProcess + watchdogs, untested on
|
Windows normal close + TerminateProcess + watchdogs; the player host
|
||||||
Windows; if it still hangs: Event Viewer 1000/1002, a Task Manager dump,
|
now owns the disposal order. CI's Windows job runs a close smoke run on
|
||||||
|
every build (`tool/smoke_windows.ps1`); its first runs on the runner
|
||||||
|
must show whether it passes or skips there (no interactive desktop). If
|
||||||
|
a real close still hangs: Event Viewer 1000/1002, a Task Manager dump,
|
||||||
the `close:` log lines.
|
the `close:` log lines.
|
||||||
- P33 No volume readout on wheel: likely fixed by the controls rework
|
- P33 No volume readout on wheel: likely fixed by the controls rework
|
||||||
(1.2.1-dev.1; a test covers both skins); confirm in real use.
|
(1.2.1-dev.1; a test covers both skins); confirm in real use.
|
||||||
@@ -68,12 +61,6 @@ the automatic continuation and countdown, relay state), the device
|
|||||||
(client id, library and folders, match corrections, watch history,
|
(client id, library and folders, match corrections, watch history,
|
||||||
settings, notification prefs, what this app is sourcing) and the
|
settings, notification prefs, what this app is sourcing) and the
|
||||||
integrations (Jellyfin played/resume, AniList list).
|
integrations (Jellyfin played/resume, AniList list).
|
||||||
5. Media pipeline + player host port: plan → resolve → open → ready,
|
|
||||||
commands queued during a switch, viewer-side recovery when a source
|
|
||||||
drops (no mpv seek loops, play what's buffered), resolver and source
|
|
||||||
chosen by capability; a Windows CI smoke job (start, open, close
|
|
||||||
within N s, no orphan process). Fixes P2 P15 P17 P18 P28 P29, P30–P32;
|
|
||||||
D11.
|
|
||||||
6. What's next (decides which/when, asks the pipeline to prepare;
|
6. What's next (decides which/when, asks the pipeline to prepare;
|
||||||
continuation and countdown as server state, shared cancel) + Watch
|
continuation and countdown as server state, shared cancel) + Watch
|
||||||
tracking (time In from the presence history, device history, Jellyfin
|
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
|
remaining `.instance` singletons move into `AppGraph` as the steps touch
|
||||||
them; `singletons_test` holds the count (today: AppSettings 55,
|
them; `singletons_test` holds the count (today: AppSettings 55,
|
||||||
JellyfinAccounts 10, UpdateController 8, Profile 8, FolderStore 6,
|
JellyfinAccounts 10, UpdateController 8, Profile 8, FolderStore 6,
|
||||||
AppYtDlp 4, RecentLinks 3).
|
AppYtDlp 2, RecentLinks 3).
|
||||||
Not in the restructure: P25 (ops).
|
Not in the restructure: P25 (ops).
|
||||||
|
|
||||||
|
|
||||||
@@ -126,6 +113,24 @@ your server leaves them there without you, unwarned; a join refused
|
|||||||
after dialling (wrong password) costs your own member there (`bye`, back
|
after dialling (wrong password) costs your own member there (`bye`, back
|
||||||
as a new member). Your server writes its session log even when you are
|
as a new member). Your server writes its session log even when you are
|
||||||
alone.
|
alone.
|
||||||
|
Left of step 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
|
Left of step 4 (library): a human check of the Files library on the
|
||||||
real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders
|
real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders
|
||||||
with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the
|
with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the
|
||||||
@@ -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
|
behind upstream) with native D3D11 on Windows (no ANGLE: may fix P30
|
||||||
and the dxva2-egl error), a bundled libmpv on Linux, triple-buffered
|
and the dxva2-egl error), a bundled libmpv on Linux, triple-buffered
|
||||||
Linux rendering, patched FFmpeg. Check: Windows shaders, whether its
|
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
|
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
|
fixes it lacks. Until then we vendor upstream 2.0.1 with the X11/EGL
|
||||||
patch in `third_party/media_kit_video`.
|
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.
|
step 6.
|
||||||
|
|
||||||
3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
|
3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
|
||||||
bundled Windows libmpv has the ytdl hook (Lua) and plays `edl://` URLs
|
bundled Windows libmpv plays the app's `edl://` URLs (video + audio
|
||||||
through media_kit; if not, resolve in the app and open the relay
|
with headers) through media_kit. YouTube: yt-dlp wants a JS runtime
|
||||||
tracks' formats. YouTube: yt-dlp wants a JS runtime (deno) for full
|
(deno) for full extraction.
|
||||||
extraction. Formats that need yt-dlp's cookies.
|
|
||||||
- Live streams through the relay (today refused: the sync model needs a
|
- Live streams through the relay (today refused: the sync model needs a
|
||||||
fixed duration and seeking).
|
fixed duration and seeking).
|
||||||
- `tool/relay_e2e.dart`: a two-track case (bytes and pieces) over a
|
- `tool/relay_e2e.dart`: a two-track case (bytes and pieces) over a
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ abstract class RelayConstants {
|
|||||||
static const int protoMax = 16;
|
static const int protoMax = 16;
|
||||||
|
|
||||||
// Relay (`/r/...`): a member's bytes through the server's buffer.
|
// Relay (`/r/...`): a member's bytes through the server's buffer.
|
||||||
static const int relaySliceBytes = 1 << 20;
|
static const int relaySliceBytes = 256 * 1024;
|
||||||
static const int relayFrameBytes = 64 * 1024;
|
static const int relayFrameBytes = 64 * 1024;
|
||||||
/// Defaults for the buffer; they mirror a viewer's mpv buffer (150 s /
|
/// Defaults for the buffer; they mirror a viewer's mpv buffer (150 s /
|
||||||
/// 256 MiB ahead, 64 MiB back), so whatever a viewer may seek to soon is
|
/// 256 MiB ahead, 64 MiB back), so whatever a viewer may seek to soon is
|
||||||
|
|||||||
@@ -164,6 +164,45 @@ Map<String, List<NetscapeCookie>> cookiesBySite(Iterable<NetscapeCookie> cookies
|
|||||||
return {for (final k in m.keys.toList()..sort()) k: m[k]!};
|
return {for (final k in m.keys.toList()..sort()) k: m[k]!};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [parseCookies], or none for a file without any.
|
||||||
|
List<NetscapeCookie> parseCookiesOrEmpty(String text) {
|
||||||
|
try {
|
||||||
|
return parseCookies(text);
|
||||||
|
} on FormatException {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Firefox", "Chrome", "Firefox (work)": a browser and its profile.
|
||||||
|
String browserLabel(CookieBrowser b) {
|
||||||
|
final name = b.browser.isEmpty ? b.browser : '${b.browser[0].toUpperCase()}${b.browser.substring(1)}';
|
||||||
|
return b.profile.isEmpty ? name : '$name (${b.profile})';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cookies part of a sign-in error (D11): which cookies were used and
|
||||||
|
/// how many of them are for [site] ([count] -1: unknown; [read]: what
|
||||||
|
/// reading a browser's gave).
|
||||||
|
String cookieNoteText(CookieSource? source, String site, int count, {({int count, String? error})? read}) {
|
||||||
|
switch (source) {
|
||||||
|
case null:
|
||||||
|
return 'no cookies were used';
|
||||||
|
case CookieFile():
|
||||||
|
if (count < 0) return 'used the cookies file';
|
||||||
|
return count == 0
|
||||||
|
? 'used the cookies file, which has none for $site'
|
||||||
|
: 'used the cookies file ($count for $site: signed out or expired?)';
|
||||||
|
case CookieBrowser():
|
||||||
|
final who = "${browserLabel(source)}'s cookies";
|
||||||
|
final error = read?.error;
|
||||||
|
if (error != null) return "used $who, but yt-dlp couldn't read them: $error";
|
||||||
|
final n = read?.count ?? count;
|
||||||
|
if (n < 0) return 'used $who';
|
||||||
|
return n == 0
|
||||||
|
? 'used $who, which have none for $site: sign in to $site there'
|
||||||
|
: 'used $who ($n for $site: that account may not see it)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Sites ticked by default after an import: those yt-dlp is usually given
|
/// Sites ticked by default after an import: those yt-dlp is usually given
|
||||||
/// cookies for.
|
/// cookies for.
|
||||||
const suggestedCookieSites = {'bilibili.com', 'youtube.com'};
|
const suggestedCookieSites = {'bilibili.com', 'youtube.com'};
|
||||||
@@ -233,17 +272,6 @@ class YtDlpCookies {
|
|||||||
null => const [],
|
null => const [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The same as mpv's `ytdl-raw-options` value ('' for none); each value
|
|
||||||
/// length-quoted (`%N%`) so commas and '=' in paths survive.
|
|
||||||
String mpvRawOptions([CookieSource? source]) {
|
|
||||||
String q(String v) => '%${utf8.encode(v).length}%$v';
|
|
||||||
return switch (source ?? read(count: false)) {
|
|
||||||
CookieFile(:final path) => 'cookies=${q(path)}',
|
|
||||||
CookieBrowser b => 'cookies-from-browser=${q(b.spec)}',
|
|
||||||
null => '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keeps [cookies] as the private copy, replacing any browser setting.
|
/// Keeps [cookies] as the private copy, replacing any browser setting.
|
||||||
Future<CookieFile> saveFile(List<NetscapeCookie> cookies) async {
|
Future<CookieFile> saveFile(List<NetscapeCookie> cookies) async {
|
||||||
await Directory(dir).create(recursive: true);
|
await Directory(dir).create(recursive: true);
|
||||||
|
|||||||
@@ -95,6 +95,22 @@ final _unreachable = RegExp(
|
|||||||
r'network is unreachable|no route to host|name or service not known|getaddrinfo|unable to download webpage',
|
r'network is unreachable|no route to host|name or service not known|getaddrinfo|unable to download webpage',
|
||||||
caseSensitive: false);
|
caseSensitive: false);
|
||||||
|
|
||||||
|
/// yt-dlp's [error] (plain words or raw) may be about signing in: worth
|
||||||
|
/// saying which cookies were used (D11).
|
||||||
|
bool mayNeedSignIn(String error) {
|
||||||
|
final t = playErrorText(error);
|
||||||
|
return t == needsCookiesText || t == noFormatsText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [plain] ([playErrorText]) with the cookies [note] (D11), for the two
|
||||||
|
/// texts about signing in; anything else as is.
|
||||||
|
String withCookieNote(String plain, String? note) {
|
||||||
|
if (note == null || note.isEmpty) return plain;
|
||||||
|
if (plain == needsCookiesText) return 'This site needs cookies ($note) — Settings → Player → yt-dlp → Cookies';
|
||||||
|
if (plain == noFormatsText) return 'No video found: it may need signing in ($note)';
|
||||||
|
return plain;
|
||||||
|
}
|
||||||
|
|
||||||
/// Why a link or media cannot be played, in plain words, from the raw
|
/// Why a link or media cannot be played, in plain words, from the raw
|
||||||
/// error of yt-dlp, ffmpeg/mpv or the link probe; anything it does not
|
/// error of yt-dlp, ffmpeg/mpv or the link probe; anything it does not
|
||||||
/// know comes back as is (one line, at most 100 characters).
|
/// know comes back as is (one line, at most 100 characters).
|
||||||
@@ -131,6 +147,10 @@ class LinkProbe {
|
|||||||
final String? pageTitle;
|
final String? pageTitle;
|
||||||
/// Why yt-dlp could not read the page.
|
/// Why yt-dlp could not read the page.
|
||||||
final String? ytDlpError;
|
final String? ytDlpError;
|
||||||
|
|
||||||
|
/// Which cookies that run used ([YtDlp.cookieNote]), when it may be about
|
||||||
|
/// signing in.
|
||||||
|
final String? cookies;
|
||||||
/// The HTTP status of the probe's GET; 0 when unknown.
|
/// The HTTP status of the probe's GET; 0 when unknown.
|
||||||
final int status;
|
final int status;
|
||||||
const LinkProbe.file(this.url, this.size)
|
const LinkProbe.file(this.url, this.size)
|
||||||
@@ -138,8 +158,9 @@ class LinkProbe {
|
|||||||
info = null,
|
info = null,
|
||||||
pageTitle = null,
|
pageTitle = null,
|
||||||
ytDlpError = null,
|
ytDlpError = null,
|
||||||
|
cookies = null,
|
||||||
status = 0;
|
status = 0;
|
||||||
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError, this.status = 0})
|
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError, this.cookies, this.status = 0})
|
||||||
: isPage = true,
|
: isPage = true,
|
||||||
size = -1;
|
size = -1;
|
||||||
|
|
||||||
@@ -152,7 +173,7 @@ class LinkProbe {
|
|||||||
// A 4xx says more than yt-dlp's words it has no plain text for.
|
// A 4xx says more than yt-dlp's words it has no plain text for.
|
||||||
final known = err != null && _plainTexts.contains(err);
|
final known = err != null && _plainTexts.contains(err);
|
||||||
if (!known && status >= 400 && status < 500) return playErrorText('HTTP Error $status');
|
if (!known && status >= 400 && status < 500) return playErrorText('HTTP Error $status');
|
||||||
return err;
|
return err == null ? null : withCookieNote(err, cookies);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The name to show: yt-dlp's title, the page's, else the host; null
|
/// The name to show: yt-dlp's title, the page's, else the host; null
|
||||||
@@ -219,12 +240,13 @@ Future<LinkProbe> _probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
getError = e;
|
getError = e;
|
||||||
}
|
}
|
||||||
String? ytErr;
|
String? ytErr, cookies;
|
||||||
if (ytDlp != null) {
|
if (ytDlp != null) {
|
||||||
try {
|
try {
|
||||||
return LinkProbe.page(url, info: await ytDlp.resolve(url.toString()), status: status);
|
return LinkProbe.page(url, info: await ytDlp.resolve(url.toString()), status: status);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ytErr = '$e';
|
ytErr = '$e';
|
||||||
|
if (e is YtDlpException) cookies = e.cookies;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (getError != null) {
|
if (getError != null) {
|
||||||
@@ -238,7 +260,8 @@ Future<LinkProbe> _probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async
|
|||||||
body = await _readText(await req.close().timeout(const Duration(seconds: 15)));
|
body = await _readText(await req.close().timeout(const Duration(seconds: 15)));
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr, status: status);
|
return LinkProbe.page(url,
|
||||||
|
pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr, cookies: cookies, status: status);
|
||||||
} finally {
|
} finally {
|
||||||
if (client == null) c.close(force: true);
|
if (client == null) c.close(force: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import 'package:crypto/crypto.dart';
|
|||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
import 'cookies.dart';
|
import 'cookies.dart';
|
||||||
|
import 'link_probe.dart' show mayNeedSignIn;
|
||||||
import 'logging.dart';
|
import 'logging.dart';
|
||||||
|
import 'relay.dart' show mpvEdl;
|
||||||
|
|
||||||
// yt-dlp: turns a web page (YouTube, Twitter/X, ...) into media URLs. The
|
// yt-dlp: turns a web page (YouTube, Twitter/X, ...) into media URLs. The
|
||||||
// app and the server each keep their own copy, the official standalone
|
// app and the server each keep their own copy, the official standalone
|
||||||
@@ -19,7 +21,11 @@ final _log = createLogger('yt-dlp');
|
|||||||
|
|
||||||
class YtDlpException implements Exception {
|
class YtDlpException implements Exception {
|
||||||
final String message;
|
final String message;
|
||||||
const YtDlpException(this.message);
|
|
||||||
|
/// Which cookies the run used and what they hold for the site, when the
|
||||||
|
/// failure may be about signing in ([YtDlp.cookieNote], D11).
|
||||||
|
final String? cookies;
|
||||||
|
const YtDlpException(this.message, {this.cookies});
|
||||||
@override
|
@override
|
||||||
String toString() => message;
|
String toString() => message;
|
||||||
}
|
}
|
||||||
@@ -121,6 +127,10 @@ class YtDlpInfo {
|
|||||||
/// Every relayable format by id, to find [tracks] again after a
|
/// Every relayable format by id, to find [tracks] again after a
|
||||||
/// re-resolve.
|
/// re-resolve.
|
||||||
final Map<String, YtDlpFormat> formats;
|
final Map<String, YtDlpFormat> formats;
|
||||||
|
|
||||||
|
/// What a player opens for this page ([playFormat]); null when yt-dlp
|
||||||
|
/// picked nothing a player can open.
|
||||||
|
final YtDlpPlay? play;
|
||||||
const YtDlpInfo({
|
const YtDlpInfo({
|
||||||
required this.title,
|
required this.title,
|
||||||
this.uploader = '',
|
this.uploader = '',
|
||||||
@@ -131,6 +141,7 @@ class YtDlpInfo {
|
|||||||
this.isLive = false,
|
this.isLive = false,
|
||||||
this.tracks = const [],
|
this.tracks = const [],
|
||||||
this.formats = const {},
|
this.formats = const {},
|
||||||
|
this.play,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// One line for the log: extractor, duration, live, and each picked
|
/// One line for the log: extractor, duration, live, and each picked
|
||||||
@@ -163,6 +174,7 @@ class YtDlpInfo {
|
|||||||
duration: j['duration'] is num ? (j['duration'] as num).toDouble() : null,
|
duration: j['duration'] is num ? (j['duration'] as num).toDouble() : null,
|
||||||
thumbnail: str('thumbnail').isEmpty ? null : str('thumbnail'),
|
thumbnail: str('thumbnail').isEmpty ? null : str('thumbnail'),
|
||||||
isLive: isLive,
|
isLive: isLive,
|
||||||
|
play: YtDlpPlay.parse(j),
|
||||||
tracks: isLive ? const [] : pickTracks(formats),
|
tracks: isLive ? const [] : pickTracks(formats),
|
||||||
formats: {
|
formats: {
|
||||||
for (final f in formats)
|
for (final f in formats)
|
||||||
@@ -341,6 +353,100 @@ Map<String, String> parseSha256Sums(String text) => {
|
|||||||
m.group(2)!: m.group(1)!.toLowerCase(),
|
m.group(2)!: m.group(1)!.toLowerCase(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The formats a player opens for a page: the best video up to 1080p with
|
||||||
|
/// the best audio, else the best single file (yt-dlp's `-f`).
|
||||||
|
const playFormat = 'bv*[height<=1080]+ba/b';
|
||||||
|
|
||||||
|
/// The subtitles a player gets: every language but Bilibili's danmaku.
|
||||||
|
const playSubLangs = 'all,-danmaku';
|
||||||
|
|
||||||
|
/// A page's subtitle track for a player (`requested_subtitles`).
|
||||||
|
class YtDlpSubtitle {
|
||||||
|
final String lang;
|
||||||
|
final String name;
|
||||||
|
final String url;
|
||||||
|
final String ext;
|
||||||
|
const YtDlpSubtitle(this.lang, this.url, {this.name = '', this.ext = ''});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a player opens for a page, as yt-dlp resolved it ([playFormat]):
|
||||||
|
/// one file, or a video and an audio stream played together, with the
|
||||||
|
/// headers its CDN wants (the first format's `http_headers`, plus its
|
||||||
|
/// `cookies` as a Cookie header) and the subtitles ([playSubLangs]).
|
||||||
|
/// URLs are bound to the resolving machine (its address, its cookies).
|
||||||
|
class YtDlpPlay {
|
||||||
|
final List<String> urls;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
final List<YtDlpSubtitle> subtitles;
|
||||||
|
const YtDlpPlay(this.urls, {this.headers = const {}, this.subtitles = const []});
|
||||||
|
|
||||||
|
/// The URL mpv opens: the file, or an EDL of the streams.
|
||||||
|
String get mpvUrl => urls.length == 1 ? urls.single : mpvEdl(urls);
|
||||||
|
|
||||||
|
/// From `yt-dlp -J`: its `requested_formats`, else the top-level format;
|
||||||
|
/// null when none of them is something a player can open.
|
||||||
|
static YtDlpPlay? parse(Map<String, Object?> j) {
|
||||||
|
final picked = j['requested_formats'] is List
|
||||||
|
? [for (final f in j['requested_formats'] as List) if (f is Map) Map<String, Object?>.from(f)]
|
||||||
|
: [if (j['url'] is String) j];
|
||||||
|
if (picked.isEmpty) return null;
|
||||||
|
final urls = <String>[];
|
||||||
|
for (final f in picked) {
|
||||||
|
final url = _playUrl(f);
|
||||||
|
if (url == null) return null;
|
||||||
|
urls.add(url);
|
||||||
|
}
|
||||||
|
final first = picked.first;
|
||||||
|
final headers = <String, String>{
|
||||||
|
if (first['http_headers'] is Map)
|
||||||
|
for (final e in (first['http_headers'] as Map).entries)
|
||||||
|
if (e.key is String && e.value is String) e.key as String: e.value as String,
|
||||||
|
};
|
||||||
|
final cookie = cookieHeaderOf(first['cookies']);
|
||||||
|
if (cookie.isNotEmpty) headers['Cookie'] = cookie;
|
||||||
|
final subs = <YtDlpSubtitle>[];
|
||||||
|
final requested = j['requested_subtitles'];
|
||||||
|
if (requested is Map) {
|
||||||
|
for (final e in requested.entries) {
|
||||||
|
final lang = '${e.key}', v = e.value;
|
||||||
|
if (lang == 'danmaku' || v is! Map || v['url'] is! String) continue;
|
||||||
|
final ext = v['ext'] is String ? v['ext'] as String : '';
|
||||||
|
if (ext == 'xml' || ext == 'json') continue;
|
||||||
|
subs.add(YtDlpSubtitle(lang, v['url'] as String,
|
||||||
|
name: v['name'] is String ? v['name'] as String : '', ext: ext));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return YtDlpPlay(urls, headers: headers, subtitles: subs);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _playUrl(Map<String, Object?> f) {
|
||||||
|
final protocol = f['protocol'] is String ? f['protocol'] as String : 'https';
|
||||||
|
if (protocol.startsWith('http_dash')) {
|
||||||
|
final manifest = f['manifest_url'];
|
||||||
|
return manifest is String && manifest.startsWith('http') ? manifest : null;
|
||||||
|
}
|
||||||
|
final url = f['url'];
|
||||||
|
if (url is! String || !(url.startsWith('http://') || url.startsWith('https://'))) return null;
|
||||||
|
return const {'http', 'https', 'm3u8', 'm3u8_native'}.contains(protocol) ? url : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Cookie header from yt-dlp's `cookies` field (`name=value; Domain=...;
|
||||||
|
/// Path=/; name2=value2; ...`): the name=value pairs, attributes dropped.
|
||||||
|
String cookieHeaderOf(Object? cookies) {
|
||||||
|
if (cookies is! String || cookies.trim().isEmpty) return '';
|
||||||
|
const attributes = {'domain', 'path', 'expires', 'max-age', 'secure', 'httponly', 'samesite', 'comment', 'version'};
|
||||||
|
final pairs = <String>[];
|
||||||
|
for (final part in cookies.split(';')) {
|
||||||
|
final t = part.trim();
|
||||||
|
final i = t.indexOf('=');
|
||||||
|
final name = (i < 0 ? t : t.substring(0, i)).trim();
|
||||||
|
if (i <= 0 || attributes.contains(name.toLowerCase())) continue;
|
||||||
|
pairs.add('$name=${t.substring(i + 1).trim()}');
|
||||||
|
}
|
||||||
|
return pairs.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
/// The last `ERROR:` line of yt-dlp's stderr without its prefixes
|
/// The last `ERROR:` line of yt-dlp's stderr without its prefixes
|
||||||
/// (`ERROR: [twitter] 123: No video` -> `No video`), else the last line.
|
/// (`ERROR: [twitter] 123: No video` -> `No video`), else the last line.
|
||||||
String ytDlpErrorText(String stderr) {
|
String ytDlpErrorText(String stderr) {
|
||||||
@@ -602,7 +708,11 @@ class YtDlp {
|
|||||||
final e = await err;
|
final e = await err;
|
||||||
_log.warning('resolve ${logUrl(url)}: exit $code after ${sw.elapsedMilliseconds} ms; stderr tail:\n'
|
_log.warning('resolve ${logUrl(url)}: exit $code after ${sw.elapsedMilliseconds} ms; stderr tail:\n'
|
||||||
'${_tail(e)}');
|
'${_tail(e)}');
|
||||||
throw YtDlpException(ytDlpErrorText(e));
|
final text = ytDlpErrorText(e);
|
||||||
|
// A site that hides the video from guests: say which cookies went.
|
||||||
|
final note = mayNeedSignIn(text) ? await cookieNote(url) : null;
|
||||||
|
if (note != null) _log.info('resolve ${logUrl(url)}: $note');
|
||||||
|
throw YtDlpException(text, cookies: note);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final j = jsonDecode(await out);
|
final j = jsonDecode(await out);
|
||||||
@@ -616,6 +726,59 @@ class YtDlp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which cookies a run for [url] uses and what they hold for its site
|
||||||
|
/// ([cookieSite]), in plain words (D11): none; the private file and how
|
||||||
|
/// many of its cookies are for the site; a browser, and how many cookies
|
||||||
|
/// for the site yt-dlp could read from it (one run of yt-dlp that writes
|
||||||
|
/// the browser's cookies to a private file in [dir], counted and deleted
|
||||||
|
/// at once; values are never logged).
|
||||||
|
Future<String> cookieNote(String url) async {
|
||||||
|
final site = cookieSite(Uri.tryParse(url)?.host ?? '');
|
||||||
|
final source = cookies.read(count: false);
|
||||||
|
switch (source) {
|
||||||
|
case null:
|
||||||
|
return 'no cookies were used';
|
||||||
|
case CookieFile(:final path):
|
||||||
|
try {
|
||||||
|
final n = parseCookies(await File(path).readAsString()).where((c) => cookieSite(c.host) == site).length;
|
||||||
|
return cookieNoteText(source, site, n);
|
||||||
|
} catch (_) {
|
||||||
|
return cookieNoteText(source, site, -1);
|
||||||
|
}
|
||||||
|
case CookieBrowser():
|
||||||
|
return cookieNoteText(source, site, -1, read: await _browserCookies(source, site));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many cookies for [site] yt-dlp reads from [browser], or why it
|
||||||
|
/// cannot.
|
||||||
|
Future<({int count, String? error})> _browserCookies(CookieBrowser browser, String site) async {
|
||||||
|
final exe = await ensure();
|
||||||
|
if (exe == null) return (count: -1, error: lastError ?? 'yt-dlp is not available');
|
||||||
|
final jar = File(p.join(dir, '.cookie-check-$pid.txt'));
|
||||||
|
try {
|
||||||
|
// Private before yt-dlp writes into it (it keeps the mode).
|
||||||
|
await jar.writeAsString('# Netscape HTTP Cookie File\n');
|
||||||
|
if (!Platform.isWindows) await Process.run('chmod', ['600', jar.path]);
|
||||||
|
// No URL: yt-dlp loads the browser's cookies, saves the jar and stops.
|
||||||
|
final r = await Process.run(
|
||||||
|
exe, ['--ignore-config', '--cookies-from-browser', browser.spec, '--cookies', jar.path])
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
final cookies = parseCookiesOrEmpty(await jar.readAsString());
|
||||||
|
final err = '${r.stderr}';
|
||||||
|
if (cookies.isEmpty && err.contains('ERROR:') && !err.contains('provide at least one URL')) {
|
||||||
|
return (count: -1, error: ytDlpErrorText(err));
|
||||||
|
}
|
||||||
|
return (count: cookies.where((c) => cookieSite(c.host) == site).length, error: null);
|
||||||
|
} on TimeoutException {
|
||||||
|
return (count: -1, error: 'yt-dlp took longer than 30 s reading them');
|
||||||
|
} catch (e) {
|
||||||
|
return (count: -1, error: '$e');
|
||||||
|
} finally {
|
||||||
|
if (await jar.exists()) await jar.delete().catchError((_) => jar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// [args] for the log: the cookie source named, not its path or spec.
|
/// [args] for the log: the cookie source named, not its path or spec.
|
||||||
static String _argsForLog(List<String> args) {
|
static String _argsForLog(List<String> args) {
|
||||||
final out = <String>[];
|
final out = <String>[];
|
||||||
@@ -640,8 +803,14 @@ class YtDlp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The command line [resolve] runs.
|
/// The command line [resolve] runs.
|
||||||
List<String> resolveArgs(String url) =>
|
List<String> resolveArgs(String url) => [
|
||||||
['-J', '--no-playlist', '--no-warnings', '--ignore-config', ...cookies.args(), '--', url];
|
'-J', '--no-playlist', '--no-warnings', '--ignore-config',
|
||||||
|
// What a player opens ([YtDlpPlay]): yt-dlp's own pick at most
|
||||||
|
// 1080p, and the page's subtitles but Bilibili's danmaku (an XML
|
||||||
|
// comment feed, not subtitles).
|
||||||
|
'-f', playFormat, '--write-subs', '--sub-langs', playSubLangs,
|
||||||
|
...cookies.args(), '--', url,
|
||||||
|
];
|
||||||
|
|
||||||
Future<void> close() => _changes.close();
|
Future<void> close() => _changes.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,26 +30,93 @@ void main() {
|
|||||||
expect(again.map((c) => c.toLine()), cookies.map((c) => c.toLine()));
|
expect(again.map((c) => c.toLine()), cookies.map((c) => c.toLine()));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('kept sites become a private file yt-dlp and mpv are pointed at', () async {
|
test('kept sites become a private file yt-dlp is pointed at', () async {
|
||||||
final dir = await Directory.systemTemp.createTemp('cookies_test');
|
final dir = await Directory.systemTemp.createTemp('cookies_test');
|
||||||
addTearDown(() => dir.delete(recursive: true));
|
addTearDown(() => dir.delete(recursive: true));
|
||||||
final src = File('${dir.path}/export.json')..writeAsStringSync(_json);
|
final src = File('${dir.path}/export.json')..writeAsStringSync(_json);
|
||||||
final y = YtDlp('${dir.path}/yt-dlp, a=b');
|
final y = YtDlp('${dir.path}/yt-dlp, a=b');
|
||||||
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '--', 'U']);
|
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '-f', playFormat, '--write-subs', '--sub-langs', playSubLangs, '--', 'U']);
|
||||||
|
|
||||||
final kept = await y.cookies.import(src.path, sites: {'bilibili.com'});
|
final kept = await y.cookies.import(src.path, sites: {'bilibili.com'});
|
||||||
expect([kept.count, ...kept.sites], [1, 'bilibili.com']);
|
expect([kept.count, ...kept.sites], [1, 'bilibili.com']);
|
||||||
final path = y.cookies.filePath;
|
final path = y.cookies.filePath;
|
||||||
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '--cookies', path, '--', 'U']);
|
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '-f', playFormat, '--write-subs', '--sub-langs', playSubLangs, '--cookies', path, '--', 'U']);
|
||||||
expect(y.cookies.mpvRawOptions(), 'cookies=%${path.length}%$path');
|
|
||||||
if (!Platform.isWindows) expect(File(path).statSync().modeString(), 'rw-------');
|
if (!Platform.isWindows) expect(File(path).statSync().modeString(), 'rw-------');
|
||||||
|
|
||||||
await y.cookies.saveBrowser('firefox', 'work');
|
await y.cookies.saveBrowser('firefox', 'work');
|
||||||
expect(y.resolveArgs('U').sublist(4, 6), ['--cookies-from-browser', 'firefox:work']);
|
expect(y.resolveArgs('U').sublist(9, 11), ['--cookies-from-browser', 'firefox:work']);
|
||||||
expect(File(path).existsSync(), isFalse);
|
expect(File(path).existsSync(), isFalse);
|
||||||
expect(y.cookies.mpvRawOptions(), 'cookies-from-browser=%12%firefox:work');
|
|
||||||
|
|
||||||
await y.cookies.remove();
|
await y.cookies.remove();
|
||||||
expect(y.cookies.read(), isNull);
|
expect(y.cookies.read(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// D11: a sign-in error says which cookies were used and what they hold
|
||||||
|
// for the site.
|
||||||
|
group('cookie notes', () {
|
||||||
|
test('the texts', () {
|
||||||
|
expect(cookieNoteText(null, 'bilibili.com', -1), 'no cookies were used');
|
||||||
|
const file = CookieFile('/x', 3, ['youtube.com']);
|
||||||
|
expect(cookieNoteText(file, 'bilibili.com', 0), 'used the cookies file, which has none for bilibili.com');
|
||||||
|
expect(cookieNoteText(file, 'bilibili.com', 4),
|
||||||
|
'used the cookies file (4 for bilibili.com: signed out or expired?)');
|
||||||
|
const ff = CookieBrowser('firefox', 'work');
|
||||||
|
expect(cookieNoteText(ff, 'bilibili.com', -1, read: (count: 0, error: null)),
|
||||||
|
"used Firefox (work)'s cookies, which have none for bilibili.com: sign in to bilibili.com there");
|
||||||
|
expect(cookieNoteText(ff, 'bilibili.com', -1, read: (count: 12, error: null)),
|
||||||
|
"used Firefox (work)'s cookies (12 for bilibili.com: that account may not see it)");
|
||||||
|
expect(cookieNoteText(const CookieBrowser('chrome'), 'x.com', -1, read: (count: -1, error: 'locked')),
|
||||||
|
"used Chrome's cookies, but yt-dlp couldn't read them: locked");
|
||||||
|
expect(withCookieNote(noFormatsText, 'no cookies were used'),
|
||||||
|
'No video found: it may need signing in (no cookies were used)');
|
||||||
|
expect(withCookieNote(needsCookiesText, 'x'), 'This site needs cookies (x) — Settings → Player → yt-dlp → Cookies');
|
||||||
|
expect(withCookieNote(notFoundText, 'x'), notFoundText);
|
||||||
|
expect(mayNeedSignIn('No video formats found!'), isTrue);
|
||||||
|
expect(mayNeedSignIn('HTTP Error 404'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed resolve names the file's cookies for the site; a browser's are counted by yt-dlp",
|
||||||
|
() async {
|
||||||
|
final dir = await Directory.systemTemp.createTemp('cookie_note');
|
||||||
|
addTearDown(() => dir.delete(recursive: true));
|
||||||
|
// A stand-in yt-dlp: a version, a resolve that finds no formats, and
|
||||||
|
// the browser read (no URL) that writes the jar it is given.
|
||||||
|
final fake = File('${dir.path}/yt-dlp');
|
||||||
|
await fake.writeAsString(r'''#!/bin/sh
|
||||||
|
case "$1" in --version) echo 2099.01.01; exit 0;; esac
|
||||||
|
jar=""; prev=""
|
||||||
|
for a in "$@"; do [ "$prev" = "--cookies" ] && jar="$a"; prev="$a"; done
|
||||||
|
case "$*" in *-J*) echo "ERROR: [BiliBili] BV1: No video formats found!" >&2; exit 1;; esac
|
||||||
|
printf '# Netscape HTTP Cookie File\n.bilibili.com\tTRUE\t/\tTRUE\t2000000000\tSESSDATA\tx\n.bilibili.com\tTRUE\t/\tFALSE\t2000000000\tbuvid3\ty\n.other.org\tTRUE\t/\tFALSE\t0\ta\tb\n' > "$jar"
|
||||||
|
echo "Extracted 3 cookies from firefox" >&2
|
||||||
|
echo "yt-dlp: error: You must provide at least one URL." >&2; exit 2
|
||||||
|
''');
|
||||||
|
await Process.run('chmod', ['+x', fake.path]);
|
||||||
|
final y = YtDlp(dir.path, updateEvery: const Duration(days: 9999));
|
||||||
|
await File('${dir.path}/last-check').writeAsString(DateTime.now().toIso8601String());
|
||||||
|
const page = 'https://www.bilibili.com/video/BV1';
|
||||||
|
|
||||||
|
Future<YtDlpException> fail() async {
|
||||||
|
try {
|
||||||
|
await y.resolve(page);
|
||||||
|
} on YtDlpException catch (e) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
throw StateError('resolved');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect((await fail()).cookies, 'no cookies were used');
|
||||||
|
await y.cookies.saveFile(parseCookies('.youtube.com\tTRUE\t/\tFALSE\t0\tSID\tv\n'));
|
||||||
|
expect((await fail()).cookies, 'used the cookies file, which has none for bilibili.com');
|
||||||
|
await y.cookies.saveBrowser('firefox');
|
||||||
|
final e = await fail();
|
||||||
|
expect(e.message, contains('No video formats found'));
|
||||||
|
expect(e.cookies, "used Firefox's cookies (2 for bilibili.com: that account may not see it)");
|
||||||
|
expect(dir.listSync().map((f) => f.path).where((p) => p.contains('cookie-check')), isEmpty,
|
||||||
|
reason: 'the browser jar is deleted at once');
|
||||||
|
// The probe carries it into the plain words.
|
||||||
|
expect(LinkProbe.page(Uri.parse(page), ytDlpError: e.message, cookies: e.cookies).problem,
|
||||||
|
"No video found: it may need signing in (used Firefox's cookies (2 for bilibili.com: that account may not see it))");
|
||||||
|
}, skip: Platform.isWindows ? 'the stand-in yt-dlp is a shell script' : false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -261,4 +261,46 @@ void main() {
|
|||||||
expect(pick(['avc1.640033', 'vp09.00.40.08', 'av01.0.08M.08']), 'v2');
|
expect(pick(['avc1.640033', 'vp09.00.40.08', 'av01.0.08M.08']), 'v2');
|
||||||
expect(pick(['avc1.640033', 'vp09.00.40.08']), 'v1');
|
expect(pick(['avc1.640033', 'vp09.00.40.08']), 'v1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('what a player opens: yt-dlp\'s pick, an EDL for video + audio, headers with the cookies, subtitles but danmaku', () {
|
||||||
|
final split = YtDlpInfo.parse({
|
||||||
|
'title': 'Page',
|
||||||
|
'formats': <Object>[],
|
||||||
|
'requested_formats': [
|
||||||
|
{
|
||||||
|
'format_id': 'v', 'url': 'https://cdn/v.m4s', 'protocol': 'https',
|
||||||
|
'http_headers': {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA'},
|
||||||
|
'cookies': 'SESSDATA=abc; Domain=.bilibili.com; Path=/; Secure; Expires=1999999999; buvid3=xyz; Domain=.bilibili.com',
|
||||||
|
},
|
||||||
|
{'format_id': 'a', 'url': 'https://cdn/a.m4s', 'protocol': 'https', 'http_headers': {'Referer': 'other'}},
|
||||||
|
],
|
||||||
|
'requested_subtitles': {
|
||||||
|
'zh-CN': {'url': 'https://subs/zh.vtt', 'ext': 'vtt', 'name': 'Chinese'},
|
||||||
|
'danmaku': {'url': 'https://comment/1.xml', 'ext': 'xml'},
|
||||||
|
},
|
||||||
|
}).play!;
|
||||||
|
expect(split.urls, ['https://cdn/v.m4s', 'https://cdn/a.m4s']);
|
||||||
|
expect(split.mpvUrl, mpvEdl(['https://cdn/v.m4s', 'https://cdn/a.m4s']));
|
||||||
|
expect(split.headers, {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA', 'Cookie': 'SESSDATA=abc; buvid3=xyz'});
|
||||||
|
expect([for (final s in split.subtitles) '${s.lang} ${s.name} ${s.url}'], ['zh-CN Chinese https://subs/zh.vtt']);
|
||||||
|
|
||||||
|
final single = YtDlpInfo.parse({
|
||||||
|
'title': 'Tweet', 'url': 'https://video.twimg.com/v.mp4', 'protocol': 'https', 'ext': 'mp4',
|
||||||
|
'http_headers': {'User-Agent': 'UA'},
|
||||||
|
}).play!;
|
||||||
|
expect(single.mpvUrl, 'https://video.twimg.com/v.mp4');
|
||||||
|
expect(single.headers, {'User-Agent': 'UA'});
|
||||||
|
|
||||||
|
final hls = YtDlpInfo.parse({
|
||||||
|
'title': 'Live', 'is_live': true,
|
||||||
|
'requested_formats': [{'url': 'https://x/live.m3u8', 'protocol': 'm3u8_native'}],
|
||||||
|
}).play!;
|
||||||
|
expect(hls.mpvUrl, 'https://x/live.m3u8', reason: 'a live stream plays, it is only not relayed');
|
||||||
|
expect(YtDlpInfo.parse({'title': 'DASH', 'requested_formats': [{'url': 'https://x/f', 'protocol': 'http_dash_segments', 'manifest_url': 'https://x/m.mpd'}]}).play?.mpvUrl,
|
||||||
|
'https://x/m.mpd');
|
||||||
|
expect(YtDlpInfo.parse({'title': 'none', 'formats': <Object>[]}).play, isNull);
|
||||||
|
expect(YtDlpInfo.parse({'title': 'rtmp', 'url': 'rtmp://x/y', 'protocol': 'rtmp'}).play, isNull);
|
||||||
|
expect(cookieHeaderOf(null), '');
|
||||||
|
expect(cookieHeaderOf('a=1; Path=/; HttpOnly; b=2=3'), 'a=1; b=2=3');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -112,14 +112,41 @@ class ChannelRelayFeed extends RelayFeed {
|
|||||||
class _Pull {
|
class _Pull {
|
||||||
final int id, start, end;
|
final int id, start, end;
|
||||||
int pos;
|
int pos;
|
||||||
final buf = BytesBuilder(copy: false);
|
// The slice being filled: its chunks so far (readers take them as they
|
||||||
|
// come, see [RelayBuffer.read]).
|
||||||
|
final parts = <Uint8List>[];
|
||||||
|
int filled = 0;
|
||||||
_Pull(this.id, this.start, this.end) : pos = start;
|
_Pull(this.id, this.start, this.end) : pos = start;
|
||||||
|
|
||||||
|
/// Bytes of the slice being filled from [from] (offset in the slice).
|
||||||
|
Uint8List? partFrom(int from) {
|
||||||
|
if (from >= filled) return null;
|
||||||
|
final out = BytesBuilder(copy: false);
|
||||||
|
var at = 0;
|
||||||
|
for (final p in parts) {
|
||||||
|
final end = at + p.length;
|
||||||
|
if (end > from) out.add(at >= from ? p : Uint8List.sublistView(p, from - at));
|
||||||
|
at = end;
|
||||||
|
}
|
||||||
|
return out.takeBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List takeSlice() {
|
||||||
|
final out = BytesBuilder(copy: false);
|
||||||
|
for (final p in parts) {
|
||||||
|
out.add(p);
|
||||||
|
}
|
||||||
|
parts.clear();
|
||||||
|
filled = 0;
|
||||||
|
return out.takeBytes();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Viewer {
|
class _Viewer {
|
||||||
int pos = 0;
|
int pos = 0;
|
||||||
int open = 0; // requests in flight
|
int open = 0; // requests in flight
|
||||||
int newest = 0; // sequence of its newest request
|
int newest = 0; // sequence of its newest request
|
||||||
|
(int slice, int req)? blocking; // where its newest request waits
|
||||||
DateTime lastActive = DateTime.now();
|
DateTime lastActive = DateTime.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,9 +161,11 @@ class _Viewer {
|
|||||||
/// slowest and [ahead] past the furthest viewer. Evicted by distance from
|
/// slowest and [ahead] past the furthest viewer. Evicted by distance from
|
||||||
/// the viewers, never LRU.
|
/// the viewers, never LRU.
|
||||||
/// - Filled only while someone watches: a viewer blocked on a slice comes
|
/// - Filled only while someone watches: a viewer blocked on a slice comes
|
||||||
/// first (oldest wait first); then read-ahead.
|
/// first (oldest wait first); then read-ahead. A blocked viewer reads the
|
||||||
|
/// slice being filled as its bytes arrive, not once it is whole.
|
||||||
/// - Only a viewer's newest request moves its position or asks the feed
|
/// - Only a viewer's newest request moves its position or asks the feed
|
||||||
/// for anything; older ones still get bytes once they are buffered.
|
/// for anything; older ones still get bytes once they are buffered, and
|
||||||
|
/// stop holding the feed once superseded (a seek elsewhere).
|
||||||
/// Everything lives in memory: dropping the buffer is the cleanup.
|
/// Everything lives in memory: dropping the buffer is the cleanup.
|
||||||
class RelayBuffer implements TrackBuffer {
|
class RelayBuffer implements TrackBuffer {
|
||||||
/// Set by the relay when a media's tracks share the budget.
|
/// Set by the relay when a media's tracks share the budget.
|
||||||
@@ -166,6 +195,8 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
final _slices = <int, Uint8List>{};
|
final _slices = <int, Uint8List>{};
|
||||||
final _waiters = <int, List<Completer<void>>>{};
|
final _waiters = <int, List<Completer<void>>>{};
|
||||||
final _blocked = <int, int>{}; // slice -> wait start (us)
|
final _blocked = <int, int>{}; // slice -> wait start (us)
|
||||||
|
// The driving requests waiting on each blocked slice.
|
||||||
|
final _blockedBy = <int, Set<int>>{};
|
||||||
final _viewers = <String, _Viewer>{};
|
final _viewers = <String, _Viewer>{};
|
||||||
@override
|
@override
|
||||||
FeedClock clock = FeedClock();
|
FeedClock clock = FeedClock();
|
||||||
@@ -256,21 +287,22 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
_endPull(pl);
|
_endPull(pl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pl.buf.add(Uint8List.sublistView(data, off, off + take));
|
pl.parts.add(Uint8List.sublistView(data, off, off + take));
|
||||||
|
pl.filled += take;
|
||||||
off += take;
|
off += take;
|
||||||
pl.pos += take;
|
pl.pos += take;
|
||||||
if (pl.pos == _sliceEnd(s) + 1) {
|
final done = pl.pos == _sliceEnd(s) + 1;
|
||||||
_slices[s] = pl.buf.takeBytes();
|
if (done) _slices[s] = pl.takeSlice();
|
||||||
|
// Whole, or more of it: whoever waits on it reads on.
|
||||||
for (final c in _waiters.remove(s) ?? const <Completer<void>>[]) {
|
for (final c in _waiters.remove(s) ?? const <Completer<void>>[]) {
|
||||||
c.complete();
|
c.complete();
|
||||||
}
|
}
|
||||||
if (_shouldStop(pl, s)) {
|
if (done && _shouldStop(pl, s)) {
|
||||||
_endPull(pl);
|
_endPull(pl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void feedEnd(int id) {
|
void feedEnd(int id) {
|
||||||
@@ -405,25 +437,54 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
await _kick.future.timeout(const Duration(seconds: 5), onTimeout: () {});
|
await _kick.future.timeout(const Duration(seconds: 5), onTimeout: () {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _waitSlice(int s, bool drives) async {
|
/// The bytes of slice [s] from [from] (offset in the slice) as soon as
|
||||||
|
/// there are any: the whole slice's rest, or what the pull filling it has
|
||||||
|
/// so far. Null when they did not come within [waitLimit]. [drives]: the
|
||||||
|
/// request is its viewer's newest ([driving] says while it still is), so
|
||||||
|
/// it may move the feed.
|
||||||
|
Future<Uint8List?> _waitBytes(_Viewer v, int s, int from, int req, bool Function() driving) async {
|
||||||
final deadline = DateTime.now().add(waitLimit);
|
final deadline = DateTime.now().add(waitLimit);
|
||||||
while (!_slices.containsKey(s)) {
|
try {
|
||||||
if (_disposed || DateTime.now().isAfter(deadline)) return false;
|
while (true) {
|
||||||
if (drives) {
|
final whole = _slices[s];
|
||||||
|
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);
|
_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
|
// A pull nobody waits on gives way at once; one serving another
|
||||||
// blocked viewer hands over at its next slice boundary.
|
// blocked viewer hands over at its next slice boundary.
|
||||||
final pl = _pull;
|
|
||||||
final at = pl == null ? -1 : pl.pos ~/ slice;
|
final at = pl == null ? -1 : pl.pos ~/ slice;
|
||||||
if (pl != null && at != s && !_blocked.containsKey(at)) _endPull(pl);
|
if (pl != null && at != s && !_blocked.containsKey(at)) _endPull(pl);
|
||||||
_wake();
|
_wake();
|
||||||
|
} else {
|
||||||
|
_unblock(s, req);
|
||||||
}
|
}
|
||||||
final c = Completer<void>();
|
final c = Completer<void>();
|
||||||
_waiters.putIfAbsent(s, () => []).add(c);
|
_waiters.putIfAbsent(s, () => []).add(c);
|
||||||
await c.future.timeout(const Duration(seconds: 2), onTimeout: () {});
|
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);
|
_blocked.remove(s);
|
||||||
return true;
|
_wake();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- viewers
|
// ---------------------------------------------------------------- viewers
|
||||||
@@ -444,6 +505,10 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
final tail = start >= size - pin;
|
final tail = start >= size - pin;
|
||||||
final me = ++_reqSeq;
|
final me = ++_reqSeq;
|
||||||
if (!tail) {
|
if (!tail) {
|
||||||
|
// A seek: the request it supersedes stops holding the feed at once.
|
||||||
|
final was = v.blocking;
|
||||||
|
if (was != null) _unblock(was.$1, was.$2);
|
||||||
|
v.blocking = null;
|
||||||
v.newest = me;
|
v.newest = me;
|
||||||
v.pos = start;
|
v.pos = start;
|
||||||
}
|
}
|
||||||
@@ -454,16 +519,14 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
var off = start;
|
var off = start;
|
||||||
while (off <= end) {
|
while (off <= end) {
|
||||||
final s = off ~/ slice;
|
final s = off ~/ slice;
|
||||||
if (!await _waitSlice(s, tail || v.newest == me)) {
|
final data = await _waitBytes(v, s, off - s * slice, me, () => tail || v.newest == me);
|
||||||
throw StateError('relay: bytes at $off did not arrive');
|
if (data == null) throw StateError('relay: bytes at $off did not arrive');
|
||||||
}
|
final n = min(data.length, end - off + 1);
|
||||||
final data = _slices[s];
|
if (n <= 0) continue;
|
||||||
if (data == null) continue; // evicted between wake and read
|
yield n == data.length ? data : Uint8List.sublistView(data, 0, n);
|
||||||
final from = off - s * slice, to = min(data.length, end - s * slice + 1);
|
bytesToViewers += n;
|
||||||
yield Uint8List.sublistView(data, from, to);
|
onSent?.call(n);
|
||||||
bytesToViewers += to - from;
|
off += n;
|
||||||
onSent?.call(to - from);
|
|
||||||
off += to - from;
|
|
||||||
v.lastActive = DateTime.now();
|
v.lastActive = DateTime.now();
|
||||||
if (!tail && v.newest == me) v.pos = off;
|
if (!tail && v.newest == me) v.pos = off;
|
||||||
_wake();
|
_wake();
|
||||||
|
|||||||
Executable
+39
@@ -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"
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user