Compare commits
22
Commits
bf9fc193b3
...
v1.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81b9d05de6 | ||
|
|
f9b79aa47c | ||
|
|
e16cf50f2c | ||
|
|
8aa1186d29 | ||
|
|
49fee1ca19 | ||
|
|
c91d57633f | ||
|
|
22ce40a488 | ||
|
|
07276ce020 | ||
|
|
4c8e40769a | ||
|
|
211ba65e5e | ||
|
|
fe4749ec7f | ||
|
|
51af830160 | ||
|
|
d9d7881747 | ||
|
|
1c915067aa | ||
|
|
0924309abf | ||
|
|
cf41a13528 | ||
|
|
9bbc594048 | ||
|
|
741b258ac1 | ||
|
|
a87ee98faa | ||
|
|
25b19f2d6a | ||
|
|
8970f2694b | ||
|
|
72a3b9adac |
No files matched your search
+10
-1
@@ -41,7 +41,8 @@ jobs:
|
||||
working-directory: packages/server
|
||||
|
||||
# The updater's Windows apply path (running exe parked as .old) only
|
||||
# means something on real NTFS. Runner notes: see release.yml.
|
||||
# means something on real NTFS, and the close smoke run needs the real
|
||||
# build. Runner notes: see release.yml.
|
||||
updater-windows:
|
||||
# Not for a "Release v…" commit: release.yml builds it on the same
|
||||
# (only) Windows runner, which this job would hold up.
|
||||
@@ -71,3 +72,11 @@ jobs:
|
||||
- name: Windows build
|
||||
shell: powershell
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1
|
||||
# The close smoke run on that build (P32): start, open a short clip in
|
||||
# a player with a video output, close; it must exit within 45 s and
|
||||
# leave no process. A runner that cannot show a window skips with a
|
||||
# warning. Here rather than in a job of its own: one build, one runner.
|
||||
- name: Smoke (start, open, close)
|
||||
shell: powershell
|
||||
timeout-minutes: 3
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/smoke_windows.ps1 -Limit 45
|
||||
@@ -224,8 +224,10 @@ Up next, add the folder to Files, or fill Link).
|
||||
"Show (Season 3 - Alicization)" gives the season and its name. Shows
|
||||
are looked up on AniList for their proper name, cover and banner. A
|
||||
show's page has its seasons and episodes and a button for the next one
|
||||
after what you last played here; when episodes are missing on the way
|
||||
it says so ("66–107 aren't in your folders — skip to 108?"). Matched
|
||||
after what you last watched here; when episodes are missing on the way
|
||||
it says so ("66–107 aren't in your folders — skip to 108?"). An
|
||||
episode you put up continues with the next one at its end (see Up
|
||||
next). Matched
|
||||
the wrong show? **Wrong show?** searches AniList and pins the right one
|
||||
for the show everywhere or just one folder; **Episode numbers…** says
|
||||
which episode the first file is when the files count differently. Both
|
||||
@@ -253,7 +255,7 @@ app open for that. Everyone shares one Up next and anyone can change it; what
|
||||
you added stays when you leave.
|
||||
|
||||
In the player, Up next sits in the top bar, right of the title: the
|
||||
next item's picture and title (or the next episode's). Rest the mouse on
|
||||
next item's picture and title (or what continues the episode). Rest the mouse on
|
||||
it for a larger preview (who added it, its length, whether it is ready
|
||||
to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a
|
||||
row to play it now (▶) or remove it (×), right-click it for more (copy its
|
||||
@@ -261,17 +263,22 @@ title or link, open it in the browser or in Jellyfin). To add, press
|
||||
Ctrl+V with the queue open (a copied link or video file goes in at once,
|
||||
its title and picture showing while it is checked), type one after
|
||||
**+ Add link**, or drop a link or a video on it. When a video ends, a card shows
|
||||
what is up next with a 10 s countdown (Cancel stops it on your screen)
|
||||
and it plays; `>` or Shift+N plays it at once. Under the queue,
|
||||
**Continues with** shows the next episodes of the Jellyfin series
|
||||
playing (greyed: they play only once Up next is empty); **+** adds one,
|
||||
**Clear** drops them for this episode.
|
||||
what is up next with a 10 s countdown everyone sees, and it plays;
|
||||
Cancel stops it for everyone, and so does seeking back. `>` or Shift+N
|
||||
plays it at once. Under the queue, **Continues with** shows what
|
||||
continues the episode playing: its next episode (a Jellyfin episode's,
|
||||
or a file's next one in your Files library, saying which numbers it
|
||||
skips), added by whoever put the episode up, and greyed because it
|
||||
plays only once Up next is empty; **Clear** drops it for everyone. The
|
||||
further episodes of a Jellyfin series show under it; **+** adds one.
|
||||
|
||||
A link you pass on ("through me") or a file of yours can only be played
|
||||
by your app: if you left when its turn comes, it is skipped ("Skipped …:
|
||||
Foxyz left"). A Jellyfin item needs someone with an account on that
|
||||
server. Up next is kept by the server while it runs; restarting the
|
||||
server empties it.
|
||||
Foxyz left"). If you are only gone for a moment, the countdown waits for
|
||||
you. A Jellyfin item needs someone with an account on that server. Up
|
||||
next is kept by the server while it runs. If the server restarts, the
|
||||
apps put back what was playing, where it was, and Up next with it (a
|
||||
link the server fetched has to be added again).
|
||||
|
||||
## Schedule
|
||||
|
||||
@@ -473,10 +480,13 @@ Watching:
|
||||
directly with their own sign-in. Jellyfin tracks progress per account:
|
||||
your watched state, Continue watching and resume point follow what you
|
||||
saw (reported on start, every 10 s, on pause, resume, seek and track
|
||||
change, and when it stops). Watched means you actually played most of
|
||||
it (or reached the ending after half), not where you stopped: opening
|
||||
a watched episode to check a moment keeps it watched, and a quick peek
|
||||
leaves no resume point.
|
||||
change, and when it stops). Watched means you were there while most
|
||||
of it played (or while half of it played and it reached the ending),
|
||||
not where it stopped: time you sat out does not count, skipping around
|
||||
does not count, opening a watched episode to check a moment keeps it
|
||||
watched, and a quick peek leaves no resume point. Files and links you
|
||||
watch are remembered the same way on your computer (a show's next
|
||||
episode in Files follows them).
|
||||
- Friends without an account watch through the server: whoever put the
|
||||
item up passes it on automatically, with their own sign-in, which never
|
||||
leaves their computer (only the current item, only to people in the
|
||||
@@ -493,12 +503,11 @@ Episodes: the ⏮ / ⏭ buttons next to play (or `<` / `>`,
|
||||
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
||||
the ending (when it is known, see Skipping above; else in the last two
|
||||
minutes) a "Next episode" button floats in the corner while the controls
|
||||
are up. When an
|
||||
episode ends, a card shows what is next with Play next; with "Play the
|
||||
next episode automatically" (Settings, on by default) it counts down 10 s
|
||||
first, and Cancel stops it on your screen (seeking back stops it for
|
||||
everyone). Anyone with an account can put the next one up; friends
|
||||
watching via the host cannot.
|
||||
are up. With "Play the next episode automatically" (Settings, on by
|
||||
default), the episode you put up continues with the next one: when it
|
||||
ends, everyone sees a 10 s countdown, and anyone can cancel it (see Up
|
||||
next). Anyone with an account can put the next one up; friends watching
|
||||
via the host cannot.
|
||||
|
||||
Audio and subtitles: the Audio and Subtitles buttons in the controls pick tracks.
|
||||
Your choice is remembered per series (and as a default) and the closest
|
||||
@@ -533,7 +542,11 @@ export (picked or dropped; every site in it is kept). They stay on this
|
||||
computer and are used by its yt-dlp only (your own playback, "through
|
||||
me", and a server you host). Cookies log yt-dlp in as you on those
|
||||
sites: keep the file private, and for YouTube consider a throwaway
|
||||
account (Google may flag accounts used with yt-dlp).
|
||||
account (Google may flag accounts used with yt-dlp). When a page shows
|
||||
no video, the message says which cookies were used: none, your file
|
||||
(and whether it has any for that site), or your browser's (and how many
|
||||
it has for that site), so you know whether to sign in there or add
|
||||
cookies.
|
||||
YouTube works in all three too, and so do pages and links that only
|
||||
offer streaming playlists (HLS `.m3u8`, DASH `.mpd`, the HLS of X videos).
|
||||
Through me and Server fetches it never show the link to anyone; they can't pass on live
|
||||
|
||||
@@ -3,15 +3,16 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'src/app/app_graph.dart';
|
||||
import 'src/files/thumbnails.dart' show thumbnailPlayers;
|
||||
import 'src/platform/app_log.dart';
|
||||
import 'src/platform/app_shutdown.dart' show closeAppWith;
|
||||
import 'src/platform/desktop/desktop_app_dirs.dart';
|
||||
import 'src/platform/desktop_entry.dart';
|
||||
import 'src/platform/smoke.dart';
|
||||
import 'src/screens/home/ui.dart';
|
||||
import 'src/session/app_events.dart' show UpdateAvailable;
|
||||
import 'src/state/settings.dart';
|
||||
@@ -65,9 +66,12 @@ Future<void> main(List<String> args) async {
|
||||
} catch (e, st) {
|
||||
debugPrint('load connect form failed: $e\n$st');
|
||||
}
|
||||
// The library's matches, pins and offsets (device-owned).
|
||||
// The library's matches, pins and offsets, and the watch history
|
||||
// (device-owned).
|
||||
unawaited(graph.library.ensureLoaded());
|
||||
MediaKit.ensureInitialized();
|
||||
unawaited(graph.watch.history.ensureLoaded());
|
||||
ports.players.init();
|
||||
thumbnailPlayers = ports.players;
|
||||
// yt-dlp for web page links: downloaded on first run, kept current.
|
||||
ports.links.warmUp();
|
||||
// Linux: the launcher entry and icon Wayland needs (release installs).
|
||||
@@ -82,6 +86,11 @@ Future<void> main(List<String> args) async {
|
||||
}
|
||||
});
|
||||
runApp(AppScope(graph: graph, child: const WatchTogetherApp()));
|
||||
// CI's close smoke run: open a file, play, close (see smoke.dart).
|
||||
final smoke = smokeMedia;
|
||||
if (smoke != null) {
|
||||
unawaited(WidgetsBinding.instance.endOfFrame.then((_) => runSmoke(graph, smoke)));
|
||||
}
|
||||
// Home: your own server, quietly (only this app can reach it until
|
||||
// Invite).
|
||||
unawaited(graph.navigation.start());
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../library/library_store.dart';
|
||||
import '../library/local_next.dart';
|
||||
import '../library/show_matcher.dart';
|
||||
import '../platform/app_log.dart' show appLogDir;
|
||||
import '../platform/desktop/desktop_ports.dart';
|
||||
@@ -20,6 +21,8 @@ import '../sfx/sound_bank.dart';
|
||||
import '../sfx/synth_sfx.dart';
|
||||
import '../state/connect_prefs.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../watch/watch_history.dart';
|
||||
import '../watch/watch_tracker.dart';
|
||||
|
||||
/// The composition root: builds the app's systems once (in `main`), wires
|
||||
/// them through their constructors and disposes them. No logic and no
|
||||
@@ -51,6 +54,9 @@ class AppGraph {
|
||||
/// The library's matches (L2): local shows to AniList, pins, offsets.
|
||||
final ShowMatcher library;
|
||||
|
||||
/// Watch tracking and the device's watch history (D3).
|
||||
final WatchTracker watch;
|
||||
|
||||
AppGraph._({
|
||||
required this.ports,
|
||||
required this.sfx,
|
||||
@@ -61,6 +67,7 @@ class AppGraph {
|
||||
required this.yourServer,
|
||||
required this.navigation,
|
||||
required this.library,
|
||||
required this.watch,
|
||||
});
|
||||
|
||||
/// The desktop app's graph. Tests pass their own [ports], [identity]
|
||||
@@ -72,12 +79,14 @@ class AppGraph {
|
||||
ServerStarter? serverStarter,
|
||||
JellyfinAccounts? jellyfin,
|
||||
ShowMatcher? library,
|
||||
WatchHistory? history,
|
||||
}) {
|
||||
final p = ports ?? desktopPorts();
|
||||
final settings = AppSettings.instance;
|
||||
final sfx = SynthSfx(
|
||||
volume: () => AppSettings.instance.soundVolume.peek(),
|
||||
volume: () => settings.soundVolume.peek(),
|
||||
// A nudge is an explicit poke: it ignores its switch (not the volume).
|
||||
enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name),
|
||||
enabled: (k) => k == SfxKind.nudge || !settings.soundsOff.peek().contains(k.name),
|
||||
presetFor: soundPresetFor,
|
||||
);
|
||||
final notifier = Notifier(
|
||||
@@ -85,11 +94,19 @@ class AppGraph {
|
||||
attention: p.shell.requestAttention,
|
||||
system: p.notifications,
|
||||
focusWindow: p.shell.focus,
|
||||
prefs: () => AppSettings.instance.notifyPrefs,
|
||||
prefs: () => settings.notifyPrefs,
|
||||
);
|
||||
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
|
||||
final coordinator =
|
||||
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin);
|
||||
// Device-owned: the watch history box.
|
||||
final watch = WatchTracker(history: history ?? WatchHistory());
|
||||
final coordinator = SessionCoordinator(
|
||||
notifier: notifier,
|
||||
connection: connection,
|
||||
ytDlp: p.links,
|
||||
jellyfin: jellyfin,
|
||||
players: p.players,
|
||||
settings: settings,
|
||||
watch: watch);
|
||||
// Device-owned: the library box; AnimeFlow's runs when signed in.
|
||||
final matcher = library ??
|
||||
ShowMatcher(
|
||||
@@ -97,7 +114,9 @@ class AppGraph {
|
||||
aniList: AniListClient(),
|
||||
flow: AnimeFlowLookup(AnimeFlowClient.fromAccounts(coordinator.jellyfin)),
|
||||
);
|
||||
coordinator.fileRef = matcher.refForPath;
|
||||
coordinator
|
||||
..fileRef = matcher.refForPath
|
||||
..nextFile = LocalNextFinder(matcher: matcher, scan: p.folders.scan).nextAfter;
|
||||
final forms = ConnectStore();
|
||||
final yourServer = YourServer(
|
||||
forms: forms,
|
||||
@@ -137,12 +156,14 @@ class AppGraph {
|
||||
yourServer: yourServer,
|
||||
navigation: navigation,
|
||||
library: matcher,
|
||||
watch: watch,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
navigation.dispose();
|
||||
await coordinator.dispose();
|
||||
await watch.dispose();
|
||||
await yourServer.stop();
|
||||
await sfx.dispose();
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ class MinimalControls extends StatelessWidget {
|
||||
const Expanded(
|
||||
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()),
|
||||
),
|
||||
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.minimal, hold: hold),
|
||||
UpNextButton(queue: m.queue, look: UpNextLook.minimal, hold: hold),
|
||||
const SizedBox(width: 4),
|
||||
SignalBuilder(
|
||||
builder: (context) {
|
||||
|
||||
@@ -28,6 +28,9 @@ abstract final class MinimalTimelineSpec {
|
||||
/// What is left of it while they are hidden.
|
||||
static const lineSize = 3.0;
|
||||
|
||||
/// The played part's accent tint.
|
||||
static const played = 0.4;
|
||||
|
||||
/// The position line.
|
||||
static const lineWidth = 2.0;
|
||||
static const fontSize = 15.0;
|
||||
@@ -467,13 +470,10 @@ class MinimalTimelinePainter extends CustomPainter {
|
||||
final w = size.width, h = size.height;
|
||||
final x = lineX(w);
|
||||
final band = Offset.zero & size;
|
||||
// The band, darker as it grows; hidden, a faint track.
|
||||
canvas.drawRect(band, Paint()..color = Color.lerp(const Color(0x40FFFFFF), MinimalStyle.band, visibility)!);
|
||||
// Played: an accent tint, solid once the band is a line.
|
||||
canvas.drawRect(
|
||||
Rect.fromLTRB(0, 0, x, h),
|
||||
Paint()..color = accent.withValues(alpha: 0.28 + 0.72 * (1 - visibility)),
|
||||
);
|
||||
// The same colours shown and hidden: hidden, it is the band shrunk to
|
||||
// a line, so growing and shrinking never switches colour.
|
||||
canvas.drawRect(band, Paint()..color = MinimalStyle.band);
|
||||
canvas.drawRect(Rect.fromLTRB(0, 0, x, h), Paint()..color = accent.withValues(alpha: MinimalTimelineSpec.played));
|
||||
_stripes(canvas, w, h);
|
||||
_chapters(canvas, w, h);
|
||||
if (visibility > 0) {
|
||||
@@ -500,7 +500,8 @@ class MinimalTimelinePainter extends CustomPainter {
|
||||
canvas.save();
|
||||
canvas.clipRect(Rect.fromLTRB(ax, 0, w, h));
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.10 + 0.08 * (1 - visibility))
|
||||
// Stronger on the line, where only a few pixels of each show.
|
||||
..color = Colors.white.withValues(alpha: 0.12 + 0.22 * (1 - visibility))
|
||||
..strokeWidth = 1.5 * scale;
|
||||
for (var c = (ax / period).floorToDouble() * period - h; c <= w; c += period) {
|
||||
canvas.drawLine(Offset(c, h), Offset(c + h, 0), paint);
|
||||
@@ -509,7 +510,7 @@ class MinimalTimelinePainter extends CustomPainter {
|
||||
}
|
||||
|
||||
void _chapters(Canvas canvas, double w, double h) {
|
||||
final cut = Paint()..color = Colors.black.withValues(alpha: 0.55);
|
||||
final cut = Paint()..color = Colors.black.withValues(alpha: 0.3);
|
||||
for (final f in chapters) {
|
||||
if (f <= 0) continue;
|
||||
final cx = w * f;
|
||||
|
||||
@@ -81,13 +81,16 @@ class PlayerControlsModel {
|
||||
/// Everything the layout depends on (not the position: that changes
|
||||
/// four times a second and only the timeline needs it).
|
||||
late final Computed<ControlsSnapshot> snapshot = computed(() {
|
||||
final items = queue.items.value;
|
||||
final queued = queue.items.value.firstOrNull;
|
||||
final continues = queue.continues.value;
|
||||
final next = coord.nextEpisode.value;
|
||||
return ControlsSnapshot(
|
||||
play: play.value,
|
||||
previousLabel: coord.previousEpisode.value?.label,
|
||||
nextLabel: items.isNotEmpty ? items.first.title : next?.label,
|
||||
nextIsUpNext: items.isNotEmpty,
|
||||
// Up next's first item, else the next episode, else what continues
|
||||
// this (a file's next episode).
|
||||
nextLabel: queued?.title ?? next?.label ?? continues?.title,
|
||||
nextIsUpNext: queued != null,
|
||||
hasChapters: chapters.value.isNotEmpty,
|
||||
hasDuration: duration.value > Duration.zero,
|
||||
alone: coord.alone.value,
|
||||
@@ -121,7 +124,7 @@ class PlayerControlsModel {
|
||||
|
||||
/// A seek bar's press or drag; through `player.seek`, the user-seek
|
||||
/// path, so everyone follows.
|
||||
void seek(Duration to) => playback.player.seek(to);
|
||||
void seek(Duration to) => playback.userSeek(to);
|
||||
|
||||
/// Arrow keys: ±[secs], relative to a seek still in flight.
|
||||
void nudge(double secs) => coord.nudge(secs);
|
||||
@@ -137,9 +140,10 @@ class PlayerControlsModel {
|
||||
|
||||
void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6);
|
||||
|
||||
/// Up next's first item, else the next episode. False when neither.
|
||||
/// What plays next (Up next's first item, else the continuation), else
|
||||
/// the next episode. False when neither.
|
||||
bool next() {
|
||||
if (queue.items.peek().isNotEmpty) {
|
||||
if (queue.head.peek() != null) {
|
||||
queue.skip();
|
||||
return true;
|
||||
}
|
||||
@@ -157,7 +161,7 @@ class PlayerControlsModel {
|
||||
|
||||
void playNextEpisode() => coord.playNextEpisode();
|
||||
|
||||
void setVolume(double v) => playback.player.setVolume(v.clamp(0.0, vol.maxVolume));
|
||||
void setVolume(double v) => playback.setVolume(v.clamp(0.0, vol.maxVolume));
|
||||
|
||||
/// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓;
|
||||
/// flashes the volume readout.
|
||||
|
||||
@@ -222,7 +222,9 @@ class ShaderPresetSetting extends StatelessWidget {
|
||||
final preset = s.shaderPreset.value;
|
||||
final quality = s.shaderQuality.value;
|
||||
final hasCustom = s.customShaders.value.isNotEmpty;
|
||||
final mode = (renderMode ?? PlaybackController.lastRenderMode).value;
|
||||
final mode = (renderMode ??
|
||||
context.getInheritedWidgetOfExactType<AppScope>()?.graph.ports.players.renderMode)
|
||||
?.value;
|
||||
final warning = switch (mode) {
|
||||
RenderMode.software => 'Software rendering: shaders have no effect',
|
||||
RenderMode.softwareGl => 'OpenGL on the CPU: shaders will be slow',
|
||||
@@ -267,10 +269,10 @@ class ShaderPresetSetting extends StatelessWidget {
|
||||
/// on the fly). `SYNC_RELAY_HWDEC` wins, as at player start.
|
||||
Future<void> applyHwdecLive(PlaybackController playback) async {
|
||||
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
|
||||
if (!playback.alive.peek()) return;
|
||||
final p = playback.current;
|
||||
if (p == null) return;
|
||||
try {
|
||||
final dynamic platform = playback.player.platform;
|
||||
await platform?.setProperty('hwdec', AppSettings.instance.hwdec.peek());
|
||||
await p.setProperty('hwdec', AppSettings.instance.hwdec.peek());
|
||||
} catch (e) {
|
||||
debugPrint('live hwdec failed: $e');
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ class StandardControls extends StatelessWidget {
|
||||
return Row(
|
||||
children: [
|
||||
const Expanded(child: NowPlayingTitle()),
|
||||
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.standard, hold: hold),
|
||||
UpNextButton(queue: m.queue, look: UpNextLook.standard, hold: hold),
|
||||
SignalBuilder(
|
||||
builder: (context) {
|
||||
final full = m.fullscreen.value;
|
||||
|
||||
@@ -4,9 +4,11 @@ import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../platform/desktop/media_kit_host.dart' show MediaKitHost;
|
||||
import '../platform/ports/player_host.dart';
|
||||
|
||||
// Poster frames for the Files source, made by mpv itself (no ffmpeg on
|
||||
// users' machines; Windows ships only libmpv). A hidden player per file
|
||||
// with `vo=null`: mpv still keeps the last decoded frame, so
|
||||
@@ -23,6 +25,9 @@ class Thumbnail {
|
||||
|
||||
const thumbnailWidth = 480;
|
||||
|
||||
/// The players thumbnails are made with (the app's host, set in `main`).
|
||||
PlayerHost thumbnailPlayers = MediaKitHost()..init();
|
||||
|
||||
/// Where thumbnails are cached; swapped in tests.
|
||||
Future<Directory> Function() thumbnailCacheRoot = getApplicationCacheDirectory;
|
||||
|
||||
@@ -33,7 +38,7 @@ const _timeout = Duration(seconds: 20);
|
||||
int _running = 0;
|
||||
final _waiting = <Completer<void>>[];
|
||||
final _inFlight = <String, Future<Thumbnail?>>{};
|
||||
final _players = <Player>{};
|
||||
final _players = <HostedPlayer>{};
|
||||
bool _closed = false;
|
||||
|
||||
/// App close: no new thumbnails, running ones stop at their next check
|
||||
@@ -49,7 +54,7 @@ Future<void> cancelAllThumbnails({
|
||||
for (final p in _players.toList()) {
|
||||
_players.remove(p);
|
||||
try {
|
||||
await p.dispose().timeout(deadline);
|
||||
await thumbnailPlayers.dispose(p).timeout(deadline);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -147,19 +152,10 @@ Future<Thumbnail?> _generate(
|
||||
String out,
|
||||
bool Function() cancelled,
|
||||
) async {
|
||||
final player = Player(
|
||||
configuration: const PlayerConfiguration(
|
||||
vo: 'null',
|
||||
muted: true,
|
||||
osc: false,
|
||||
libass: false,
|
||||
title: 'thumbnail',
|
||||
bufferSize: 4 << 20,
|
||||
),
|
||||
);
|
||||
final player = await thumbnailPlayers.create(PlayerRole.thumbnail);
|
||||
_players.add(player);
|
||||
try {
|
||||
final mpv = player.platform as NativePlayer;
|
||||
final mpv = player;
|
||||
for (final (k, v) in [
|
||||
('ao', 'null'),
|
||||
('aid', 'no'),
|
||||
@@ -176,7 +172,7 @@ Future<Thumbnail?> _generate(
|
||||
]) {
|
||||
await mpv.setProperty(k, v);
|
||||
}
|
||||
await player.open(Media(path), play: false);
|
||||
await player.open(path);
|
||||
final target = File(out);
|
||||
final deadline = DateTime.now().add(_timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
@@ -199,6 +195,6 @@ Future<Thumbnail?> _generate(
|
||||
return null;
|
||||
} finally {
|
||||
// [cancelAllThumbnails] may have taken it already.
|
||||
if (_players.remove(player)) await player.dispose();
|
||||
if (_players.remove(player)) await thumbnailPlayers.dispose(player);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '../../library/show_matcher.dart';
|
||||
import '../../shell/motion.dart';
|
||||
import '../../platform/ports/folder_access.dart';
|
||||
import '../../app/app_graph.dart';
|
||||
import '../../watch/watch_history.dart';
|
||||
import '../../widgets/app_menu.dart';
|
||||
import '../folder_store.dart';
|
||||
import '../scanner.dart';
|
||||
@@ -127,6 +128,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
late final ThumbCache _thumbs =
|
||||
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
|
||||
FolderStore get _store => widget.store ?? FolderStore.instance;
|
||||
WatchHistory get _history => AppScope.of(context).watch.history;
|
||||
|
||||
/// Null: Recent; [libraryPlace]: every folder.
|
||||
String? _open = _lastOpen;
|
||||
@@ -325,8 +327,15 @@ class _FilesPageState extends State<FilesPage> {
|
||||
thumbs: _thumbs,
|
||||
);
|
||||
|
||||
/// The last episode of [show] played here (Recent), by key.
|
||||
/// The last episode of [show] watched here (the watch history: the
|
||||
/// newest one watched to the end), else the last one opened (Recent), by
|
||||
/// key.
|
||||
String? _lastPlayed(_Library lib, LocalShow show) {
|
||||
for (final (key, entry) in _history.entries) {
|
||||
if (!entry.played || !key.value.startsWith('file:')) continue;
|
||||
final e = lib.byPath[key.value.substring(5)];
|
||||
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
|
||||
}
|
||||
for (final p in _store.recent.value) {
|
||||
final e = lib.byPath[p];
|
||||
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
|
||||
@@ -342,6 +351,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
final folders = _store.folders.value;
|
||||
final recent = _store.recent.value;
|
||||
_matcher.revision.value;
|
||||
_history.revision.value;
|
||||
final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace;
|
||||
final showKey = open == null ? null : _openShows[open];
|
||||
final main = fadeThrough(
|
||||
|
||||
@@ -26,13 +26,14 @@ class PlayerSnapshot {
|
||||
/// (the session shows one quiet toast) and never touches playback. A 401
|
||||
/// calls [onUnauthorized] and ends reporting.
|
||||
///
|
||||
/// Watched or not is [WatchSession]'s rule, not Jellyfin's position
|
||||
/// percentages (see ARCHITECTURE.md "Jellyfin in a session"): an item
|
||||
/// played before ([playedBefore]) stays played (re-marked after Playing,
|
||||
/// Progress without a position, Stopped without one, then marked played
|
||||
/// again); otherwise Progress leaves the position out past
|
||||
/// [_noPositionFrom] of the runtime so Jellyfin cannot mark it played on
|
||||
/// its own, and Stopped carries what the rule says.
|
||||
/// Watched or not is [WatchSession]'s rule (time In, D3), not Jellyfin's
|
||||
/// position percentages (see ARCHITECTURE.md "Watch tracking"): the
|
||||
/// session's watch tracker feeds [watch] and this reporter only reads its
|
||||
/// verdict. An item played before ([playedBefore]) stays played
|
||||
/// (re-marked after Playing, Progress without a position, Stopped without
|
||||
/// one, then marked played again); otherwise Progress leaves the position
|
||||
/// out past [_noPositionFrom] of the runtime so Jellyfin cannot mark it
|
||||
/// played on its own, and Stopped carries what the rule says.
|
||||
class JellyfinProgressReporter {
|
||||
final JellyfinClient client;
|
||||
final String itemId;
|
||||
@@ -43,7 +44,8 @@ class JellyfinProgressReporter {
|
||||
final void Function(Object error)? onError;
|
||||
final VoidCallback? onUnauthorized;
|
||||
|
||||
/// What this viewer watched of the item while it was open here.
|
||||
/// What this viewer watched of the item while it was open here (fed by
|
||||
/// the watch tracker, not by this reporter).
|
||||
final WatchSession watch;
|
||||
|
||||
JellyfinProgressReporter({
|
||||
@@ -57,12 +59,14 @@ class JellyfinProgressReporter {
|
||||
this.onError,
|
||||
this.onUnauthorized,
|
||||
this.onStopped,
|
||||
WatchSession? watch,
|
||||
DateTime Function()? clock,
|
||||
}) : _clock = clock ?? DateTime.now,
|
||||
_keepPlayed = playedBefore,
|
||||
_runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond,
|
||||
watch = WatchSession(
|
||||
playedBefore: playedBefore, resumeBefore: resumeTicksBefore / jellyfinTicksPerSecond);
|
||||
watch = (watch ?? WatchSession())
|
||||
..playedBefore = playedBefore
|
||||
..resumeBefore = resumeTicksBefore / jellyfinTicksPerSecond;
|
||||
|
||||
/// Past this share of the runtime Progress carries no position: Jellyfin
|
||||
/// marks an item played past its "max resume" share (90 % by default).
|
||||
@@ -126,7 +130,6 @@ class JellyfinProgressReporter {
|
||||
if (_playing || _dead) return;
|
||||
_playing = true;
|
||||
_mark(s);
|
||||
watch.sample(s.position, playing: !s.paused, at: _clock());
|
||||
_send('Playing', () => client.reportPlaybackStart(reportFor(s)));
|
||||
if (_keepPlayed) _send('played', () => client.markPlayed(itemId));
|
||||
}
|
||||
@@ -136,7 +139,6 @@ class JellyfinProgressReporter {
|
||||
if (!_playing || _dead) return;
|
||||
final last = _last, lastAt = _lastAt, sentAt = _lastSentAt;
|
||||
final now = _clock();
|
||||
watch.sample(s.position, playing: !s.paused, at: now);
|
||||
var due = force || last == null || lastAt == null || sentAt == null;
|
||||
if (!due) {
|
||||
final elapsed = now.difference(lastAt).inMilliseconds / 1000;
|
||||
@@ -163,11 +165,10 @@ class JellyfinProgressReporter {
|
||||
Future<void> stop(PlayerSnapshot s) {
|
||||
if (!_playing || _dead) return _chain;
|
||||
_playing = false;
|
||||
watch.sample(s.position, playing: !s.paused, at: _clock());
|
||||
final v = _keepPlayed
|
||||
? const WatchVerdict(WatchOutcome.keepPlayed)
|
||||
: watch.verdict(runtime: _runtime, endingStart: _endingStart);
|
||||
debugPrint('jellyfin $itemId: $v (covered ${watch.covered.round()} s of ${_runtime.round()} s)');
|
||||
debugPrint('jellyfin $itemId: $v (${watch.credited.round()} s In of ${_runtime.round()} s)');
|
||||
final int? ticks;
|
||||
switch (v.outcome) {
|
||||
case WatchOutcome.played || WatchOutcome.keepPlayed:
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../session/party.dart' show isPresent;
|
||||
import 'accounts.dart';
|
||||
import 'format.dart';
|
||||
import '../library/media_refs.dart';
|
||||
@@ -341,26 +340,3 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
|
||||
const lead = Duration(minutes: 2);
|
||||
return runtime > lead ? runtime - lead : Duration.zero;
|
||||
}
|
||||
|
||||
/// Who performs the auto-advance swap, so several counting clients do not
|
||||
/// swap twice: the lowest member id among members that play the item with
|
||||
/// their own account (`jf[serverId] == true`, present: `in` or joining). Only what every
|
||||
/// client sees in `state` decides (who put the item up is not in it: a
|
||||
/// late joiner never saw that swap), so all clients agree.
|
||||
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
|
||||
final eligible = [
|
||||
for (final m in members)
|
||||
if (isPresent(m) && m.jf[serverId] == true) m
|
||||
];
|
||||
if (eligible.isEmpty) return null;
|
||||
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
|
||||
return eligible.first.id;
|
||||
}
|
||||
|
||||
/// "m2" before "m10": numeric suffix order, then plain text.
|
||||
int compareMemberIds(String a, String b) {
|
||||
final na = int.tryParse(a.replaceFirst(RegExp(r'^\D+'), ''));
|
||||
final nb = int.tryParse(b.replaceFirst(RegExp(r'^\D+'), ''));
|
||||
if (na != null && nb != null && na != nb) return na.compareTo(nb);
|
||||
return a.compareTo(b);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../files/scanner.dart' show FolderScan;
|
||||
import 'show_matcher.dart';
|
||||
|
||||
// The next episode of a local show (the library's `nextAfter`, numbers
|
||||
// skipped on the way included): what continues a file this app put up
|
||||
// (see ARCHITECTURE.md "What's next").
|
||||
|
||||
/// [path]'s next episode: from the library the Files page saw this run
|
||||
/// ([ShowMatcher.showAt]), else from the file's own folder read now (one
|
||||
/// [scan], grouped by `groupLocal` with that folder's name as a hint). Null when there is none (the last
|
||||
/// episode, not an episode, an unreadable folder).
|
||||
class LocalNextFinder {
|
||||
final ShowMatcher matcher;
|
||||
final Future<FolderScan> Function(String dir) scan;
|
||||
LocalNextFinder({required this.matcher, required this.scan});
|
||||
|
||||
Future<LocalNext?> nextAfter(String path) async {
|
||||
var show = matcher.showAt(path);
|
||||
if (show == null) {
|
||||
final dir = File(path).parent.path;
|
||||
final found = await scan(dir);
|
||||
// The folder itself is a hint (a bare `01.mkv` in `Bleach`).
|
||||
final lib = groupLocal(found.videos, roots: [Directory(dir).parent.path]);
|
||||
show = lib.shows.where((s) => s.files.any((e) => e.path == path)).firstOrNull;
|
||||
}
|
||||
final at = show?.files.where((e) => e.path == path).firstOrNull;
|
||||
if (show == null || at == null || at.kind != LocalKind.episode) return null;
|
||||
return show.nextAfter(at.key);
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,7 @@ class ShowMatcher {
|
||||
|
||||
// Files seen in scans this run, for refs of files put up.
|
||||
final _byPath = <String, LocalEpisode>{};
|
||||
final _showByPath = <String, LocalShow>{};
|
||||
final _entries = <int, AniListEntry>{};
|
||||
final _flows = <int, FlowShow?>{};
|
||||
final _jobs = <String, Future<void> Function()>{};
|
||||
@@ -152,12 +153,21 @@ class ShowMatcher {
|
||||
|
||||
// ------------------------------------------------------------- reads
|
||||
|
||||
/// Remembers [library]'s files, so [refForPath] knows their show.
|
||||
/// Remembers [library]'s files, so [refForPath] and [showAt] know
|
||||
/// their show.
|
||||
void remember(LocalLibrary library) {
|
||||
for (final e in library.files) {
|
||||
_byPath[e.path] = e;
|
||||
}
|
||||
for (final s in library.shows) {
|
||||
for (final e in s.files) {
|
||||
_showByPath[e.path] = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The show the file at [path] belongs to, as seen in a scan this run.
|
||||
LocalShow? showAt(String path) => _showByPath[path];
|
||||
|
||||
AniListEntry? entry(int id) {
|
||||
final known = _entries[id];
|
||||
|
||||
@@ -47,6 +47,8 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
|
||||
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
||||
_step('sounds', graph.sfx.dispose(), const Duration(milliseconds: 500)),
|
||||
]);
|
||||
// The host owns the disposal order: anything still alive goes now.
|
||||
await _step('all players', graph.ports.players.disposeAll(), const Duration(milliseconds: 1500));
|
||||
// Windows: media_kit_video frees a player's video output on a thread of
|
||||
// its own after dispose returns; it must be done before the engine
|
||||
// stops, or the plugin's teardown waits on it for good.
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'desktop_folders.dart';
|
||||
import 'desktop_notifications.dart';
|
||||
import 'desktop_shell.dart';
|
||||
import 'desktop_urls.dart';
|
||||
import 'media_kit_host.dart';
|
||||
|
||||
/// The desktop's [Ports].
|
||||
Ports desktopPorts() {
|
||||
@@ -14,5 +15,6 @@ Ports desktopPorts() {
|
||||
folders: const DesktopFolders(urls),
|
||||
links: AppYtDlp.instance,
|
||||
notifications: DesktopNotifications(),
|
||||
players: MediaKitHost(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi' show Int8;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:signals/signals.dart';
|
||||
import 'package:sync_relay_core/core.dart' show LogThrottle, createLogger;
|
||||
|
||||
import '../../state/settings.dart';
|
||||
import '../ports/player_host.dart';
|
||||
|
||||
// The desktop's players: media_kit over libmpv. Everything mpv-specific
|
||||
// that is not about one media lives here: the cache and HTTP pins, hwdec,
|
||||
// mpv's log level (media_kit has no API for it: `mpv_request_log_messages`
|
||||
// on its handle), the render-mode probe and its diagnostics, mpv's log into
|
||||
// the app log, and the order players are torn down in.
|
||||
|
||||
/// Read-ahead target: about two minutes rides out short outages and makes
|
||||
/// skipping an intro instant.
|
||||
const readAheadSecs = 150;
|
||||
|
||||
/// Forward demuxer cache of a session player. Holds [readAheadSecs] up to
|
||||
/// ~14 Mbit/s (a 1.4 GB / 24 min file is ~8); higher bitrates get less
|
||||
/// time. In RAM: `cache-on-disk=no` (media_kit sets it on without a
|
||||
/// cache-dir, and mpv warned "Failed to create file cache." per open).
|
||||
const sessionAheadBytes = 256 * 1024 * 1024;
|
||||
|
||||
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
|
||||
/// seek back without holding another 256 MiB.
|
||||
const sessionBackBytes = 64 * 1024 * 1024;
|
||||
|
||||
/// The preload's forward cache: its first seconds and the container's
|
||||
/// index, at little memory. Raised to [sessionAheadBytes] when promoted.
|
||||
const preloadAheadBytes = 48 * 1024 * 1024;
|
||||
const preloadBackBytes = 8 * 1024 * 1024;
|
||||
|
||||
/// mpv's `volume-max` (its default is 130): the volume controls go this
|
||||
/// far, past 100 % amplified.
|
||||
const mpvMaxVolume = 150;
|
||||
|
||||
/// mpv's log level for session players. media_kit drains mpv's log on the
|
||||
/// UI thread one line at a time, so a chatty level (ffmpeg retrying a
|
||||
/// refused request logs every attempt) can starve the UI: warnings and
|
||||
/// errors only, except during the render-mode probe, which needs mpv's
|
||||
/// verbose GL init lines.
|
||||
const sessionLogLevel = 'warn';
|
||||
|
||||
/// ffmpeg's HTTP options (mpv `stream-lavf-o`, comma form): reconnect after
|
||||
/// a dropped connection, also on a streamed (growing) relay response, and a
|
||||
/// server error (5xx) while connecting; a refused request (4xx) fails at
|
||||
/// once. At most 3 tries per read, 4 s apart at most.
|
||||
const streamLavfOptions = 'reconnect_streamed=1,reconnect_on_http_error=5xx,'
|
||||
'reconnect_delay_max=4,reconnect_max_retries=3';
|
||||
|
||||
/// mpv echoes numbers in its own format (`150` reads back `150.000000`).
|
||||
bool pinMatches(String wanted, String got) {
|
||||
if (wanted == got) return true;
|
||||
final a = double.tryParse(wanted), b = double.tryParse(got);
|
||||
return a != null && b != null && a == b;
|
||||
}
|
||||
|
||||
/// The `VideoController` behind [video], for the `Video` widget (the only
|
||||
/// media_kit use outside this file).
|
||||
VideoController videoControllerOf(HostedVideo video) => (video as _MkVideo).controller;
|
||||
|
||||
final _mpvLog = createLogger('mpv');
|
||||
|
||||
class MediaKitHost implements PlayerHost {
|
||||
/// The hwdec setting ([AppSettings.hwdec] by default).
|
||||
final String? Function() _hwdecSetting;
|
||||
MediaKitHost({String? Function()? hwdecSetting})
|
||||
: _hwdecSetting = hwdecSetting ?? (() => AppSettings.instance.hwdec.peek());
|
||||
|
||||
final _live = <_MkPlayer>{};
|
||||
final _renderMode = signal(RenderMode.unknown);
|
||||
final _http = StreamController<String>.broadcast();
|
||||
final _throttle = LogThrottle(burst: 5);
|
||||
Future<void> _retiring = Future.value();
|
||||
var _closing = false;
|
||||
|
||||
@override
|
||||
final List<String> pinMismatches = [];
|
||||
|
||||
@override
|
||||
void init() => MediaKit.ensureInitialized();
|
||||
|
||||
@override
|
||||
ReadonlySignal<RenderMode> get renderMode => _renderMode;
|
||||
|
||||
@override
|
||||
Stream<String> get httpErrors => _http.stream;
|
||||
|
||||
@override
|
||||
int get live => _live.length;
|
||||
|
||||
@override
|
||||
Future<void> get retiring => _retiring;
|
||||
|
||||
@override
|
||||
String? get hwdec {
|
||||
final env = Platform.environment['SYNC_RELAY_HWDEC'];
|
||||
if (env != null && env.isNotEmpty) return env;
|
||||
return _hwdecSetting();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HostedPlayer> create(PlayerRole role, {bool muted = false}) async {
|
||||
if (_closing) throw StateError('closing: no new players');
|
||||
final mk = _SyncPlayer(
|
||||
configuration: switch (role) {
|
||||
PlayerRole.session => PlayerConfiguration(
|
||||
bufferSize: sessionAheadBytes,
|
||||
libass: true,
|
||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||
logLevel: MPVLogLevel.warn),
|
||||
PlayerRole.preload => PlayerConfiguration(
|
||||
bufferSize: preloadAheadBytes,
|
||||
libass: true,
|
||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||
logLevel: MPVLogLevel.error),
|
||||
PlayerRole.thumbnail => const PlayerConfiguration(
|
||||
vo: 'null', muted: true, osc: false, libass: false, title: 'thumbnail', bufferSize: 4 << 20),
|
||||
});
|
||||
final p = _MkPlayer(this, mk, role);
|
||||
_live.add(p);
|
||||
if (role != PlayerRole.thumbnail) {
|
||||
p._logSub = p.logs.listen((l) => _route(p, l));
|
||||
final mismatches = <String>[];
|
||||
await _pinCache(p, role, mismatches);
|
||||
if (await p.pin('volume-max', '$mpvMaxVolume') case final m?) mismatches.add(m);
|
||||
if (muted || role == PlayerRole.preload) {
|
||||
if (await p.pin('mute', 'yes') case final m?) mismatches.add(m);
|
||||
}
|
||||
if (role == PlayerRole.session) {
|
||||
pinMismatches
|
||||
..clear()
|
||||
..addAll(mismatches);
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> promote(HostedPlayer p) async {
|
||||
final mk = p as _MkPlayer;
|
||||
mk._role = PlayerRole.session;
|
||||
mk.setLogLevel(sessionLogLevel);
|
||||
final mismatches = <String>[];
|
||||
if (await p.pin('mute', 'no') case final m?) mismatches.add(m);
|
||||
await _pinCache(mk, PlayerRole.session, mismatches);
|
||||
pinMismatches
|
||||
..clear()
|
||||
..addAll(mismatches);
|
||||
}
|
||||
|
||||
Future<void> _pinCache(_MkPlayer p, PlayerRole role, List<String> out) async {
|
||||
final session = role == PlayerRole.session;
|
||||
for (final (k, v) in [
|
||||
('demuxer-readahead-secs', '$readAheadSecs'),
|
||||
('cache-secs', '$readAheadSecs'),
|
||||
('demuxer-max-bytes', '${session ? sessionAheadBytes : preloadAheadBytes}'),
|
||||
('demuxer-max-back-bytes', '${session ? sessionBackBytes : preloadBackBytes}'),
|
||||
('cache-on-disk', 'no'),
|
||||
if (hwdec case final h?) ('hwdec', h),
|
||||
('stream-lavf-o', streamLavfOptions),
|
||||
// Pages are resolved by the app's own yt-dlp (the media pipeline):
|
||||
// mpv's Lua hook never runs one.
|
||||
('ytdl', 'no'),
|
||||
]) {
|
||||
if (await p.pin(k, v) case final m?) out.add(m);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
HostedVideo video(HostedPlayer player, {bool probe = false}) {
|
||||
final p = player as _MkPlayer;
|
||||
if (probe) {
|
||||
_logRenderEnv();
|
||||
p.setLogLevel('v'); // back to the session level at the probe's end
|
||||
}
|
||||
// media_kit's video output sets hwdec itself on attach ('auto' unless
|
||||
// configured), which used to override SYNC_RELAY_HWDEC.
|
||||
final controller = VideoController(p.mk, configuration: VideoControllerConfiguration(hwdec: hwdec));
|
||||
final v = _MkVideo(p, controller);
|
||||
// mpv gets `vo=libmpv` before the output's render context exists; a
|
||||
// media opened in between fails its video output ("No render context
|
||||
// set") and plays black. Opens wait for the output (seen on X11, where
|
||||
// the context comes up later, when joining with media already up).
|
||||
p._videoReady = controller.platform.future
|
||||
.then((_) {})
|
||||
.timeout(const Duration(seconds: 5), onTimeout: () {})
|
||||
.catchError((Object _) {});
|
||||
if (probe) unawaited(_probeRender(p, v));
|
||||
return v;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ the log
|
||||
|
||||
// mpv's warnings and errors to the app log, a few per kind at a time (a
|
||||
// refused request makes ffmpeg retry and warn on every try); ffmpeg's
|
||||
// HTTP status to [httpErrors]; the GL init lines while probing.
|
||||
void _route(_MkPlayer p, PlayerLogLine log) {
|
||||
if (log.prefix == 'ffmpeg') {
|
||||
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
|
||||
if (http != null) _http.add(http[0]!.trim());
|
||||
}
|
||||
if (log.prefix == 'libmpv_render') {
|
||||
if (_glInfo.hasMatch(log.text)) _mpvLog.info('render: [libmpv_render] ${log.text.trim()}');
|
||||
if (log.text.contains('suspected software renderer')) {
|
||||
_probeMode = RenderMode.softwareGl;
|
||||
_renderMode.value = RenderMode.softwareGl;
|
||||
} else if (log.text.startsWith('GL_VERSION') && p._probing && _probeMode == RenderMode.unknown) {
|
||||
_probeMode = RenderMode.gpu;
|
||||
_renderMode.value = RenderMode.gpu;
|
||||
}
|
||||
}
|
||||
final level = log.level;
|
||||
if (level != 'warn' && !log.isError) return;
|
||||
final text = log.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
final who = p.role == PlayerRole.preload ? 'preload ' : '';
|
||||
final pass = _throttle.allow('$who${log.prefix} $level ${text.length > 40 ? text.substring(0, 40) : text}');
|
||||
if (pass == null) return;
|
||||
final line = '$pass$who[${log.prefix}] $text';
|
||||
level == 'warn' ? _mpvLog.info(line) : _mpvLog.warning(line);
|
||||
}
|
||||
|
||||
// mpv's GL init lines (`GL_VENDOR='...'`, `Detected desktop OpenGL 4.6.`).
|
||||
static final _glInfo =
|
||||
RegExp(r'^\s*(GL_(VENDOR|RENDERER|VERSION|SHADING_LANGUAGE_VERSION)=|Detected (GLES|desktop OpenGL))');
|
||||
|
||||
// Once per app start: what decides how media_kit and mpv render (the
|
||||
// display server, Flutter's and Mesa's overrides). Not secrets.
|
||||
static bool _renderEnvLogged = false;
|
||||
static void _logRenderEnv() {
|
||||
if (_renderEnvLogged || !Platform.isLinux) return;
|
||||
_renderEnvLogged = true;
|
||||
const names = [
|
||||
'XDG_SESSION_TYPE',
|
||||
'WAYLAND_DISPLAY',
|
||||
'DISPLAY',
|
||||
'GDK_BACKEND',
|
||||
'FLUTTER_LINUX_RENDERER',
|
||||
'LIBGL_ALWAYS_SOFTWARE',
|
||||
'__EGL_VENDOR_LIBRARY_FILENAMES',
|
||||
];
|
||||
final env = Platform.environment;
|
||||
_mpvLog.info('render env: ${[for (final n in names) '$n=${env[n] ?? '-'}'].join(' ')}');
|
||||
}
|
||||
|
||||
RenderMode _probeMode = RenderMode.unknown;
|
||||
|
||||
// media_kit's Linux/Windows video output creates mpv's render context
|
||||
// while the controller is created: OpenGL when it can, else the software
|
||||
// renderer, which never logs GL init (and ignores shaders). No GL line
|
||||
// within 1.5 s of the output being up means software.
|
||||
Future<void> _probeRender(_MkPlayer p, _MkVideo v) async {
|
||||
p._probing = true;
|
||||
_probeMode = RenderMode.unknown;
|
||||
try {
|
||||
await v.controller.platform.future;
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
} catch (_) {
|
||||
return;
|
||||
} finally {
|
||||
p._probing = false;
|
||||
if (!p.disposed) p.setLogLevel(sessionLogLevel);
|
||||
}
|
||||
if (p.disposed) return;
|
||||
final mode = _probeMode;
|
||||
if (mode != RenderMode.unknown) {
|
||||
_renderMode.value = mode;
|
||||
_mpvLog.info('render: ${mode.name} (${mode == RenderMode.gpu ? 'OpenGL on the GPU' : 'OpenGL on the CPU'})');
|
||||
return;
|
||||
}
|
||||
_renderMode.value = RenderMode.software;
|
||||
_mpvLog.info("render: software (no GL init logged: media_kit's own fallback; "
|
||||
'its reason is on stderr, "media_kit: VideoOutput: ..." when run from a terminal)');
|
||||
}
|
||||
|
||||
// Once per player, after its first frame: the decoder mpv picked; the
|
||||
// first time per app start also mpv's and ffmpeg's versions.
|
||||
static bool _versionsLogged = false;
|
||||
Future<void> _logFirstFrame(_MkPlayer p) async {
|
||||
try {
|
||||
final current = await p.getProperty('hwdec-current');
|
||||
final wanted = await p.getProperty('hwdec');
|
||||
_mpvLog.info('render: first frame ${p.state.width}x${p.state.height}, '
|
||||
'hwdec-current=${current.isEmpty ? 'no' : current} (hwdec=$wanted), mode ${_renderMode.peek().name}');
|
||||
if (!_versionsLogged) {
|
||||
_versionsLogged = true;
|
||||
_mpvLog.info('render: ${await versions(p)}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('first frame info: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> versions(HostedPlayer player) async {
|
||||
try {
|
||||
return '${await player.getProperty('mpv-version')}, ffmpeg ${await player.getProperty('ffmpeg-version')}';
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- tear-down
|
||||
|
||||
@override
|
||||
Future<void> retire(HostedPlayer player) {
|
||||
final p = player as _MkPlayer;
|
||||
p._retired = true;
|
||||
unawaited(p.mk.pause().catchError((_) {}));
|
||||
return _retiring = () async {
|
||||
await _settleUi();
|
||||
await dispose(p);
|
||||
}();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose(HostedPlayer player) async {
|
||||
final p = player as _MkPlayer;
|
||||
if (!_live.remove(p)) return;
|
||||
p._retired = true;
|
||||
await p._logSub?.cancel();
|
||||
try {
|
||||
await p.mk.dispose();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disposeAll({Duration each = const Duration(milliseconds: 1500)}) async {
|
||||
_closing = true;
|
||||
await Future.wait([
|
||||
for (final p in _live.toList()) dispose(p).timeout(each).catchError((Object _) {}),
|
||||
]);
|
||||
_closing = false;
|
||||
}
|
||||
|
||||
// Two frames: one may already be mid-build when the view drops the
|
||||
// output. Then the length of a route pop (the fullscreen route holds its
|
||||
// own `Video` and leaves animated). Bounded, since a hidden window
|
||||
// renders no frames; headless tests have no binding and skip it all.
|
||||
static Future<void> _settleUi() async {
|
||||
try {
|
||||
for (var i = 0; i < 2; i++) {
|
||||
await WidgetsBinding.instance.endOfFrame.timeout(const Duration(milliseconds: 300));
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 350));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [Player] whose public [seek] belongs to the user. media_kit's controls
|
||||
/// call `player.seek`; with [onUserSeek] set those calls become explicit
|
||||
/// events for the sync layer instead of moving mpv behind its back.
|
||||
class _SyncPlayer extends Player {
|
||||
_SyncPlayer({super.configuration});
|
||||
|
||||
void Function(Duration position)? onUserSeek;
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration duration) async {
|
||||
final hook = onUserSeek;
|
||||
if (hook == null) return super.seek(duration);
|
||||
hook(duration);
|
||||
}
|
||||
|
||||
Future<void> syncSeek(Duration duration) => super.seek(duration);
|
||||
}
|
||||
|
||||
class _MkPlayer implements HostedPlayer {
|
||||
final MediaKitHost host;
|
||||
final _SyncPlayer mk;
|
||||
PlayerRole _role;
|
||||
_MkPlayer(this.host, this.mk, this._role) {
|
||||
_firstFrame = mk.stream.width.listen((w) {
|
||||
if (w == null || w <= 0 || _role == PlayerRole.thumbnail) return;
|
||||
unawaited(_firstFrame?.cancel());
|
||||
unawaited(host._logFirstFrame(this));
|
||||
});
|
||||
}
|
||||
|
||||
StreamSubscription<PlayerLogLine>? _logSub;
|
||||
StreamSubscription<int?>? _firstFrame;
|
||||
bool _probing = false;
|
||||
bool _retired = false;
|
||||
Future<void>? _videoReady; // set once a video output is attached
|
||||
|
||||
dynamic get _mpv => mk.platform;
|
||||
|
||||
@override
|
||||
PlayerRole get role => _role;
|
||||
|
||||
@override
|
||||
bool get disposed => _retired;
|
||||
|
||||
@override
|
||||
HostedPlayerState get state {
|
||||
final s = mk.state;
|
||||
return HostedPlayerState(
|
||||
position: s.position,
|
||||
duration: s.duration,
|
||||
buffer: s.buffer,
|
||||
volume: s.volume,
|
||||
playing: s.playing,
|
||||
completed: s.completed,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Duration> get positionStream => mk.stream.position;
|
||||
@override
|
||||
Stream<Duration> get durationStream => mk.stream.duration;
|
||||
@override
|
||||
Stream<Duration> get bufferStream => mk.stream.buffer;
|
||||
@override
|
||||
Stream<double> get volumeStream => mk.stream.volume;
|
||||
@override
|
||||
Stream<bool> get completedStream => mk.stream.completed;
|
||||
@override
|
||||
Stream<int?> get widthStream => mk.stream.width;
|
||||
@override
|
||||
Stream<PlayerLogLine> get logs => mk.stream.log.map((l) => PlayerLogLine(l.prefix, l.level, l.text));
|
||||
|
||||
@override
|
||||
void Function(Duration position)? get onUserSeek => mk.onUserSeek;
|
||||
@override
|
||||
set onUserSeek(void Function(Duration position)? f) => mk.onUserSeek = f;
|
||||
|
||||
@override
|
||||
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false}) async {
|
||||
if (_videoReady case final ready?) await ready;
|
||||
// An empty map, not null: media_kit caches headers per URI.
|
||||
await mk.open(Media(url, httpHeaders: headers), play: play);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() => mk.play();
|
||||
@override
|
||||
Future<void> pause() => mk.pause();
|
||||
@override
|
||||
Future<void> stop() => mk.stop();
|
||||
@override
|
||||
Future<void> seek(Duration position) => mk.seek(position);
|
||||
@override
|
||||
Future<void> syncSeek(Duration position) => mk.syncSeek(position);
|
||||
@override
|
||||
Future<void> setRate(double rate) => mk.setRate(rate);
|
||||
@override
|
||||
Future<void> setVolume(double volume) => mk.setVolume(volume);
|
||||
|
||||
// media_kit has no public property API: a guarded dynamic call on
|
||||
// `player.platform` (its NativePlayer).
|
||||
@override
|
||||
Future<void> setProperty(String name, String value) async => await _mpv?.setProperty(name, value);
|
||||
|
||||
@override
|
||||
Future<String> getProperty(String name) async => '${await _mpv?.getProperty(name)}';
|
||||
|
||||
@override
|
||||
Future<void> observeProperty(String name, Future<void> Function(String value) onChange) async =>
|
||||
await _mpv?.observeProperty(name, onChange);
|
||||
|
||||
@override
|
||||
Future<void> command(List<String> args) async => await _mpv?.command(args);
|
||||
|
||||
@override
|
||||
Future<String?> pin(String name, String value) async {
|
||||
try {
|
||||
await setProperty(name, value);
|
||||
final got = await getProperty(name);
|
||||
if (pinMatches(value, got)) return null;
|
||||
debugPrint('pin $name=$value not applied, mpv has "$got"');
|
||||
return '$name: $value -> $got';
|
||||
} catch (e) {
|
||||
debugPrint('pin $name=$value failed: $e');
|
||||
return '$name: $value -> error $e';
|
||||
}
|
||||
}
|
||||
|
||||
// mpv_request_log_messages on media_kit's handle.
|
||||
@override
|
||||
void setLogLevel(String level) {
|
||||
try {
|
||||
final dynamic platform = mk.platform;
|
||||
final name = level.toNativeUtf8();
|
||||
try {
|
||||
platform.mpv.mpv_request_log_messages(platform.ctx, name.cast<Int8>());
|
||||
} finally {
|
||||
malloc.free(name);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('mpv log level $level not set: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MkVideo implements HostedVideo {
|
||||
@override
|
||||
final _MkPlayer player;
|
||||
final VideoController controller;
|
||||
_MkVideo(this.player, this.controller);
|
||||
|
||||
@override
|
||||
Future<void> get ready => controller.platform.future;
|
||||
}
|
||||
@@ -10,8 +10,10 @@ abstract interface class LinkResolver {
|
||||
/// Gets it ready in the background (download on first run, updates).
|
||||
void warmUp();
|
||||
|
||||
/// mpv's `ytdl-raw-options` (sub languages, cookies).
|
||||
Future<String> mpvRawOptions();
|
||||
/// This device opens web pages itself (the media plan's `resolve`
|
||||
/// capability). Desktop: always (yt-dlp is downloaded when first
|
||||
/// needed); a device without it gets pages passed on by someone else.
|
||||
bool get resolvesLinks;
|
||||
|
||||
/// What [url] is, remembered for a while ([keep]); [fresh] asks again.
|
||||
Future<LinkProbe> probe(String url, {Duration keep, bool fresh});
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:signals/signals.dart';
|
||||
|
||||
// The media players as the app sees them: mpv instances, their video
|
||||
// outputs, how the video is rendered, mpv's log and the order players are
|
||||
// torn down in. Desktop: media_kit over libmpv
|
||||
// (`platform/desktop/media_kit_host.dart`); nothing else in the app
|
||||
// touches media_kit except the `Video` widget glue.
|
||||
|
||||
/// How the video output renders (shaders need [gpu]).
|
||||
enum RenderMode {
|
||||
unknown,
|
||||
|
||||
/// OpenGL on a real GPU: shaders work.
|
||||
gpu,
|
||||
|
||||
/// OpenGL on a software rasterizer (llvmpipe): shaders crawl.
|
||||
softwareGl,
|
||||
|
||||
/// The video output's own software fallback: shaders are ignored.
|
||||
software,
|
||||
}
|
||||
|
||||
/// What a player is for: its cache, log level and log routing.
|
||||
enum PlayerRole {
|
||||
/// The session's player (and a fresh one per media): full cache, mpv log
|
||||
/// at `warn` into the app log.
|
||||
session,
|
||||
|
||||
/// The hidden player holding what plays next: small cache, muted, log
|
||||
/// at `error`.
|
||||
preload,
|
||||
|
||||
/// A poster frame for Files: no audio or video output, no cache, no log.
|
||||
thumbnail,
|
||||
}
|
||||
|
||||
/// One line of mpv's log.
|
||||
class PlayerLogLine {
|
||||
final String prefix;
|
||||
final String level;
|
||||
final String text;
|
||||
const PlayerLogLine(this.prefix, this.level, this.text);
|
||||
|
||||
bool get isError => level == 'error' || level == 'fatal';
|
||||
}
|
||||
|
||||
/// A snapshot of a player's state.
|
||||
class HostedPlayerState {
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
|
||||
/// End of the cached range (mpv `demuxer-cache-time`).
|
||||
final Duration buffer;
|
||||
final double volume;
|
||||
final bool playing;
|
||||
|
||||
/// At the end of the file.
|
||||
final bool completed;
|
||||
final int? width;
|
||||
final int? height;
|
||||
const HostedPlayerState({
|
||||
this.position = Duration.zero,
|
||||
this.duration = Duration.zero,
|
||||
this.buffer = Duration.zero,
|
||||
this.volume = 100,
|
||||
this.playing = false,
|
||||
this.completed = false,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
}
|
||||
|
||||
/// One mpv player. Each media gets its own (see ARCHITECTURE "Player
|
||||
/// lifecycle"); [PlayerHost.retire] / [PlayerHost.disposeAll] end it.
|
||||
abstract interface class HostedPlayer {
|
||||
PlayerRole get role;
|
||||
HostedPlayerState get state;
|
||||
Stream<Duration> get positionStream;
|
||||
Stream<Duration> get durationStream;
|
||||
Stream<Duration> get bufferStream;
|
||||
Stream<double> get volumeStream;
|
||||
Stream<bool> get completedStream;
|
||||
Stream<int?> get widthStream;
|
||||
|
||||
/// mpv's log lines at the player's level.
|
||||
Stream<PlayerLogLine> get logs;
|
||||
|
||||
/// The player is gone (disposed or being disposed): commands do nothing.
|
||||
bool get disposed;
|
||||
|
||||
/// Seeks from the video widget's own controls land here instead of in
|
||||
/// mpv when set ([seek] goes through it too); [syncSeek] always moves
|
||||
/// mpv.
|
||||
void Function(Duration position)? onUserSeek;
|
||||
|
||||
/// Opens [url] (with [headers] on every request), paused unless [play].
|
||||
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false});
|
||||
Future<void> play();
|
||||
Future<void> pause();
|
||||
Future<void> stop();
|
||||
|
||||
/// A user seek: through [onUserSeek] when set, else [syncSeek].
|
||||
Future<void> seek(Duration position);
|
||||
|
||||
/// Moves mpv (sync corrections, scrub preview).
|
||||
Future<void> syncSeek(Duration position);
|
||||
Future<void> setRate(double rate);
|
||||
Future<void> setVolume(double volume);
|
||||
Future<void> setProperty(String name, String value);
|
||||
Future<String> getProperty(String name);
|
||||
Future<void> observeProperty(String name, Future<void> Function(String value) onChange);
|
||||
Future<void> command(List<String> args);
|
||||
|
||||
/// Sets [name] and reads it back: null when mpv took [value], else
|
||||
/// `name: value -> got` (mpv ignores invalid values silently).
|
||||
Future<String?> pin(String name, String value);
|
||||
|
||||
/// mpv's log level for this player (`warn`, `v`, ...).
|
||||
void setLogLevel(String level);
|
||||
}
|
||||
|
||||
/// A player's video output (a texture the `Video` widget shows).
|
||||
abstract interface class HostedVideo {
|
||||
HostedPlayer get player;
|
||||
|
||||
/// Completes once the output is up (its texture registered).
|
||||
Future<void> get ready;
|
||||
}
|
||||
|
||||
/// Creates, owns and disposes the app's players. One per app (the
|
||||
/// composition root's `Ports`); tests make their own.
|
||||
abstract interface class PlayerHost {
|
||||
/// Once at start, before any player.
|
||||
void init();
|
||||
|
||||
/// A new player for [role], set up before it is returned to the caller's
|
||||
/// first await: cache, pins ([pinMismatches]), hwdec, log level.
|
||||
Future<HostedPlayer> create(PlayerRole role, {bool muted = false});
|
||||
|
||||
/// [player] (a preload) becomes a session player: unmuted, the full
|
||||
/// cache pins, the session's log level.
|
||||
Future<void> promote(HostedPlayer player);
|
||||
|
||||
/// [player]'s video output, with the hwdec policy. [probe]: listen to
|
||||
/// mpv's render init to learn [renderMode] (the first output of an app
|
||||
/// start).
|
||||
HostedVideo video(HostedPlayer player, {bool probe = false});
|
||||
|
||||
/// How video renders, as last learned (kept after leaving).
|
||||
ReadonlySignal<RenderMode> get renderMode;
|
||||
|
||||
/// mpv `hwdec` for new players: env `SYNC_RELAY_HWDEC` wins over the
|
||||
/// setting (Windows' auto is `auto-copy`).
|
||||
String? get hwdec;
|
||||
|
||||
/// Pins that did not take on the last session player created.
|
||||
List<String> get pinMismatches;
|
||||
|
||||
/// ffmpeg's HTTP errors (`HTTP error 403 Forbidden`), from whichever
|
||||
/// player ffmpeg logs through (one per process).
|
||||
Stream<String> get httpErrors;
|
||||
|
||||
/// `mpv-version` and `ffmpeg-version` of [player], for diagnostics.
|
||||
Future<String?> versions(HostedPlayer player);
|
||||
|
||||
/// Ends [player] once the view let go of its texture (two frames and a
|
||||
/// route pop); at most one retiring player lingers, a new [create] of a
|
||||
/// session player waits for it ([retiring]).
|
||||
Future<void> retire(HostedPlayer player);
|
||||
|
||||
/// The last retire, until done.
|
||||
Future<void> get retiring;
|
||||
|
||||
/// Disposes [player] at once (no view showed it).
|
||||
Future<void> dispose(HostedPlayer player);
|
||||
|
||||
/// App close: every live player goes, each bounded by [each], before
|
||||
/// the engine does (a live mpv calls back into a dead isolate).
|
||||
Future<void> disposeAll({Duration each});
|
||||
|
||||
/// Live players, for tests and the close.
|
||||
int get live;
|
||||
}
|
||||
|
||||
/// [log] says the media could not be opened: before a [duration] is known,
|
||||
/// mpv's fatal "nothing to play" or a demuxer error.
|
||||
bool mpvLoadFailed(PlayerLogLine log, Duration duration) {
|
||||
return duration == Duration.zero &&
|
||||
((log.prefix == 'cplayer' && log.level == 'fatal') || (log.prefix == 'demux' && log.level == 'error'));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'folder_access.dart';
|
||||
import 'link_resolver.dart';
|
||||
import 'player_host.dart';
|
||||
import 'shell_port.dart';
|
||||
import 'system_notifications.dart';
|
||||
import 'url_opener.dart';
|
||||
@@ -7,6 +8,7 @@ import 'url_opener.dart';
|
||||
export 'app_dirs.dart';
|
||||
export 'folder_access.dart';
|
||||
export 'link_resolver.dart';
|
||||
export 'player_host.dart';
|
||||
export 'shell_port.dart';
|
||||
export 'system_notifications.dart';
|
||||
export 'url_opener.dart';
|
||||
@@ -19,11 +21,14 @@ class Ports {
|
||||
final FolderAccess folders;
|
||||
final LinkResolver links;
|
||||
final SystemNotifications notifications;
|
||||
/// The media players (mpv): created, rendered and disposed here.
|
||||
final PlayerHost players;
|
||||
const Ports({
|
||||
required this.shell,
|
||||
required this.urls,
|
||||
required this.folders,
|
||||
required this.links,
|
||||
required this.notifications,
|
||||
required this.players,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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`, `quitting`, 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, or a process that ends on its own (fail). It
|
||||
// stays up [smokeUptime] first: a start that dies by itself (the 1.3.0
|
||||
// Windows exit timer) ends before `closing`.
|
||||
|
||||
/// How long the smoke run keeps the app up before closing it.
|
||||
const smokeUptime = Duration(seconds: 10);
|
||||
|
||||
/// 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');
|
||||
}
|
||||
final wait = smokeUptime - sw.elapsed;
|
||||
if (wait > Duration.zero) await Future.delayed(wait);
|
||||
step('closing');
|
||||
// Reached only through the app's own close (not a kill on the way).
|
||||
await shutDownApp(beforeQuit: () async => step('quitting'));
|
||||
}
|
||||
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';
|
||||
|
||||
/// mpv's `ytdl-format` for links every player opens itself.
|
||||
const ytdlFormat = 'bv*[height<=1080]+ba/b';
|
||||
|
||||
/// yt-dlp's `--sub-langs` for mpv's hook (its default is `all`), without
|
||||
/// Bilibili's danmaku: an XML comment feed served raw-deflate, which
|
||||
/// ffmpeg fails to inflate on every read (a warning each time) while the
|
||||
/// video waits on it.
|
||||
const ytdlSubLangs = 'all,-danmaku';
|
||||
|
||||
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
|
||||
/// the hosted server. [probe] remembers what pasted links are for a while,
|
||||
/// so the Link preview and the swap ask once.
|
||||
@@ -36,20 +27,10 @@ class AppYtDlp implements LinkResolver {
|
||||
/// Gets yt-dlp ready in the background (download on first run, a daily
|
||||
/// update check afterwards).
|
||||
@override
|
||||
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
||||
bool get resolvesLinks => true;
|
||||
|
||||
/// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
|
||||
/// yt-dlp folder (see [YtDlpCookies]).
|
||||
@override
|
||||
Future<String> mpvRawOptions() async {
|
||||
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
|
||||
try {
|
||||
final cookies = (await ytDlp).cookies.mpvRawOptions();
|
||||
return cookies.isEmpty ? subs : '$subs,$cookies';
|
||||
} catch (_) {
|
||||
return subs;
|
||||
}
|
||||
}
|
||||
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
||||
|
||||
final _probes = <String, ({DateTime at, DateTime until, Future<LinkProbe> f})>{};
|
||||
static const _probeTtl = Duration(minutes: 10);
|
||||
|
||||
@@ -16,23 +16,27 @@ import '../screens/home/picked_media.dart';
|
||||
import '../session/app_events.dart' show AddedToUpNext;
|
||||
import '../session/media_art.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../sync/avatar_cache.dart';
|
||||
import 'queue_logic.dart';
|
||||
|
||||
// Up next on this client (see ARCHITECTURE.md "Up next"): the server's
|
||||
// queue as signals, adding to it (probed, with a picture), the end-of-media
|
||||
// countdown for its first item, putting an item up when this app is the
|
||||
// one that can, and keeping that item ready.
|
||||
// What's next on this app (see ARCHITECTURE.md "What's next" and "Up
|
||||
// next"): the server's queue as signals (Up next and its continuation),
|
||||
// adding to it (probed, with a picture), the continuation of what this
|
||||
// app put up (the next Jellyfin episode, the next file of a local show),
|
||||
// the server's countdown as the end card shows it, putting an item up
|
||||
// when the server asks this app (`queuePlay`), and keeping the item that
|
||||
// plays next ready (the media pipeline prepares it).
|
||||
|
||||
/// The end-of-media card for the first Up next item. [secondsLeft] null:
|
||||
/// no countdown here (auto-advance off, cancelled, or another app puts
|
||||
/// it up), the card offers Play now only.
|
||||
/// The end-of-media card for the first item (Up next's, else the
|
||||
/// continuation). [secondsLeft]: the server's countdown (`state.advance`);
|
||||
/// null when none runs (cancelled, or nothing counts down), then the card
|
||||
/// offers Play now only. [waitingFor]: the countdown ran out and the one
|
||||
/// who puts the item up is not here (its name).
|
||||
class UpNextCountdown {
|
||||
final int mediaId;
|
||||
final QueueItem item;
|
||||
final int? secondsLeft;
|
||||
const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft});
|
||||
final String? waitingFor;
|
||||
const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft, this.waitingFor});
|
||||
}
|
||||
|
||||
/// The first item's readiness on this app.
|
||||
@@ -46,21 +50,33 @@ class QueueController {
|
||||
late final PictureCache thumbs = PictureCache(coord.connection.client,
|
||||
route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]);
|
||||
|
||||
/// Up next, in order (empty when nothing is queued or not in a server).
|
||||
late final Computed<List<QueueItem>> items =
|
||||
computed(() => coord.serverState.value?.queue ?? const <QueueItem>[]);
|
||||
/// Up next, in order, without the continuation (empty when nothing is
|
||||
/// queued or not in a server).
|
||||
late final Computed<List<QueueItem>> items = computed(
|
||||
() => [for (final q in coord.serverState.value?.queue ?? const <QueueItem>[]) if (!q.auto) q]);
|
||||
|
||||
/// The continuation of what plays (`auto`: the next episode), shared by
|
||||
/// everyone; null when none.
|
||||
late final Computed<QueueItem?> continues =
|
||||
computed(() => coord.serverState.value?.queue.where((q) => q.auto).firstOrNull);
|
||||
|
||||
/// What plays next: Up next's first item, else the continuation.
|
||||
late final Computed<QueueItem?> head = computed(() => coord.serverState.value?.queue.firstOrNull);
|
||||
|
||||
/// The end card while the media has ended with something next (see
|
||||
/// [UpNextCountdown]).
|
||||
final countdown = signal<UpNextCountdown?>(null);
|
||||
|
||||
/// The first item's id and how ready it is here (null: nothing to get
|
||||
/// ready on this app).
|
||||
final warm = signal<({String id, WarmState state})?>(null);
|
||||
|
||||
/// The natural continuation of what plays: the next episodes of the
|
||||
/// Jellyfin series (not queued; shown greyed under Up next).
|
||||
/// The next episodes of the Jellyfin series playing, on an app that
|
||||
/// plays it with its own account (the first one is what continues it;
|
||||
/// the rest show greyed under "Continues with", "+" adds one).
|
||||
final continuation = signal<List<EpisodeLink>>(const []);
|
||||
|
||||
/// The mediaId whose continuation was cleared here.
|
||||
/// The mediaId whose continuation was cleared (by anyone: `cancel`).
|
||||
final _hiddenFor = signal(-1);
|
||||
|
||||
/// The queue popover is open: the Up next button holds the controls up.
|
||||
@@ -70,10 +86,12 @@ class QueueController {
|
||||
// here, a file here). Never leaves this app.
|
||||
final Map<String, ({PickedMedia pick, DateTime at})> _local = {};
|
||||
final Map<String, DateTime> _performing = {};
|
||||
final Set<String> _skipped = {};
|
||||
int _cancelledFor = -1;
|
||||
Timer? _timer;
|
||||
bool _attached = false;
|
||||
// The mediaId whose continuation this app added (or is adding).
|
||||
int _continuedFor = -1;
|
||||
// A restarted server waits for what played (that mediaId) to come back.
|
||||
int _restoreFrom = -1;
|
||||
String _continuationFor = '';
|
||||
final _random = Random.secure();
|
||||
|
||||
@@ -83,15 +101,26 @@ class QueueController {
|
||||
if (_attached) return;
|
||||
_attached = true;
|
||||
coord.connection.states.listen(_onState);
|
||||
// `cancel` from anyone: no continuation for that media any more.
|
||||
coord.connection.feeds.listen((f) {
|
||||
final id = coord.serverState.peek()?.mediaId;
|
||||
if (f.action == 'advance-cancel' && id != null) _hiddenFor.value = id;
|
||||
});
|
||||
effect(() {
|
||||
final next = coord.nextEpisode.value;
|
||||
final plan = coord.jellyfinPlan.value;
|
||||
unawaited(_loadContinuation(next, plan));
|
||||
});
|
||||
// The continuation and the state decide what to get ready; the media
|
||||
// playing having started (or sitting out) lets the preload go.
|
||||
// What continues this media becomes known (the next episode found, a
|
||||
// file of ours up): add it.
|
||||
effect(() {
|
||||
coord.nextEpisode.value;
|
||||
coord.ownFile.value;
|
||||
untracked(() => _continue(coord.serverState.peek()));
|
||||
});
|
||||
// The state decides what to get ready; the media playing having
|
||||
// started (or sitting out) lets the preload go.
|
||||
effect(() {
|
||||
shownContinuation.value;
|
||||
coord.settledMediaId.value;
|
||||
coord.selfAway.value;
|
||||
final r = coord.serverState.value;
|
||||
@@ -319,62 +348,54 @@ class QueueController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the first Up next item now.
|
||||
/// Plays what comes next now (Up next's first item, else the
|
||||
/// continuation).
|
||||
void skip() {
|
||||
final first = items.peek().firstOrNull;
|
||||
final first = head.peek();
|
||||
if (first != null) playNow(first.id);
|
||||
}
|
||||
|
||||
/// Cancel on the end-of-media card: no countdown here for this media.
|
||||
/// Cancel on the end card: the countdown stops for everyone, and the
|
||||
/// continuation goes (`cancel`).
|
||||
void cancelCountdown() {
|
||||
final c = countdown.peek();
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
if (c == null) return;
|
||||
_cancelledFor = c.mediaId;
|
||||
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
|
||||
coord.markInput();
|
||||
coord.connection.sendQueue(const QueueIntent.cancel());
|
||||
}
|
||||
|
||||
/// "Clear": hides the continuation of what plays now (and its next
|
||||
/// episode does not come up by itself).
|
||||
/// "Clear" under "Continues with": the continuation of what plays goes
|
||||
/// for everyone (`cancel`) and does not come back for this media.
|
||||
void clearContinuation() {
|
||||
final id = coord.serverState.peek()?.mediaId;
|
||||
if (id != null) _hiddenFor.value = id;
|
||||
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance();
|
||||
cancelCountdown();
|
||||
}
|
||||
|
||||
/// The continuation applies to [mediaId]: nothing is queued and it was
|
||||
/// not cleared.
|
||||
bool continuationShown(int mediaId) =>
|
||||
(coord.serverState.peek()?.queue.isEmpty ?? true) && _hiddenFor.peek() != mediaId;
|
||||
|
||||
/// [continuation] unless cleared for the media playing.
|
||||
/// The further episodes of [continuation] (after the one that continues
|
||||
/// what plays), unless it was cleared for the media playing.
|
||||
late final Computed<List<EpisodeLink>> shownContinuation = computed(() {
|
||||
final id = coord.serverState.value?.mediaId;
|
||||
return id != null && _hiddenFor.value == id ? const <EpisodeLink>[] : continuation.value;
|
||||
if (id == null || _hiddenFor.value == id) return const <EpisodeLink>[];
|
||||
final auto = continues.value?.media.jellyfin?.itemId;
|
||||
return [for (final e in continuation.value) if (e.item.id != auto) e];
|
||||
});
|
||||
|
||||
// --------------------------------------------------------- per state
|
||||
|
||||
void _onState(StateBroadcast r) {
|
||||
_lastState = r;
|
||||
// What played came back after a restart under a new mediaId: a
|
||||
// continuation cleared for it stays cleared.
|
||||
if (_restoreFrom != -1 && r.media != null && _hiddenFor.peek() == _restoreFrom) _hiddenFor.value = r.mediaId;
|
||||
_prune(r);
|
||||
final queue = r.queue;
|
||||
// The server asks for an item (a countdown ran out, Play now from
|
||||
// another app, nothing up yet): the app that puts it up does.
|
||||
final play = r.queuePlay;
|
||||
if (play != null) {
|
||||
final item = queue.where((q) => q.id == play).firstOrNull;
|
||||
if (item != null) _bringUp(item, r);
|
||||
}
|
||||
final finished = r.transport == 'finished';
|
||||
if (queue.isNotEmpty && r.media == null) {
|
||||
// Nothing up yet: the first item goes up at once.
|
||||
_bringUp(queue.first, r);
|
||||
} else if (finished && queue.isNotEmpty) {
|
||||
_atEnd(r);
|
||||
} else {
|
||||
if (countdown.peek() != null) _stopCountdown();
|
||||
if (!finished) _cancelledFor = -1;
|
||||
final item = r.queue.where((q) => q.id == play).firstOrNull;
|
||||
if (item != null && queuePerformer(item, r.members).id == _me) _perform(item);
|
||||
}
|
||||
_syncCountdown(r);
|
||||
_continue(r);
|
||||
_warm(r);
|
||||
}
|
||||
|
||||
@@ -385,75 +406,133 @@ class QueueController {
|
||||
final now = DateTime.now();
|
||||
_performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1));
|
||||
final keys = {for (final q in r.queue) q.key};
|
||||
// Kept while a restarted server waits for what played: Up next comes
|
||||
// back with it.
|
||||
if (_restoreFrom == -1) {
|
||||
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
|
||||
final ids = {for (final q in r.queue) q.id};
|
||||
_skipped.removeWhere((id) => !ids.contains(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// [item] should go up now: by this app when it is the one that can;
|
||||
/// nobody can: the lowest member takes it off with the reason.
|
||||
void _bringUp(QueueItem item, StateBroadcast r) {
|
||||
final perf = queuePerformer(item, r.members);
|
||||
if (perf.id == null) {
|
||||
final why = perf.why;
|
||||
if (why != null && queueJanitor(r.members) == _me && _skipped.add(item.id)) {
|
||||
coord.connection.sendQueue(QueueIntent.remove(item.id, why: why));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (perf.id == _me) _perform(item);
|
||||
}
|
||||
|
||||
/// The media finished with Up next waiting: the card, and with
|
||||
/// auto-advance on the countdown, for its first item.
|
||||
void _atEnd(StateBroadcast r) {
|
||||
// Up next goes before the next episode, even one already counting.
|
||||
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance();
|
||||
final head = r.queue.first;
|
||||
final perf = queuePerformer(head, r.members);
|
||||
if (perf.id == null && perf.why != null) {
|
||||
// Taken off by [_bringUp]'s rule; the next state shows the next one.
|
||||
_bringUp(head, r);
|
||||
return;
|
||||
}
|
||||
final c = countdown.peek();
|
||||
if (c != null && c.mediaId == r.mediaId) {
|
||||
if (c.item.id != head.id || c.item.thumb != head.thumb) {
|
||||
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: head, secondsLeft: c.secondsLeft);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final auto = AppSettings.instance.autoAdvance.peek() && _cancelledFor != r.mediaId;
|
||||
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: auto ? autoAdvanceSecs : null);
|
||||
_timer?.cancel();
|
||||
_timer = auto ? Timer.periodic(const Duration(seconds: 1), (_) => _tick()) : null;
|
||||
}
|
||||
|
||||
void _tick() {
|
||||
final c = countdown.peek();
|
||||
final r = coord.serverState.peek();
|
||||
final left = c?.secondsLeft;
|
||||
if (c == null || left == null || r == null || r.mediaId != c.mediaId || r.transport != 'finished') {
|
||||
_stopCountdown();
|
||||
return;
|
||||
}
|
||||
if (left > 1) {
|
||||
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item, secondsLeft: left - 1);
|
||||
return;
|
||||
}
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_cancelledFor = c.mediaId;
|
||||
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
|
||||
// Decided on the latest state: only the app that can puts it up.
|
||||
/// The end card from the server's state: the media ended with something
|
||||
/// next; the seconds of `state.advance` move on with a 1 s timer.
|
||||
void _syncCountdown(StateBroadcast r) {
|
||||
final head = r.queue.firstOrNull;
|
||||
if (head != null && queuePerformer(head, r.members).id == _me) _perform(head);
|
||||
}
|
||||
|
||||
void _stopCountdown() {
|
||||
if (r.transport != 'finished' || head == null || r.media == null) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
countdown.value = null;
|
||||
if (countdown.peek() != null) countdown.value = null;
|
||||
return;
|
||||
}
|
||||
final a = r.advance != null && r.advance!.id == head.id ? r.advance : null;
|
||||
int? left;
|
||||
String? waiting;
|
||||
if (a != null) {
|
||||
left = advanceSecondsLeft(a, coord.connection.serverNow());
|
||||
if (left == 0 && queuePerformer(head, r.members).id == null) {
|
||||
waiting = r.members.where((m) => m.id == head.by).firstOrNull?.name ?? head.byName;
|
||||
}
|
||||
}
|
||||
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: left, waitingFor: waiting);
|
||||
if (a == null) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
} else {
|
||||
_timer ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
final now = coord.serverState.peek();
|
||||
if (now != null) _syncCountdown(now);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ restart
|
||||
|
||||
/// The server restarted without media; what played ([mediaId]) is being
|
||||
/// offered back (-1: that is over).
|
||||
void restoreFrom(int mediaId) => _restoreFrom = mediaId;
|
||||
|
||||
/// What played is back after a server restart: Up next as [before] had
|
||||
/// it comes back too. The app whose offer won ([winner]) adds the items
|
||||
/// anyone can put up (links everyone opens, Jellyfin items); every app
|
||||
/// its own links passed on and files (only it has them; [self] is its
|
||||
/// member id then). A server-fetched link's address stayed with the old
|
||||
/// server, so it is gone; the continuation comes back as usual.
|
||||
void restoreAfterRestart(StateBroadcast before, String self, {required bool winner}) {
|
||||
for (final q in before.queue) {
|
||||
if (q.auto) continue;
|
||||
final ok = switch (q.how) {
|
||||
QueueHow.direct || QueueHow.jellyfin => winner,
|
||||
QueueHow.through || QueueHow.file => self.isNotEmpty && q.by == self && _local.containsKey(q.key),
|
||||
QueueHow.server => false,
|
||||
};
|
||||
if (!ok) continue;
|
||||
final local = _local[q.key];
|
||||
if (local != null) _local[q.key] = (pick: local.pick, at: DateTime.now());
|
||||
final thumb = thumbs.bytesFor(q.thumb);
|
||||
coord.connection.sendQueue(QueueIntent.add(
|
||||
QueueItem(media: q.media, how: q.how, title: q.title, sub: q.sub, duration: q.duration, key: q.key),
|
||||
thumb: thumb == null ? '' : base64Encode(thumb)));
|
||||
}
|
||||
_restoreFrom = -1;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- continuation
|
||||
|
||||
/// Adds what continues the media playing (`auto`, PROTOCOL.md "Queue")
|
||||
/// when this app may ([mayAddContinuation]: it put it up, or that
|
||||
/// member is not in and this is the first who is), knows what comes
|
||||
/// next, and it is not there or cancelled yet; with the setting on. A
|
||||
/// Jellyfin episode played with this app's account continues with the
|
||||
/// series' next one, a file this app put up with the next episode of its
|
||||
/// show in the library (numbers skipped on the way said in its line).
|
||||
void _continue(StateBroadcast? r) {
|
||||
if (r == null || r.media == null || !coord.settings.autoAdvance.peek()) return;
|
||||
final id = r.mediaId;
|
||||
if (_continuedFor == id || _hiddenFor.peek() == id) return;
|
||||
if (r.queue.any((q) => q.auto && q.forMedia == id)) {
|
||||
_continuedFor = id;
|
||||
return;
|
||||
}
|
||||
final swapBy = r.media?['swapBy'];
|
||||
if (!mayAddContinuation(_me, swapBy is String ? swapBy : null, r.members)) return;
|
||||
final ref = refOfStateMedia(r.media);
|
||||
final next = coord.nextEpisode.peek();
|
||||
if (ref != null) {
|
||||
if (next == null || !sameJellyfinItem(ref, coord.currentRef.peek())) return;
|
||||
_continuedFor = id;
|
||||
unawaited(_addContinuation(id, next.pick));
|
||||
return;
|
||||
}
|
||||
final own = coord.ownFile.peek();
|
||||
final finder = coord.nextFile;
|
||||
if (own == null || own.$1 != id || finder == null) return;
|
||||
_continuedFor = id;
|
||||
unawaited(() async {
|
||||
try {
|
||||
final n = await finder(own.$2);
|
||||
if (n == null) return;
|
||||
final gap = n.skipped;
|
||||
await _addContinuation(id, FileMedia(n.episode.path),
|
||||
note: gap == null ? null : 'skips ${gap.label}');
|
||||
} catch (e) {
|
||||
debugPrint('queue: what continues ${own.$2}: $e');
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
Future<void> _addContinuation(int mediaId, PickedMedia pick, {String? note}) async {
|
||||
try {
|
||||
final b = await _build(pick);
|
||||
if (b is! ({QueueItem item, Uint8List? thumb})) return;
|
||||
final r = coord.serverState.peek();
|
||||
if (r == null || r.mediaId != mediaId || _hiddenFor.peek() == mediaId) return;
|
||||
final sub = [b.item.sub, ?note].where((s) => s.isNotEmpty).join(' · ');
|
||||
final item = b.item.copyWith(auto: true, forMedia: mediaId, sub: sub);
|
||||
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
|
||||
final thumb = b.thumb;
|
||||
coord.connection.sendQueue(QueueIntent.add(item, thumb: thumb == null ? '' : base64Encode(thumb)));
|
||||
} catch (e) {
|
||||
debugPrint('queue: adding what continues media $mediaId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts [item] up from here (once per item while it is in flight).
|
||||
@@ -530,9 +609,8 @@ class QueueController {
|
||||
bool _ownAccount(String serverId) =>
|
||||
coord.jellyfin.account(serverId) != null && coord.jellyfin.statusOf(serverId) != JellyfinAccountStatus.expired;
|
||||
|
||||
/// What this app gets ready: Up next's first item, else (Up next empty)
|
||||
/// the first continuation episode (only here when this app plays the
|
||||
/// series with its own account). Every app does, each its own copy (see
|
||||
/// What this app gets ready: the first item of the queue (Up next's,
|
||||
/// else the continuation). Every app does, each its own copy (see
|
||||
/// [_WarmTarget]); while sitting out only the probe of a link this app
|
||||
/// puts up (its swap reads it). Null: nothing.
|
||||
_WarmTarget? _warmTarget(StateBroadcast? r) {
|
||||
@@ -550,7 +628,7 @@ class QueueController {
|
||||
// The server's warm relay of the item, once bytes can flow: what
|
||||
// everyone without their own copy preloads.
|
||||
final relay = head.media.relay;
|
||||
final warmUrl = relay != null && relay.live ? coord.warmRelayUrl(head, relay) : null;
|
||||
final warmUrl = relay != null && relay.live ? coord.media.warmRelayUrl(head, relay) : null;
|
||||
switch (head.how) {
|
||||
case QueueHow.jellyfin:
|
||||
final ref = head.media.jellyfin;
|
||||
@@ -579,9 +657,7 @@ class QueueController {
|
||||
return performer ? _WarmTarget(head.id) : null;
|
||||
}
|
||||
}
|
||||
final next = shownContinuation.peek().firstOrNull;
|
||||
if (next == null) return null;
|
||||
return _WarmTarget('next:${next.item.id}', ref: mediaForPick(next.pick).jellyfin);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Keeps the one item that plays next ready on this app, so a skip or
|
||||
@@ -589,7 +665,7 @@ class QueueController {
|
||||
/// puts it up (`AppYtDlp.probe`, kept while it stays first), a Jellyfin
|
||||
/// item resolved with this app's account
|
||||
/// ([SessionCoordinator.warmJellyfin]), and then opened in the hidden
|
||||
/// preload player ([SessionCoordinator.preloadMedia]) once the media
|
||||
/// preload player ([MediaPipeline.prepare]) once the media
|
||||
/// playing has started. Done as soon as it is first, again before that
|
||||
/// runs out, dropped when it is no longer first.
|
||||
void _warm(StateBroadcast? r) {
|
||||
@@ -617,7 +693,7 @@ class QueueController {
|
||||
/// Feeds the server's warm relay of Up next's first item from here when
|
||||
/// it names this member as its source (a link passed on, a file), or a
|
||||
/// Jellyfin item this app puts up with its own account when the server
|
||||
/// asks for a source; see [SessionCoordinator.feedWarm]. The server
|
||||
/// asks for a source; see [RelayFeeder.feedWarm]. The server
|
||||
/// drops the relay (and with it the source) once the item is no longer
|
||||
/// first.
|
||||
void _feedWarm(StateBroadcast? r) {
|
||||
@@ -633,7 +709,7 @@ class QueueController {
|
||||
final pick = _local[head.key]?.pick;
|
||||
if (pick is FileMedia) {
|
||||
_fedWarm = key;
|
||||
coord.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
|
||||
coord.media.feeder.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
|
||||
} else if (pick is LinkMedia) {
|
||||
_fedWarm = key;
|
||||
unawaited(() async {
|
||||
@@ -642,7 +718,7 @@ class QueueController {
|
||||
final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
|
||||
final y = await coord.ytDlp.ytDlp;
|
||||
if (_fedWarm != key) return;
|
||||
coord.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe));
|
||||
coord.media.feeder.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe));
|
||||
} catch (e) {
|
||||
debugPrint('queue: feeding ${head.id}: $e');
|
||||
}
|
||||
@@ -656,7 +732,7 @@ class QueueController {
|
||||
final w = coord.warmJellyfin;
|
||||
if (w == null || !sameJellyfinItem(w.ref, ref)) return;
|
||||
_fedWarm = key;
|
||||
coord.feedWarm(
|
||||
coord.media.feeder.feedWarm(
|
||||
head.id, RelayMedia.single(HttpRelayBytes(Uri.parse(w.plan.url), headers: w.plan.headers)));
|
||||
case QueueHow.direct:
|
||||
case QueueHow.server:
|
||||
@@ -710,7 +786,7 @@ class QueueController {
|
||||
// A refresh opens a link again (a page's stream URLs expire); the
|
||||
// same Jellyfin stream stays as it is.
|
||||
_preloadFresh = fresh && ok && t.ref == null && open != null;
|
||||
final waits = ok && open != null && coord.playback.preloadEnabled;
|
||||
final waits = ok && open != null && coord.media.preloadEnabled;
|
||||
warm.value = (id: t.id, state: !ok ? WarmState.failed : (waits ? WarmState.warming : WarmState.ready));
|
||||
_syncPreload(coord.serverState.peek());
|
||||
_warmTimer = Timer(ok ? _warmRefresh : _warmRetry, () => _resolveWarm(t, fresh: true));
|
||||
@@ -730,25 +806,25 @@ class QueueController {
|
||||
final out = coord.selfAway.peek();
|
||||
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
|
||||
final want = out ? null : _preloadWant;
|
||||
final playback = coord.playback;
|
||||
final media = coord.media;
|
||||
if (want == null) {
|
||||
_preloadKey = '';
|
||||
_preloadGen++;
|
||||
if (playback.preloadUrl != null) unawaited(playback.dropPreload());
|
||||
if (media.preloadUrl != null) unawaited(media.dropPrepared());
|
||||
return;
|
||||
}
|
||||
if (!playback.preloadEnabled) return;
|
||||
if (!media.preloadEnabled) return;
|
||||
final key = '${want.url}|${want.headers}';
|
||||
final fresh = _preloadFresh;
|
||||
// Asked for already: in flight, there, or failed (tried again with the
|
||||
// next refresh). One that went away (Reload video) is asked again.
|
||||
if (!fresh && key == _preloadKey && (_preloadBusy || !_preloadOk || playback.preloadUrl == want.url)) return;
|
||||
if (!fresh && key == _preloadKey && (_preloadBusy || !_preloadOk || media.preloadUrl == want.url)) return;
|
||||
_preloadFresh = false;
|
||||
_preloadKey = key;
|
||||
_preloadBusy = true;
|
||||
final gen = ++_preloadGen;
|
||||
final id = warm.peek()?.id;
|
||||
unawaited(coord.preloadMedia(want.url, headers: want.headers, fresh: fresh).then((ok) {
|
||||
unawaited(coord.media.prepare(want.url, headers: want.headers, fresh: fresh).then((ok) {
|
||||
if (gen != _preloadGen) return;
|
||||
_preloadBusy = false;
|
||||
_preloadOk = ok;
|
||||
|
||||
@@ -1,66 +1,15 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/session_media.dart' show compareMemberIds;
|
||||
import '../session/party.dart' show isPresent;
|
||||
|
||||
// Who puts an Up next item up. Pure: only what every client sees in
|
||||
// `state` decides, so all clients agree without a wire message (like the
|
||||
// Jellyfin auto-advance swapper).
|
||||
|
||||
/// Who puts [item] up: [id] a member id; null with [why] when nobody here
|
||||
/// can (the item is skipped), null without [why] when nobody is around
|
||||
/// right now (nothing happens).
|
||||
typedef QueuePerformer = ({String? id, String? why});
|
||||
|
||||
/// - A link passed on by its adder, or the adder's file: only the adder
|
||||
/// (their app has the file, the cookies). Gone (disconnected, in its
|
||||
/// grace): it waits for them; left for good: skipped.
|
||||
/// - Jellyfin: the adder when it plays that server with its own account,
|
||||
/// else the lowest member id that does (`jf[serverId] == true`,
|
||||
/// present: `in` or joining). Nobody: skipped.
|
||||
/// - A link everyone opens, or one the server fetches: the adder when
|
||||
/// present, else the lowest present member id.
|
||||
QueuePerformer queuePerformer(QueueItem item, List<MemberView> members) {
|
||||
MemberView? adder;
|
||||
for (final m in members) {
|
||||
if (m.id == item.by) adder = m;
|
||||
}
|
||||
bool active(MemberView m) => isPresent(m);
|
||||
if (item.how.adderOnly) {
|
||||
if (adder == null) return (id: null, why: '${_name(item)} left');
|
||||
if (adder.presence == Presence.gone) return (id: null, why: null);
|
||||
return (id: adder.id, why: null);
|
||||
}
|
||||
final ref = item.media.jellyfin;
|
||||
if (item.how == QueueHow.jellyfin && ref != null) {
|
||||
bool can(MemberView m) => active(m) && m.jf[ref.serverId] == true;
|
||||
if (adder != null && can(adder)) return (id: adder.id, why: null);
|
||||
final id = autoAdvanceLowest(members.where(can));
|
||||
return id == null ? (id: null, why: 'nobody here has that Jellyfin server') : (id: id, why: null);
|
||||
}
|
||||
if (adder != null && active(adder)) return (id: adder.id, why: null);
|
||||
return (id: autoAdvanceLowest(members.where(active)), why: null);
|
||||
}
|
||||
|
||||
String _name(QueueItem item) => item.byName.isEmpty ? 'who added it' : item.byName;
|
||||
|
||||
/// The lowest member id of [members] ("m2" before "m10"), or null.
|
||||
String? autoAdvanceLowest(Iterable<MemberView> members) {
|
||||
final ids = [for (final m in members) m.id]..sort(compareMemberIds);
|
||||
return ids.isEmpty ? null : ids.first;
|
||||
}
|
||||
|
||||
/// Who takes an item nobody can put up off the queue (`remove` with why):
|
||||
/// the lowest member id that is connected (not gone).
|
||||
String? queueJanitor(List<MemberView> members) =>
|
||||
autoAdvanceLowest(members.where((m) => m.presence != Presence.gone));
|
||||
// Up next's words for an item. Who puts an item up is core's
|
||||
// `queuePerformer` (whats_next.dart), read the same by the server and
|
||||
// every app.
|
||||
|
||||
/// How [item] plays, in a few words for its row: "through Foxyz" (you:
|
||||
/// "through you"), "server fetches", "everyone fetches", "Jellyfin",
|
||||
/// "Foxyz's file".
|
||||
String queueHowLabel(QueueItem item, {required String selfId}) {
|
||||
final mine = item.by == selfId && selfId.isNotEmpty;
|
||||
final who = mine ? 'you' : _name(item);
|
||||
final who = mine ? 'you' : (item.byName.isEmpty ? 'who added it' : item.byName);
|
||||
return switch (item.how) {
|
||||
QueueHow.through => 'through $who',
|
||||
QueueHow.file => mine ? 'your file' : "$who's file",
|
||||
|
||||
@@ -12,7 +12,6 @@ import '../controls/minimal/style.dart';
|
||||
import '../jellyfin/images.dart';
|
||||
import '../jellyfin/session_media.dart' show EpisodeLink;
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../session/session_coordinator.dart' show autoAdvanceSecs;
|
||||
import '../shell/avatar.dart';
|
||||
import '../shell/handoff.dart' show handoffHolds;
|
||||
import '../shell/library_link.dart' show LinkPreview, cachedLinkPreview;
|
||||
@@ -38,17 +37,14 @@ enum UpNextLook { standard, minimal }
|
||||
/// The next item's picture, "UP NEXT" over its title, a +n count and ⏭
|
||||
/// (play it now) in the player's top bar, one flat control among its
|
||||
/// buttons; the title opens the queue, resting on it shows a preview card.
|
||||
/// Without Up next, the continuation ("NEXT · S1 E8" over its name, ⏭
|
||||
/// Without Up next, the continuation ("NEXT · S1E8" over its name, ⏭
|
||||
/// plays it). Nothing when both are empty.
|
||||
class UpNextButton extends StatefulWidget {
|
||||
final QueueController queue;
|
||||
/// Plays the continuation's first episode (the next episode).
|
||||
final VoidCallback onPlayNextEpisode;
|
||||
final UpNextLook look;
|
||||
/// Held while the preview card is up, so the controls stay up with it.
|
||||
final ControlsHold? hold;
|
||||
const UpNextButton(
|
||||
{super.key, required this.queue, required this.onPlayNextEpisode, required this.look, this.hold});
|
||||
const UpNextButton({super.key, required this.queue, required this.look, this.hold});
|
||||
|
||||
@override
|
||||
State<UpNextButton> createState() => _UpNextButtonState();
|
||||
@@ -143,9 +139,9 @@ class _UpNextButtonState extends State<UpNextButton> {
|
||||
final thumbRadius = _minimal ? MinimalStyle.radius - 4 : 6.0;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final items = _q.items.value;
|
||||
final cont = _q.shownContinuation.value;
|
||||
final auto = _q.continues.value;
|
||||
final open = _q.popoverOpen.value;
|
||||
if (items.isEmpty && cont.isEmpty && !open) {
|
||||
if (items.isEmpty && auto == null && !open) {
|
||||
// Gone with the preview up: let go of the controls.
|
||||
if (_preview.isShowing) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -155,21 +151,15 @@ class _UpNextButtonState extends State<UpNextButton> {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final queued = items.isNotEmpty;
|
||||
final item = items.firstOrNull;
|
||||
final episode = cont.firstOrNull;
|
||||
final item = items.firstOrNull ?? auto;
|
||||
final more = items.length - 1;
|
||||
final String kicker;
|
||||
final String title;
|
||||
final Widget thumb;
|
||||
if (item != null) {
|
||||
kicker = 'UP NEXT';
|
||||
kicker = queued ? 'UP NEXT' : (item.sub.isEmpty ? 'NEXT' : 'NEXT · ${item.sub}');
|
||||
title = item.title;
|
||||
thumb = QueueThumb(queue: _q, item: item, width: thumbWidth, radius: thumbRadius);
|
||||
} else if (episode != null) {
|
||||
final n = episodeNumbers(episode.item);
|
||||
kicker = n == null ? 'NEXT' : 'NEXT · $n';
|
||||
title = episode.item.name;
|
||||
thumb = _EpisodeThumb(queue: _q, episode: episode, width: thumbWidth, radius: thumbRadius);
|
||||
} else {
|
||||
kicker = 'UP NEXT';
|
||||
title = 'Nothing yet';
|
||||
@@ -246,10 +236,8 @@ class _UpNextButtonState extends State<UpNextButton> {
|
||||
),
|
||||
),
|
||||
);
|
||||
final playTip = queued
|
||||
? 'Play ${item!.title} now ($playNextKeys)'
|
||||
: 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)';
|
||||
final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null);
|
||||
final playTip = 'Play ${item?.title ?? ''} now ($playNextKeys)';
|
||||
final VoidCallback? play = item == null ? null : _q.skip;
|
||||
final playButton = _minimal
|
||||
? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
|
||||
: Tooltip(
|
||||
@@ -337,60 +325,35 @@ class UpNextPreview extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(_pad),
|
||||
child: SignalBuilder(builder: (context) {
|
||||
final item = queue.items.value.firstOrNull;
|
||||
final episode = queue.shownContinuation.value.firstOrNull;
|
||||
final queued = queue.items.value.firstOrNull;
|
||||
final item = queued ?? queue.continues.value;
|
||||
final warm = queue.warm.value;
|
||||
const picture = _width - 2 * _pad;
|
||||
if (item != null) {
|
||||
if (item == null) return const SizedBox.shrink();
|
||||
final r = queue.coord.serverState.value;
|
||||
final selfId = queue.coord.connection.memberId;
|
||||
final more = queue.items.value.length - 1;
|
||||
final ready = warm?.id == item.id ? warm!.state : null;
|
||||
final kicker = queued == null
|
||||
? 'CONTINUES WITH'
|
||||
: more > 0
|
||||
? 'UP NEXT · $more MORE AFTER IT'
|
||||
: 'UP NEXT';
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
QueueThumb(queue: queue, item: item, width: picture, radius: 8),
|
||||
const SizedBox(height: 10),
|
||||
Text(more > 0 ? 'UP NEXT · $more MORE AFTER IT' : 'UP NEXT', style: _kicker),
|
||||
Text(kicker, style: _kicker),
|
||||
const SizedBox(height: 3),
|
||||
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
|
||||
if (item.sub.isNotEmpty)
|
||||
Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
|
||||
if (item.sub.isNotEmpty) Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
|
||||
const SizedBox(height: 6),
|
||||
_MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
|
||||
_footer(ready, ready == null ? performerNote(queue, item, r) : null),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (episode == null) return const SizedBox.shrink();
|
||||
final n = episodeNumbers(episode.item);
|
||||
final runtime = episode.pick.runtime;
|
||||
final sub = [episode.item.seriesName ?? '', if (runtime != null) queueClock(runtime.inSeconds.toDouble())]
|
||||
.where((s) => s.isNotEmpty)
|
||||
.join(' · ');
|
||||
final overview = episode.item.overview?.trim() ?? '';
|
||||
final ready = warm?.id == 'next:${episode.item.id}' ? warm!.state : null;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_EpisodeThumb(queue: queue, episode: episode, width: picture, radius: 8),
|
||||
const SizedBox(height: 10),
|
||||
Text(n == null ? 'NEXT EPISODE' : 'NEXT EPISODE · $n', style: _kicker),
|
||||
const SizedBox(height: 3),
|
||||
Text(episode.item.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
|
||||
if (sub.isNotEmpty) Text(sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
|
||||
if (overview.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(overview,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12, height: 1.35)),
|
||||
],
|
||||
_footer(ready, null),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -481,8 +444,7 @@ class _EpisodeThumb extends StatelessWidget {
|
||||
final QueueController queue;
|
||||
final EpisodeLink episode;
|
||||
final double width;
|
||||
final double radius;
|
||||
const _EpisodeThumb({required this.queue, required this.episode, required this.width, this.radius = 6});
|
||||
const _EpisodeThumb({required this.queue, required this.episode, required this.width});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -490,7 +452,7 @@ class _EpisodeThumb extends StatelessWidget {
|
||||
final client = queue.coord.jellyfin.clientFor(e.pick.serverId);
|
||||
final image = e.item.ownImage(JellyfinImageType.primary) ?? e.item.image(JellyfinImageType.thumb);
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: width * 9 / 16,
|
||||
@@ -838,6 +800,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
),
|
||||
child: SignalBuilder(builder: (context) {
|
||||
final items = _ordered(_q.items.value);
|
||||
final auto = _q.continues.value;
|
||||
final cont = _q.shownContinuation.value;
|
||||
final warm = _q.warm.value;
|
||||
final r = _q.coord.serverState.value;
|
||||
@@ -848,7 +811,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
children: [
|
||||
_header(items.length),
|
||||
Flexible(
|
||||
child: items.isEmpty && cont.isEmpty && _adding.isEmpty
|
||||
child: items.isEmpty && auto == null && cont.isEmpty && _adding.isEmpty
|
||||
? _empty()
|
||||
: ListView(
|
||||
shrinkWrap: true,
|
||||
@@ -900,7 +863,9 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
adding: a,
|
||||
onDismiss: () => setState(() => _adding.remove(a)),
|
||||
),
|
||||
if (cont.isNotEmpty) ..._continuation(cont, items.isEmpty && _adding.isEmpty),
|
||||
if (auto != null || cont.isNotEmpty)
|
||||
..._continuation(auto, cont, items.isEmpty && _adding.isEmpty,
|
||||
warm: auto != null && warm?.id == auto.id ? warm!.state : null),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -956,7 +921,9 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
),
|
||||
);
|
||||
|
||||
List<Widget> _continuation(List<EpisodeLink> cont, bool alone) => [
|
||||
/// "Continues with": the continuation everyone shares ([auto], Clear
|
||||
/// drops it for everyone), then the series' further episodes known here.
|
||||
List<Widget> _continuation(QueueItem? auto, List<EpisodeLink> cont, bool alone, {WarmState? warm}) => [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, alone ? 0 : 8, 2, 0),
|
||||
child: Row(
|
||||
@@ -964,6 +931,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
const Text('CONTINUES WITH',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
if (auto != null)
|
||||
TextButton(
|
||||
key: const ValueKey('queue-clear-continuation'),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact),
|
||||
@@ -973,6 +941,7 @@ class _QueuePanelState extends State<QueuePanel> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (auto != null) _ContinuesRow(item: auto, queue: _q, warm: warm),
|
||||
for (final e in cont) _ContinuationRow(episode: e, queue: _q),
|
||||
];
|
||||
|
||||
@@ -1479,6 +1448,76 @@ class _WarmDotState extends State<_WarmDot> with SingleTickerProviderStateMixin
|
||||
}
|
||||
}
|
||||
|
||||
/// The continuation's row (the `auto` item): greyed like what is not
|
||||
/// queued yet, its picture with how ready it is here, ▶ plays it now.
|
||||
class _ContinuesRow extends StatefulWidget {
|
||||
final QueueItem item;
|
||||
final QueueController queue;
|
||||
final WarmState? warm;
|
||||
const _ContinuesRow({required this.item, required this.queue, required this.warm});
|
||||
|
||||
@override
|
||||
State<_ContinuesRow> createState() => _ContinuesRowState();
|
||||
}
|
||||
|
||||
class _ContinuesRowState extends State<_ContinuesRow> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final item = widget.item;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
opacity: _hover ? 0.95 : 0.7,
|
||||
child: Container(
|
||||
key: ValueKey('queue-continues-${item.id}'),
|
||||
height: queueRowHeight,
|
||||
padding: const EdgeInsets.only(left: _lead, right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _hover ? AppMenuStyle.hover : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
QueueThumb(queue: widget.queue, item: item, width: _thumb),
|
||||
if (widget.warm != null) Positioned(top: 3, right: 3, child: _WarmDot(state: widget.warm!)),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: _titleStyle),
|
||||
Text(
|
||||
[item.sub, queueClock(item.duration)].where((s) => s.isNotEmpty).join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: _subStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_RoundIcon(
|
||||
key: ValueKey('queue-play-${item.id}'),
|
||||
icon: Icons.play_arrow_rounded,
|
||||
tooltip: 'Play now',
|
||||
onPressed: () => widget.queue.playNow(item.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContinuationRow extends StatefulWidget {
|
||||
final EpisodeLink episode;
|
||||
final QueueController queue;
|
||||
@@ -1545,8 +1584,9 @@ class _ContinuationRowState extends State<_ContinuationRow> {
|
||||
|
||||
// -------------------------------------------------------------- end card
|
||||
|
||||
/// When the media ended with Up next waiting: its picture, title and who
|
||||
/// added it, the countdown with Cancel, and Play now.
|
||||
/// When the media ended with something next (Up next's first item, else
|
||||
/// the continuation): its picture, title and who added it, the server's
|
||||
/// countdown with Cancel (for everyone), and Play now.
|
||||
class UpNextCard extends StatelessWidget {
|
||||
final QueueController queue;
|
||||
final UpNextCountdown countdown;
|
||||
@@ -1559,7 +1599,13 @@ class UpNextCard extends StatelessWidget {
|
||||
final left = c.secondsLeft;
|
||||
final r = queue.coord.serverState.value;
|
||||
final selfId = queue.coord.connection.memberId;
|
||||
final waiting = left == null ? performerNote(queue, item, r) : null;
|
||||
final waitingFor = c.waitingFor;
|
||||
final waiting = waitingFor != null
|
||||
? 'Waiting for $waitingFor'
|
||||
: left == null || left == 0
|
||||
? performerNote(queue, item, r)
|
||||
: null;
|
||||
final what = item.auto ? 'Next' : 'Up next';
|
||||
return AppGlass(
|
||||
key: const ValueKey('up-next-card'),
|
||||
child: SizedBox(
|
||||
@@ -1579,7 +1625,7 @@ class UpNextCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(left == null ? 'Up next' : 'Up next in $left s',
|
||||
Text(left == null || left == 0 ? what : '$what in $left s',
|
||||
style: const TextStyle(color: Colors.white60, fontSize: 12)),
|
||||
const SizedBox(height: 2),
|
||||
Text(item.title,
|
||||
@@ -1605,7 +1651,7 @@ class UpNextCard extends StatelessWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: left / autoAdvanceSecs),
|
||||
tween: Tween(end: left / RelayConstants.advanceSecs),
|
||||
duration: const Duration(seconds: 1),
|
||||
builder: (context, v, _) => LinearProgressIndicator(
|
||||
value: v,
|
||||
|
||||
@@ -220,8 +220,8 @@ class AutoAdvanceSetting extends StatelessWidget {
|
||||
key: const ValueKey('auto-advance'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Play the next episode automatically'),
|
||||
subtitle: const Text('When a Jellyfin episode ends, a 10 s countdown puts up the next one; '
|
||||
'anyone can cancel it'),
|
||||
subtitle: const Text('When you put up an episode (Jellyfin, or a file of a show in Files), the next one '
|
||||
'continues it after a 10 s countdown everyone sees; anyone can cancel it'),
|
||||
value: s.autoAdvance.value,
|
||||
onChanged: s.setAutoAdvance,
|
||||
),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../jellyfin/session_media.dart';
|
||||
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
|
||||
import '../../widgets/app_menu.dart' show AppGlass;
|
||||
import 'player_controls.dart' show FloatingPill;
|
||||
|
||||
// Jellyfin pieces of the session player. Plain widgets fed by the
|
||||
@@ -24,80 +22,6 @@ class NextEpisodePill extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// At the end of an episode: "Next: S1E7 title" with Play now, and while
|
||||
/// the auto-advance countdown runs, the seconds left and Cancel.
|
||||
class EndOfEpisodeCard extends StatelessWidget {
|
||||
final EpisodeLink next;
|
||||
final AutoAdvance? countdown;
|
||||
final VoidCallback onPlayNow;
|
||||
final VoidCallback onCancel;
|
||||
const EndOfEpisodeCard({
|
||||
super.key,
|
||||
required this.next,
|
||||
required this.countdown,
|
||||
required this.onPlayNow,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = countdown;
|
||||
// The same card as Up next's (`UpNextCard`): the app's glass, 400 wide.
|
||||
return AppGlass(
|
||||
key: const ValueKey('end-of-episode'),
|
||||
child: SizedBox(
|
||||
width: 400,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s',
|
||||
style: const TextStyle(color: Colors.white60, fontSize: 12)),
|
||||
const SizedBox(height: 2),
|
||||
Text('Next: ${next.label}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, height: 1.25)),
|
||||
if (c != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: c.secondsLeft / autoAdvanceSecs),
|
||||
duration: const Duration(seconds: 1),
|
||||
builder: (context, v, _) =>
|
||||
LinearProgressIndicator(value: v, minHeight: 3, backgroundColor: const Color(0x1FFFFFFF)),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (c != null)
|
||||
TextButton(
|
||||
key: const ValueKey('auto-advance-cancel'),
|
||||
onPressed: onCancel,
|
||||
child: const Text('Cancel')),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('play-next'),
|
||||
onPressed: onPlayNow,
|
||||
icon: const Icon(Icons.skip_next_rounded, size: 18),
|
||||
label: Text(c == null ? 'Play next' : 'Play now'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// This viewer cannot play the Jellyfin media: say why and offer to link
|
||||
/// an account (never a silent stall).
|
||||
class JellyfinBlockedCard extends StatelessWidget {
|
||||
|
||||
@@ -391,7 +391,8 @@ class Connection {
|
||||
void sendReady(bool ready, {String source = 'manual'}) => client.sendReady(ready, source: source);
|
||||
void sendForce(String target, bool ready) => client.sendForce(target, ready);
|
||||
void sendSeek(double position, {int seq = 0}) => client.sendSeek(position, seq: seq);
|
||||
void sendSwap(HelloMedia media, {String? queueId}) => client.sendSwap(media, queueId: queueId);
|
||||
void sendSwap(HelloMedia media, {String? queueId, SwapRestore? restore}) =>
|
||||
client.sendSwap(media, queueId: queueId, restore: restore);
|
||||
void sendQueue(QueueIntent intent) => client.sendQueue(intent);
|
||||
void sendReport({
|
||||
required double position,
|
||||
|
||||
@@ -0,0 +1,899 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint, mapEquals, visibleForTesting;
|
||||
import 'package:signals/signals.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../platform/ports/link_resolver.dart';
|
||||
import '../platform/ports/player_host.dart';
|
||||
import '../playback/playback_controller.dart';
|
||||
import '../sync/state_diff.dart';
|
||||
import 'app_events.dart';
|
||||
import 'delivery.dart' show stateMediaRelay;
|
||||
import 'media_plan.dart';
|
||||
import 'relay_feeder.dart';
|
||||
import 'switch_trace.dart';
|
||||
|
||||
export 'media_plan.dart';
|
||||
|
||||
final _log = createLogger('session');
|
||||
|
||||
// The one way to open media: plan → resolve → open → ready.
|
||||
//
|
||||
// - plan ([planMedia]): from the state, what this member serves and its
|
||||
// capabilities, where this app reads the media from.
|
||||
// - resolve: a Jellyfin item through this app's account, the server's
|
||||
// login or the member passing it on ([openJellyfin]); a web page through
|
||||
// this app's yt-dlp ([LinkResolver]: its stream URLs, an EDL of video and
|
||||
// audio, the CDN's headers, its subtitles; mpv's own yt-dlp hook is
|
||||
// off); a relay waits until it is fed.
|
||||
// - open: a player through the [PlayerHost]: the prepared one (Up next's
|
||||
// preload) promoted, else a fresh one per media; the same media again
|
||||
// keeps its player.
|
||||
// - ready: the buffer the gate needs is reported at once (the
|
||||
// coordinator's report).
|
||||
//
|
||||
// Being a relay source ([RelayFeeder]) and the viewer's side of a source
|
||||
// that drops ([recover]) live here too.
|
||||
|
||||
/// What the coordinator does next after [MediaPipeline.switchTo]: a
|
||||
/// Jellyfin item resolves and opens through [MediaPipeline.openJellyfin]
|
||||
/// (the coordinator keeps its per-item work: progress, episodes, tracks).
|
||||
sealed class SwitchResult {
|
||||
const SwitchResult();
|
||||
}
|
||||
|
||||
final class SwitchDone extends SwitchResult {
|
||||
const SwitchDone();
|
||||
}
|
||||
|
||||
final class SwitchJellyfin extends SwitchResult {
|
||||
final JellyfinSource source;
|
||||
const SwitchJellyfin(this.source);
|
||||
}
|
||||
|
||||
/// The session context the pipeline reads (the coordinator's connection
|
||||
/// and accounts).
|
||||
class MediaSession {
|
||||
final ServerAddress Function() server;
|
||||
final String Function() selfId;
|
||||
final ClientCaps Function() caps;
|
||||
final JellyfinAccounts jellyfin;
|
||||
const MediaSession({required this.server, required this.selfId, required this.caps, required this.jellyfin});
|
||||
|
||||
static final none = MediaSession(
|
||||
server: () => const ServerAddress('', 0, ''),
|
||||
selfId: () => '',
|
||||
caps: () => const ClientCaps(),
|
||||
jellyfin: JellyfinAccounts(store: MemoryJellyfinStore()));
|
||||
}
|
||||
|
||||
class MediaPipeline {
|
||||
final PlayerHost host;
|
||||
final LinkResolver? links;
|
||||
final PlaybackController playback;
|
||||
final SwitchTrace trace;
|
||||
late final RelayFeeder feeder;
|
||||
MediaSession session;
|
||||
|
||||
/// The player could not open the current media (raw words: the
|
||||
/// coordinator turns them into `report.error`; [cookies]: which ones a
|
||||
/// page's resolve used, D11).
|
||||
void Function(String raw, {String? cookies}) onError = (_, {cookies}) {};
|
||||
void Function(AppEvent event) notify = (_) {};
|
||||
|
||||
/// A media opened in the session player ([open]): the coordinator
|
||||
/// reports at once (a promoted player already has its buffer).
|
||||
void Function(int mediaId, String url) onOpened = (_, _) {};
|
||||
|
||||
MediaPipeline({
|
||||
required this.host,
|
||||
this.links,
|
||||
PlaybackController? playback,
|
||||
SwitchTrace? trace,
|
||||
MediaSession? session,
|
||||
}) : playback = playback ?? PlaybackController(host),
|
||||
trace = trace ?? SwitchTrace(),
|
||||
session = session ?? MediaSession.none {
|
||||
feeder = RelayFeeder(server: () => this.session.server(), notify: (e) => notify(e));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ players
|
||||
|
||||
// The current player has had media opened in it (one fresh from
|
||||
// [start] has not, so the first open after join or Reload video uses
|
||||
// it).
|
||||
bool _held = false;
|
||||
|
||||
/// A live session player exists.
|
||||
bool get ready => playback.alive.peek();
|
||||
|
||||
/// The session's first player (on join); each new media gets its own.
|
||||
Future<void> start() async {
|
||||
if (playback.current != null) return;
|
||||
final p = await host.create(PlayerRole.session);
|
||||
_held = false;
|
||||
playback.attach(p);
|
||||
}
|
||||
|
||||
/// "Reload video": a fresh player and video output, the current media
|
||||
/// opened again (the caller resyncs).
|
||||
Future<void> reload() async {
|
||||
final old = playback.detach();
|
||||
if (old != null) await host.retire(old);
|
||||
await start();
|
||||
final url = _openUrl, headers = _openHeaders;
|
||||
_openUrl = null;
|
||||
// A page is resolved again: its stream URLs may have expired.
|
||||
if (url != null) await open(url, headers: headers, fresh: true, resolve: _openResolve);
|
||||
}
|
||||
|
||||
/// Leave: the prepared player and the session player go.
|
||||
Future<void> release() async {
|
||||
await dropPrepared();
|
||||
final old = playback.detach();
|
||||
_held = false;
|
||||
if (old != null) await host.retire(old);
|
||||
}
|
||||
|
||||
/// App close: every player goes now (no mpv may outlive the engine).
|
||||
Future<void> disposeAll() async {
|
||||
_pre = null;
|
||||
playback.detach();
|
||||
await host.disposeAll();
|
||||
}
|
||||
|
||||
/// Replaces the current player with a fresh one (pins, hwdec, volume,
|
||||
/// yt-dlp hook, shaders), its video output ready before the view swaps
|
||||
/// to it when the view is up. True once it is the session player.
|
||||
Future<bool> _freshPlayer() async {
|
||||
await host.retiring;
|
||||
final old = playback.current;
|
||||
if (old == null) return false;
|
||||
final HostedPlayer p;
|
||||
try {
|
||||
p = await host.create(PlayerRole.session);
|
||||
} catch (e) {
|
||||
debugPrint('fresh player: $e');
|
||||
return false;
|
||||
}
|
||||
HostedVideo? video;
|
||||
try {
|
||||
// The view is up: the new output is created (and its texture
|
||||
// registered) before the view swaps, so the old picture stays until
|
||||
// then. Made by the view instead while the render mode is unknown,
|
||||
// so the probe hears its GL init.
|
||||
if (playback.hasVideo && host.renderMode.peek() != RenderMode.unknown) {
|
||||
video = host.video(p);
|
||||
await video.ready;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('fresh player setup: $e');
|
||||
}
|
||||
// Released or reloaded meanwhile: that player stays.
|
||||
if (!identical(old, playback.current)) {
|
||||
await host.dispose(p);
|
||||
return false;
|
||||
}
|
||||
final prev = playback.attach(p, video: video);
|
||||
_held = true;
|
||||
if (prev != null) unawaited(host.retire(prev));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ resolve
|
||||
|
||||
/// [url] is a web page this app resolves: http(s), not this server, not
|
||||
/// named like a media file.
|
||||
bool _isPage(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return false;
|
||||
final s = session.server();
|
||||
if (uri.host == s.host && uri.port == s.port) return false;
|
||||
return !hasMediaExtension(uri);
|
||||
}
|
||||
|
||||
/// The resolve step for a link: a page through this app's yt-dlp (the
|
||||
/// probe, remembered a while; [fresh] asks again: stream URLs expire)
|
||||
/// gives what a player opens; a media file is opened as is, and so is a
|
||||
/// stream opened with headers (a Jellyfin item's, with its token). Throws
|
||||
/// a [ResolveFailure] in plain words, with the cookies used (D11).
|
||||
Future<_Target> _resolve(String url, Map<String, String>? headers, {bool fresh = false}) async {
|
||||
if (!_isPage(url) || (headers != null && headers.isNotEmpty)) return _Target(url, headers ?? const {});
|
||||
final l = links;
|
||||
if (l == null) throw const ResolveFailure('no link resolver here');
|
||||
final LinkProbe probe;
|
||||
try {
|
||||
probe = await l.probe(url, fresh: fresh).timeout(const Duration(seconds: 60));
|
||||
} catch (e) {
|
||||
throw ResolveFailure('$e');
|
||||
}
|
||||
if (!probe.isPage) return _Target(url, headers ?? const {});
|
||||
final play = probe.info?.play;
|
||||
if (play == null) {
|
||||
throw ResolveFailure(probe.ytDlpError ?? (probe.info == null ? 'Not a video page yt-dlp knows' : 'no format a player opens'),
|
||||
cookies: probe.cookies);
|
||||
}
|
||||
return _Target(play.mpvUrl, {...play.headers, ...?headers}, subtitles: play.subtitles);
|
||||
}
|
||||
|
||||
/// The page's subtitles on [p] once its media is in (not selected: the
|
||||
/// usual choice applies).
|
||||
Future<void> _addSubtitles(HostedPlayer p, List<YtDlpSubtitle> subs) async {
|
||||
if (subs.isEmpty) return;
|
||||
final end = DateTime.now().add(const Duration(seconds: 30));
|
||||
while (p.state.duration <= Duration.zero) {
|
||||
if (p.disposed || DateTime.now().isAfter(end)) return;
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
for (final sub in subs) {
|
||||
try {
|
||||
await p.command(['sub-add', sub.url, 'auto', sub.name.isEmpty ? sub.lang : sub.name, sub.lang]);
|
||||
} catch (e) {
|
||||
debugPrint('subtitle ${sub.lang}: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- open
|
||||
|
||||
String? _openUrl;
|
||||
Map<String, String>? _openHeaders;
|
||||
// [_openUrl] went through the page resolve (not a Jellyfin stream).
|
||||
bool _openResolve = true;
|
||||
bool _opening = false;
|
||||
int _opens = 0;
|
||||
|
||||
/// The URL open (or opening) in the player; null for none.
|
||||
String? get openUrl => _openUrl;
|
||||
|
||||
/// An open is in flight: the player's duration may still be the
|
||||
/// previous media's.
|
||||
bool get opening => _opening;
|
||||
|
||||
/// How many times this session opened a media (a drop and return must
|
||||
/// not add one).
|
||||
int get opens => _opens;
|
||||
|
||||
/// The mediaId whose media the player holds (or is opening); -1 none.
|
||||
int playerMediaId = -1;
|
||||
// [playerMediaId] at the last open: the same media again (a reconnect,
|
||||
// joining back) keeps the player, any other gets a fresh one.
|
||||
int _openedFor = -1;
|
||||
int _mediaId = -1;
|
||||
|
||||
/// The media of the latest state is open here (or has nothing to open,
|
||||
/// could not, or waits for the server): its mediaId. Up next prepares
|
||||
/// what comes next only then, so the two never compete.
|
||||
final settledMediaId = signal(-1);
|
||||
|
||||
/// Opens [url] (with [headers] on every request) in the session player:
|
||||
/// the prepared player when it holds the same, else a fresh one when the
|
||||
/// player held other media. False on failure (the error went to
|
||||
/// [onError]). [resolve]: a web page is resolved first (not for a
|
||||
/// Jellyfin stream).
|
||||
Future<bool> open(String url, {Map<String, String>? headers, bool fresh = false, bool resolve = true}) async {
|
||||
if (url == _openUrl) return true;
|
||||
_opens++;
|
||||
final mediaId = _mediaId;
|
||||
_log.info('media $mediaId: open ${_urlKind(url, headers)}: ${logUrl(url)}'
|
||||
'${headers == null || headers.isEmpty ? '' : ' (headers: ${headers.keys.join(', ')})'}');
|
||||
final keepPlayer = playerMediaId == _openedFor;
|
||||
_openedFor = playerMediaId;
|
||||
_openUrl = url;
|
||||
_openHeaders = headers;
|
||||
_openResolve = resolve;
|
||||
_opening = true;
|
||||
try {
|
||||
trace.mark(mediaId, SwitchTrace.open);
|
||||
final promoted = await _openInPlayer(url, headers,
|
||||
keepPlayer: keepPlayer,
|
||||
fresh: fresh,
|
||||
resolve: resolve,
|
||||
onFresh: () => trace.mark(mediaId, SwitchTrace.player));
|
||||
trace.mark(mediaId, promoted ? SwitchTrace.preloaded : SwitchTrace.opened);
|
||||
_opening = false;
|
||||
onOpened(mediaId, url);
|
||||
return true;
|
||||
} on ResolveFailure catch (e) {
|
||||
_log.warning('media $mediaId: cannot resolve ${logUrl(url)}: ${e.message}${e.cookies == null ? '' : ' (${e.cookies})'}');
|
||||
onError(e.message, cookies: e.cookies);
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('open media failed: $e');
|
||||
onError('$e');
|
||||
return false;
|
||||
} finally {
|
||||
_opening = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _openInPlayer(String url, Map<String, String>? headers,
|
||||
{required bool keepPlayer, bool fresh = false, bool resolve = true, void Function()? onFresh}) async {
|
||||
if (playback.current == null) return false;
|
||||
final pre = _pre;
|
||||
if (pre != null && pre.matches(url, headers) && await _promote(pre)) return true;
|
||||
final target = resolve ? await _resolve(url, headers, fresh: fresh) : _Target(url, headers ?? const {});
|
||||
if (!identical(target.url, url)) trace.mark(_mediaId, SwitchTrace.resolved);
|
||||
if (_held && !keepPlayer && await _freshPlayer()) onFresh?.call();
|
||||
final p = playback.current;
|
||||
if (p == null) return false;
|
||||
_held = true;
|
||||
playback.beginOpen();
|
||||
await p.open(target.url, headers: target.headers);
|
||||
unawaited(_addSubtitles(p, target.subtitles));
|
||||
return false;
|
||||
}
|
||||
|
||||
/// What [url] is, for the log.
|
||||
String _urlKind(String url, Map<String, String>? headers) {
|
||||
if (url.startsWith('edl://')) return 'relay (tracks)';
|
||||
final u = Uri.tryParse(url);
|
||||
if (u == null || !(u.isScheme('http') || u.isScheme('https'))) return 'local file';
|
||||
final s = session.server();
|
||||
if (u.host == s.host && u.port == s.port && u.pathSegments.isNotEmpty) {
|
||||
return switch (u.pathSegments.first) {
|
||||
'r' => 'relay',
|
||||
'm' => "the host's file",
|
||||
'j' => "Jellyfin through the server's login",
|
||||
_ => 'server URL',
|
||||
};
|
||||
}
|
||||
if (headers != null && headers.isNotEmpty) return 'Jellyfin, own sign-in';
|
||||
return 'link, fetched here';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- plan and switch
|
||||
|
||||
// What this member serves: set at its swap, claimed by the state that
|
||||
// names it as the relay's source.
|
||||
MediaOrigin? _pendingOrigin;
|
||||
(int, MediaOrigin)? _activeOrigin;
|
||||
// The relayed media this viewer opens once it is live.
|
||||
int _awaitLiveFor = -1;
|
||||
|
||||
/// This member's swap is about to be relayed from [origin].
|
||||
void expectOrigin(MediaOrigin origin) => _pendingOrigin = origin;
|
||||
|
||||
/// Where this app reads [r]'s media from (claims a pending origin when
|
||||
/// the state names this member as the source).
|
||||
SourcePlan plan(StateBroadcast r) {
|
||||
final relay = stateMediaRelay(r);
|
||||
final self = session.selfId();
|
||||
if (relay != null && relay.source.isNotEmpty && relay.source == self) {
|
||||
final pending = _pendingOrigin;
|
||||
if (pending != null) {
|
||||
_pendingOrigin = null;
|
||||
_activeOrigin = (r.mediaId, pending);
|
||||
}
|
||||
}
|
||||
final active = _activeOrigin;
|
||||
return planMedia(PlanInput(
|
||||
state: r,
|
||||
selfId: self,
|
||||
caps: session.caps(),
|
||||
server: session.server(),
|
||||
origin: active != null && active.$1 == r.mediaId ? active.$2 : null,
|
||||
ownJellyfin: (id) =>
|
||||
session.jellyfin.account(id) != null &&
|
||||
session.jellyfin.statusOf(id) != JellyfinAccountStatus.expired,
|
||||
));
|
||||
}
|
||||
|
||||
/// A new media [r.mediaId]: the previous one leaves the player at once
|
||||
/// (nobody sees or hears it while the new one resolves, is fetched or
|
||||
/// opens), then plan → resolve → open. A Jellyfin item comes back to
|
||||
/// the coordinator ([SwitchJellyfin]).
|
||||
Future<SwitchResult> switchTo(StateBroadcast r) async {
|
||||
_mediaId = r.mediaId;
|
||||
_awaitLiveFor = -1;
|
||||
_recovery = null;
|
||||
final source = plan(r);
|
||||
if (feeder.mediaId != r.mediaId) feeder.stop();
|
||||
_jellyfinFeed = null;
|
||||
await leavePrevious(r.mediaId);
|
||||
final name = (r.media?['name'] as String?) ?? '';
|
||||
final queueId = stateMediaRelay(r)?.queueId ?? '';
|
||||
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||
switch (source) {
|
||||
case JellyfinSource():
|
||||
return SwitchJellyfin(source);
|
||||
case OwnFile(:final path):
|
||||
// The source first: viewers wait on it, not on this player.
|
||||
feeder.serve(r.mediaId, RelayMedia.single(FileRelayBytes(File(path))),
|
||||
announce: true, queueId: queueId, name: name, duration: duration);
|
||||
await open(path);
|
||||
case OwnLink(:final url, :final origin):
|
||||
feeder.serve(r.mediaId, LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
|
||||
announce: true, queueId: queueId, name: name, duration: duration);
|
||||
await open(url);
|
||||
case RelayedSource(:final url, :final waiting, :final fetch, :final sourceId):
|
||||
if (waiting) {
|
||||
// The server is still getting it (yt-dlp takes seconds), or the
|
||||
// member passing it on has not dialled in yet: mpv gives up on a
|
||||
// silent request after 5 s, so open once it is live.
|
||||
_openUrl = null;
|
||||
_awaitLiveFor = r.mediaId;
|
||||
final who = r.members.where((m) => m.id == sourceId).firstOrNull?.name;
|
||||
_log.info('media ${r.mediaId}: waiting for ${fetch ? 'the server' : who} before opening the relay');
|
||||
} else {
|
||||
await open(url);
|
||||
}
|
||||
case Unresolvable(:final reason):
|
||||
_openUrl = null;
|
||||
onError(reason);
|
||||
case ServerFile(:final url) || DirectLink(:final url):
|
||||
await open(url);
|
||||
case NoSource():
|
||||
_openUrl = null;
|
||||
}
|
||||
return const SwitchDone();
|
||||
}
|
||||
|
||||
/// The previous media leaves the player at once; a reconnect to the
|
||||
/// media the player holds keeps it.
|
||||
Future<void> leavePrevious(int mediaId) async {
|
||||
final held = playerMediaId;
|
||||
playerMediaId = mediaId;
|
||||
// Opened from the welcome, before any state: that was this media.
|
||||
if (held == -1 && _openedFor == -1 && _openUrl != null) _openedFor = mediaId;
|
||||
if (held == -1 || held == mediaId) return;
|
||||
_openUrl = null;
|
||||
_openHeaders = null;
|
||||
await playback.stop();
|
||||
}
|
||||
|
||||
/// The same media came back as [r.mediaId] (a restarted server put back
|
||||
/// what played, `swap.restore`) and the player keeps it open: what this
|
||||
/// member passes on is served again (its file, its link, the Jellyfin
|
||||
/// item played with its own account: [ownJellyfin]).
|
||||
void resumeAs(StateBroadcast r, {({String url, Map<String, String> headers})? ownJellyfin}) {
|
||||
renumber(r.mediaId);
|
||||
settledMediaId.value = r.mediaId;
|
||||
final source = plan(r);
|
||||
final name = (r.media?['name'] as String?) ?? '';
|
||||
final queueId = stateMediaRelay(r)?.queueId ?? '';
|
||||
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||
switch (source) {
|
||||
case OwnFile(:final path):
|
||||
feeder.serve(r.mediaId, RelayMedia.single(FileRelayBytes(File(path))),
|
||||
queueId: queueId, name: name, duration: duration);
|
||||
case OwnLink(:final url, :final origin):
|
||||
feeder.serve(r.mediaId, LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
|
||||
queueId: queueId, name: name, duration: duration);
|
||||
case JellyfinSource(:final ref, :final origin):
|
||||
if (origin == null || ownJellyfin == null) return;
|
||||
final media = RelayMedia.single(HttpRelayBytes(Uri.parse(ownJellyfin.url), headers: ownJellyfin.headers));
|
||||
if (jellyfinRelayNeeded(r, ref, session.selfId())) {
|
||||
feeder.serve(r.mediaId, media, queueId: queueId, name: name, duration: duration);
|
||||
} else {
|
||||
_jellyfinFeed = (mediaId: r.mediaId, ref: ref, media: media, queueId: queueId, name: name, duration: duration);
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// The media was settled here (opened, failed, nothing to open).
|
||||
void settled(int mediaId) {
|
||||
if (_mediaId == mediaId) settledMediaId.value = mediaId;
|
||||
}
|
||||
|
||||
/// Same media, a new state: a relay that became live opens, a relay
|
||||
/// opened while its source was away and never loaded opens again now
|
||||
/// that it is back (D6), a Jellyfin stream this member holds back starts
|
||||
/// once someone needs it.
|
||||
Future<void> onState(StateBroadcast r) async {
|
||||
final rec = _recovery;
|
||||
final relay = stateMediaRelay(r);
|
||||
final url = _openUrl;
|
||||
if (rec != null && rec.mediaId == r.mediaId && relay != null && relay.live && !relay.sourceGone &&
|
||||
url != null && !_opening && playback.durationSecs <= 0) {
|
||||
_recovery = null;
|
||||
_log.info('media ${r.mediaId}: the relay has its source again; opening it again (it never loaded)');
|
||||
_openUrl = null;
|
||||
_openedFor = -2; // a fresh player: the old one may be stuck in a broken open
|
||||
await open(url, headers: _openHeaders, resolve: _openResolve);
|
||||
}
|
||||
if (_awaitLiveFor == r.mediaId) {
|
||||
final source = plan(r);
|
||||
if (source is RelayedSource && !source.waiting) {
|
||||
_awaitLiveFor = -1;
|
||||
await open(source.url);
|
||||
}
|
||||
}
|
||||
final feed = _jellyfinFeed;
|
||||
if (feed != null && feed.mediaId == r.mediaId && jellyfinRelayNeeded(r, feed.ref, session.selfId())) {
|
||||
_jellyfinFeed = null;
|
||||
_log.info('media ${r.mediaId}: someone needs the Jellyfin item passed on, starting the relay');
|
||||
feeder.serve(r.mediaId, feed.media, queueId: feed.queueId, name: feed.name, duration: feed.duration);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sitting out: nothing open, nothing pulled. A relay this member serves
|
||||
/// goes on (it does not use the player).
|
||||
Future<void> unload() async {
|
||||
_awaitLiveFor = -1;
|
||||
if (_mediaId == -1 && _openUrl == null) return;
|
||||
_mediaId = -1;
|
||||
playerMediaId = -1;
|
||||
_openUrl = null;
|
||||
_openHeaders = null;
|
||||
_recovery = null;
|
||||
if (ready) await playback.stop();
|
||||
}
|
||||
|
||||
/// The next state's media is new to this player: it opens again from
|
||||
/// scratch (a relay this member served for the old one stops).
|
||||
void forget() {
|
||||
_mediaId = -1;
|
||||
playerMediaId = -1;
|
||||
_openUrl = null;
|
||||
_openHeaders = null;
|
||||
_awaitLiveFor = -1;
|
||||
_recovery = null;
|
||||
_activeOrigin = null;
|
||||
_jellyfinFeed = null;
|
||||
feeder.stopAll();
|
||||
}
|
||||
|
||||
/// The same media under a restarted server's numbering: the player keeps
|
||||
/// it.
|
||||
void renumber(int mediaId) => _mediaId = playerMediaId = _openedFor = mediaId;
|
||||
|
||||
/// A new session (connect): nothing held, nothing served.
|
||||
void reset() {
|
||||
_mediaId = -1;
|
||||
playerMediaId = -1;
|
||||
_openedFor = -1;
|
||||
_openUrl = null;
|
||||
_openHeaders = null;
|
||||
_awaitLiveFor = -1;
|
||||
_recovery = null;
|
||||
_pendingOrigin = null;
|
||||
_activeOrigin = null;
|
||||
_jellyfinFeed = null;
|
||||
settledMediaId.value = -1;
|
||||
feeder.stopAll();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- Jellyfin
|
||||
|
||||
// A Jellyfin stream this member holds back: nobody present needs it
|
||||
// passed on yet.
|
||||
({int mediaId, JellyfinRef ref, RelayMedia media, String queueId, String name, double duration})? _jellyfinFeed;
|
||||
|
||||
/// The resolve step of a Jellyfin item: this app's account (or the
|
||||
/// [warm] plan resolved ahead), else the server's login, else the member
|
||||
/// passing it on, else blocked. This member's own item is passed on to
|
||||
/// viewers without an account once someone needs it. Then it opens.
|
||||
/// Null when [current] went false meanwhile.
|
||||
Future<JellyfinPlan?> openJellyfin(JellyfinSource source, StateBroadcast r,
|
||||
{OwnPlay? warm, required bool Function() current}) async {
|
||||
// Until it resolves nothing is open: no syncing the previous media.
|
||||
final prev = _openUrl;
|
||||
_openUrl = null;
|
||||
_opening = true;
|
||||
if (ready) {
|
||||
try {
|
||||
await playback.player.pause();
|
||||
} catch (_) {}
|
||||
}
|
||||
JellyfinPlan plan;
|
||||
try {
|
||||
plan = warm ??
|
||||
await resolveJellyfinRef(session.jellyfin, source.ref,
|
||||
relay: source.serverLogin != null, relayUrl: source.serverLogin, memberRelayUrl: source.memberRelay);
|
||||
} finally {
|
||||
if (current()) _opening = false;
|
||||
}
|
||||
if (!current()) return null;
|
||||
trace.mark(r.mediaId, SwitchTrace.resolved);
|
||||
final origin = source.origin;
|
||||
if (origin != null) {
|
||||
final name = (r.media?['name'] as String?) ?? '';
|
||||
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||
final queueId = stateMediaRelay(r)?.queueId ?? '';
|
||||
RelayMedia? media;
|
||||
if (plan is OwnPlay) {
|
||||
media = RelayMedia.single(HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers));
|
||||
} else if (origin.link != null) {
|
||||
plan = RelayPlay(origin.link!);
|
||||
media = RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!)));
|
||||
}
|
||||
if (media != null) {
|
||||
if (jellyfinRelayNeeded(r, source.ref, session.selfId())) {
|
||||
feeder.serve(r.mediaId, media, queueId: queueId, name: name, duration: duration);
|
||||
} else {
|
||||
_jellyfinFeed =
|
||||
(mediaId: r.mediaId, ref: source.ref, media: media, queueId: queueId, name: name, duration: duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
switch (plan) {
|
||||
case BlockedPlay():
|
||||
await playback.stop();
|
||||
case RelayPlay(:final url):
|
||||
// The same relay URL (a resumed member): the player still has it.
|
||||
if (url == prev) {
|
||||
_openUrl = url;
|
||||
} else if (!await open(url, resolve: false)) {
|
||||
return current() ? plan : null;
|
||||
}
|
||||
case OwnPlay(:final url, :final headers):
|
||||
if (!await open(url, headers: headers, resolve: false)) return current() ? plan : null;
|
||||
}
|
||||
return current() ? plan : null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ recovery (D6)
|
||||
|
||||
// The relay this viewer reads lost its source and the buffer ran dry:
|
||||
// what it waits for.
|
||||
_Recovery? _recovery;
|
||||
|
||||
/// This viewer reads the current media through a relay whose source is
|
||||
/// gone, or left: it plays what the server has.
|
||||
bool get sourceDown => _recovery != null;
|
||||
|
||||
/// The user sought to [to] (applied at once): while the source is down,
|
||||
/// that is the one try.
|
||||
void noteUserSeek(double to) {
|
||||
final rec = _recovery;
|
||||
if (rec == null) return;
|
||||
rec.tried = true;
|
||||
rec.tryTo = to;
|
||||
}
|
||||
|
||||
/// While the source is down, this player cannot be where the server is
|
||||
/// ([serverPosition]): a seek waits, or the player is stuck short of it.
|
||||
/// The report then says nothing is buffered, so the server waits for the
|
||||
/// source instead of playing on without this viewer.
|
||||
bool starved(double serverPosition) {
|
||||
final rec = _recovery;
|
||||
if (rec == null) return false;
|
||||
return rec.seekTo != null || (playback.positionSecs - serverPosition).abs() > 3;
|
||||
}
|
||||
|
||||
/// Seeks while the source is down, not applied (tests: never a loop).
|
||||
@visibleForTesting
|
||||
int heldBackSeeks = 0;
|
||||
|
||||
/// The viewer's side of a source that drops (D6): while the relay this
|
||||
/// app reads has no source, it plays what is buffered. A seek past it
|
||||
/// (the server's buffer may still hold it) is tried once; any further
|
||||
/// one waits (mpv would wait on bytes nobody sends, time out after 5 s
|
||||
/// and be asked again: the seek loop), and the report says nothing is
|
||||
/// buffered ([starved]), so the server holds everyone with the reason
|
||||
/// `source` ("waiting for Bob"). When the source is back, one seek to
|
||||
/// where the server is resumes the player. Returns the [actions] to
|
||||
/// apply.
|
||||
List<PlayerAction> recover(StateBroadcast r, List<PlayerAction> actions, {required double serverPosition}) {
|
||||
final relay = stateMediaRelay(r);
|
||||
final url = _openUrl;
|
||||
final viaRelay = url != null && (url.startsWith('edl://') || Uri.tryParse(url)?.pathSegments.firstOrNull == 'r');
|
||||
final down = relay != null &&
|
||||
viaRelay &&
|
||||
!relay.fetch &&
|
||||
relay.source != session.selfId() &&
|
||||
(relay.sourceGone || !relay.live);
|
||||
final rec = _recovery;
|
||||
if (down) {
|
||||
final at = playback.positionSecs;
|
||||
final reach = at + playback.bufferedSecsAhead;
|
||||
final now = _recovery ??= _Recovery(r.mediaId);
|
||||
if (rec == null) {
|
||||
_log.info('media ${r.mediaId}: the relay has no source; playing what is buffered '
|
||||
'(to ${reach.toStringAsFixed(1)} s)');
|
||||
}
|
||||
final out = <PlayerAction>[];
|
||||
for (final a in actions) {
|
||||
if (a is SeekAction && (a.position > reach + 0.5 || a.position < at - 30)) {
|
||||
final last = now.tryTo;
|
||||
if (!now.tried && (last == null || (a.position - last).abs() > 1.5)) {
|
||||
now.tried = true;
|
||||
now.tryTo = a.position;
|
||||
out.add(a);
|
||||
continue;
|
||||
}
|
||||
heldBackSeeks++;
|
||||
now.seekTo = a.position;
|
||||
} else {
|
||||
out.add(a);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
_recovery = null;
|
||||
if (rec == null || rec.mediaId != r.mediaId) return actions;
|
||||
final stuck = rec.seekTo != null ||
|
||||
(playback.positionSecs - serverPosition).abs() > 1.0 ||
|
||||
playback.bufferedSecsAhead < 1;
|
||||
_log.info('media ${r.mediaId}: the relay has its source again'
|
||||
'${stuck ? '; resuming at ${serverPosition.toStringAsFixed(1)} s' : ''}');
|
||||
if (!stuck) return actions;
|
||||
// One seek to where the server is: the demuxer reads from there again.
|
||||
return [
|
||||
for (final a in actions) if (a is! SeekAction) a,
|
||||
SeekAction(serverPosition),
|
||||
];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ prepare
|
||||
|
||||
/// Preparing on (env `SYNC_RELAY_PRELOAD=0` turns it off; tests set it).
|
||||
bool preloadEnabled = Platform.environment['SYNC_RELAY_PRELOAD'] != '0';
|
||||
|
||||
_Prepared? _pre;
|
||||
|
||||
/// The URL prepared now (null: none).
|
||||
String? get preloadUrl => _pre?.url;
|
||||
|
||||
/// This viewer's URL of the warm relay of Up next's first [item]: the
|
||||
/// same URL the media has once the item goes up, so the prepared player
|
||||
/// is promoted.
|
||||
String warmRelayUrl(QueueItem item, MediaRelay relay) =>
|
||||
session.server().relayUrl(item.media.name.isEmpty ? item.title : item.media.name, item.id, relay.tracks);
|
||||
|
||||
/// Opens [url] early in a hidden player (paused at the start, muted, a
|
||||
/// small cache, its own video output when the view is up), replacing any
|
||||
/// other; completes once [need] seconds are buffered or the whole media
|
||||
/// is: true, false when it failed (or was dropped or replaced
|
||||
/// meanwhile). The same [url] and [headers] again is the same one unless
|
||||
/// [fresh]. [open] for the same URL promotes it.
|
||||
Future<bool> prepare(String url, {Map<String, String>? headers, double need = 4, bool fresh = false}) async {
|
||||
if (!preloadEnabled || !ready) return false;
|
||||
final had = _pre;
|
||||
if (had != null && had.matches(url, headers) && !had.failed && !fresh) return had.ready.future;
|
||||
// Set before any await: a second call for the same URL finds it.
|
||||
_pre = null;
|
||||
if (had != null) unawaited(_disposePrepared(had));
|
||||
final pre = _pre = _Prepared(url, headers ?? const {});
|
||||
try {
|
||||
final target = await _resolve(url, headers, fresh: fresh);
|
||||
if (!identical(_pre, pre)) {
|
||||
pre.fail();
|
||||
return false;
|
||||
}
|
||||
final p = pre.player = await host.create(PlayerRole.preload);
|
||||
pre.logSub = p.logs.listen((log) {
|
||||
if (mpvLoadFailed(log, p.state.duration)) pre.fail();
|
||||
});
|
||||
pre.subtitles = target.subtitles;
|
||||
if (playback.hasVideo) {
|
||||
pre.video = host.video(p);
|
||||
await pre.video!.ready;
|
||||
}
|
||||
if (!identical(_pre, pre)) {
|
||||
pre.fail();
|
||||
await _disposePrepared(pre);
|
||||
return false;
|
||||
}
|
||||
await p.open(target.url, headers: target.headers);
|
||||
if (!pre.opened.isCompleted) pre.opened.complete();
|
||||
} catch (e) {
|
||||
debugPrint('preload failed: $e');
|
||||
pre.fail();
|
||||
if (identical(_pre, pre)) _pre = null;
|
||||
await _disposePrepared(pre);
|
||||
return false;
|
||||
}
|
||||
unawaited(_watchPrepared(pre, need));
|
||||
return pre.ready.future;
|
||||
}
|
||||
|
||||
Future<void> _watchPrepared(_Prepared pre, double need) async {
|
||||
final end = DateTime.now().add(const Duration(seconds: 60));
|
||||
while (identical(_pre, pre) && !pre.failed && DateTime.now().isBefore(end)) {
|
||||
final st = pre.player!.state;
|
||||
final secs = st.duration.inMilliseconds / 1000;
|
||||
final buffered = (st.buffer - st.position).inMilliseconds / 1000;
|
||||
if (secs > 0 && (buffered >= need || st.buffer.inMilliseconds / 1000 >= secs - 0.5)) {
|
||||
if (!pre.ready.isCompleted) pre.ready.complete(true);
|
||||
return;
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
if (!pre.ready.isCompleted) pre.ready.complete(false);
|
||||
}
|
||||
|
||||
/// Drops the prepared player (nothing to get ready, or something else
|
||||
/// is).
|
||||
Future<void> dropPrepared() async {
|
||||
final pre = _pre;
|
||||
if (pre == null) return;
|
||||
_pre = null;
|
||||
await _disposePrepared(pre);
|
||||
}
|
||||
|
||||
Future<void> _disposePrepared(_Prepared pre) async {
|
||||
pre.fail();
|
||||
await pre.logSub?.cancel();
|
||||
final p = pre.player;
|
||||
if (p != null) await host.dispose(p);
|
||||
}
|
||||
|
||||
/// [pre] becomes the session player; its buffer comes along.
|
||||
Future<bool> _promote(_Prepared pre) async {
|
||||
await pre.opened.future;
|
||||
final old = playback.current;
|
||||
if (!identical(_pre, pre) || old == null) return false;
|
||||
_pre = null;
|
||||
final p = pre.player;
|
||||
if (pre.failed || p == null) {
|
||||
unawaited(_disposePrepared(pre));
|
||||
return false;
|
||||
}
|
||||
await pre.logSub?.cancel();
|
||||
if (!pre.ready.isCompleted) pre.ready.complete(true);
|
||||
await host.promote(p);
|
||||
final prev = playback.attach(p, video: pre.video, loaded: true);
|
||||
_held = true;
|
||||
unawaited(_addSubtitles(p, pre.subtitles));
|
||||
if (prev != null) unawaited(host.retire(prev));
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await release();
|
||||
feeder.stopAll();
|
||||
await playback.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _Recovery {
|
||||
final int mediaId;
|
||||
|
||||
/// The latest seek held back (where the server went meanwhile).
|
||||
double? seekTo;
|
||||
|
||||
/// The one seek past the buffer was tried ([tryTo]: where to).
|
||||
bool tried = false;
|
||||
double? tryTo;
|
||||
_Recovery(this.mediaId);
|
||||
}
|
||||
|
||||
class _Prepared {
|
||||
final String url;
|
||||
final Map<String, String> headers;
|
||||
HostedPlayer? player;
|
||||
HostedVideo? video;
|
||||
StreamSubscription<PlayerLogLine>? logSub;
|
||||
List<YtDlpSubtitle> subtitles = const [];
|
||||
|
||||
/// Completes once the media is handed to mpv (or it failed).
|
||||
final opened = Completer<void>();
|
||||
bool failed = false;
|
||||
final ready = Completer<bool>();
|
||||
_Prepared(this.url, this.headers);
|
||||
|
||||
bool matches(String url, Map<String, String>? headers) =>
|
||||
url == this.url && mapEquals(headers ?? const {}, this.headers);
|
||||
|
||||
void fail() {
|
||||
failed = true;
|
||||
if (!ready.isCompleted) ready.complete(false);
|
||||
if (!opened.isCompleted) opened.complete();
|
||||
}
|
||||
}
|
||||
|
||||
/// What a player opens for a link (the resolve step).
|
||||
class _Target {
|
||||
final String url;
|
||||
final Map<String, String> headers;
|
||||
final List<YtDlpSubtitle> subtitles;
|
||||
const _Target(this.url, this.headers, {this.subtitles = const []});
|
||||
}
|
||||
|
||||
/// A page could not be resolved: why ([message], yt-dlp's words) and which
|
||||
/// cookies that used ([cookies], D11).
|
||||
class ResolveFailure implements Exception {
|
||||
final String message;
|
||||
final String? cookies;
|
||||
const ResolveFailure(this.message, {this.cookies});
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/session_media.dart' show refOfStateMedia, relayUrlFor;
|
||||
import 'delivery.dart' show stateMediaRelay;
|
||||
import 'party.dart' show isPresent;
|
||||
|
||||
// The plan step of the media pipeline: from the server's state, what this
|
||||
// member serves, its own capabilities and who is present, where this app
|
||||
// reads the current media from. One switch, pure: `media_plan_test` holds
|
||||
// the table.
|
||||
|
||||
/// What this member serves for the media it put up (set at its swap,
|
||||
/// claimed by the state that names it as the relay's source).
|
||||
sealed class MediaOrigin {
|
||||
const MediaOrigin();
|
||||
}
|
||||
|
||||
/// A file on this computer, passed on through the relay.
|
||||
final class FileOrigin extends MediaOrigin {
|
||||
final String path;
|
||||
const FileOrigin(this.path);
|
||||
}
|
||||
|
||||
/// A link this computer reads and passes on ("through me").
|
||||
final class LinkOrigin extends MediaOrigin {
|
||||
final String url;
|
||||
|
||||
/// What the link is (a page goes through [ytDlp]).
|
||||
final LinkProbe probe;
|
||||
final YtDlp ytDlp;
|
||||
const LinkOrigin(this.url, this.probe, this.ytDlp);
|
||||
}
|
||||
|
||||
/// A Jellyfin item: served with this install's account, else with the
|
||||
/// pasted [link] (its api_key stays on this machine).
|
||||
final class JellyfinOrigin extends MediaOrigin {
|
||||
final String? link;
|
||||
const JellyfinOrigin(this.link);
|
||||
}
|
||||
|
||||
/// Where this app's server is, as this member dialled it.
|
||||
class ServerAddress {
|
||||
final String host;
|
||||
final int port;
|
||||
final String token;
|
||||
const ServerAddress(this.host, this.port, this.token);
|
||||
|
||||
/// This viewer's `/m` URL of your server's file [name].
|
||||
String fileUrl(String name) => 'http://$host:$port/m/default/$token/${Uri.encodeComponent(name)}';
|
||||
|
||||
/// This viewer's `/r` URL of a relay ([key]: the media id, or the queue
|
||||
/// id of one started in Up next); with tracks, a pieces playlist or an
|
||||
/// mpv EDL of every track.
|
||||
String relayUrl(String name, String key, [List<RelayTrackSpec> tracks = const []]) =>
|
||||
relayViewerUrl('http://$host:$port/r/default/$token/$key', name, tracks: tracks);
|
||||
|
||||
/// This member's `/j` URL of [ref] (the server's Jellyfin login).
|
||||
String jellyfinUrl(JellyfinRef ref) => relayUrlFor(host, port, token, ref.itemId);
|
||||
|
||||
/// The relay channel this member feeds ([queueId]: the warm relay of
|
||||
/// that Up next item).
|
||||
Uri sourceChannel([String queueId = '']) => Uri.parse('ws://$host:$port/r/default/$token/source'
|
||||
'${queueId.isEmpty ? '' : '?q=${Uri.encodeQueryComponent(queueId)}'}');
|
||||
}
|
||||
|
||||
/// Where this app reads the current media from.
|
||||
sealed class SourcePlan {
|
||||
const SourcePlan();
|
||||
|
||||
/// The URL a player opens; null when there is nothing to open yet.
|
||||
String? get url => null;
|
||||
}
|
||||
|
||||
/// No media, or nothing this app can open (yet).
|
||||
final class NoSource extends SourcePlan {
|
||||
const NoSource();
|
||||
}
|
||||
|
||||
/// This member passes its file on: the player plays the file itself, the
|
||||
/// relay source feeds the server.
|
||||
final class OwnFile extends SourcePlan {
|
||||
final String path;
|
||||
const OwnFile(this.path);
|
||||
@override
|
||||
String get url => path;
|
||||
}
|
||||
|
||||
/// This member passes a link on: it plays the link itself (a page through
|
||||
/// its yt-dlp), the relay source reads it for the server.
|
||||
final class OwnLink extends SourcePlan {
|
||||
@override
|
||||
final String url;
|
||||
final LinkOrigin origin;
|
||||
const OwnLink(this.url, this.origin);
|
||||
}
|
||||
|
||||
/// Your server's own file (`/m`).
|
||||
final class ServerFile extends SourcePlan {
|
||||
@override
|
||||
final String url;
|
||||
const ServerFile(this.url);
|
||||
}
|
||||
|
||||
/// A link everyone opens itself; [page]: a web page, resolved by this
|
||||
/// app's yt-dlp (the pipeline's resolve step).
|
||||
final class DirectLink extends SourcePlan {
|
||||
@override
|
||||
final String url;
|
||||
final bool page;
|
||||
const DirectLink(this.url, {required this.page});
|
||||
}
|
||||
|
||||
/// The server's relay (`/r`): a member passing it on ([sourceId]) or the
|
||||
/// server's own fetch ([fetch]). [waiting]: nobody feeds it yet, so it opens
|
||||
/// once `relay.live` (mpv gives up on a silent request after 5 s).
|
||||
final class RelayedSource extends SourcePlan {
|
||||
@override
|
||||
final String url;
|
||||
final bool fetch;
|
||||
final String sourceId;
|
||||
final bool waiting;
|
||||
const RelayedSource(this.url, {required this.fetch, required this.sourceId, required this.waiting});
|
||||
}
|
||||
|
||||
/// A Jellyfin item: this app's own account first ([ownAccount]), then the
|
||||
/// server's login ([serverLogin], `/j`), then the member who put it up
|
||||
/// ([memberRelay], `/r`); the resolve step decides. [origin]: this member
|
||||
/// put it up (it passes it on to viewers without an account).
|
||||
final class JellyfinSource extends SourcePlan {
|
||||
final JellyfinRef ref;
|
||||
final bool ownAccount;
|
||||
final String? serverLogin;
|
||||
final String? memberRelay;
|
||||
final JellyfinOrigin? origin;
|
||||
const JellyfinSource(this.ref, {required this.ownAccount, this.serverLogin, this.memberRelay, this.origin});
|
||||
}
|
||||
|
||||
/// A page link this app cannot resolve (no yt-dlp here: `caps.resolve`
|
||||
/// false): [via] names who can pass it on.
|
||||
final class Unresolvable extends SourcePlan {
|
||||
final String link;
|
||||
final String via;
|
||||
const Unresolvable(this.link, this.via);
|
||||
|
||||
String get reason => "This device can't open web pages; ${via.isEmpty ? 'ask someone' : via} to pass it on (Through me)";
|
||||
}
|
||||
|
||||
/// What [planMedia] reads.
|
||||
class PlanInput {
|
||||
final StateBroadcast state;
|
||||
final String selfId;
|
||||
final ClientCaps caps;
|
||||
final ServerAddress server;
|
||||
|
||||
/// What this member serves for [StateBroadcast.mediaId] (null: nothing).
|
||||
final MediaOrigin? origin;
|
||||
|
||||
/// This app plays Jellyfin [serverId] with its own account.
|
||||
final bool Function(String serverId) ownJellyfin;
|
||||
const PlanInput({
|
||||
required this.state,
|
||||
required this.selfId,
|
||||
required this.caps,
|
||||
required this.server,
|
||||
this.origin,
|
||||
required this.ownJellyfin,
|
||||
});
|
||||
}
|
||||
|
||||
/// A relay nobody feeds yet: the server's fetch before it is read, or a
|
||||
/// member's before its channel is up while that member is still here
|
||||
/// (once it is gone or left, the server's buffer is all there is: open
|
||||
/// that).
|
||||
bool relayWaitsForSource(StateBroadcast r, MediaRelay relay) =>
|
||||
!relay.live && !relay.sourceGone && (relay.fetch || r.members.any((m) => m.id == relay.source));
|
||||
|
||||
/// The members that can resolve a page link (`caps.resolve`), present
|
||||
/// ones, lowest id first.
|
||||
List<MemberView> linkResolvers(StateBroadcast r, {String except = ''}) =>
|
||||
[for (final m in r.members) if (m.caps.resolve && isPresent(m) && m.id != except) m];
|
||||
|
||||
/// Where this app reads [input]'s media from.
|
||||
SourcePlan planMedia(PlanInput input) {
|
||||
final r = input.state;
|
||||
final media = r.media;
|
||||
if (media == null) return const NoSource();
|
||||
final name = (media['name'] as String?) ?? '';
|
||||
final relay = stateMediaRelay(r);
|
||||
final mine = relay != null && relay.source.isNotEmpty && relay.source == input.selfId;
|
||||
final origin = mine ? input.origin : null;
|
||||
final key = relay == null || relay.queueId.isEmpty ? '${r.mediaId}' : relay.queueId;
|
||||
|
||||
final ref = refOfStateMedia(media);
|
||||
if (ref != null) {
|
||||
return JellyfinSource(
|
||||
ref,
|
||||
ownAccount: input.ownJellyfin(ref.serverId),
|
||||
serverLogin: r.relay && input.server.token.isNotEmpty ? input.server.jellyfinUrl(ref) : null,
|
||||
memberRelay: relay != null && !mine ? input.server.relayUrl(name, key) : null,
|
||||
origin: origin is JellyfinOrigin ? origin : (mine ? const JellyfinOrigin(null) : null),
|
||||
);
|
||||
}
|
||||
switch (origin) {
|
||||
case FileOrigin(:final path):
|
||||
return OwnFile(path);
|
||||
case LinkOrigin(:final url):
|
||||
return OwnLink(url, origin);
|
||||
case JellyfinOrigin() || null:
|
||||
break;
|
||||
}
|
||||
if (relay != null) {
|
||||
// Another member's relay or the server's fetch; this member's own
|
||||
// relay without its origin (a restarted app) reads the server's buffer.
|
||||
return RelayedSource(input.server.relayUrl(name, key, relay.tracks),
|
||||
fetch: relay.fetch, sourceId: relay.source, waiting: !mine && relayWaitsForSource(r, relay));
|
||||
}
|
||||
final url = media['url'];
|
||||
if (url is String && url.isNotEmpty) {
|
||||
final uri = Uri.tryParse(url);
|
||||
final web = uri != null && (uri.isScheme('http') || uri.isScheme('https'));
|
||||
final page = web && !hasMediaExtension(uri);
|
||||
if (page && !input.caps.resolve) {
|
||||
final who = linkResolvers(r, except: input.selfId).firstOrNull;
|
||||
return Unresolvable(url, who == null ? '' : 'ask ${who.name}');
|
||||
}
|
||||
return DirectLink(url, page: page);
|
||||
}
|
||||
if (name.isNotEmpty && input.server.token.isNotEmpty) return ServerFile(input.server.fileUrl(name));
|
||||
return const NoSource();
|
||||
}
|
||||
@@ -187,6 +187,12 @@ Notice _plain(AppEvent e) => switch (e) {
|
||||
category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor),
|
||||
MemberReturned(:final feed) =>
|
||||
Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor),
|
||||
// Put back after a server restart: news, but not a change.
|
||||
MediaSwapped(:final feed) when feed.reason == 'restore' => Notice(
|
||||
category: NoticeCategory.upNext,
|
||||
log: feed,
|
||||
toast: '${feed.actor} put back what was playing',
|
||||
actor: feed.actor),
|
||||
MediaSwapped(:final feed) => Notice(
|
||||
category: NoticeCategory.upNext,
|
||||
log: feed,
|
||||
|
||||
@@ -12,8 +12,8 @@ const presencePolicy = PresencePolicy();
|
||||
bool countsForReady(MemberView m) => m.error.isEmpty && presencePolicy.countsFor(m.presence, Purpose.readyCounter);
|
||||
|
||||
/// Connected and watching (`in` or `joining`): can do things for the
|
||||
/// party now (put up an Up next item, the auto-advance swap).
|
||||
bool isPresent(MemberView m) => m.presence == Presence.watching || m.presence == Presence.joining;
|
||||
/// party now (put up an Up next item; core's [isPresentMember]).
|
||||
bool isPresent(MemberView m) => isPresentMember(m);
|
||||
|
||||
/// Nobody but [selfId] is connected in [members] (D9, core's [isAlone]).
|
||||
bool aloneIn(List<MemberView> members, String selfId) =>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:signals/signals.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'app_events.dart';
|
||||
import 'media_plan.dart' show ServerAddress;
|
||||
import 'party.dart' show isPresent;
|
||||
|
||||
/// This member as a relay source: the current media's bytes (its file, a
|
||||
/// link it passes on, its Jellyfin stream) on `/r/.../source`, and Up next's
|
||||
/// first item on the warm relay (`?q=<queueId>`), which the swap that puts
|
||||
/// the item up carries on as the media's. A source re-dials by itself
|
||||
/// through a drop of this app (the server keeps the relay through the
|
||||
/// grace; the same `info` continues it).
|
||||
class RelayFeeder {
|
||||
final ServerAddress Function() server;
|
||||
final void Function(AppEvent event) notify;
|
||||
RelayFeeder({required this.server, required this.notify});
|
||||
|
||||
RelaySource? _source;
|
||||
int _mediaId = -1;
|
||||
({String queueId, RelaySource src})? _warm;
|
||||
|
||||
/// This member serves the current media's bytes through the server.
|
||||
final relaying = signal(false);
|
||||
|
||||
/// The media id this member serves (-1: none).
|
||||
int get mediaId => _mediaId;
|
||||
|
||||
/// The tracks of Up next item [queueId] when this member feeds its warm
|
||||
/// relay and has opened them (empty otherwise).
|
||||
List<RelayTrack> warmTracks(String queueId) => _warm?.queueId == queueId ? _warm!.src.tracks : const [];
|
||||
|
||||
/// Serves [media] for media [mediaId] on this member's relay channel.
|
||||
/// The server only pulls while someone watches through it. When the
|
||||
/// media came from Up next item [queueId] whose warm relay this member
|
||||
/// feeds, that source carries on instead. [name] and [duration] go in
|
||||
/// the `info`, the same on every dial, so the server knows a re-dial
|
||||
/// after a drop as the same media.
|
||||
void serve(int mediaId, RelayMedia media,
|
||||
{bool announce = false, String queueId = '', String name = '', double duration = 0}) {
|
||||
// Already serving this media (it re-dials by itself through a drop).
|
||||
if (_source != null && _mediaId == mediaId) {
|
||||
unawaited(media.close());
|
||||
return;
|
||||
}
|
||||
stop();
|
||||
final warm = _warm;
|
||||
if (queueId.isNotEmpty && warm != null && warm.queueId == queueId) {
|
||||
_warm = null;
|
||||
unawaited(media.close());
|
||||
_adopt(mediaId, warm.src, announce: announce, started: true);
|
||||
return;
|
||||
}
|
||||
final src = RelaySource(server().sourceChannel(), media, name: name, duration: duration);
|
||||
_adopt(mediaId, src, announce: announce, started: false);
|
||||
}
|
||||
|
||||
void _adopt(int mediaId, RelaySource src, {required bool announce, required bool started}) {
|
||||
_source = src;
|
||||
_mediaId = mediaId;
|
||||
relaying.value = true;
|
||||
if (announce) notify(const RelayStarted());
|
||||
unawaited(() async {
|
||||
if (!started) {
|
||||
try {
|
||||
await src.start();
|
||||
} catch (e) {
|
||||
debugPrint('relay source failed: $e');
|
||||
if (identical(_source, src)) {
|
||||
_clear();
|
||||
notify(RelayFailed('$e'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
await src.done;
|
||||
if (identical(_source, src)) _clear();
|
||||
}());
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
_source = null;
|
||||
_mediaId = -1;
|
||||
relaying.value = false;
|
||||
}
|
||||
|
||||
/// Stops serving the current media.
|
||||
void stop() {
|
||||
final src = _source;
|
||||
_clear();
|
||||
if (src != null) unawaited(src.close());
|
||||
}
|
||||
|
||||
/// Feeds the warm relay of Up next item [queueId] from here with [media];
|
||||
/// one already feeding it is kept. The server pulls only while someone
|
||||
/// preloads it, and closes the channel once the item is no longer first
|
||||
/// (the source then stops).
|
||||
void feedWarm(String queueId, RelayMedia media) {
|
||||
if (_warm?.queueId == queueId) {
|
||||
unawaited(media.close());
|
||||
return;
|
||||
}
|
||||
stopWarm();
|
||||
final src = RelaySource(server().sourceChannel(queueId), media);
|
||||
_warm = (queueId: queueId, src: src);
|
||||
unawaited(() async {
|
||||
try {
|
||||
await src.start();
|
||||
} catch (e) {
|
||||
debugPrint('warm relay source of $queueId failed: $e');
|
||||
if (identical(_warm?.src, src)) _warm = null;
|
||||
return;
|
||||
}
|
||||
await src.done;
|
||||
if (identical(_warm?.src, src)) _warm = null;
|
||||
}());
|
||||
}
|
||||
|
||||
void stopWarm() {
|
||||
final w = _warm;
|
||||
_warm = null;
|
||||
if (w != null) unawaited(w.src.close());
|
||||
}
|
||||
|
||||
/// Leave, or a media this member no longer serves.
|
||||
void stopAll() {
|
||||
stop();
|
||||
stopWarm();
|
||||
}
|
||||
}
|
||||
|
||||
/// Someone present needs this member's relay of Jellyfin [ref]: the
|
||||
/// server's own login does not pass it on (`state.relay`) and a present
|
||||
/// member other than [selfId] does not play that server with its own
|
||||
/// account. A member whose account list is not reported yet (`caps` say it
|
||||
/// has Jellyfin, `jf` still empty) is asked again with the next state.
|
||||
/// Without anyone needing it, no relay channel is opened.
|
||||
bool jellyfinRelayNeeded(StateBroadcast r, JellyfinRef ref, String selfId) {
|
||||
if (r.relay) return false;
|
||||
for (final m in r.members) {
|
||||
if (m.id == selfId || !isPresent(m)) continue;
|
||||
if (m.jf[ref.serverId] == true) continue;
|
||||
if (m.caps.jellyfin && m.jf.isEmpty) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -210,6 +210,20 @@ StatusLine? _line(StatusFacts f, StateBroadcast? r, MemberView? self, SourceTrou
|
||||
return StatusLine(StatusKind.problem, "Can't play this: ${f.playerError}",
|
||||
level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this");
|
||||
}
|
||||
// The server holds everyone for the relay's source (D6): a viewer's
|
||||
// buffer ran dry while it is gone.
|
||||
if (r.transport == 'held' && r.hold?.reason == HoldReason.source) {
|
||||
final relayed = stateMediaRelay(r);
|
||||
final id = relayed?.source ?? '';
|
||||
final name = source?.name ??
|
||||
r.members.where((m) => m.id == id).firstOrNull?.name ??
|
||||
f.names?[id] ??
|
||||
'the member passing it on';
|
||||
return StatusLine(StatusKind.waiting, 'Waiting for $name',
|
||||
detail: "$name's connection dropped; what the server had has played",
|
||||
level: StatusLevel.warn,
|
||||
short: 'Waiting for $name');
|
||||
}
|
||||
// The relay: what everyone reads comes through the server.
|
||||
final relay = f.ownPlan ? null : stateMediaRelay(r);
|
||||
if (relay != null && relay.source != f.selfId) {
|
||||
|
||||
@@ -278,7 +278,7 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
|
||||
case 'seek':
|
||||
return (Icons.fast_forward, scheme.primary, 'seeked');
|
||||
case 'swap':
|
||||
return (Icons.swap_horiz, scheme.primary, 'changed the media');
|
||||
return (Icons.swap_horiz, scheme.primary, f.reason == 'restore' ? 'put back what was playing' : 'changed the media');
|
||||
case 'gone':
|
||||
return (Icons.cloud_off, Colors.orange, 'lost signal');
|
||||
case 'return':
|
||||
@@ -296,6 +296,9 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
|
||||
case 'queue-skip':
|
||||
final why = f.reason.isEmpty ? '' : ' · ${f.reason}';
|
||||
return (Icons.redo_rounded, Colors.orange, 'skipped ${f.target ?? ''}$why');
|
||||
case 'advance-cancel':
|
||||
final what = f.target == null || f.target!.isEmpty ? '' : ' (${f.target} is off Up next)';
|
||||
return (Icons.cancel_outlined, Colors.grey, 'cancelled what plays next$what');
|
||||
case 'fetch-fail':
|
||||
return (Icons.cloud_off, Colors.red, "couldn't fetch ${f.target ?? 'the video'}");
|
||||
default:
|
||||
@@ -304,9 +307,16 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's own lines (pause / resume).
|
||||
/// The server's own lines (pause / resume, the end, an Up next item
|
||||
/// nobody here can put up).
|
||||
(IconData, Color, String) serverFeedStyle(FeedMessage f) => switch (f.action) {
|
||||
'resume' => (Icons.play_arrow, Colors.green, 'Playback resumed'),
|
||||
'finished' => (Icons.stop_circle_outlined, Colors.grey, 'Finished'),
|
||||
'queue-skip' => (
|
||||
Icons.redo_rounded,
|
||||
Colors.orange,
|
||||
'Skipped ${f.target ?? ''}${f.reason.isEmpty ? '' : ' · ${f.reason}'}'
|
||||
),
|
||||
_ => (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness'),
|
||||
};
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@ import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../controls/controls_probe.dart';
|
||||
import '../controls/minimal/style.dart';
|
||||
import '../controls/model/controls_layout.dart';
|
||||
import '../controls/model/controls_model.dart';
|
||||
import '../controls/session_controls.dart';
|
||||
import '../controls/volume.dart';
|
||||
import '../platform/desktop/media_kit_host.dart' show videoControllerOf;
|
||||
import '../queue/queue_ui.dart';
|
||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||
import '../screens/session/jellyfin_widgets.dart';
|
||||
@@ -290,7 +293,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_videoGen = gen;
|
||||
_videoKey = GlobalKey<VideoState>();
|
||||
}
|
||||
final VideoControlsBuilder? controls = _full ? _sessionControls : null;
|
||||
// Never null for the mini: media_kit keeps the last controls
|
||||
// when given null, so the full player's would stay under it.
|
||||
final VideoControlsBuilder controls = _full ? _sessionControls : _noControls;
|
||||
final video = widget.videoBuilder?.call(context, controls) ?? _video(controls);
|
||||
// The wheel anywhere on the full player steps the volume
|
||||
// (lists, menus and popovers under the mouse scroll instead).
|
||||
@@ -321,7 +326,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
|
||||
Widget _video(VideoControlsBuilder? controls) => Video(
|
||||
key: _videoKey,
|
||||
controller: _coord.playback.video,
|
||||
controller: videoControllerOf(_coord.playback.video),
|
||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||
controls: controls,
|
||||
);
|
||||
@@ -418,16 +423,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
});
|
||||
}
|
||||
|
||||
/// The action area's right end: the end-of-media card (Up next, else the
|
||||
/// next episode; it stays up), else the pills, shown only with the
|
||||
/// The action area's right end: the end-of-media card (Up next, else
|
||||
/// the continuation; it stays up), else the pills, shown only with the
|
||||
/// controls ([PillGate]).
|
||||
Widget _actionSide() {
|
||||
final coord = _coord;
|
||||
return SignalBuilder(
|
||||
builder: (context) {
|
||||
final next = coord.nextEpisode.value;
|
||||
final countdown = coord.autoAdvance.value;
|
||||
// Up next goes before the next episode.
|
||||
final upNext = coord.queue.countdown.value;
|
||||
final finished = coord.serverState.value?.transport == 'finished';
|
||||
final pill = coord.nextPromptVisible.value;
|
||||
@@ -436,18 +439,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
if (upNext != null && finished) {
|
||||
return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext));
|
||||
}
|
||||
// "Clear" in Up next hides the next episode for this media.
|
||||
if (next != null && (finished || countdown != null) && coord.queue.shownContinuation.value.isNotEmpty) {
|
||||
return ControlsHoldRegion(
|
||||
hold: _hold,
|
||||
child: EndOfEpisodeCard(
|
||||
next: next,
|
||||
countdown: countdown,
|
||||
onPlayNow: coord.playNextEpisode,
|
||||
onCancel: coord.cancelAutoAdvance,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!showNext && skip == null) return const SizedBox.shrink();
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -581,11 +572,13 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
),
|
||||
);
|
||||
|
||||
/// Mini: title, expand and close on hover; ready, time and a seekable
|
||||
/// bar at the bottom. A thin progress line stays while not hovered.
|
||||
/// Mini: title, expand and close on hover; the status, play (the full
|
||||
/// player's play button: ready in a server) and the time, and a bar that
|
||||
/// seeks. A thin progress line stays while not hovered.
|
||||
Widget _miniOverlay() {
|
||||
final coord = _coord;
|
||||
final playback = coord.playback;
|
||||
const scale = 0.8;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _miniHover = true),
|
||||
onExit: (_) => setState(() => _miniHover = false),
|
||||
@@ -602,58 +595,76 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.black54, Colors.transparent, Colors.black87],
|
||||
stops: [0, 0.35, 0.55, 1],
|
||||
colors: [Color(0x99000000), Color(0x00000000), Color(0x00000000), Color(0xCC000000)],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 6, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(child: NowPlayingTitle(cover: false, compact: true)),
|
||||
IconButton(
|
||||
focusNode: _noFocus,
|
||||
onPressed: widget.onExpand,
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 4),
|
||||
child: NowPlayingTitle(cover: false, compact: true),
|
||||
),
|
||||
),
|
||||
MinimalButton(
|
||||
icon: Icons.open_in_full_rounded,
|
||||
tooltip: 'Expand',
|
||||
icon: const Icon(Icons.open_in_full, color: Colors.white, size: 18),
|
||||
),
|
||||
IconButton(
|
||||
focusNode: _noFocus,
|
||||
onPressed: widget.onClose,
|
||||
scale: scale,
|
||||
onPressed: widget.onExpand),
|
||||
MinimalButton(
|
||||
icon: Icons.close_rounded,
|
||||
tooltip: 'Sit out (stay in the server)',
|
||||
icon: const Icon(Icons.close, color: Colors.white, size: 18),
|
||||
),
|
||||
scale: scale,
|
||||
onPressed: widget.onClose),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
SignalBuilder(builder: (context) {
|
||||
final ready = coord.selfMember.value?.ready ?? false;
|
||||
final snap = _model.snapshot.value;
|
||||
final pos = playback.position.value, dur = playback.duration.value;
|
||||
return Row(
|
||||
final status = playerStatusNow(coord)?.short;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 12, 2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
focusNode: _noFocus,
|
||||
tooltip: ready ? 'Ready (click: not ready)' : 'Not ready (click: ready)',
|
||||
onPressed: coord.toggleReady,
|
||||
icon: Icon(
|
||||
ready ? Icons.check_circle : Icons.radio_button_unchecked,
|
||||
color: ready ? Colors.greenAccent : Colors.white,
|
||||
),
|
||||
),
|
||||
Text('${formatClock(pos)} / ${formatClock(dur)}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
const Spacer(),
|
||||
Flexible(
|
||||
child: Text(playerStatusNow(_coord)?.short ?? '',
|
||||
if (status != null && status.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 0, 2),
|
||||
child: Text(status,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.amber, fontSize: 12)),
|
||||
style: const TextStyle(color: Color(0xFFFFC857), fontSize: 12)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Row(
|
||||
children: [
|
||||
MinimalButton(
|
||||
icon: controlIcon(BarControl.playPause, snap),
|
||||
tooltip: controlTooltip(BarControl.playPause, snap),
|
||||
accent: true,
|
||||
scale: scale,
|
||||
onPressed: _model.togglePlay,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('${formatClock(pos)} / ${formatClock(dur)}',
|
||||
style: const TextStyle(
|
||||
color: MinimalStyle.dim,
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()])),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
_MiniSeekBar(onSeek: (d) => playback.player.seek(d)),
|
||||
_MiniSeekBar(onSeek: playback.userSeek),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -667,6 +678,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The mini player's empty controls slot (see [SessionPlayer]'s video).
|
||||
Widget _noControls(VideoState state) => const SizedBox.shrink();
|
||||
|
||||
/// A floating pill's visibility: only while the controls are up (the
|
||||
/// mouse moved) or the mouse is on it. With [announce] it is also up for
|
||||
/// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
|
||||
@@ -781,7 +795,9 @@ const _shaderKeys = [
|
||||
LogicalKeyboardKey.digit6,
|
||||
];
|
||||
|
||||
/// Mini player progress: a 2 px line, or (hovered) a 4 px bar that seeks.
|
||||
/// Mini player progress across the bottom edge: a 3 px line, or (hovered)
|
||||
/// a 4 px bar with its track and the cached part that seeks (taller to
|
||||
/// hit). Plain rectangles: the player's rounded clip shapes the ends.
|
||||
class _MiniSeekBar extends StatelessWidget {
|
||||
final bool thin;
|
||||
final ValueChanged<Duration>? onSeek;
|
||||
@@ -790,26 +806,59 @@ class _MiniSeekBar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final playback = AppScope.of(context).coordinator.playback;
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final d = playback.duration.value;
|
||||
final p = playback.position.value;
|
||||
final v = d.inMilliseconds == 0 ? 0.0 : (p.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
|
||||
final bar = LinearProgressIndicator(
|
||||
value: v,
|
||||
minHeight: thin ? 2 : 4,
|
||||
backgroundColor: thin ? Colors.transparent : Colors.white24,
|
||||
double f(Duration x) => d.inMilliseconds == 0 ? 0.0 : (x.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0);
|
||||
final bar = SizedBox(
|
||||
height: thin ? 3 : 4,
|
||||
width: double.infinity,
|
||||
child: CustomPaint(
|
||||
painter: _MiniBarPainter(
|
||||
played: f(playback.position.value),
|
||||
cached: thin ? null : f(playback.buffered.value),
|
||||
accent: accent,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (thin || onSeek == null || d == Duration.zero) return bar;
|
||||
void seekAt(double x, double width) => onSeek!(d * (x / width).clamp(0.0, 1.0));
|
||||
return LayoutBuilder(
|
||||
builder: (context, box) => GestureDetector(
|
||||
builder: (context, box) => MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth),
|
||||
onHorizontalDragEnd: null,
|
||||
onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth),
|
||||
child: Padding(padding: const EdgeInsets.symmetric(vertical: 6), child: bar),
|
||||
child: Padding(padding: const EdgeInsets.only(top: 8), child: bar),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniBarPainter extends CustomPainter {
|
||||
final double played;
|
||||
|
||||
/// End of the cache ahead (a track is drawn only with it).
|
||||
final double? cached;
|
||||
final Color accent;
|
||||
const _MiniBarPainter({required this.played, required this.cached, required this.accent});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width, h = size.height;
|
||||
final c = cached;
|
||||
if (c != null) {
|
||||
canvas.drawRect(Offset.zero & size, Paint()..color = Colors.white.withValues(alpha: 0.18));
|
||||
if (c > played) {
|
||||
canvas.drawRect(Rect.fromLTRB(w * played, 0, w * c, h), Paint()..color = Colors.white.withValues(alpha: 0.3));
|
||||
}
|
||||
}
|
||||
canvas.drawRect(Rect.fromLTRB(0, 0, w * played, h), Paint()..color = accent);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_MiniBarPainter old) => old.played != played || old.cached != cached || old.accent != accent;
|
||||
}
|
||||
@@ -152,7 +152,7 @@ class SettingsTab extends StatelessWidget {
|
||||
}
|
||||
},
|
||||
),
|
||||
const SettingsPanel(title: 'Jellyfin playback', child: AutoAdvanceSetting()),
|
||||
const SettingsPanel(title: 'Episodes', child: AutoAdvanceSetting()),
|
||||
const SettingsPanel(title: 'Jellyfin library', child: HideReReleasesSetting()),
|
||||
];
|
||||
case SettingsSection.notifications:
|
||||
|
||||
@@ -59,7 +59,9 @@ class AppSettings {
|
||||
final hwdecMode = signal(HwdecMode.auto);
|
||||
final hwdecCustom = signal('');
|
||||
final autoCheckUpdates = signal(true);
|
||||
/// Jellyfin episodes: at the end, count down 10 s and put up the next.
|
||||
/// An episode this app puts up (Jellyfin, a file of a local show) is
|
||||
/// continued by the next one (Up next's `auto` item, the server's
|
||||
/// countdown at its end).
|
||||
final autoAdvance = signal(true);
|
||||
/// Session: send ready=false when the video changes (a swap keeps the
|
||||
/// server's ready flags).
|
||||
|
||||
@@ -288,8 +288,8 @@ class SyncClient {
|
||||
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
|
||||
}
|
||||
|
||||
void sendSwap(HelloMedia media, {String? queueId}) {
|
||||
_send(SwapIntent(media: media, queueId: queueId).toJson());
|
||||
void sendSwap(HelloMedia media, {String? queueId, SwapRestore? restore}) {
|
||||
_send(SwapIntent(media: media, queueId: queueId, restore: restore).toJson());
|
||||
}
|
||||
|
||||
/// One change to Up next (see [QueueIntent]).
|
||||
|
||||
@@ -1,26 +1,5 @@
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
/// A [Player] whose public [seek] belongs to the user. media_kit's seek bar
|
||||
/// (and anything else in the video controls) calls `player.seek`; with
|
||||
/// [onUserSeek] set those calls become explicit events for the sync layer
|
||||
/// instead of moving mpv behind its back. Sync-driven seeks use [syncSeek].
|
||||
class SyncPlayer extends Player {
|
||||
SyncPlayer({super.configuration});
|
||||
|
||||
void Function(Duration position)? onUserSeek;
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration duration) async {
|
||||
final hook = onUserSeek;
|
||||
if (hook == null) return super.seek(duration);
|
||||
hook(duration);
|
||||
}
|
||||
|
||||
/// Seek mpv directly (preview while scrubbing, server corrections).
|
||||
Future<void> syncSeek(Duration duration) => super.seek(duration);
|
||||
}
|
||||
|
||||
/// Turns a burst of user seeks (a seek bar drag fires one per pointer move,
|
||||
/// arrow keys one per press) into one `seek` intent, and tells the
|
||||
/// coordinator when server seeks must not touch the player:
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
// The device's watch history (device-owned, one Hive box): what this app
|
||||
// watched of files and links, by [WatchKey], with the verdict of the
|
||||
// watched rule (`WatchSession`, D3). Jellyfin keeps its own (reported by
|
||||
// `JellyfinProgressReporter`); AniList later reads the tracker's records.
|
||||
// See ARCHITECTURE.md "Watch tracking".
|
||||
|
||||
const watchHistoryBoxName = 'watch_history';
|
||||
|
||||
/// What a watch is about, independent of the opening: a file by its path
|
||||
/// on this computer, a link by its URL, a Jellyfin item by its ids, else
|
||||
/// the media's name and size (someone else's file, a link passed on).
|
||||
class WatchKey {
|
||||
final String value;
|
||||
const WatchKey._(this.value);
|
||||
|
||||
factory WatchKey.file(String path) => WatchKey._('file:$path');
|
||||
factory WatchKey.link(String url) => WatchKey._('link:$url');
|
||||
factory WatchKey.jellyfin(JellyfinRef ref) => WatchKey._('jf:${ref.serverId}/${ref.itemId}');
|
||||
factory WatchKey.media(String name, int size) => WatchKey._('media:$name|$size');
|
||||
|
||||
bool get isJellyfin => value.startsWith('jf:');
|
||||
|
||||
/// The key of `state.media` [media]: [ownFile] / [ownLink] when this app
|
||||
/// put it up from there, else its Jellyfin ref, its shared link, its
|
||||
/// name and size.
|
||||
static WatchKey? of(Map<String, Object?>? media, {String? ownFile, String? ownLink}) {
|
||||
if (media == null) return null;
|
||||
if (ownFile != null) return WatchKey.file(ownFile);
|
||||
final jf = media['jellyfin'];
|
||||
if (jf is Map) {
|
||||
final ref = JellyfinRef.fromJson(Map<String, Object?>.from(jf));
|
||||
if (ref.serverId.isNotEmpty && ref.itemId.isNotEmpty) return WatchKey.jellyfin(ref);
|
||||
}
|
||||
if (ownLink != null) return WatchKey.link(ownLink);
|
||||
final url = media['url'];
|
||||
if (url is String && (url.startsWith('http://') || url.startsWith('https://'))) return WatchKey.link(url);
|
||||
final name = media['name'];
|
||||
final size = media['size'];
|
||||
return WatchKey.media(name is String ? name : '', size is num ? size.toInt() : 0);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is WatchKey && other.value == value;
|
||||
@override
|
||||
int get hashCode => value.hashCode;
|
||||
@override
|
||||
String toString() => value;
|
||||
}
|
||||
|
||||
/// One closed watch: what the tracker hands its sinks (the device history,
|
||||
/// AniList later).
|
||||
class WatchRecord {
|
||||
final WatchKey key;
|
||||
final String title;
|
||||
final MediaRef? ref;
|
||||
final WatchVerdict verdict;
|
||||
/// Seconds the media played while this viewer was In, and its runtime
|
||||
/// (0 when unknown).
|
||||
final double timeIn;
|
||||
final double runtime;
|
||||
final DateTime at;
|
||||
const WatchRecord({
|
||||
required this.key,
|
||||
required this.title,
|
||||
required this.verdict,
|
||||
required this.timeIn,
|
||||
required this.runtime,
|
||||
required this.at,
|
||||
this.ref,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'WatchRecord($key, $verdict, ${timeIn.round()} s In of ${runtime.round()} s)';
|
||||
}
|
||||
|
||||
/// What the history holds for a key: watched (played), or where to
|
||||
/// resume, and when.
|
||||
class WatchEntry {
|
||||
final bool played;
|
||||
/// Seconds; 0 when played or none.
|
||||
final double resumeAt;
|
||||
final String title;
|
||||
final MediaRef? ref;
|
||||
final DateTime at;
|
||||
const WatchEntry({required this.played, this.resumeAt = 0, this.title = '', this.ref, required this.at});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'played': played,
|
||||
if (resumeAt > 0) 'resumeAt': resumeAt,
|
||||
if (title.isNotEmpty) 'title': title,
|
||||
if (ref != null) 'ref': ref!.toJson(),
|
||||
'at': at.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
static WatchEntry? fromJson(Object? j) {
|
||||
if (j is! Map) return null;
|
||||
final at = DateTime.tryParse('${j['at']}');
|
||||
if (at == null) return null;
|
||||
final resume = j['resumeAt'];
|
||||
MediaRef? ref;
|
||||
try {
|
||||
ref = MediaRef.fromJson(j['ref']);
|
||||
} catch (_) {}
|
||||
return WatchEntry(
|
||||
played: j['played'] == true,
|
||||
resumeAt: resume is num && resume.isFinite && resume > 0 ? resume.toDouble() : 0,
|
||||
title: j['title'] is String ? j['title'] as String : '',
|
||||
ref: ref,
|
||||
at: at,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The device's watch history: [entry] per key, [record] from the
|
||||
/// tracker. [revision] bumps on every change (pages that show it listen).
|
||||
class WatchHistory {
|
||||
final Map<String, Object?> _memory = {};
|
||||
/// False: memory only (tests).
|
||||
final bool persist;
|
||||
Box? _box;
|
||||
Future<void>? _load;
|
||||
final revision = signal(0);
|
||||
|
||||
WatchHistory({this.persist = true});
|
||||
|
||||
Future<void> ensureLoaded() => _load ??= () async {
|
||||
if (!persist) return;
|
||||
try {
|
||||
final box = _box = await Hive.openBox(watchHistoryBoxName);
|
||||
for (final e in _memory.entries) {
|
||||
await box.put(e.key, e.value);
|
||||
}
|
||||
_memory.clear();
|
||||
revision.value++;
|
||||
} catch (e, st) {
|
||||
debugPrint('open watch history failed: $e\n$st');
|
||||
}
|
||||
}();
|
||||
|
||||
WatchEntry? entry(WatchKey key) => WatchEntry.fromJson(_box?.get(key.value) ?? _memory[key.value]);
|
||||
|
||||
/// A closed watch: played -> played (the resume point goes); part way
|
||||
/// -> its resume point, unless it was played before (it stays played);
|
||||
/// a peek changes nothing. Jellyfin items are not kept here.
|
||||
void record(WatchRecord r) {
|
||||
if (r.key.isJellyfin) return;
|
||||
final before = entry(r.key);
|
||||
final WatchEntry next;
|
||||
switch (r.verdict.outcome) {
|
||||
case WatchOutcome.played || WatchOutcome.keepPlayed:
|
||||
next = WatchEntry(played: true, title: r.title, ref: r.ref, at: r.at);
|
||||
case WatchOutcome.partial:
|
||||
next = WatchEntry(
|
||||
played: before?.played ?? false,
|
||||
resumeAt: (before?.played ?? false) ? 0 : r.verdict.resumeAt ?? 0,
|
||||
title: r.title,
|
||||
ref: r.ref,
|
||||
at: r.at);
|
||||
case WatchOutcome.peek:
|
||||
return;
|
||||
}
|
||||
final json = next.toJson();
|
||||
final box = _box;
|
||||
if (box == null) {
|
||||
_memory[r.key.value] = json;
|
||||
} else {
|
||||
box.put(r.key.value, json).catchError((Object e) => debugPrint('watch history put ${r.key}: $e'));
|
||||
}
|
||||
revision.value++;
|
||||
}
|
||||
|
||||
/// Every entry, newest first.
|
||||
List<(WatchKey, WatchEntry)> get entries {
|
||||
final raw = <String, Object?>{..._memory, if (_box != null) for (final k in _box!.keys) '$k': _box!.get(k)};
|
||||
final out = [
|
||||
for (final e in raw.entries)
|
||||
if (WatchEntry.fromJson(e.value) case final w?) (WatchKey._(e.key), w),
|
||||
];
|
||||
out.sort((a, b) => b.$2.at.compareTo(a.$2.at));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'watch_history.dart';
|
||||
|
||||
// Watch tracking (ARCHITECTURE.md "Watch tracking", D3): one watch per
|
||||
// media of the server (its `mediaId`), fed every second by the session
|
||||
// with what this app sees, judged by core's `WatchSession` when it ends.
|
||||
// The verdict goes to the device history (files and links) and out on
|
||||
// [WatchTracker.records] (the seam for AniList later); Jellyfin's
|
||||
// played/resume reports read the same session.
|
||||
|
||||
final _log = createLogger('watch');
|
||||
|
||||
/// What the session hands the tracker each second.
|
||||
class WatchSample {
|
||||
/// The server's media and its id.
|
||||
final int mediaId;
|
||||
final Map<String, Object?>? media;
|
||||
/// This app's own source of it: the file it put up, the link it put up.
|
||||
final String? ownFile;
|
||||
final String? ownLink;
|
||||
final double position;
|
||||
/// The media plays here: the server plays it and this player has it.
|
||||
final bool playing;
|
||||
/// This viewer is In (`in`): not away, joining or gone.
|
||||
final bool present;
|
||||
/// The player's duration (0 while unknown) and where the ending starts.
|
||||
final double duration;
|
||||
final double? endingStart;
|
||||
final DateTime at;
|
||||
const WatchSample({
|
||||
required this.mediaId,
|
||||
required this.media,
|
||||
required this.position,
|
||||
required this.playing,
|
||||
required this.present,
|
||||
required this.at,
|
||||
this.ownFile,
|
||||
this.ownLink,
|
||||
this.duration = 0,
|
||||
this.endingStart,
|
||||
});
|
||||
}
|
||||
|
||||
class WatchTracker {
|
||||
final WatchHistory history;
|
||||
WatchTracker({WatchHistory? history}) : history = history ?? WatchHistory(persist: false);
|
||||
|
||||
final _records = StreamController<WatchRecord>.broadcast();
|
||||
|
||||
/// Every closed watch (peeks too): the device history takes files and
|
||||
/// links; AniList progress later listens here.
|
||||
Stream<WatchRecord> get records => _records.stream;
|
||||
|
||||
int _mediaId = -1;
|
||||
WatchKey? _key;
|
||||
String _title = '';
|
||||
MediaRef? _ref;
|
||||
double _runtime = 0;
|
||||
double? _endingStart;
|
||||
WatchSession? _session;
|
||||
|
||||
/// The watch of the media playing (null before the first sample).
|
||||
WatchSession? get session => _session;
|
||||
WatchKey? get key => _key;
|
||||
|
||||
/// One second of the session. A new media closes the watch of the one
|
||||
/// before.
|
||||
void sample(WatchSample s) {
|
||||
if (s.mediaId != _mediaId) {
|
||||
close(at: s.at);
|
||||
if (s.media == null) return;
|
||||
_open(s);
|
||||
}
|
||||
final session = _session;
|
||||
if (session == null) return;
|
||||
// A file or link this app put up is known once its swap landed.
|
||||
final key = WatchKey.of(s.media, ownFile: s.ownFile, ownLink: s.ownLink);
|
||||
if (key != null && key != _key && !(_key?.isJellyfin ?? false)) _key = key;
|
||||
if (s.duration > 0) _runtime = s.duration;
|
||||
_endingStart = s.endingStart;
|
||||
session.sample(s.position, playing: s.playing, present: s.present, at: s.at);
|
||||
}
|
||||
|
||||
void _open(WatchSample s) {
|
||||
final key = WatchKey.of(s.media, ownFile: s.ownFile, ownLink: s.ownLink);
|
||||
final before = key == null ? null : history.entry(key);
|
||||
_mediaId = s.mediaId;
|
||||
_key = key;
|
||||
_title = '${s.media?['name'] ?? ''}';
|
||||
try {
|
||||
_ref = MediaRef.fromJson(s.media?['ref']);
|
||||
} catch (_) {
|
||||
_ref = null;
|
||||
}
|
||||
final d = s.media?['duration'];
|
||||
_runtime = d is num && d > 0 ? d.toDouble() : 0;
|
||||
_endingStart = null;
|
||||
_session = WatchSession(playedBefore: before?.played ?? false, resumeBefore: before?.resumeAt ?? 0);
|
||||
}
|
||||
|
||||
/// The same media goes on under another [mediaId] (a restarted server
|
||||
/// numbers it anew): the watch carries on.
|
||||
void renumber(int from, int to) {
|
||||
if (_mediaId == from) _mediaId = to;
|
||||
}
|
||||
|
||||
/// What Jellyfin says about the item before this opening (its played
|
||||
/// state and resume point, read once it is open).
|
||||
void before({required bool played, double resumeAt = 0}) {
|
||||
final session = _session;
|
||||
if (session == null) return;
|
||||
session
|
||||
..playedBefore = played
|
||||
..resumeBefore = resumeAt;
|
||||
}
|
||||
|
||||
/// The verdict so far (the runtime as last seen); null without a watch.
|
||||
WatchVerdict? get verdict => _session?.verdict(runtime: _runtime, endingStart: _endingStart);
|
||||
|
||||
/// Records the watch as it stands (the end of the media; it may go on
|
||||
/// after a seek back and be recorded again).
|
||||
WatchRecord? checkpoint({DateTime? at}) {
|
||||
final session = _session, key = _key;
|
||||
if (session == null || key == null) return null;
|
||||
final v = session.verdict(runtime: _runtime, endingStart: _endingStart);
|
||||
final r = WatchRecord(
|
||||
key: key,
|
||||
title: _title,
|
||||
ref: _ref,
|
||||
verdict: v,
|
||||
timeIn: session.timeIn,
|
||||
runtime: _runtime,
|
||||
at: at ?? DateTime.now().toUtc());
|
||||
_log.info('$key: ${v.outcome.name} (${session.timeIn.round()} s In of ${_runtime.round()} s)');
|
||||
history.record(r);
|
||||
_records.add(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
/// The watch ends (another media, leaving): recorded and forgotten.
|
||||
WatchRecord? close({DateTime? at}) {
|
||||
final r = _session == null ? null : checkpoint(at: at);
|
||||
_mediaId = -1;
|
||||
_key = null;
|
||||
_session = null;
|
||||
_ref = null;
|
||||
_runtime = 0;
|
||||
_endingStart = null;
|
||||
return r;
|
||||
}
|
||||
|
||||
/// The media the current watch is of (-1: none).
|
||||
int get mediaId => _mediaId;
|
||||
|
||||
Future<void> dispose() => _records.close();
|
||||
}
|
||||
@@ -538,10 +538,9 @@ packages:
|
||||
media_kit_video:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: media_kit_video
|
||||
sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
path: "../../third_party/media_kit_video"
|
||||
relative: true
|
||||
source: path
|
||||
version: "2.0.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
@@ -866,14 +865,14 @@ packages:
|
||||
path: "../../packages/core"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.2.0"
|
||||
version: "1.3.0"
|
||||
sync_relay_server:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/server"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.2.0"
|
||||
version: "1.3.0"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: starsync
|
||||
description: Starsync desktop app (repo sync_relay_project). One binary.
|
||||
publish_to: none
|
||||
version: 1.2.0
|
||||
version: 1.3.2
|
||||
environment:
|
||||
sdk: '>=3.13.0-0 <4.0.0'
|
||||
|
||||
@@ -10,7 +10,9 @@ dependencies:
|
||||
sdk: flutter
|
||||
signals: ^7.0.0
|
||||
media_kit: ^1.1.0
|
||||
media_kit_video: ^2.0.1
|
||||
# Vendored with Starsync's patches (third_party/media_kit_video/starsync).
|
||||
media_kit_video:
|
||||
path: ../../third_party/media_kit_video
|
||||
media_kit_libs_video: ^1.0.0
|
||||
path_provider: ^2.1.0
|
||||
web_socket_channel: ^3.0.0
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'dart:io';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:signals/signals.dart' show effect;
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_core/testing.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
@@ -362,6 +363,120 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('a restarted server comes back empty: what played is offered back where it was, nothing reopens, '
|
||||
'the watch goes on, Up next comes back', timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart'));
|
||||
await first.start();
|
||||
final port = first.boundPort;
|
||||
final origin = await _origin(await _media());
|
||||
SyncServer? second;
|
||||
try {
|
||||
final alice = await member(port, 'alice');
|
||||
final bob = await member(port, 'bob');
|
||||
await playTogether(alice, [alice, bob], 'http://127.0.0.1:${origin.port}/ep.mkv');
|
||||
expect(await bob.queue.add(LinkMedia('http://127.0.0.1:${origin.port}/next.mkv', anyway: true)), isNull);
|
||||
await _poll(() => alice.queue.items.peek().length == 1, 'Up next has an item');
|
||||
await Future.delayed(const Duration(seconds: 4));
|
||||
final opens = [alice.opens, bob.opens];
|
||||
final watch = alice.watch.session;
|
||||
final watched = watch!.timeIn;
|
||||
expect(watched, greaterThan(2));
|
||||
|
||||
await first.stop();
|
||||
await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting');
|
||||
second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart'));
|
||||
await second.start();
|
||||
final engine = second.hub.engine;
|
||||
await _poll(() => engine.media?.name == 'ep.mkv', 'the media offered back', timeout: const Duration(seconds: 30));
|
||||
expect(engine.mediaId, 2, reason: 'one offer taken: the server started at 1');
|
||||
expect(engine.positionAt(), greaterThan(4), reason: 'where it was, not the start');
|
||||
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'),
|
||||
'playing again', timeout: const Duration(seconds: 20));
|
||||
expect([alice.opens, bob.opens], opens, reason: 'the players kept it: nothing reopened');
|
||||
await _poll(() => engine.queue.items.length == 1, 'Up next back');
|
||||
expect(engine.queue.items.single.media.url, endsWith('/next.mkv'));
|
||||
// The watch went on through the restart (one watch, still counting).
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
expect(identical(alice.watch.session, watch), isTrue);
|
||||
expect(alice.watch.mediaId, engine.mediaId);
|
||||
expect(watch.timeIn, greaterThan(watched + 1));
|
||||
} finally {
|
||||
await second?.stop();
|
||||
await origin.close(force: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('a restarted server comes back empty: the member passing its file on puts it back and feeds it again',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart'));
|
||||
await first.start();
|
||||
final port = first.boundPort;
|
||||
final file = await _media();
|
||||
SyncServer? second;
|
||||
try {
|
||||
final alice = await member(port, 'alice');
|
||||
final bob = await member(port, 'bob');
|
||||
expect(await alice.swapPicked(FileMedia(file)), isNull);
|
||||
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 100), 'both opened it',
|
||||
timeout: const Duration(seconds: 30));
|
||||
for (final x in [alice, bob]) {
|
||||
x.toggleReady();
|
||||
}
|
||||
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing), 'both playing');
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
final aliceOpens = alice.opens;
|
||||
|
||||
await first.stop();
|
||||
await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting');
|
||||
second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart'));
|
||||
await second.start();
|
||||
final hub = second.hub;
|
||||
await _poll(() => hub.engine.media?.name == 'coord_presence_120s.mkv', 'the file offered back',
|
||||
timeout: const Duration(seconds: 30));
|
||||
expect(hub.engine.positionAt(), greaterThan(3));
|
||||
await _poll(() => hub.relay?.sourceToken == alice.connection.token && alice.relaying.peek(), 'alice feeds it again');
|
||||
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'),
|
||||
'playing again', timeout: const Duration(seconds: 30));
|
||||
expect(alice.opens, aliceOpens, reason: 'her own file stayed open');
|
||||
expect(hub.relay!.buffers.single.bytesToViewers, greaterThan(0), reason: 'bob reads the new relay');
|
||||
} finally {
|
||||
await second?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('moving to another server never shows the last one\'s media', timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
final home = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'home'));
|
||||
final empty = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'empty'));
|
||||
await home.start();
|
||||
await empty.start();
|
||||
final origin = await _origin(await _media());
|
||||
try {
|
||||
final alice = await member(home.boundPort, 'alice');
|
||||
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${origin.port}/ep.mkv', anyway: true)), isNull);
|
||||
await _poll(() => alice.serverState.peek()?.media != null, 'media up at home');
|
||||
// The shell opens the player on any state with media as soon as the
|
||||
// app is in a server again: nothing from the last one may be left
|
||||
// while the new one is dialled.
|
||||
final seen = <String>[];
|
||||
final stop = effect(() {
|
||||
if (alice.connection.state.value is ConnConnecting) {
|
||||
seen.add(alice.serverState.peek()?.media?['name'] as String? ?? '');
|
||||
}
|
||||
});
|
||||
await alice.connect(ConnectParams(host: '127.0.0.1', port: empty.boundPort, secret: 'pw', name: 'alice'));
|
||||
await _poll(() => alice.serverState.peek() != null, 'the new server\'s state');
|
||||
stop();
|
||||
expect(seen, isNotEmpty);
|
||||
expect(seen.where((n) => n.isNotEmpty), isEmpty, reason: 'no media from the last server');
|
||||
expect(alice.serverState.peek()?.media, isNull);
|
||||
} finally {
|
||||
await home.stop();
|
||||
await empty.stop();
|
||||
await origin.close(force: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel'));
|
||||
await server.start();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// restructure: rewrite in step 6 (Watch tracking): the progress-reporting
|
||||
// checks; the guest-through-the-relay flow stays.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -14,6 +12,7 @@ import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||
import 'package:starsync/src/playback/media_marks.dart';
|
||||
import 'package:starsync/src/screens/home/picked_media.dart';
|
||||
import 'package:starsync/src/session/session_coordinator.dart';
|
||||
import 'package:starsync/src/state/settings.dart';
|
||||
|
||||
// Two account holders and a guest watch Jellyfin episodes together over a
|
||||
// real sync server, real mpv and a local fake Jellyfin (never a real one):
|
||||
@@ -220,7 +219,8 @@ String _kind(_Call c) => c.path.split('/').last;
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'account holders direct-play and report, the guest plays through the relay, all in sync',
|
||||
'account holders direct-play and report what they watched, the guest plays through the relay, '
|
||||
'the next episode continues it for everyone',
|
||||
timeout: const Timeout(Duration(minutes: 4)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
@@ -296,6 +296,11 @@ void main() {
|
||||
expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos');
|
||||
}
|
||||
|
||||
// Carol sits out for the rest of the episode: she is not tracked as
|
||||
// having watched it (P7).
|
||||
c.sitOut();
|
||||
await _poll(() => c.selfAway.peek(), 'carol away');
|
||||
|
||||
// Pause (A unreadies): an immediate Progress with IsPaused.
|
||||
a.toggleReady();
|
||||
await _poll(() => !a.playback.player.state.playing, 'held');
|
||||
@@ -304,7 +309,7 @@ void main() {
|
||||
_kind(s) == 'Progress' && s.body?['IsPaused'] == true),
|
||||
'paused progress');
|
||||
a.toggleReady();
|
||||
await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again');
|
||||
await _poll(() => [a, b].every((x) => x.playback.player.state.playing), 'playing again');
|
||||
|
||||
// Account holders see the "Next episode" prompt (this item is
|
||||
// shorter than its 2 min lead); the guest cannot put it up.
|
||||
@@ -319,23 +324,28 @@ void main() {
|
||||
_kind(s) == 'Progress' && s.body?['AudioStreamIndex'] == 2),
|
||||
'track change reported');
|
||||
|
||||
// Near the end: finished -> Stopped, then one auto-advance swap.
|
||||
a.seekTo(16);
|
||||
// Alice put the episode up and has the next one: it continues it,
|
||||
// in Up next for everyone.
|
||||
await _poll(() => b.queue.continues.peek()?.media.jellyfin?.itemId == _ep2, 'the continuation');
|
||||
final auto = b.queue.continues.peek()!;
|
||||
expect(auto.by, a.connection.memberId);
|
||||
|
||||
// Played to the end: finished -> Stopped, the server's countdown
|
||||
// on every screen, then one swap.
|
||||
await _poll(() => a.serverState.peek()?.transport == 'finished', 'finished',
|
||||
timeout: const Duration(seconds: 20));
|
||||
timeout: const Duration(seconds: 40));
|
||||
final finishedId = a.serverState.peek()!.mediaId;
|
||||
await _poll(() => a.autoAdvance.peek() != null && c.autoAdvance.peek() != null,
|
||||
'both account holders count down');
|
||||
expect(b.autoAdvance.peek(), isNull);
|
||||
final swappers = [a.autoAdvance.peek()!.swapper, c.autoAdvance.peek()!.swapper];
|
||||
expect(swappers.where((s) => s), hasLength(1),
|
||||
reason: 'exactly one designated swapper: the lowest member id (alice, m1)');
|
||||
expect(a.autoAdvance.peek()!.swapper, isTrue);
|
||||
await _poll(() => [a, b].every((x) => (x.queue.countdown.peek()?.secondsLeft ?? 0) > 0),
|
||||
'everyone sees the countdown');
|
||||
expect(a.serverState.peek()!.advance!.id, auto.id);
|
||||
expect(a.queue.countdown.peek()!.item.id, auto.id);
|
||||
await _poll(() => (a.serverState.peek()?.mediaId ?? 0) > finishedId, 'advanced',
|
||||
timeout: const Duration(seconds: 20));
|
||||
timeout: const Duration(seconds: 25));
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
expect(a.serverState.peek()!.mediaId, finishedId + 1, reason: 'no double swap');
|
||||
expect(refOfStateMedia(a.serverState.peek()!.media)!.itemId, _ep2);
|
||||
expect(a.serverState.peek()!.queue, isEmpty, reason: 'the swap took the continuation');
|
||||
c.joinBack();
|
||||
await _poll(() => all.every((x) => x.playback.durationSecs > 19 && x.jellyfinPlan.peek() != null),
|
||||
'all opened the next episode');
|
||||
expect(b.jellyfinPlan.peek(), isA<RelayPlay>());
|
||||
@@ -383,13 +393,17 @@ void main() {
|
||||
expect(ep1.last, 'Stopped');
|
||||
expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
|
||||
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
|
||||
// Part of it watched, then a seek to the end: not watched (see
|
||||
// WatchSession). A resume point capped at 85 % of the 20 s, so
|
||||
// Jellyfin cannot mark it played on its own.
|
||||
expect(stopped1.body!['PositionTicks'], lessThan(18 * 10000000),
|
||||
reason: 'part way: resume point under 90 %, so not marked played');
|
||||
expect(jelly.calls.any((c) => c.method == 'POST' && c.path == '/UserPlayedItems/$_ep1'), isFalse,
|
||||
reason: 'seeking to the end is not watching');
|
||||
// Alice watched it through (time In, see WatchSession): Stopped
|
||||
// without a position, then marked played.
|
||||
expect(stopped1.body!.containsKey('PositionTicks'), isFalse, reason: 'watched: Jellyfin marks it played');
|
||||
bool markedPlayed(String token) => jelly.calls.any((c) =>
|
||||
c.method == 'POST' &&
|
||||
c.path == '/UserPlayedItems/$_ep1' &&
|
||||
_FakeJellyfin.authFields(c.auth)['Token'] == token);
|
||||
expect(markedPlayed('ATOKEN'), isTrue);
|
||||
expect(markedPlayed('CTOKEN'), isFalse, reason: 'carol sat out most of it (P7)');
|
||||
final carol1 = jelly.sessions(token: 'CTOKEN').lastWhere((s) => s.body?['ItemId'] == _ep1);
|
||||
expect(_kind(carol1), 'Stopped', reason: 'sitting out stops her reports');
|
||||
expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1');
|
||||
expect(ep2.first, 'Playing');
|
||||
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped');
|
||||
@@ -442,7 +456,12 @@ void main() {
|
||||
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
|
||||
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await accB.ensureLoaded(validate: false);
|
||||
final a = SessionCoordinator(jellyfin: accA, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||
// No continuation: its warm relay would be read while the test stops
|
||||
// (a cut relay response fails a test, see DEVELOPMENT "Traps").
|
||||
final a = SessionCoordinator(
|
||||
jellyfin: accA,
|
||||
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
|
||||
settings: AppSettings()..autoAdvance.value = false);
|
||||
final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||
try {
|
||||
await a.connect(ConnectParams(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// restructure: rewrite in step 6 (What's next + Watch tracking): the
|
||||
// "progress" and "episodes" groups (reporting, auto-advance swapper).
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
@@ -14,8 +12,9 @@ import 'package:starsync/src/jellyfin/session_media.dart';
|
||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||
import 'package:starsync/src/screens/home/media_picker.dart';
|
||||
import 'package:starsync/src/screens/session/jellyfin_widgets.dart';
|
||||
import 'package:starsync/src/queue/queue_controller.dart' show UpNextCountdown;
|
||||
import 'package:starsync/src/queue/queue_ui.dart' show UpNextCard;
|
||||
import 'package:starsync/src/session/delivery.dart';
|
||||
import 'package:starsync/src/session/session_coordinator.dart' show AutoAdvance;
|
||||
import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia;
|
||||
|
||||
import 'jellyfin_accounts_test.dart';
|
||||
@@ -212,9 +211,12 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// The reporter reads the watch the session's tracker feeds (time In,
|
||||
// D3); here the test feeds it.
|
||||
group('progress', () {
|
||||
late JellyfinAccounts accounts;
|
||||
late DateTime now;
|
||||
late WatchSession watch;
|
||||
late JellyfinProgressReporter reporter;
|
||||
final errors = <Object>[];
|
||||
var unauthorized = 0;
|
||||
@@ -224,27 +226,35 @@ void main() {
|
||||
now = DateTime(2026, 1, 1, 20);
|
||||
errors.clear();
|
||||
unauthorized = 0;
|
||||
watch = WatchSession();
|
||||
reporter = JellyfinProgressReporter(
|
||||
client: accounts.clientFor(fixtureServerId)!,
|
||||
itemId: episodeId,
|
||||
source: fixtureSource(),
|
||||
playSessionId: playSessionId,
|
||||
watch: watch,
|
||||
clock: () => now,
|
||||
onError: errors.add,
|
||||
onUnauthorized: () => unauthorized++,
|
||||
);
|
||||
});
|
||||
|
||||
/// One second of the session at wall second [secs]: the watch gets its
|
||||
/// sample (In unless [away]), the reporter its snapshot.
|
||||
void tick(int secs, double pos, {bool paused = false, bool away = false, int? aid, int? sid}) {
|
||||
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs));
|
||||
watch.sample(pos, playing: !paused, present: !away, at: now);
|
||||
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
|
||||
}
|
||||
|
||||
Future<void> idle() => reporter.stop(const PlayerSnapshot(position: 0, paused: true))
|
||||
.then((_) {});
|
||||
|
||||
test('Playing, Progress every 10 s and on pause / seek / track change, Stopped', () async {
|
||||
// English audio (mpv aid 2 = Jellyfin index 2), English full subs (sid 1 = 3).
|
||||
reporter.start(const PlayerSnapshot(position: 0, paused: true, aid: 2, sid: 1));
|
||||
void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) {
|
||||
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs));
|
||||
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
|
||||
}
|
||||
void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) =>
|
||||
tick(secs, pos, paused: paused, aid: aid, sid: sid);
|
||||
|
||||
at(1, 0); // resume -> Progress
|
||||
at(2, 1);
|
||||
@@ -282,15 +292,14 @@ void main() {
|
||||
'MediaSourceId': episodeId,
|
||||
'PlaySessionId': playSessionId,
|
||||
'PositionTicks': 0,
|
||||
}, reason: '11 s watched is a peek: the resume point from before (none) stays');
|
||||
}, reason: '11 s In is a peek: the resume point from before (none) stays');
|
||||
expect(errors, isEmpty);
|
||||
});
|
||||
|
||||
test('watched to the end: Stopped without a position, then marked played', () async {
|
||||
reporter.start(const PlayerSnapshot(position: 0, paused: false));
|
||||
for (var i = 1; i <= 1420; i++) {
|
||||
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: i));
|
||||
reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false));
|
||||
for (var i = 0; i <= 1420; i++) {
|
||||
tick(i, i.toDouble());
|
||||
}
|
||||
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
|
||||
expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3),
|
||||
@@ -302,6 +311,19 @@ void main() {
|
||||
expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide');
|
||||
});
|
||||
|
||||
test('sat out for most of it: a resume point, never played (P7)', () async {
|
||||
reporter.start(const PlayerSnapshot(position: 0, paused: false));
|
||||
for (var i = 0; i <= 1420; i++) {
|
||||
// In for five minutes, then away while the others watch on.
|
||||
tick(i, i.toDouble(), away: i > 300);
|
||||
}
|
||||
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
|
||||
expect(reports.map((r) => r.path.split('/').last), isNot(contains('UserPlayedItems')));
|
||||
final stopped = reports.lastWhere((r) => r.path.endsWith('Stopped')).body;
|
||||
expect(stopped['PositionTicks'], 300 * jellyfinTicksPerSecond,
|
||||
reason: 'it resumes where this viewer stopped watching');
|
||||
});
|
||||
|
||||
test('played before: a peek never unmarks it', () async {
|
||||
final r = JellyfinProgressReporter(
|
||||
client: accounts.clientFor(fixtureServerId)!,
|
||||
@@ -376,21 +398,6 @@ void main() {
|
||||
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
|
||||
});
|
||||
|
||||
test('one auto-advance swapper: the lowest id among members with their own account', () {
|
||||
final members = [
|
||||
member('m10', 'Ann', jf: {fixtureServerId: true}),
|
||||
member('m2', 'Bob', jf: {fixtureServerId: true}),
|
||||
member('m1', 'Guest', jf: {fixtureServerId: false}),
|
||||
member('m0', 'Old', jf: {fixtureServerId: true}, gone: true),
|
||||
member('m3', 'Fresh'),
|
||||
];
|
||||
expect(autoAdvanceSwapper(members, fixtureServerId), 'm2');
|
||||
expect(autoAdvanceSwapper(members.reversed.toList(), fixtureServerId), 'm2',
|
||||
reason: 'order does not matter: every client agrees');
|
||||
expect(autoAdvanceSwapper(members, 'other-server'), isNull);
|
||||
expect(compareMemberIds('m9', 'm10'), lessThan(0));
|
||||
});
|
||||
|
||||
test('member route: via server only for relay viewers of the current item', () {
|
||||
DeliveryRoute? route(MemberView m, {bool relay = true}) =>
|
||||
routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay);
|
||||
@@ -508,23 +515,36 @@ void main() {
|
||||
const JellyfinMedia(
|
||||
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
|
||||
|
||||
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async {
|
||||
var play = 0, cancel = 0;
|
||||
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard(
|
||||
next: next, countdown: null, onPlayNow: () => play++, onCancel: () => cancel++))));
|
||||
expect(find.text('Next: S1E7 Battle with the Heroes'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('auto-advance-cancel')), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey('play-next')));
|
||||
expect(play, 1);
|
||||
testWidgets("end card: the server's countdown with Cancel, Play now; waiting for who puts it up",
|
||||
(tester) async {
|
||||
final queue = _graph.coordinator.queue;
|
||||
const item = QueueItem(
|
||||
id: 'q4',
|
||||
media: HelloMedia(name: 'ep7', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: fixtureServerId, itemId: nextEpisodeId)),
|
||||
how: QueueHow.jellyfin,
|
||||
title: 'Battle with the Heroes',
|
||||
sub: 'S1E7',
|
||||
by: 'm1',
|
||||
byName: 'Ann',
|
||||
auto: true,
|
||||
forMedia: 3);
|
||||
await tester.pumpWidget(scoped(_graph, app(UpNextCard(
|
||||
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item, secondsLeft: 7)))));
|
||||
expect(find.text('Next in 7 s'), findsOneWidget);
|
||||
expect(find.text('Battle with the Heroes'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('up-next-cancel')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('up-next-play-now')), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard(
|
||||
next: next,
|
||||
countdown: AutoAdvance(mediaId: 3, secondsLeft: 7, next: next, swapper: false),
|
||||
onPlayNow: () => play++,
|
||||
onCancel: () => cancel++))));
|
||||
expect(find.text('Next episode in 7 s'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('auto-advance-cancel')));
|
||||
expect(cancel, 1);
|
||||
// Cancelled (for everyone): Play now only.
|
||||
await tester.pumpWidget(scoped(_graph, app(UpNextCard(
|
||||
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item)))));
|
||||
expect(find.text('Next'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('up-next-cancel')), findsNothing);
|
||||
|
||||
// Ran out while the one who puts it up is gone: the card says so.
|
||||
await tester.pumpWidget(scoped(_graph, app(UpNextCard(
|
||||
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item, secondsLeft: 0, waitingFor: 'Ann')))));
|
||||
expect(find.text('Waiting for Ann'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async {
|
||||
|
||||
@@ -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,25 +1,20 @@
|
||||
// restructure: rewrite in step 6 (What's next): who performs an item and
|
||||
// the janitor become server state.
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/files/scanner.dart' show FolderScan;
|
||||
import 'package:starsync/src/library/library_store.dart';
|
||||
import 'package:starsync/src/library/local_next.dart';
|
||||
import 'package:starsync/src/library/show_matcher.dart';
|
||||
import 'package:starsync/src/queue/queue_logic.dart';
|
||||
|
||||
MemberView member(String id,
|
||||
{bool gone = false, bool away = false, Map<String, bool> jf = const {}, String name = ''}) =>
|
||||
MemberView(
|
||||
id: id,
|
||||
name: name.isEmpty ? id : name,
|
||||
ready: false,
|
||||
inSet: true,
|
||||
buffered: 0,
|
||||
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching,
|
||||
jf: jf);
|
||||
import 'show_matcher_test.dart' show FakeAniList;
|
||||
|
||||
// What's next on the app: an item's words, and what continues a file of
|
||||
// a local show (who puts an item up and the countdown are core's and the
|
||||
// server's: core `whats_next_test`, `queue_test`).
|
||||
|
||||
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
|
||||
id: 'q1',
|
||||
media: how == QueueHow.jellyfin
|
||||
? const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i'))
|
||||
: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
|
||||
media: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
|
||||
how: how,
|
||||
title: 'x',
|
||||
by: by,
|
||||
@@ -27,49 +22,58 @@ QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => Queue
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('queuePerformer', () {
|
||||
test('a link passed on or a file: only its adder; gone waits for it, left means skipped', () {
|
||||
for (final how in [QueueHow.through, QueueHow.file]) {
|
||||
expect(queuePerformer(item(how), [member('m1'), member('m2', away: true)]).id, 'm2');
|
||||
final waits = queuePerformer(item(how), [member('m1'), member('m2', gone: true)]);
|
||||
expect((waits.id, waits.why), (null, null), reason: 'a gone adder may come back within its grace');
|
||||
final left = queuePerformer(item(how), [member('m1'), member('m3')]);
|
||||
expect(left.id, isNull);
|
||||
expect(left.why, 'Bob left');
|
||||
}
|
||||
});
|
||||
|
||||
test('Jellyfin: the adder with access, else the lowest member with access, else skipped', () {
|
||||
final access = {'S': true};
|
||||
expect(queuePerformer(item(QueueHow.jellyfin), [member('m1', jf: access), member('m2', jf: access)]).id, 'm2');
|
||||
expect(
|
||||
queuePerformer(item(QueueHow.jellyfin), [member('m10', jf: access), member('m3', jf: access), member('m2')])
|
||||
.id,
|
||||
'm3');
|
||||
expect(queuePerformer(item(QueueHow.jellyfin), [member('m3', jf: access, away: true), member('m2')]).why,
|
||||
isNotNull);
|
||||
});
|
||||
|
||||
test('a link everyone opens or the server fetches: the adder, else the lowest active member', () {
|
||||
for (final how in [QueueHow.direct, QueueHow.server]) {
|
||||
expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2');
|
||||
expect(queuePerformer(item(how), [member('m4'), member('m2', gone: true), member('m3')]).id, 'm3');
|
||||
final nobody = queuePerformer(item(how), [member('m2', away: true)]);
|
||||
expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('the janitor is the lowest member that is not gone', () {
|
||||
expect(queueJanitor([member('m9'), member('m1', gone: true), member('m2')]), 'm2');
|
||||
});
|
||||
|
||||
test('labels', () {
|
||||
expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you');
|
||||
expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob');
|
||||
expect(queueHowLabel(item(QueueHow.file), selfId: 'm1'), "Bob's file");
|
||||
expect(queueHowLabel(item(QueueHow.file, byName: ''), selfId: 'm1'), "who added it's file");
|
||||
expect(queueClock(3725), '1:02:05');
|
||||
expect(queueClock(65), '1:05');
|
||||
expect(queueClock(0), '');
|
||||
});
|
||||
|
||||
group('what continues a file of a local show', () {
|
||||
const dir = '/lib/Bleach';
|
||||
String ep(int n) => '$dir/[Judas] Bleach - ${n.toString().padLeft(3, '0')}.mkv';
|
||||
// 064 and 065 are there, 066..107 are not.
|
||||
final files = [for (final n in [63, 64, 65, 108, 109]) LocalFile(ep(n), 1 << 30, DateTime(2026))];
|
||||
late List<String> scanned;
|
||||
late LocalNextFinder finder;
|
||||
late ShowMatcher matcher;
|
||||
|
||||
setUp(() {
|
||||
scanned = [];
|
||||
matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: FakeAniList(), delay: (_) async {});
|
||||
finder = LocalNextFinder(
|
||||
matcher: matcher,
|
||||
scan: (d) async {
|
||||
scanned.add(d);
|
||||
return FolderScan(videos: files, hiddenCount: 0, hiddenExamples: const [], subtitles: const {}, truncated: false);
|
||||
});
|
||||
});
|
||||
|
||||
test('from its own folder when the library has not seen it this run', () async {
|
||||
final next = await finder.nextAfter(ep(64));
|
||||
expect(next!.episode.path, ep(65));
|
||||
expect(next.skipped, isNull);
|
||||
expect(scanned, [dir]);
|
||||
});
|
||||
|
||||
test('numbers skipped on the way are said', () async {
|
||||
final next = await finder.nextAfter(ep(65));
|
||||
expect(next!.episode.path, ep(108));
|
||||
expect(next.skipped!.label, '66–107');
|
||||
});
|
||||
|
||||
test('the last one, or not an episode: nothing continues it', () async {
|
||||
expect(await finder.nextAfter(ep(109)), isNull);
|
||||
expect(await finder.nextAfter('$dir/notes.mkv'), isNull);
|
||||
});
|
||||
|
||||
test('the library the Files page saw is used without a scan', () async {
|
||||
matcher.remember(groupLocal(files, roots: const ['/lib']));
|
||||
expect((await finder.nextAfter(ep(63)))!.episode.path, ep(64));
|
||||
expect(scanned, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,30 +1,31 @@
|
||||
// restructure: rewrite in step 5 (Media pipeline): preload and the warm
|
||||
// relay become one pipeline; keep the timing per source.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:starsync/src/jellyfin/accounts.dart';
|
||||
import 'package:starsync/src/jellyfin/session_media.dart';
|
||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
|
||||
import 'package:starsync/src/playback/yt_dlp_app.dart';
|
||||
import 'package:starsync/src/queue/queue_controller.dart' show WarmState;
|
||||
import 'package:starsync/src/screens/home/picked_media.dart';
|
||||
import 'package:starsync/src/session/session_coordinator.dart';
|
||||
import 'package:starsync/src/session/switch_trace.dart';
|
||||
import 'package:starsync/src/state/settings.dart';
|
||||
|
||||
// How long an Up next switch takes, end to end, over real mpv and a real
|
||||
// sync server: two Jellyfin account holders and a guest (through the
|
||||
// server's /j login) watch an episode, then ⏭ puts up the next episode,
|
||||
// then a direct link, then a link passed on "through" alice, then one the
|
||||
// server fetches (both relayed: the guests preload them from the server's
|
||||
// warm relay of the item). The fake Jellyfin and the link's host answer with a
|
||||
// delay and a capped rate, like a server elsewhere. Prints each client's
|
||||
// phases (SwitchTrace) and the wall time from ⏭ to the server playing.
|
||||
// How long an Up next switch takes through the media pipeline, end to
|
||||
// end, over real mpv and a real sync server: two Jellyfin account holders
|
||||
// and a guest (through the server's /j login) watch an episode, then ⏭ puts
|
||||
// up the next episode, then a direct link, then a link passed on "through"
|
||||
// alice, then one the server fetches (both relayed: the guests prepare
|
||||
// them from the server's warm relay of the item). Every client prepares
|
||||
// the next item in a hidden player (`MediaPipeline.prepare`) and the swap
|
||||
// promotes it. The fake Jellyfin and the link's host answer with a delay
|
||||
// and a capped rate, like a server elsewhere. Prints each client's phases
|
||||
// (SwitchTrace) and the wall time from ⏭ to the server playing.
|
||||
//
|
||||
// WT_SWITCH_PRELOAD=0 turns the preload off (the cold path, for comparison);
|
||||
// WT_SWITCH_DELAY_MS and WT_SWITCH_RATE_KBS set the remote's delay and rate.
|
||||
@@ -202,7 +203,12 @@ void main() {
|
||||
'Up next: ⏭ to a Jellyfin episode, then to a direct link, timed per client',
|
||||
timeout: const Timeout(Duration(minutes: 4)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
// mpv's warnings, as the host writes them to the app log.
|
||||
final mpvLines = <String>[];
|
||||
// (The root's records, the app's logging not being hierarchical.)
|
||||
final logSub = createLogger('mpv').onRecord.listen((r) {
|
||||
if (r.loggerName == 'mpv') mpvLines.add(r.message);
|
||||
});
|
||||
final preload = Platform.environment['WT_SWITCH_PRELOAD'] != '0';
|
||||
final file = await _media();
|
||||
final jelly = _FakeJellyfin(file);
|
||||
@@ -236,9 +242,21 @@ void main() {
|
||||
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
|
||||
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await accB.ensureLoaded(validate: false);
|
||||
SessionCoordinator coord(JellyfinAccounts acc) => SessionCoordinator(
|
||||
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp)
|
||||
..playback.preloadEnabled = preload;
|
||||
final hosts = <SessionCoordinator, MediaKitHost>{};
|
||||
SessionCoordinator coord(JellyfinAccounts acc) {
|
||||
final host = MediaKitHost()..init();
|
||||
// Up next alone: no continuation of the episodes (it would be
|
||||
// queued after the items this test adds and times).
|
||||
final c = SessionCoordinator(
|
||||
jellyfin: acc,
|
||||
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
|
||||
ytDlp: ytDlp,
|
||||
players: host,
|
||||
settings: AppSettings()..autoAdvance.value = false)
|
||||
..media.preloadEnabled = preload;
|
||||
hosts[c] = host;
|
||||
return c;
|
||||
}
|
||||
final a = coord(accA), b = coord(accB), c = coord(accC);
|
||||
final all = {'alice': a, 'guest': b, 'carol': c};
|
||||
ConnectParams params(String name, [HelloMedia? media]) =>
|
||||
@@ -252,7 +270,7 @@ void main() {
|
||||
if (preload) {
|
||||
for (final x in all.values) {
|
||||
expect(x.queue.warm.peek()?.state, WarmState.ready, reason: 'ready on every client before ⏭');
|
||||
expect(x.playback.preloadUrl, isNotNull, reason: 'preloaded on every client');
|
||||
expect(x.media.preloadUrl, isNotNull, reason: 'preloaded on every client');
|
||||
}
|
||||
}
|
||||
final clock = Stopwatch()..start();
|
||||
@@ -269,11 +287,14 @@ void main() {
|
||||
}
|
||||
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all play $what');
|
||||
// The promoted player is the session player: marks, tracks and
|
||||
// the volume carried over, the clock moves.
|
||||
// the volume carried over, the clock moves; the old one retired.
|
||||
for (final x in all.values) {
|
||||
expect(x.playback.durationSecs, greaterThan(59));
|
||||
expect(x.playback.player.state.volume, 0, reason: 'volume kept');
|
||||
await _poll(() => x.playback.tracks.peek().isNotEmpty, 'tracks listed');
|
||||
final host = hosts[x]!;
|
||||
await host.retiring;
|
||||
expect(host.live, lessThanOrEqualTo(2), reason: 'the session player and at most the next prepared one');
|
||||
}
|
||||
final at = a.playback.positionSecs;
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
@@ -286,7 +307,7 @@ void main() {
|
||||
await b.connect(params('guest'));
|
||||
await c.connect(params('carol'));
|
||||
for (final x in all.values) {
|
||||
await x.playback.player.setVolume(0);
|
||||
await x.playback.setVolume(0);
|
||||
}
|
||||
await _poll(() => all.values.every((x) => x.playback.durationSecs > 59), 'all opened episode 1');
|
||||
expect(await a.queue.add(pickForRef(accA, const JellyfinRef(serverId: _sid, itemId: _ep2, title: 'Second'))!),
|
||||
@@ -332,14 +353,17 @@ void main() {
|
||||
// ignore: avoid_print
|
||||
print(report.join('\n'));
|
||||
expect(serverErrors, isEmpty);
|
||||
// No command reached a player still opening, and the
|
||||
// cache is in RAM on every player (no "Failed to create file cache").
|
||||
expect(mpvLines.where((l) => l.contains('error running command') || l.contains('file cache')), isEmpty);
|
||||
|
||||
// Nothing queued: no preload. Queued and taken off again: dropped.
|
||||
if (preload) {
|
||||
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'nothing preloaded');
|
||||
await _poll(() => all.values.every((x) => x.media.preloadUrl == null), 'nothing preloaded');
|
||||
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
|
||||
await _poll(() => all.values.every((x) => x.playback.preloadUrl == linkUrl), 'preloaded again');
|
||||
await _poll(() => all.values.every((x) => x.media.preloadUrl == linkUrl), 'preloaded again');
|
||||
a.queue.remove(a.serverState.peek()!.queue.first.id);
|
||||
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'dropped when taken off');
|
||||
await _poll(() => all.values.every((x) => x.media.preloadUrl == null), 'dropped when taken off');
|
||||
}
|
||||
// Preloaded everywhere: every client starts from its preload.
|
||||
if (preload) {
|
||||
@@ -348,6 +372,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await logSub.cancel();
|
||||
for (final x in all.values) {
|
||||
await x.dispose();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:starsync/src/playback/playback_controller.dart';
|
||||
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
|
||||
import 'package:starsync/src/session/media_pipeline.dart';
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
|
||||
@@ -42,13 +43,15 @@ void main() {
|
||||
final server = SyncServer(AppConfig(
|
||||
port: 0, secret: '', name: 'readahead', mediaPath: media));
|
||||
await server.start();
|
||||
final p = PlaybackController();
|
||||
final host = MediaKitHost();
|
||||
final m = MediaPipeline(host: host);
|
||||
final p = m.playback;
|
||||
try {
|
||||
await p.init();
|
||||
expect(p.pinMismatches, isEmpty,
|
||||
await m.start();
|
||||
expect(host.pinMismatches, isEmpty,
|
||||
reason: 'every pinned mpv property took effect');
|
||||
final token = server.session.issueToken('probe', '');
|
||||
await p.openUrl(
|
||||
await m.open(
|
||||
'http://127.0.0.1:${server.boundPort}/m/default/$token/readahead.mp4');
|
||||
var best = 0.0;
|
||||
final end = DateTime.now().add(const Duration(seconds: 30));
|
||||
@@ -62,7 +65,7 @@ void main() {
|
||||
expect(best, greaterThan(120),
|
||||
reason: 'paused player reads ahead about two minutes');
|
||||
} finally {
|
||||
await p.dispose();
|
||||
await m.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -35,15 +35,130 @@ Future<String> _media() async {
|
||||
}
|
||||
|
||||
Future<void> _poll(FutureOr<bool> Function() cond, String what,
|
||||
{Duration timeout = const Duration(seconds: 30)}) async {
|
||||
{Duration timeout = const Duration(seconds: 30), Duration every = const Duration(milliseconds: 200)}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (await cond()) return;
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
await Future.delayed(every);
|
||||
}
|
||||
fail('timed out waiting for $what');
|
||||
}
|
||||
|
||||
/// 60 s at about 3 Mbit/s: long enough that a slow source's relay holds
|
||||
/// only part of it. An mp4 with its index first: headless players have no
|
||||
/// video track selected (vid=no), and mkv's cues list only the video, so
|
||||
/// an mkv would be seeked by scanning from its start (an app's player,
|
||||
/// with its video output, uses the cues).
|
||||
Future<String> _longMedia() async {
|
||||
final path = '$_tmp/relay_e2e_60s.mp4';
|
||||
if (await File(path).exists()) return path;
|
||||
final r = await Process.run('ffmpeg', [
|
||||
'-y', '-v', 'error',
|
||||
'-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=24',
|
||||
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
|
||||
'-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-b:v', '3M', '-minrate', '3M', '-maxrate', '3M',
|
||||
'-bufsize', '2M', '-g', '24', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', path,
|
||||
]);
|
||||
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
|
||||
return path;
|
||||
}
|
||||
|
||||
/// A file read at most [bytesPerSec] (a source's uplink).
|
||||
class _SlowFile implements RelayBytes {
|
||||
final File file;
|
||||
final int bytesPerSec;
|
||||
_SlowFile(this.file, this.bytesPerSec);
|
||||
|
||||
@override
|
||||
Future<int> size() => file.length();
|
||||
|
||||
@override
|
||||
Stream<List<int>> read(int start, int end) async* {
|
||||
const chunk = 32 * 1024;
|
||||
final sw = Stopwatch()..start();
|
||||
var sent = 0;
|
||||
await for (final c in file.openRead(start, end + 1)) {
|
||||
for (var o = 0; o < c.length; o += chunk) {
|
||||
final part = c.sublist(o, o + chunk > c.length ? c.length : o + chunk);
|
||||
sent += part.length;
|
||||
final due = sent * 1000 ~/ bytesPerSec - sw.elapsedMilliseconds;
|
||||
if (due > 0) await Future<void>.delayed(Duration(milliseconds: due));
|
||||
yield part;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
|
||||
/// A member that is only a source: it sits out (out of the gate), puts
|
||||
/// [file] up relayed and feeds it at [bytesPerSec]. [drop] goes away
|
||||
/// without a `bye` (the member `gone`, the relay kept); [back] returns as
|
||||
/// the same member and feeds again.
|
||||
class _BareSource {
|
||||
final int port;
|
||||
final File file;
|
||||
final String name;
|
||||
static const _client = 'bareSourceClient01';
|
||||
var _seq = 0;
|
||||
WebSocket? ws;
|
||||
String token = '';
|
||||
RelaySource? src;
|
||||
Timer? _alive;
|
||||
_BareSource(this.port, this.file) : name = 'slow.mp4';
|
||||
|
||||
Future<void> join() async {
|
||||
final w = ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||
final welcome = Completer<Map>();
|
||||
w.listen((f) {
|
||||
final j = jsonDecode('$f') as Map;
|
||||
if (j['t'] == 'welcome' && !welcome.isCompleted) welcome.complete(j);
|
||||
}, onError: (_) {});
|
||||
w.add(jsonEncode(HelloMessage(
|
||||
secret: 'pw', user: const HelloUser(name: 'bob'), client: _client, seq: ++_seq)
|
||||
.toJson()));
|
||||
token = (await welcome.future.timeout(const Duration(seconds: 5)))['token'] as String;
|
||||
w.add(jsonEncode(const PresenceIntent(state: Presence.away).toJson()));
|
||||
// Not silent, like an app (a silent member goes gone).
|
||||
_alive?.cancel();
|
||||
_alive = Timer.periodic(const Duration(seconds: 1),
|
||||
(_) => w.add(jsonEncode({'t': 'report', 'position': 0, 'buffered': 0, 'eventId': 0})));
|
||||
}
|
||||
|
||||
Future<void> swap() async {
|
||||
ws!.add(jsonEncode({
|
||||
't': 'swap',
|
||||
'media': {'name': name, 'size': await file.length(), 'duration': 0, 'relay': <String, Object?>{}},
|
||||
}));
|
||||
}
|
||||
|
||||
Future<void> feed(int bytesPerSec) async {
|
||||
final s = src = RelaySource(Uri.parse('ws://127.0.0.1:$port/r/default/$token/source'),
|
||||
RelayMedia.single(_SlowFile(file, bytesPerSec)),
|
||||
name: name);
|
||||
await s.start();
|
||||
}
|
||||
|
||||
/// Gone without a word: the channel and the socket close, no `bye`.
|
||||
Future<void> drop() async {
|
||||
_alive?.cancel();
|
||||
await src?.close();
|
||||
await ws?.close();
|
||||
}
|
||||
|
||||
Future<void> back(int bytesPerSec) async {
|
||||
await join();
|
||||
await feed(bytesPerSec);
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
_alive?.cancel();
|
||||
await src?.close();
|
||||
await ws?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves one file with ranges; [requests] counts hits.
|
||||
Future<(HttpServer, List<String>)> _origin(String file) async {
|
||||
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
@@ -267,4 +382,113 @@ void main() {
|
||||
await proxy.close();
|
||||
}
|
||||
});
|
||||
|
||||
// A seek into a part the relay has not buffered yet. The source's
|
||||
// uplink is about 3x the media's bitrate; the time from the seek to the
|
||||
// player playing there again is printed and loosely bounded.
|
||||
test('a seek past what a slow source has sent plays within a few seconds',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final file = File(await _longMedia());
|
||||
final alice = await member('alice');
|
||||
final bob = _BareSource(server.boundPort, file);
|
||||
try {
|
||||
await bob.join();
|
||||
await bob.swap();
|
||||
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
|
||||
await bob.feed(1200 * 1024);
|
||||
await _poll(() => alice.playback.durationSecs > 59, 'alice opened it');
|
||||
alice.toggleReady();
|
||||
await _poll(() => alice.playback.playing && alice.playback.positionSecs > 2, 'playing');
|
||||
final took = <int>[];
|
||||
for (final to in [40.0, 52.0]) {
|
||||
final sw = Stopwatch()..start();
|
||||
alice.seekTo(to);
|
||||
await _poll(() => alice.playback.playing && alice.playback.positionSecs > to + 0.5, 'played at $to',
|
||||
every: const Duration(milliseconds: 20));
|
||||
took.add(sw.elapsedMilliseconds);
|
||||
}
|
||||
// ignore: avoid_print
|
||||
print('relay seek: seek to an unbuffered part until playing there: ${took.join(', ')} ms');
|
||||
for (final t in took) {
|
||||
expect(t, lessThan(5000), reason: "a seek into what the source has not sent yet (4 s of buffer at about 3x the bitrate)");
|
||||
}
|
||||
} finally {
|
||||
await bob.close();
|
||||
}
|
||||
});
|
||||
|
||||
// D6, the viewer's side: the source goes, a seek lands past what
|
||||
// the server got from it, and the buffer runs dry.
|
||||
test('the source drops: what is buffered plays, a seek past it waits for the source, no seek loop, it resumes (D6)',
|
||||
timeout: const Timeout(Duration(minutes: 3)), () async {
|
||||
final file = File(await _longMedia());
|
||||
final alice = await member('alice');
|
||||
final carol = await member('carol');
|
||||
final bob = _BareSource(server.boundPort, file);
|
||||
final errors = <String>[];
|
||||
final subs = <StreamSubscription<Object?>>[];
|
||||
try {
|
||||
await bob.join();
|
||||
await bob.swap();
|
||||
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
|
||||
// Just above the bitrate: the relay holds only a little ahead.
|
||||
await bob.feed(560 * 1024);
|
||||
await _poll(() => alice.playback.durationSecs > 59 && carol.playback.durationSecs > 59, 'both opened it');
|
||||
for (final x in [alice, carol]) {
|
||||
subs.add(x.playback.player.logs.listen((l) {
|
||||
if (l.text.contains('error running command')) errors.add(l.text);
|
||||
}));
|
||||
x.toggleReady();
|
||||
}
|
||||
await _poll(() => alice.playback.playing && alice.playback.positionSecs > 4, 'playing');
|
||||
|
||||
await bob.drop();
|
||||
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.sourceGone ?? false, 'source gone');
|
||||
expect(alice.status.peek().line?.text, startsWith("bob's connection dropped"));
|
||||
final seeks = [alice.playback.seeksSent, carol.playback.seeksSent];
|
||||
// Carol skips to where the source never got to.
|
||||
carol.seekTo(50);
|
||||
await _poll(() => alice.serverState.peek()!.hold?.reason == HoldReason.source, 'held for the source',
|
||||
timeout: const Duration(seconds: 60));
|
||||
expect(alice.status.peek().line?.text, 'Waiting for bob');
|
||||
expect(carol.status.peek().line?.text, 'Waiting for bob');
|
||||
// Held for a while: no seek loop into bytes nobody sends.
|
||||
await Future.delayed(const Duration(seconds: 12));
|
||||
final sent = [alice.playback.seeksSent - seeks[0], carol.playback.seeksSent - seeks[1]];
|
||||
expect(sent.every((n) => n <= 1), isTrue, reason: 'one try each, no loop: $sent');
|
||||
expect(alice.serverState.peek()!.transport, 'held');
|
||||
expect([alice.playerError.peek(), carol.playerError.peek()], everyElement(isEmpty), reason: 'not a broken player');
|
||||
// Someone joins meanwhile: it opens what the server has and waits too.
|
||||
final dave = await member('dave');
|
||||
subs.add(dave.playback.player.logs.listen((l) {
|
||||
if (l.text.contains('error running command')) errors.add(l.text);
|
||||
}));
|
||||
await _poll(() => dave.status.peek().line?.text == 'Waiting for bob', 'dave waits for bob');
|
||||
dave.toggleReady();
|
||||
|
||||
await bob.back(4 * 1024 * 1024);
|
||||
await _poll(() {
|
||||
final relay = stateMediaRelay(alice.serverState.peek()!);
|
||||
return relay != null && relay.live && !relay.sourceGone;
|
||||
}, 'source back');
|
||||
await _poll(
|
||||
() =>
|
||||
alice.serverState.peek()!.transport == 'playing' &&
|
||||
alice.playback.playing &&
|
||||
alice.playback.positionSecs > 50.5 &&
|
||||
carol.playback.positionSecs > 50.5 &&
|
||||
dave.playback.positionSecs > 50.5,
|
||||
'all playing at 50 again',
|
||||
timeout: const Duration(seconds: 40));
|
||||
// ignore: avoid_print
|
||||
print('D6: seeks while the source was gone $sent, held back '
|
||||
'${alice.media.heldBackSeeks} + ${carol.media.heldBackSeeks}');
|
||||
expect(errors, isEmpty, reason: 'no command reached a player without its media');
|
||||
} finally {
|
||||
for (final s in subs) {
|
||||
await s.cancel();
|
||||
}
|
||||
await bob.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:starsync/src/playback/playback_controller.dart';
|
||||
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
|
||||
import 'package:starsync/src/session/media_pipeline.dart';
|
||||
import 'package:starsync/src/shaders/shader_assets.dart';
|
||||
import 'package:starsync/src/shaders/shader_presets.dart';
|
||||
import 'package:starsync/src/state/settings.dart';
|
||||
@@ -21,12 +22,14 @@ void main() {
|
||||
support.createSync(recursive: true);
|
||||
shaderSupportDir = () async => support;
|
||||
final s = AppSettings.instance;
|
||||
final p = PlaybackController();
|
||||
final m = MediaPipeline(host: MediaKitHost());
|
||||
final p = m.playback;
|
||||
try {
|
||||
// The saved choice goes on at player creation.
|
||||
s.setShaderPreset(ShaderPreset.a);
|
||||
await p.init();
|
||||
final dynamic mpv = p.player.platform;
|
||||
await m.start();
|
||||
await p.applyShaders();
|
||||
final mpv = p.player;
|
||||
final modeA =
|
||||
await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const []);
|
||||
expect(shaderListMatches(modeA, await mpv.getProperty('glsl-shaders')), isTrue);
|
||||
@@ -59,17 +62,16 @@ void main() {
|
||||
expect(await mpv.getProperty('glsl-shaders'), good.path);
|
||||
|
||||
// A recreated player (Reload video) gets the choice again.
|
||||
await p.recreate();
|
||||
expect(await (p.player.platform as dynamic).getProperty('glsl-shaders'),
|
||||
good.path);
|
||||
await m.reload();
|
||||
await p.applyShaders();
|
||||
expect(await p.player.getProperty('glsl-shaders'), good.path);
|
||||
|
||||
s.setShaderPreset(ShaderPreset.off);
|
||||
await p.applyShaders(announce: true);
|
||||
expect(await (p.player.platform as dynamic).getProperty('glsl-shaders'),
|
||||
'');
|
||||
expect(await p.player.getProperty('glsl-shaders'), '');
|
||||
expect(p.shaderNotice.value?.text, 'Shaders: Off');
|
||||
} finally {
|
||||
await p.dispose();
|
||||
await m.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -418,6 +418,25 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('the mini player clears the full player\'s controls (media_kit keeps the last ones on null)',
|
||||
(tester) async {
|
||||
coord.playback.alive.value = true;
|
||||
final given = <PlayerMode, Object?>{};
|
||||
PlayerMode mode() => tester.state<AppShellState>(find.byType(AppShell)).mode;
|
||||
await pumpShell(tester, const Size(1280, 800), video: (context, controls) {
|
||||
given[mode()] = controls;
|
||||
return const ColoredBox(color: Color(0xFF7A3B2E));
|
||||
});
|
||||
online(party);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('tab-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(mode(), PlayerMode.mini);
|
||||
expect(given[PlayerMode.full], isNotNull);
|
||||
expect(given[PlayerMode.mini], isNotNull, reason: 'null would keep the full controls');
|
||||
expect(given[PlayerMode.mini], isNot(same(given[PlayerMode.full])));
|
||||
});
|
||||
|
||||
testWidgets('status pill: who is not ready, with the ready count', (tester) async {
|
||||
coord.playback.alive.value = true;
|
||||
await pumpShell(tester, const Size(1280, 800));
|
||||
|
||||
@@ -7,12 +7,12 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
/// (`AppGraph`, widgets through `AppScope`); this list may only shrink.
|
||||
/// Lower a number when a use goes; never raise one or add a class.
|
||||
const baseline = {
|
||||
'AppSettings': 55,
|
||||
'AppSettings': 49,
|
||||
'JellyfinAccounts': 10,
|
||||
'UpdateController': 8,
|
||||
'Profile': 8,
|
||||
'FolderStore': 6,
|
||||
'AppYtDlp': 4,
|
||||
'AppYtDlp': 2,
|
||||
'RecentLinks': 3,
|
||||
};
|
||||
|
||||
|
||||
@@ -26,8 +26,7 @@ Future<void> _poll(
|
||||
Future<void> _quiet(SessionCoordinator c) async {
|
||||
await c.playback.player.setVolume(0);
|
||||
try {
|
||||
final dynamic platform = c.playback.player.platform;
|
||||
await platform?.setProperty('loop-file', 'inf');
|
||||
await c.playback.player.setProperty('loop-file', 'inf');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// restructure: rewrite in step 6 (Media pipeline + player host): the
|
||||
// fresh player per video.
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
|
||||
import 'package:starsync/src/session/session_coordinator.dart';
|
||||
import 'package:starsync/src/sync/state_diff.dart';
|
||||
|
||||
// "Reload video" through the media pipeline: a fresh player from the
|
||||
// host, the old one retired, the same media opened again and resynced,
|
||||
// the ready flag untouched; leaving releases every player the host made.
|
||||
|
||||
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4';
|
||||
|
||||
Future<void> _poll(
|
||||
@@ -26,47 +28,46 @@ Future<void> _poll(
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'reload video: new player, same media, resynced, ready untouched; '
|
||||
'leave disposes the player',
|
||||
'reload video: a fresh player from the host, same media, resynced, ready untouched; '
|
||||
'leave releases every player',
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final server = SyncServer(const AppConfig(
|
||||
port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
|
||||
await server.start();
|
||||
final c = SessionCoordinator();
|
||||
final host = MediaKitHost()..init();
|
||||
final c = SessionCoordinator(players: host);
|
||||
try {
|
||||
await c.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.boundPort,
|
||||
secret: 'reload',
|
||||
name: 'solo'));
|
||||
await c.playback.player.setVolume(0);
|
||||
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'reload', name: 'solo'));
|
||||
await c.playback.setVolume(0);
|
||||
await _poll(() => c.serverState.peek() != null, 'server state');
|
||||
c.toggleReady();
|
||||
await _poll(
|
||||
() => c.playback.player.state.playing && c.playback.positionSecs > 2,
|
||||
'solo playback past 2 s');
|
||||
await _poll(() => c.playback.playing && c.playback.positionSecs > 2, 'solo playback past 2 s');
|
||||
final opens = c.opens;
|
||||
|
||||
final old = c.playback.player;
|
||||
final gen = c.playback.generation.peek();
|
||||
await c.reloadVideo();
|
||||
expect(identical(c.playback.player, old), isFalse);
|
||||
expect(identical(c.playback.player, old), isFalse, reason: 'a fresh player');
|
||||
expect(old.disposed, isTrue, reason: 'the old one retired');
|
||||
expect(host.live, 1, reason: 'only the new one is alive');
|
||||
expect(c.playback.generation.peek(), gen + 1);
|
||||
expect(c.opens, opens + 1, reason: 'the same media opened again');
|
||||
expect(c.playback.volume.peek(), 0, reason: 'the volume carried over');
|
||||
expect(c.selfMember.peek()?.ready, isTrue);
|
||||
|
||||
await _poll(() {
|
||||
final r = c.serverState.peek();
|
||||
if (r == null || !c.playback.player.state.playing) return false;
|
||||
if (r == null || !c.playback.playing) return false;
|
||||
final want = serverPositionAt(r, DateTime.now().toUtc());
|
||||
return (c.playback.positionSecs - want).abs() < 1.5 &&
|
||||
c.playback.positionSecs > 2;
|
||||
return (c.playback.positionSecs - want).abs() < 1.5 && c.playback.positionSecs > 2;
|
||||
}, 'reloaded player playing at the synced position');
|
||||
expect(c.selfMember.peek()?.ready, isTrue);
|
||||
expect(c.serverState.peek()?.transport, 'playing');
|
||||
|
||||
await c.leave();
|
||||
expect(c.playback.alive.peek(), isFalse);
|
||||
await host.retiring;
|
||||
expect(host.live, 0, reason: 'leaving releases every player');
|
||||
} finally {
|
||||
await c.dispose();
|
||||
await server.stop();
|
||||
|
||||
@@ -27,7 +27,7 @@ void main() {
|
||||
// dispose would wait on them for ever; the test process ending ends it.
|
||||
setUpAll(() async {
|
||||
MediaKit.ensureInitialized();
|
||||
await coord.playback.init();
|
||||
await coord.media.start();
|
||||
});
|
||||
|
||||
testWidgets('wheel over the video: readout for about a second', (tester) async {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/watch/watch_history.dart';
|
||||
import 'package:starsync/src/watch/watch_tracker.dart';
|
||||
|
||||
// Watch tracking (D3): one watch per media of the server, time In from the
|
||||
// presence the server reports, the device history for files and links,
|
||||
// every closed watch out on `records` (the seam for AniList later).
|
||||
|
||||
final _t0 = DateTime.utc(2026, 1, 1);
|
||||
|
||||
void main() {
|
||||
late WatchTracker tracker;
|
||||
late List<WatchRecord> records;
|
||||
|
||||
setUp(() {
|
||||
tracker = WatchTracker(history: WatchHistory(persist: false));
|
||||
records = [];
|
||||
tracker.records.listen(records.add);
|
||||
});
|
||||
|
||||
tearDown(() => tracker.dispose());
|
||||
|
||||
const episode = {'name': 'Bleach - 064.mkv', 'size': 100, 'duration': 1440, 'ref': {'kind': 'episode', 'title': 'Bleach', 'absolute': 64}};
|
||||
|
||||
/// [secs] seconds of media [mediaId] from wall second [t], playing,
|
||||
/// with self [present] or not.
|
||||
int run(int mediaId, Map<String, Object?> media, int t, int secs,
|
||||
{bool present = true, bool playing = true, String? ownFile, double from = 0}) {
|
||||
for (var i = 0; i <= secs; i++) {
|
||||
tracker.sample(WatchSample(
|
||||
mediaId: mediaId,
|
||||
media: media,
|
||||
ownFile: ownFile,
|
||||
position: from + i,
|
||||
playing: playing,
|
||||
present: present,
|
||||
duration: 1440,
|
||||
at: _t0.add(Duration(seconds: t + i)),
|
||||
));
|
||||
}
|
||||
return t + secs + 1;
|
||||
}
|
||||
|
||||
test('a file watched through goes into the history as watched; the next media closes the watch', () async {
|
||||
const path = '/lib/Bleach/Bleach - 064.mkv';
|
||||
final t = run(2, episode, 0, 1200, ownFile: path);
|
||||
run(3, const {'name': 'next', 'size': 1}, t, 1);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(records.first.key, WatchKey.file(path));
|
||||
expect(records.first.verdict.outcome, WatchOutcome.played);
|
||||
expect(records.first.ref?.absolute, 64);
|
||||
final e = tracker.history.entry(WatchKey.file(path))!;
|
||||
expect((e.played, e.resumeAt, e.title), (true, 0.0, 'Bleach - 064.mkv'));
|
||||
expect(tracker.mediaId, 3);
|
||||
});
|
||||
|
||||
test('sat out for most of it: not watched (P7), a resume point instead', () async {
|
||||
const url = 'https://example.com/v.mp4';
|
||||
final media = {'name': 'v', 'size': 0, 'duration': 1440, 'url': url};
|
||||
var t = run(2, media, 0, 300);
|
||||
t = run(2, media, t, 1100, present: false, from: 300);
|
||||
tracker.close(at: _t0.add(Duration(seconds: t)));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(records.single.verdict.outcome, WatchOutcome.partial);
|
||||
expect(records.single.timeIn, closeTo(300, 2));
|
||||
final e = tracker.history.entry(WatchKey.link(url))!;
|
||||
expect(e.played, isFalse);
|
||||
expect(e.resumeAt, closeTo(300, 1), reason: 'where this viewer was when it sat out');
|
||||
});
|
||||
|
||||
test('a peek changes nothing; a played item stays played', () async {
|
||||
const path = '/lib/x.mkv';
|
||||
run(2, episode, 0, 1200, ownFile: path);
|
||||
tracker.close();
|
||||
expect(tracker.history.entry(WatchKey.file(path))!.played, isTrue);
|
||||
run(4, episode, 0, 20, ownFile: path);
|
||||
expect(tracker.session!.playedBefore, isTrue, reason: 'the history says it was watched');
|
||||
tracker.close();
|
||||
expect(tracker.history.entry(WatchKey.file(path))!.played, isTrue);
|
||||
final other = WatchKey.of(const {'name': 'peeked', 'size': 5})!;
|
||||
run(5, const {'name': 'peeked', 'size': 5, 'duration': 1440}, 0, 20);
|
||||
tracker.close();
|
||||
expect(tracker.history.entry(other), isNull);
|
||||
});
|
||||
|
||||
test('Jellyfin: the watch is shared with its reports, not kept in the device history', () async {
|
||||
final media = {
|
||||
'name': 'ep',
|
||||
'size': 0,
|
||||
'duration': 1440,
|
||||
'jellyfin': {'serverId': 'S', 'itemId': 'i'},
|
||||
};
|
||||
run(2, media, 0, 10);
|
||||
tracker.before(played: true, resumeAt: 0);
|
||||
expect(tracker.verdict!.outcome, WatchOutcome.keepPlayed);
|
||||
tracker.close();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(records.single.key.isJellyfin, isTrue);
|
||||
expect(tracker.history.entries, isEmpty);
|
||||
});
|
||||
|
||||
test('keys: own file, Jellyfin, own or shared link, else name and size', () {
|
||||
expect(WatchKey.of(const {'name': 'a', 'size': 1}, ownFile: '/x.mkv'), WatchKey.file('/x.mkv'));
|
||||
expect(
|
||||
WatchKey.of(const {
|
||||
'name': 'a',
|
||||
'jellyfin': {'serverId': 'S', 'itemId': 'i'}
|
||||
}),
|
||||
WatchKey.jellyfin(const JellyfinRef(serverId: 'S', itemId: 'i')));
|
||||
expect(WatchKey.of(const {'name': 'a', 'relay': {}}, ownLink: 'https://x/y'), WatchKey.link('https://x/y'));
|
||||
expect(WatchKey.of(const {'name': 'a', 'url': 'https://x/z'}), WatchKey.link('https://x/z'));
|
||||
expect(WatchKey.of(const {'name': 'a', 'size': 3}), WatchKey.media('a', 3));
|
||||
expect(WatchKey.of(null), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:starsync/src/files/scanner.dart' show scanFolder;
|
||||
import 'package:starsync/src/jellyfin/accounts.dart';
|
||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||
import 'package:starsync/src/library/library_store.dart';
|
||||
import 'package:starsync/src/library/local_next.dart';
|
||||
import 'package:starsync/src/library/show_matcher.dart';
|
||||
import 'package:starsync/src/screens/home/picked_media.dart';
|
||||
import 'package:starsync/src/session/session_coordinator.dart';
|
||||
import 'package:starsync/src/watch/watch_history.dart';
|
||||
import 'package:starsync/src/watch/watch_tracker.dart';
|
||||
|
||||
import 'show_matcher_test.dart' show FakeAniList;
|
||||
|
||||
// What's next with real coordinators, real mpv and a dedicated server: a
|
||||
// file of a local show put up by a member is continued by the show's next
|
||||
// episode (numbers skipped on the way said), the server's countdown runs
|
||||
// on every screen, the member who has the file puts it up, a cancel is
|
||||
// shared, and what was watched goes into the device's watch history.
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
final _show = '$_tmp/whats_next_e2e/Show';
|
||||
|
||||
/// `Show - NN.mp4`, 6 s each, with audio (headless mpv needs it) and the
|
||||
/// index first.
|
||||
Future<String> _episode(int n) async {
|
||||
final path = '$_show/Show - ${n.toString().padLeft(2, '0')}.mp4';
|
||||
if (await File(path).exists()) return path;
|
||||
await Directory(_show).create(recursive: true);
|
||||
final r = await Process.run('ffmpeg', [
|
||||
'-y', '-v', 'error',
|
||||
'-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24',
|
||||
'-f', 'lavfi', '-i', 'sine=frequency=${300 + 100 * n}:sample_rate=48000',
|
||||
'-t', '6', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24', '-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac', '-movflags', '+faststart', path,
|
||||
]);
|
||||
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
|
||||
return path;
|
||||
}
|
||||
|
||||
Future<void> _poll(FutureOr<bool> Function() cond, String what,
|
||||
{Duration timeout = const Duration(seconds: 30)}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (await cond()) return;
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
fail('timed out waiting for $what');
|
||||
}
|
||||
|
||||
void main() {
|
||||
late SyncServer server;
|
||||
late List<SessionCoordinator> all;
|
||||
late List<Object> serverErrors;
|
||||
|
||||
Future<SessionCoordinator> member(String name, {Future<LocalNext?> Function(String)? nextFile}) async {
|
||||
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await acc.ensureLoaded(validate: false);
|
||||
final c = SessionCoordinator(
|
||||
jellyfin: acc,
|
||||
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
|
||||
watch: WatchTracker(history: WatchHistory(persist: false)))
|
||||
..nextFile = nextFile;
|
||||
all.add(c);
|
||||
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name));
|
||||
await c.playback.player.setVolume(0);
|
||||
return c;
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
MediaKit.ensureInitialized();
|
||||
all = [];
|
||||
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps'));
|
||||
// A relay read cut short at a swap or the stop is reported to the
|
||||
// zone the server started in (DEVELOPMENT "Traps").
|
||||
serverErrors = [];
|
||||
await runZonedGuarded(server.start, (e, _) {
|
||||
if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return;
|
||||
serverErrors.add(e);
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
for (final x in all) {
|
||||
await x.dispose();
|
||||
}
|
||||
await server.stop();
|
||||
expect(serverErrors, isEmpty, reason: 'server errors');
|
||||
});
|
||||
|
||||
test('a file of a show continues with its next episode; the countdown and a cancel are shared',
|
||||
timeout: const Timeout(Duration(minutes: 3)), () async {
|
||||
final ep1 = await _episode(1);
|
||||
for (final n in [3, 4]) {
|
||||
await _episode(n);
|
||||
}
|
||||
final matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: FakeAniList(), delay: (_) async {});
|
||||
final alice = await member('alice', nextFile: LocalNextFinder(matcher: matcher, scan: scanFolder).nextAfter);
|
||||
final bob = await member('bob');
|
||||
|
||||
expect(await alice.swapPicked(FileMedia(ep1)), isNull);
|
||||
await _poll(() => bob.queue.continues.peek() != null, 'the continuation, for everyone');
|
||||
final next = bob.queue.continues.peek()!;
|
||||
expect((next.how, next.by, next.auto), (QueueHow.file, alice.connection.memberId, true));
|
||||
expect(next.media.name, 'Show - 03.mp4');
|
||||
expect(next.sub, contains('skips 2'));
|
||||
expect(bob.queue.items.peek(), isEmpty, reason: 'it is not an Up next item of its own');
|
||||
|
||||
// Both ready: it plays to its end; the countdown is on both screens.
|
||||
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 5), 'both opened episode 1');
|
||||
final first = alice.serverState.peek()!.mediaId;
|
||||
for (final x in [alice, bob]) {
|
||||
x.toggleReady();
|
||||
}
|
||||
await _poll(() => alice.serverState.peek()?.transport == 'finished', 'episode 1 ended');
|
||||
await _poll(() => [alice, bob].every((x) => (x.queue.countdown.peek()?.secondsLeft ?? 0) > 0), 'the countdown');
|
||||
expect(bob.queue.countdown.peek()!.item.id, next.id);
|
||||
await _poll(() => (bob.serverState.peek()?.mediaId ?? 0) > first, 'episode 3 up', timeout: const Duration(seconds: 20));
|
||||
expect(bob.serverState.peek()!.media!['name'], 'Show - 03.mp4');
|
||||
expect(bob.serverState.peek()!.media!['swapBy'], alice.connection.memberId, reason: 'who has the file put it up');
|
||||
|
||||
// Watched through: in the device history of both (alice's by its path,
|
||||
// bob's by its name and size), recorded as the next one came up.
|
||||
await _poll(() => alice.watch.history.entry(WatchKey.file(ep1))?.played ?? false, "alice's history");
|
||||
await _poll(() => bob.watch.history.entries.any((e) => e.$2.played), "bob's history");
|
||||
|
||||
// Episode 3 continues with 4; at its end bob cancels: for everyone.
|
||||
await _poll(() => alice.queue.continues.peek()?.media.name == 'Show - 04.mp4', 'episode 4 next');
|
||||
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 5 && x.playback.player.state.playing),
|
||||
'both play episode 3', timeout: const Duration(seconds: 20));
|
||||
final third = alice.serverState.peek()!.mediaId;
|
||||
await _poll(() => (alice.queue.countdown.peek()?.secondsLeft ?? 0) > 0, 'the countdown again');
|
||||
bob.queue.cancelCountdown();
|
||||
await _poll(() => alice.queue.countdown.peek()?.secondsLeft == null && alice.queue.continues.peek() == null,
|
||||
'cancelled for alice too');
|
||||
await Future.delayed(const Duration(seconds: 12));
|
||||
expect(alice.serverState.peek()!.mediaId, third, reason: 'nothing came up');
|
||||
expect(alice.serverState.peek()!.transport, 'finished');
|
||||
});
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:starsync/src/playback/playback_controller.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
|
||||
import 'package:starsync/src/playback/yt_dlp_app.dart';
|
||||
import 'package:starsync/src/session/media_pipeline.dart';
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
const _referer = 'https://www.bilibili.com/';
|
||||
@@ -22,7 +24,8 @@ Future<(String, String)> _media() async {
|
||||
return (v, a);
|
||||
}
|
||||
|
||||
/// A CDN like Bilibili's: 403 without the page's Referer.
|
||||
/// A CDN like Bilibili's: 403 without the page's Referer. `/video/...` is
|
||||
/// the page itself (HTML: the app's probe sends it to yt-dlp).
|
||||
class _Cdn {
|
||||
_Cdn(this._server, this._files) {
|
||||
_server.listen(_serve);
|
||||
@@ -30,11 +33,27 @@ class _Cdn {
|
||||
final HttpServer _server;
|
||||
final Map<String, String> _files;
|
||||
int ok = 0, forbidden = 0;
|
||||
final cookies = <String?>[];
|
||||
|
||||
String url(String name) => 'http://127.0.0.1:${_server.port}/$name';
|
||||
|
||||
String page(String id) => 'http://127.0.0.1:${_server.port}/video/$id';
|
||||
int subtitles = 0;
|
||||
|
||||
Future<void> _serve(HttpRequest req) async {
|
||||
final res = req.response;
|
||||
if (req.uri.pathSegments.firstOrNull == 'video') {
|
||||
res.headers.contentType = ContentType.html;
|
||||
res.write('<html><title>Fake page</title></html>');
|
||||
await res.close();
|
||||
return;
|
||||
}
|
||||
if (req.uri.pathSegments.lastOrNull == 'en.vtt') {
|
||||
subtitles++;
|
||||
res.write('WEBVTT\n\n00:00.000 --> 00:05.000\nHello\n');
|
||||
await res.close();
|
||||
return;
|
||||
}
|
||||
final file = _files[req.uri.pathSegments.lastOrNull];
|
||||
if (file == null || req.headers.value('referer') != _referer) {
|
||||
forbidden++;
|
||||
@@ -43,6 +62,7 @@ class _Cdn {
|
||||
return;
|
||||
}
|
||||
ok++;
|
||||
cookies.add(req.headers.value('cookie'));
|
||||
final bytes = await File(file).readAsBytes();
|
||||
var start = 0, end = bytes.length - 1;
|
||||
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
|
||||
@@ -59,25 +79,37 @@ class _Cdn {
|
||||
}
|
||||
}
|
||||
|
||||
/// A stand-in yt-dlp printing what yt-dlp gives for a split-track page:
|
||||
/// `requested_formats` with per-format `http_headers`, none at the top.
|
||||
Future<String> _fakeYtDlp(_Cdn cdn, {required bool headers}) async {
|
||||
final dir = await Directory('$_tmp/ytdl_headers_fake').create(recursive: true);
|
||||
/// A stand-in yt-dlp (the managed copy in its own folder) printing what
|
||||
/// yt-dlp gives for a split-track page: `requested_formats` with
|
||||
/// per-format `http_headers` and `cookies`, subtitles with Bilibili's
|
||||
/// danmaku among them. [fail]: it finds no formats instead.
|
||||
Future<AppYtDlp> _fakeYtDlp(_Cdn cdn, {required bool headers, bool fail = false}) async {
|
||||
final dir = Directory('$_tmp/ytdl_direct_${headers ? 'h' : 'n'}${fail ? 'f' : ''}');
|
||||
if (dir.existsSync()) dir.deleteSync(recursive: true);
|
||||
dir.createSync(recursive: true);
|
||||
final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{};
|
||||
final json = jsonEncode({
|
||||
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
|
||||
'format_id': 'v+a', 'ext': 'mp4',
|
||||
'requested_formats': [
|
||||
{'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4',
|
||||
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h},
|
||||
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h,
|
||||
'cookies': 'SESSDATA=abc; Domain=.127.0.0.1; Path=/'},
|
||||
{'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a',
|
||||
'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h},
|
||||
],
|
||||
'requested_subtitles': {
|
||||
'en': {'url': cdn.url('en.vtt'), 'ext': 'vtt', 'name': 'English'},
|
||||
'danmaku': {'url': cdn.url('danmaku.xml'), 'ext': 'xml'},
|
||||
},
|
||||
});
|
||||
final f = File('${dir.path}/yt-dlp-${headers ? 'h' : 'n'}');
|
||||
await f.writeAsString("#!/bin/sh\ncat <<'EOF'\n$json\nEOF\n");
|
||||
final f = File('${dir.path}/yt-dlp');
|
||||
await f.writeAsString('#!/bin/sh\n'
|
||||
'case "\$1" in --version) echo 2099.01.01; exit 0;; esac\n'
|
||||
'${fail ? 'echo "ERROR: [fake] BV1: No video formats found!" >&2; exit 1\n' : "cat <<'EOF'\n$json\nEOF\n"}');
|
||||
await Process.run('chmod', ['+x', f.path]);
|
||||
return f.path;
|
||||
await File('${dir.path}/last-check').writeAsString(DateTime.now().toIso8601String());
|
||||
return AppYtDlp(YtDlp(dir.path, updateEvery: const Duration(days: 9999)));
|
||||
}
|
||||
|
||||
void main() {
|
||||
@@ -85,32 +117,39 @@ void main() {
|
||||
late HttpServer server;
|
||||
|
||||
setUpAll(() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final (v, a) = await _media();
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
cdn = _Cdn(server, {'v.m4s': v, 'a.m4s': a});
|
||||
});
|
||||
tearDownAll(() => server.close(force: true));
|
||||
|
||||
// A page "everyone fetches" opens through the app's own resolve: its
|
||||
// stream URLs (an EDL of video + audio) with the CDN's headers; mpv's
|
||||
// yt-dlp hook is off.
|
||||
test(
|
||||
"mpv's yt-dlp hook sends each format's Referer to the CDN",
|
||||
"a page plays from yt-dlp's stream URLs with each CDN's Referer and the cookies; subtitles but danmaku",
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
final p = PlaybackController();
|
||||
final host = MediaKitHost()..init();
|
||||
final m = MediaPipeline(host: host, links: await _fakeYtDlp(cdn, headers: true));
|
||||
final p = m.playback;
|
||||
try {
|
||||
await p.init();
|
||||
expect(p.pinMismatches, isEmpty, reason: 'stream-lavf-o and the rest took effect');
|
||||
await p.useYtDlp(await _fakeYtDlp(cdn, headers: true));
|
||||
await p.openUrl('https://www.bilibili.com/video/BV1fake');
|
||||
await m.start();
|
||||
expect(host.pinMismatches, isEmpty, reason: 'stream-lavf-o, ytdl=no and the rest took effect');
|
||||
expect(await p.player.getProperty('ytdl'), 'no');
|
||||
expect(await m.open(cdn.page('BV1fake')), isTrue);
|
||||
final end = DateTime.now().add(const Duration(seconds: 20));
|
||||
while (DateTime.now().isBefore(end) && p.duration.peek() == Duration.zero) {
|
||||
while (DateTime.now().isBefore(end) && (p.duration.peek() == Duration.zero || cdn.subtitles == 0)) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
expect(p.duration.peek(), greaterThan(Duration.zero), reason: 'both tracks opened');
|
||||
expect(cdn.forbidden, 0, reason: 'every request had the Referer');
|
||||
expect(cdn.ok, greaterThanOrEqualTo(2));
|
||||
expect(cdn.cookies, everyElement('SESSDATA=abc'));
|
||||
expect(cdn.subtitles, 1, reason: 'the page\'s subtitles are added, danmaku is not');
|
||||
await _until(() => p.tracks.peek().any((t) => t.isExternal), 'the subtitle track listed');
|
||||
} finally {
|
||||
await p.dispose();
|
||||
await m.dispose();
|
||||
}
|
||||
},
|
||||
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
|
||||
@@ -120,29 +159,54 @@ void main() {
|
||||
'a 403 is a load error, not a reconnect loop',
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
final p = PlaybackController();
|
||||
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: false));
|
||||
final p = m.playback;
|
||||
final errors = <String>[];
|
||||
p.onLoadError = errors.add;
|
||||
try {
|
||||
await p.init();
|
||||
await m.start();
|
||||
var logs = 0;
|
||||
final sub = p.player.stream.log.listen((_) => logs++);
|
||||
await p.useYtDlp(await _fakeYtDlp(cdn, headers: false));
|
||||
final sub = p.player.logs.listen((_) => logs++);
|
||||
final before = cdn.forbidden;
|
||||
await p.openUrl('https://www.bilibili.com/video/BV2fake');
|
||||
await m.open(cdn.page('BV2fake'));
|
||||
await Future.delayed(const Duration(seconds: 8));
|
||||
await sub.cancel();
|
||||
// ignore: avoid_print
|
||||
print('403s: ${cdn.forbidden - before}, log lines: $logs, errors: $errors');
|
||||
expect(cdn.forbidden - before, lessThan(10), reason: 'no retry loop on 403');
|
||||
expect(errors, hasLength(1));
|
||||
// The status only when ffmpeg logs through this mpv (see _onLog).
|
||||
// The status only when ffmpeg logs through this mpv (see the host).
|
||||
expect(errors.single, anyOf(contains('403'), contains('could not be opened')));
|
||||
expect(logs, lessThan(30), reason: 'warnings and errors only');
|
||||
} finally {
|
||||
await p.dispose();
|
||||
await m.dispose();
|
||||
}
|
||||
},
|
||||
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
|
||||
);
|
||||
|
||||
// D11: yt-dlp finds no video; the error names the cookies it used.
|
||||
test('a page yt-dlp finds no video on: the error says which cookies were used', () async {
|
||||
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: true, fail: true));
|
||||
final got = <(String, String?)>[];
|
||||
m.onError = (raw, {cookies}) => got.add((raw, cookies));
|
||||
try {
|
||||
await m.start();
|
||||
expect(await m.open(cdn.page('BV3fake')), isFalse);
|
||||
expect(got.single.$1, contains('No video formats found'));
|
||||
expect(got.single.$2, 'no cookies were used');
|
||||
expect(withCookieNote(playErrorText(got.single.$1), got.single.$2),
|
||||
'No video found: it may need signing in (no cookies were used)');
|
||||
} finally {
|
||||
await m.dispose();
|
||||
}
|
||||
}, skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script');
|
||||
}
|
||||
|
||||
Future<void> _until(bool Function() cond, String what) async {
|
||||
final end = DateTime.now().add(const Duration(seconds: 10));
|
||||
while (!cond()) {
|
||||
if (DateTime.now().isAfter(end)) fail('timed out waiting for $what');
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,11 @@ bool FlutterWindow::OnCreate() {
|
||||
|
||||
void FlutterWindow::OnDestroy() {
|
||||
// The engine and the plugins go now; the app released its players first.
|
||||
// Win32Window::Create runs this too, before there is an engine: only a
|
||||
// real teardown may end the process (else every start dies at 3 s).
|
||||
if (flutter_controller_) {
|
||||
ExitAfter(3000);
|
||||
}
|
||||
window_channel_ = nullptr;
|
||||
if (flutter_controller_) {
|
||||
flutter_controller_ = nullptr;
|
||||
|
||||
+429
-201
@@ -7,11 +7,12 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
|
||||
|
||||
| Path | What | Rule |
|
||||
|---|---|---|
|
||||
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`), the library's L1 (`library/`: release and folder names, `groupLocal`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter |
|
||||
| `packages/core` | protocol messages, sync engine (`SyncEngine`, with what's next: `whatsNext`), constants, config, Range parsing, logging, Jellyfin client, updater, the rules every side reads alike (`whats_next.dart`: who puts an Up next item up, who adds the continuation), watched rule (`WatchSession`), the library's L1 (`library/`: release and folder names, `groupLocal`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter |
|
||||
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
|
||||
| `apps/desktop` | Flutter app: UI, `AppGraph` (composition root), `SyncClient`, `Connection`, `SessionCoordinator`, `Navigation`, `YourServer` (`ServerIsolate`), `PlaybackController` (media_kit/mpv), update UI (`UpdateController`) | ships with `starsync-server` |
|
||||
| `apps/desktop` | Flutter app: UI, `AppGraph` (composition root), `SyncClient`, `Connection`, `SessionCoordinator`, `Navigation`, `YourServer` (`ServerIsolate`), `MediaPipeline` (opening media), `QueueController` (what's next on this app), `WatchTracker` (watch tracking), `PlaybackController` (the session player's marks and commands), the player host (`PlayerHost`, `MediaKitHost`: the only media_kit user besides the `Video` widget glue), update UI (`UpdateController`) | ships with `starsync-server` |
|
||||
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
|
||||
| `deploy` | systemd unit for the dedicated server | |
|
||||
| `third_party/media_kit_video` | upstream media_kit_video 2.0.1 with Starsync's patches (`starsync/README.md`: Flutter ≥ 3.38 EGL display on the platform thread, the X11 GLX step-aside) | vendored; re-apply patches on update |
|
||||
|
||||
Guard: `packages/core/test/flutter_free_test.dart` fails if anything under
|
||||
`core/lib` imports Flutter, `dart:ui` or `signals_flutter`; CI runs
|
||||
@@ -132,11 +133,14 @@ only stopping sharing sends guests out.
|
||||
|
||||
`AppGraph` (`app/app_graph.dart`) is built once in `main.dart`
|
||||
(`AppGraph.build`) and constructs and disposes the app's systems: the
|
||||
`Ports`, the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the
|
||||
`SessionCoordinator`, the connect form and recents (`ConnectStore`,
|
||||
`state/connect_prefs.dart`), `YourServer`, `Navigation` and the library's
|
||||
matches (`ShowMatcher`, "Library", whose `refForPath` it hands the
|
||||
coordinator as `fileRef`); it wires them
|
||||
`Ports` (the player host among them, handed to the coordinator), the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the
|
||||
`SessionCoordinator` (handed the settings and the watch tracker), the
|
||||
connect form and recents (`ConnectStore`, `state/connect_prefs.dart`),
|
||||
`YourServer`, `Navigation`, the library's matches (`ShowMatcher`,
|
||||
"Library", whose `refForPath` it hands the coordinator as `fileRef`, and
|
||||
a `LocalNextFinder` over it as `nextFile`) and watch tracking
|
||||
(`WatchTracker` with the device's `WatchHistory`, "Watch tracking"); it
|
||||
wires them
|
||||
through their constructors (and gives `Profile` the form and the push) and
|
||||
has no logic or signals of its own. Widgets reach it through one
|
||||
`AppScope` InheritedWidget (`AppScope.of(context)`, readable in
|
||||
@@ -264,8 +268,8 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
pause after a newer play. Between events: drift
|
||||
over 5 s for more than 2 s seeks, ahead by more than 1.5 s sets rate 0.95
|
||||
until level, otherwise nothing.
|
||||
- **User seeks**: the player is a `SyncPlayer` whose public `seek` is the
|
||||
user's: the session's seek bar (`ChapterSeekBar`, one `seek` per pointer
|
||||
- **User seeks**: a hosted player's public `seek` is the user's (its
|
||||
`syncSeek` moves mpv): the session's seek bar (`ChapterSeekBar`, one `seek` per pointer
|
||||
move while dragging and one on release, like media_kit's), the arrow
|
||||
keys, skips, chapter jumps and `seekTo` all become explicit events. The player moves at
|
||||
once (scrub preview); `UserSeekGate` sends one `seek` intent with a fresh
|
||||
@@ -274,8 +278,9 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
most), server seeks never touch the player; the echo of our own seek is not
|
||||
applied either (the player is already there). A server seek that has not
|
||||
landed after 1.5 s is re-applied once (`_checkSeekLanded`), to the server
|
||||
position recomputed now, and only while its event is still current and the
|
||||
user is not seeking.
|
||||
position recomputed now, and only while its event is still current, the
|
||||
user is not seeking and the relay it reads has its source ("Viewer
|
||||
recovery" under "Media pipeline").
|
||||
- **Liveness**: WS ping every 5 s; a socket without any frame for 15 s
|
||||
(`silence`) is closed (4015) and its member goes `gone`; the grace, the
|
||||
`gone` notice, the joiner bound and the joining timeout follow. The
|
||||
@@ -299,11 +304,35 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
unstable", 9 s is lost (well under the server's 15 s). A welcome that
|
||||
resumed needs nothing re-sent; a new member (grace over, server
|
||||
restarted) gets the app's ready and sitting out again, and the media
|
||||
opens again only when it changed (or its URL named the old token). The
|
||||
opens again only when it changed (or its URL named the old token). A
|
||||
restarted server that has no media while this app had some gets it
|
||||
back ("Server restart" below). The
|
||||
app's own `ping` every second keeps it under the server's silence limit
|
||||
even with nothing playing. Cancel during setup aborts the join. Leave
|
||||
and closing the window send `bye` (see "App close" under Desktop UI);
|
||||
Update and restart does not.
|
||||
- **Server restart** (the queue stays in memory, the server stores
|
||||
nothing): a reconnect that finds another `welcome.instance` with no
|
||||
media, while this app had media up, holds it: the player plays on and
|
||||
states without media are not applied (the watch goes on with it, see
|
||||
"Watch tracking"). One app offers it back (`swap.restore`: the media,
|
||||
this player's position, whether it played); who goes first is read
|
||||
from the old state alone, so the apps agree: who put it up or passes
|
||||
it on after 0.5 s, the others by their old member id from 3 s on,
|
||||
1.5 s apart, each only while the server is still empty and only what
|
||||
it can put up again (`_offerBack`: a Jellyfin item with its own
|
||||
account, its file, the link it put up, a link everyone opens). The
|
||||
server keeps the first offer. When it is back as the same media under
|
||||
a new `mediaId`, the player keeps it unless its URL named the old
|
||||
token (a relay viewer's `/r/`, the host's `/m/`: opened again), and
|
||||
what this member passes on is served again (`MediaPipeline.resumeAs`:
|
||||
its file, its link, its Jellyfin item for viewers without an account);
|
||||
the watch, the Jellyfin reports and a cleared continuation carry over.
|
||||
Up next comes back too (`QueueController.restoreAfterRestart`): the
|
||||
app whose offer won adds the links everyone opens and the Jellyfin
|
||||
items, every app its own links passed on and files; a server-fetched
|
||||
link is lost with the old server. Nobody offering within 30 s: the
|
||||
app lets go of it.
|
||||
- **Auto sit-out** (`AutoSitOut`, `session/auto_sit_out.dart`; Settings
|
||||
"Auto sit-out", `AppSettings.autoSitOut`, default on): not playing and
|
||||
no input for 5 min sets this member `away` (reason `idle`); minimised
|
||||
@@ -406,8 +435,8 @@ Three sources, same sync. Any of them can also go through the relay
|
||||
|
||||
- **Link**: the media field holds an http(s) URL. It travels as `media.url`
|
||||
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
|
||||
directly (a web page through mpv's yt-dlp hook, see "yt-dlp"); the
|
||||
server is only the clock. A pasted link that
|
||||
directly (a web page through its own yt-dlp, see "yt-dlp"); the server
|
||||
is only the clock. A pasted link that
|
||||
`parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the
|
||||
ServerId is known (in the link, else `/System/Info/Public`), so its
|
||||
`api_key` is never shared (`mediaForLink`, host and swap alike).
|
||||
@@ -452,7 +481,8 @@ open-ended), then every seek is a fresh `bytes=N-` connection. A server
|
||||
outage of a few seconds is ridden out from mpv's cache with no error. Longer
|
||||
outages exhaust the cache and rely on reconnect (not chaos-tested yet).
|
||||
|
||||
mpv settings (`PlaybackController`): `Player` with `bufferSize` 256 MiB
|
||||
mpv settings (the player host, `platform/desktop/media_kit_host.dart`,
|
||||
set on every player before its first open): `Player` with `bufferSize` 256 MiB
|
||||
(media_kit's `bufferSize` sets both `demuxer-max-bytes` and
|
||||
`demuxer-max-back-bytes`), `libass` on; pinned via runtime property:
|
||||
`demuxer-readahead-secs=150`, `cache-secs=150` (about two minutes of
|
||||
@@ -463,21 +493,25 @@ file is ~8), `demuxer-max-back-bytes=67108864`,
|
||||
(comma form; this mpv build rejects colon-joined): a dropped connection
|
||||
or a 5xx is retried, at most 3 times per read and 4 s apart at most; a
|
||||
4xx (a CDN refusing a request) fails at once instead of being retried
|
||||
(older ffmpeg ignores `reconnect_max_retries`). A load that fails (mpv's
|
||||
(older ffmpeg ignores `reconnect_max_retries`), `ytdl=no` (pages are
|
||||
resolved by the app, never by mpv's Lua hook). A load that fails (mpv's
|
||||
fatal "No video or audio streams selected", or an EDL track that cannot
|
||||
be opened) before a duration is known is reported once per open
|
||||
(`onLoadError`), with ffmpeg's HTTP status when this player gets
|
||||
ffmpeg's log (ffmpeg logs through one mpv instance per process: the
|
||||
first one, and once that is disposed the next one created; the preload
|
||||
player passes the HTTP errors it gets on to the session player). Since
|
||||
every media gets its own player, the session player is often not that
|
||||
instance: the status is a bonus, load errors are detected either way. That, a yt-dlp hook error and a failed `openUrl` become
|
||||
the coordinator's `playerError` in plain words (`playErrorText`, core
|
||||
first one, and once that is disposed the next one created; the host
|
||||
passes the HTTP errors any player gets on as `httpErrors`). Since every
|
||||
media gets its own player, the session player is often not that
|
||||
instance: the status is a bonus, load errors are detected either way.
|
||||
That, a page's resolve error (yt-dlp's words and the cookies it used,
|
||||
D11, see "yt-dlp") and a failed open become the coordinator's
|
||||
`playerError` in plain words (`playErrorText`, core
|
||||
`link_probe.dart`: 412/precondition/login/cookies/sign in → "This site
|
||||
needs cookies — Settings → Player → yt-dlp → Cookies", 403 → "Not allowed
|
||||
(403)", 404/410 → "Not found (404)", "Unsupported URL" → "Not a video
|
||||
page yt-dlp knows", network errors → "Couldn't reach the site", anything
|
||||
else as is). It rides in `report.error` (see "Player error" in the sync
|
||||
else as is; `withCookieNote` adds the cookies to the two sign-in texts).
|
||||
It rides in `report.error` (see "Player error" in the sync
|
||||
model) until the media opens (a new duration) or the server's `mediaId`
|
||||
moves on; the pill shows "Can't play this: ..." with Retry (Reload
|
||||
video). The cache is in RAM: `cache-on-disk=no` is pinned (media_kit
|
||||
@@ -486,8 +520,8 @@ sets it on without a `cache-dir`, so mpv fell back to RAM anyway, warning
|
||||
ignores invalid values silently, so every pin is read back and a mismatch is
|
||||
logged and kept in `pinMismatches`. Measured against the `/m` endpoint
|
||||
(`readahead_e2e_test`): a paused player buffers ~147 s ahead (10 s before).
|
||||
media_kit has no public `setProperty`/`getProperty`; the code calls them
|
||||
through a guarded dynamic call on `player.platform`. Hardware decoding comes from Settings (auto, off, custom)
|
||||
media_kit has no public `setProperty`/`getProperty`; the host calls them
|
||||
through a guarded dynamic call on `player.platform` (`HostedPlayer`). Hardware decoding comes from Settings (auto, off, custom)
|
||||
and is passed to the `VideoController`; env `SYNC_RELAY_HWDEC` (e.g. `no`)
|
||||
overrides it. Auto is mpv `auto` on Linux and `auto-copy` on Windows
|
||||
(`autoHwdec`, also for a blank custom value): media_kit renders through
|
||||
@@ -516,10 +550,17 @@ set (M + S). A missing file, a path containing the list separator, a
|
||||
read-back mismatch, or an mpv `libmpv_render` error while shaders are on
|
||||
turns shaders off (saved) with a toast.
|
||||
|
||||
A player with a video output opens media only once that output is up:
|
||||
media_kit sets mpv's `vo=libmpv` before the output's render context
|
||||
exists, and a media opened in between gets no video output ("No render
|
||||
context set", black picture with sound). On X11 the context comes up
|
||||
later, which widens that gap (joining with media already up).
|
||||
|
||||
Render mode: media_kit's Linux/Windows video output creates mpv's render
|
||||
context (OpenGL, else its software renderer) when the `VideoController` is
|
||||
created, and nothing in its API says which. The player therefore requests
|
||||
mpv logs at `v` while the controller is created: OpenGL init logs
|
||||
created, and nothing in its API says which. The host therefore requests
|
||||
mpv logs at `v` while the first output of an app start is created
|
||||
(`PlayerHost.video(probe: true)`): OpenGL init logs
|
||||
`GL_VERSION` under `libmpv_render`
|
||||
(`RenderMode.gpu`; "suspected software renderer" means llvmpipe,
|
||||
`softwareGl`); none within 1.5 s of the controller being ready means the
|
||||
@@ -534,48 +575,121 @@ unset); from that `v` window only mpv's GL init lines (`GL_VENDOR`,
|
||||
frame, `hwdec-current` and the `hwdec` asked for (the first one per app
|
||||
start also `mpv-version` and `ffmpeg-version`). Why media_kit fell back to
|
||||
software it only prints on stderr (`media_kit: VideoOutput: ...`), which
|
||||
the app does not capture. Otherwise the session player logs at `warn`
|
||||
(`PlaybackController.logLevel`, switched with `mpv_request_log_messages`
|
||||
on media_kit's handle): media_kit drains mpv's log on the UI thread, one
|
||||
message at a time, so a chatty level, or ffmpeg logging every retry of a
|
||||
refused request, could starve the UI. Shader and yt-dlp hook errors are
|
||||
`error` lines and still arrive. The thumbnail players keep
|
||||
media_kit's default (`error`) and nothing listens to their log. The hardware path is
|
||||
the app does not capture. Otherwise a session player logs at `warn`
|
||||
(`sessionLogLevel`; the host switches levels with
|
||||
`mpv_request_log_messages` on media_kit's handle, the only raw mpv call
|
||||
in the app): media_kit drains mpv's log on the UI thread, one message at
|
||||
a time, so a chatty level, or ffmpeg logging every retry of a refused
|
||||
request, could starve the UI. Shader errors are `error` lines and still
|
||||
arrive. The host writes every session and preload player's warnings and
|
||||
errors to the app log (`mpv`, a few per kind at a time, `preload `
|
||||
before the prepared player's). Thumbnail players log nothing. The hardware path is
|
||||
fragile: media_kit borrows whatever EGL context GDK left current on the main
|
||||
thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change
|
||||
can silently fall back to software.
|
||||
|
||||
Player lifecycle: a `Player` and its `VideoController` live for one
|
||||
media: created on join, disposed on leave, replaced by `recreate()` for
|
||||
"Reload video" (button or Ctrl+R; volume kept), and replaced on every
|
||||
media change, so nothing a player went through (a stuck video output, a
|
||||
frozen frame) carries over to the next media. `openUrl` promotes the
|
||||
preload when it holds the new media (see "Getting ready" under "Up
|
||||
next"); otherwise, when the player already held media, it creates a fresh
|
||||
one first, set up as on join (pins, hwdec, yt-dlp hook, shaders, log
|
||||
level, observers) and, with the view up and the render mode known, its
|
||||
`VideoController` created and ready before the view swaps to it (else the
|
||||
view creates it and the render-mode probe runs on it). Both go through
|
||||
one hand-over (`_adopt`): observers move over, the view swaps to the new
|
||||
output (`generation`), volume (and so mute) and shaders carry over, and
|
||||
the old player is paused and disposed once the view let go of its
|
||||
texture (as on leave). A fresh player waits for the previous old one to
|
||||
be gone, so at most one old player lingers. The first open after join or
|
||||
Reload video uses the player just created; joining back to the media the
|
||||
player held keeps it (`_openedFor`); a dropped connection never touches
|
||||
the player. Cost (`SwitchTrace`
|
||||
`player`, headless, so without the `VideoController`): 45 to 65 ms per
|
||||
swap for one app in the process (mpv created and initialised, then
|
||||
pinned), about 150 ms with three in one process
|
||||
(`queue_switch_e2e_test` with `WT_SWITCH_PRELOAD=0`), against a cold
|
||||
switch of about 1.5 s. `openUrl` opens with `play: false`; play/pause/seek/rate come only from
|
||||
`StateDiff` actions, the play gate (`localPlayAllowed`: self in-set and
|
||||
ready) or the user's own seeks (see Sync model). A server seek within 0.5 s of the current position is skipped. `leave()`
|
||||
stops the player (otherwise audio keeps playing). On media change
|
||||
(`mediaId` bump) the coordinator stops the previous media at once (no old
|
||||
picture or sound while the new one resolves, is fetched or opens), then opens the new URL
|
||||
(or promotes the preload holding it) and `StateDiff` seeks to 0, holds
|
||||
and resets rate.
|
||||
**Player host** (`PlayerHost`, `platform/ports/player_host.dart`; the
|
||||
desktop's `MediaKitHost`, one per app in `Ports.players`, tests make
|
||||
their own): creates players by role (`session`: the full cache, `warn`;
|
||||
`preload`: the prepared one, 48 MiB ahead, muted, `error`; `thumbnail`:
|
||||
no outputs, no cache, no log) with every pin set before the caller's
|
||||
first command (`pinMismatches` lists the read-backs that differ), gives
|
||||
a player its video output (`HostedVideo`, with the hwdec policy:
|
||||
`SYNC_RELAY_HWDEC`, else the setting, `auto-copy` on Windows), runs the
|
||||
render-mode probe and its diagnostics (`renderMode`, also after leaving),
|
||||
routes mpv's log, and owns the order players go in: `retire` (pause, let
|
||||
the view drop the texture: two frames and a route pop, then dispose; a
|
||||
new session player waits for the last one, so at most one old player
|
||||
lingers), `dispose` (one no view showed) and `disposeAll` (app close:
|
||||
every live player, each bounded). A `HostedPlayer` is one mpv: state and
|
||||
streams (position, duration, buffer, volume, completed, width, log),
|
||||
open, play/pause/stop, `seek` (the user's, see "User seeks") and
|
||||
`syncSeek`, rate, volume, properties, commands, `pin` (set and read
|
||||
back), its log level. Nothing else in the app imports media_kit except
|
||||
the `Video` widget glue (`session_player.dart`, `videoControllerOf`).
|
||||
|
||||
**Media pipeline** (`MediaPipeline`, `session/media_pipeline.dart`, owned
|
||||
by the coordinator): the one way to open media, plan → resolve → open →
|
||||
ready.
|
||||
- Plan (`planMedia`, `session/media_plan.dart`, pure; `media_plan_test`
|
||||
holds the table): from `state.media`, what this member serves (its
|
||||
`MediaOrigin`: a file or a link it passes on, a Jellyfin item it put
|
||||
up; set at its swap, claimed by the state that names it the relay's
|
||||
source), its own capabilities and who is present, where this app reads
|
||||
the media from (`SourcePlan`): your server's file (`/m`), a link
|
||||
everyone opens (`DirectLink`; a page is resolved here), another
|
||||
member's relay or the server's fetch (`/r`, `waiting` until it is fed:
|
||||
`relayWaitsForSource`), its own file or link (it plays its own copy and
|
||||
feeds the relay), a Jellyfin item (`JellyfinSource`: own account, the
|
||||
server's login `/j`, the member passing it on `/r`), or `Unresolvable`
|
||||
(a page on a device that cannot resolve: `LinkResolver.resolvesLinks`
|
||||
false; who can is read from `members[].caps.resolve`; desktop always
|
||||
can, so this is Android's plug-in point).
|
||||
- Resolve: a Jellyfin item through this app's account (or the plan
|
||||
resolved ahead for Up next, `warmJellyfin`), else the server's login,
|
||||
else the member passing it on, else blocked (`openJellyfin`); a web
|
||||
page through the app's own yt-dlp (`LinkResolver.probe`: yt-dlp's
|
||||
stream URLs, an EDL of video and audio, the CDN's headers with its
|
||||
cookies, the subtitles; see "yt-dlp"), failing in plain words with the
|
||||
cookies that were used (`ResolveFailure`, D11); a relay not fed yet is
|
||||
opened once `relay.live`.
|
||||
- Open: a player per media through the host. The prepared player
|
||||
(Up next's, see "Getting ready") is promoted when it holds the same URL
|
||||
and headers; else, when the session player already held media, a fresh
|
||||
one takes over (pins, volume, shaders; with the view up and the render
|
||||
mode known, its video output ready before the view swaps to it, else
|
||||
the view makes it and the probe runs on it); the same media again (a
|
||||
reconnect, joining back) keeps the player. Every hand-over is
|
||||
`PlaybackController.attach`: log, marks and track observers move over,
|
||||
the view swaps to the new output (`generation`), volume and shaders
|
||||
carry over, the old player is retired. The first open after join or
|
||||
Reload video uses the player just created; a dropped connection never
|
||||
touches the player. "Reload video" (button or Ctrl+R) retires the
|
||||
player, starts a fresh one and opens the media again (a page resolved
|
||||
afresh: its stream URLs may have expired). Cost (`SwitchTrace` `player`,
|
||||
headless, without the video output): 45 to 65 ms per swap for one app
|
||||
in the process, about 150 ms with three in one process
|
||||
(`queue_switch_e2e_test` with `WT_SWITCH_PRELOAD=0`), against a cold
|
||||
switch of about 1.6 s.
|
||||
- Ready: the coordinator reports at once after an open (`onOpened`) and
|
||||
when the buffer reaches the gate's need while held, not on the next
|
||||
tick.
|
||||
- Commands: play/pause/seek/rate come only from `StateDiff` actions, the
|
||||
play gate (`localPlayAllowed`: self in-set and ready) or the user's
|
||||
own seeks (see Sync model); a server seek within 0.5 s of the current
|
||||
position is skipped. They go through `PlaybackController.apply`: while
|
||||
a switch is under way (no player, or its media has no duration yet)
|
||||
they wait, the latest seek, transport and rate apply once to the new
|
||||
player when its media is in; mpv never gets a seek it cannot run
|
||||
(media_kit logged "error running command seek" / `event:5` for each).
|
||||
The volume is the controller's (`setVolume`), on every next player.
|
||||
Opens are paused (`play: false`). On media change (`mediaId` bump) the
|
||||
previous media stops at once (no old picture or sound while the new
|
||||
one resolves, is fetched or opens) and `StateDiff` seeks to 0, holds
|
||||
and resets rate. Leaving stops and releases the players.
|
||||
- Being a relay source (`RelayFeeder`, `session/relay_feeder.dart`): the
|
||||
current media's channel and Up next's warm relay (see "Relay"), with
|
||||
the step-1 re-dial (a source rides through this app's own drop). A
|
||||
Jellyfin item this member put up is passed on only once someone
|
||||
present needs it (`jellyfinRelayNeeded`: the server's login does not
|
||||
cover it and a present member does not play that server with its own
|
||||
account; one whose `caps` say Jellyfin but whose `jf` is not reported
|
||||
yet is asked again with the next state), so a session where everyone
|
||||
has an account opens no relay channel.
|
||||
- Viewer recovery (D6): while the relay this app reads has no source
|
||||
(`sourceGone`, or not live with the source away), it plays what is
|
||||
buffered. A seek past that (a server seek, a drift correction) is
|
||||
tried once; more wait, the seek check does not re-apply, and the
|
||||
report says nothing is buffered once this player cannot be where the
|
||||
server is (`starved`), so the server holds everyone with the reason
|
||||
`source` ("Waiting for Bob"; see "App events and platform ports").
|
||||
When the source is back, one seek to the server position resumes the
|
||||
player, and a player that never loaded meanwhile (it opened while the
|
||||
source was gone and its first bytes never came) opens again in a fresh
|
||||
player. Measured (`relay_e2e`, a source just above the bitrate that
|
||||
drops, a seek past what the server got, a member joining meanwhile):
|
||||
at most one seek per viewer while it is gone, nobody's player broken,
|
||||
all play from the seek once it is back.
|
||||
|
||||
Chapters, segments, tracks (`PlaybackController` signals, values from
|
||||
`playback/media_marks.dart`, no widgets; any controls UI reads them):
|
||||
@@ -716,7 +830,7 @@ and URL, played together by viewers as an mpv EDL
|
||||
(it writes it to a playlist and `loadlist`s it) and mpv keeps both
|
||||
streams in sync across seeks; one URL keeps the app's "one media URL"
|
||||
path (sync, swap, reopen) as is. The source member plays the page
|
||||
itself (yt-dlp hook) like any other link.
|
||||
itself (its own resolve, see "yt-dlp") like any other link.
|
||||
|
||||
A page with only segmented streams (HLS `m3u8_native`, DASH
|
||||
`http_dash_segments`, e.g. X's HLS, generic `.m3u8`/`.mpd`) is relayed as
|
||||
@@ -757,11 +871,11 @@ How bytes move:
|
||||
6.6 s unchunked) and even beats mpv reading YouTube directly, since the
|
||||
buffer reads ahead at full speed.
|
||||
- The server keeps one buffer per track of a relayed media (one for a
|
||||
file), a `RelayBuffer` for bytes: 1 MiB slices in
|
||||
memory, filled by one sequential pull at a time. Kept: the first and
|
||||
file), a `RelayBuffer` for bytes: 256 KiB slices in
|
||||
memory (`relaySliceBytes`), filled by one sequential pull at a time. Kept: the first and
|
||||
last 2 MiB (container indexes; mpv reads both on open and on seeks),
|
||||
64 MiB behind the slowest viewer and 256 MiB past the furthest, the
|
||||
same as each viewer's mpv buffer (`PlaybackController`), so a skip
|
||||
same as each viewer's mpv buffer (the player host's pins), so a skip
|
||||
inside the viewers' buffer window is served from memory. Evicted by
|
||||
distance from the viewers, never LRU. With 2+ viewers the source sends
|
||||
each byte about once. Several tracks split the ahead and behind budgets
|
||||
@@ -789,12 +903,21 @@ How bytes move:
|
||||
opened in 0.02 s from the buffer.
|
||||
- Filling a byte buffer: a viewer blocked on a slice first (oldest wait
|
||||
first, handing the source over at slice boundaries), then the pinned
|
||||
slices, then read-ahead from the slowest viewer. Nothing is fetched while nobody
|
||||
watches through the relay (a Jellyfin source with only account holders
|
||||
uploads nothing).
|
||||
slices, then read-ahead from the slowest viewer. A viewer reads the
|
||||
slice being filled as its bytes arrive, not once it is whole (mpv's
|
||||
demuxer, blocked in a read, takes a seek only once that read returns).
|
||||
Nothing is fetched while nobody watches through the relay (a Jellyfin
|
||||
source with only account holders uploads nothing).
|
||||
- mpv leaves superseded requests open but unread after a seek: only a
|
||||
viewer's newest request moves its position or asks the source for
|
||||
anything; older ones still get bytes that are already buffered.
|
||||
anything; older ones still get bytes that are already buffered, and a
|
||||
request a newer one superseded stops holding the pull at once, so a
|
||||
seek's pull starts without waiting for the old slice to finish.
|
||||
Measured (`relay_e2e` "a seek past what a slow source has sent", a
|
||||
source at about 3x a 3 Mbit/s mp4, the gate's 4 s of buffer): a seek
|
||||
into what the source has not sent plays there after 2.8 to 2.9 s (4.0
|
||||
to 4.3 s with 1 MiB slices served whole and the superseded request
|
||||
holding the pull).
|
||||
- Flow control on the source (`RelayWindow`): one in-flight budget per
|
||||
connection, 2 x delivery rate x min RTT (rate max-filtered over 2 s),
|
||||
clamped 256 KiB to 8 MiB; the server acks bytes as they arrive. A
|
||||
@@ -834,20 +957,21 @@ How bytes move:
|
||||
media_kit's mpv gives up on a silent request after 5 s
|
||||
(`network-timeout`) with "could not be opened". So viewers open a
|
||||
member relay's `/r/` URL only once `relay.live` is set, as for a
|
||||
server fetch (`_waitForSource`; "waiting for <name> to pass the video
|
||||
on…" meanwhile), unless the source already left (then they open what
|
||||
the server has); Up next preloads wait the same way. The putter starts
|
||||
server fetch (`relayWaitsForSource` in the media plan; "waiting for
|
||||
<name> to pass the video on…" meanwhile), unless the source already
|
||||
left (then they open what the server has); Up next preloads wait the
|
||||
same way. The putter starts
|
||||
its source before opening its own player, and the source reads its
|
||||
tracks' sizes at once.
|
||||
- Source away (D6): viewers keep playing from the buffer; a request
|
||||
waits up to 60 s for missing bytes. The source member going `gone`
|
||||
does not end the relay: it lives through the source's grace
|
||||
(`media.relay.sourceGone`, `ServerHub.refreshRelaySource`). The source
|
||||
(`RelaySource`, kept by the coordinator through the app's own drop)
|
||||
reconnects its channel with backoff (its token lasts through the
|
||||
grace; a half-open channel is caught by its 5 s WebSocket ping) and
|
||||
sends its `info` again, the same on every dial (the coordinator passes
|
||||
the media's `name` and `duration`): the same as its first (sizes, piece
|
||||
(`RelaySource`, kept by the media pipeline's `RelayFeeder` through the
|
||||
app's own drop) reconnects its channel with backoff (its token lasts
|
||||
through the grace; a half-open channel is caught by its 5 s WebSocket
|
||||
ping) and sends its `info` again, the same on every dial (the feeder
|
||||
passes the media's `name` and `duration`): the same as its first (sizes, piece
|
||||
shapes, kinds, and name and duration when given) carries on, the
|
||||
buffer filler pulling what is missing; another one is closed with
|
||||
4002 and ends the relay (`ServerHub.relayOtherMedia`); a second channel
|
||||
@@ -860,6 +984,10 @@ How bytes move:
|
||||
a viewer opening then opens the buffer instead of waiting) and "<name>
|
||||
left — playing what the server has" once it left the server; the source
|
||||
gone for 5 s (`sourceDropNotice`) or leaving is also a toast, once each.
|
||||
A viewer's buffer running dry holds everyone (`state.hold.reason`
|
||||
`source`: "Waiting for <name>"); the viewer's side, never seeking past
|
||||
what it has and resuming when the source is back, is "Viewer recovery"
|
||||
under "Media pipeline".
|
||||
- Stats (`relay_stats.dart`, `ServerHub.sampleRates` on the 1 s
|
||||
heartbeat; wire in [PROTOCOL.md](PROTOCOL.md)): the source's rate is
|
||||
its bytes over the time a pull is in flight (`FeedClock`, one per media
|
||||
@@ -942,18 +1070,32 @@ all three link modes. Nothing is bundled or installed by hand.
|
||||
→ Player → yt-dlp → Cookies (a browser and profile, or a file picked or
|
||||
dropped, kept whole: every site in it; "Remove cookies"; the shell's
|
||||
own drop target is off on Settings so only this box takes the file); your server shares the folder,
|
||||
so it uses them too. mpv's hook gets the same as `ytdl-raw-options`
|
||||
(`cookies=%N%<path>` / `cookies-from-browser=%N%<spec>`, length-quoted),
|
||||
re-set before each external link when it changed, after
|
||||
`sub-langs=all,-danmaku` (Bilibili's danmaku XML is served raw-deflate:
|
||||
ffmpeg warns on every read and the video waits on the track). The dedicated
|
||||
so it uses them too. A player gets them through the app's resolve
|
||||
(the format's cookies as its Cookie header), never on its own. The dedicated
|
||||
server: `--yt-dlp-cookies <file>` / `ytDlpCookies` converted into its
|
||||
folder at start; without it the copy and browser setting are removed.
|
||||
Values are never logged and never leave the machine.
|
||||
A resolve that fails in a way that may be about signing in (cookies,
|
||||
log in, "No video formats found") says which cookies it used (D11,
|
||||
`YtDlp.cookieNote`, `YtDlpException.cookies`, `LinkProbe.cookies`,
|
||||
`withCookieNote`): none; the file and how many of its cookies are for
|
||||
the site (`cookieSite`); a browser and how many cookies for the site
|
||||
yt-dlp reads from it (one run without a URL, `--cookies-from-browser
|
||||
<spec> --cookies <private file>`, which writes the browser's jar to a
|
||||
0600 file in the yt-dlp folder, counted and deleted at once), or that
|
||||
it could not read them. "No video found: it may need signing in (used
|
||||
Firefox's cookies, which have none for bilibili.com: sign in to
|
||||
bilibili.com there)".
|
||||
- **Resolve** (`YtDlp.resolve`, `resolveArgs`): `yt-dlp -J --no-playlist --no-warnings
|
||||
--ignore-config` plus the cookies, 25 s timeout: title, uploader, duration, thumbnail,
|
||||
live or not, and what a relay reads (`pickTracks`), each format with
|
||||
its URL, `http_headers` and `http_chunk_size`. Only plain http(s)
|
||||
--ignore-config -f bv*[height<=1080]+ba/b --write-subs --sub-langs
|
||||
all,-danmaku` plus the cookies, 25 s timeout: title, uploader, duration,
|
||||
thumbnail, live or not; what a player opens (`YtDlpPlay`: yt-dlp's
|
||||
`requested_formats`, else the top-level format; http(s), HLS, or a DASH
|
||||
manifest; one URL, or an mpv EDL of the video and the audio; the first
|
||||
format's `http_headers` plus its `cookies` as a Cookie header; the
|
||||
requested subtitles but Bilibili's danmaku, an XML comment feed); and
|
||||
what a relay reads (`pickTracks`), each format with its URL,
|
||||
`http_headers` and `http_chunk_size`. For a relay only plain http(s)
|
||||
formats without DRM count:
|
||||
- the best single file (`pickSingleFile`: video and audio not `none`, a
|
||||
video container, tallest up to 1080p, else the shortest above), unless
|
||||
@@ -986,18 +1128,19 @@ all three link modes. Nothing is bundled or installed by hand.
|
||||
under the button) and offers "Put it up anyway" (`LinkMedia.anyway`),
|
||||
for when the server or a friend with cookies may still open it;
|
||||
`swapLink` refuses it the same way for any other caller.
|
||||
- **Everyone fetches it**: the swap sends the page URL. Before opening an
|
||||
external link every app points mpv's hook at its managed copy
|
||||
(`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`,
|
||||
once per player), waiting for the download unless the URL names a media
|
||||
file. A hook error is a player error (see the mpv settings under "Media"). The hook (mpv
|
||||
0.41) gives mpv the first requested format's `http_headers` (User-Agent
|
||||
as `user-agent`; Referer, Cookie, X-Forwarded-For as file-local
|
||||
`http-header-fields`, which win over the empty list media_kit sets on
|
||||
load) and its `cookies` (`stream-lavf-o`), for every track of its EDL:
|
||||
Bilibili's CDN answers 403 without the Referer. The app never opens a
|
||||
resolved format URL in mpv itself (the putter of "Through me" plays the
|
||||
page through the hook too); relays read them with yt-dlp's headers.
|
||||
- **Everyone fetches it**: the swap sends the page URL. Every app
|
||||
resolves it with its own yt-dlp (the media pipeline's resolve step,
|
||||
`LinkResolver.probe`, waiting for the download on first use) and opens
|
||||
what yt-dlp picked (`YtDlpPlay.mpvUrl`) with its headers as mpv's
|
||||
`http-header-fields` (media_kit's `Media.httpHeaders`), for every
|
||||
track of the EDL: Bilibili's CDN answers 403 without the Referer. Its
|
||||
subtitles are added once the media is in (`sub-add <url> auto`). mpv's
|
||||
own Lua yt-dlp hook is off (`ytdl=no`): one resolve path for every mode,
|
||||
cookies and headers always the app's, and a libmpv without Lua plays
|
||||
links the same. A resolve that fails is a player error in plain words,
|
||||
with the cookies it used. Stream URLs are bound to the resolving
|
||||
machine, so each app resolves its own; a page is resolved again at
|
||||
Reload video and for Up next's 30-minute refresh (they expire).
|
||||
- **Through me**: the putter's probe must give tracks, and a pieces
|
||||
track's playlist must be readable, else the swap is refused ("Live
|
||||
streams can't be passed on", DRM, or nothing a relay can read) with a
|
||||
@@ -1044,29 +1187,19 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
||||
should be) or a track change; Stopped on swap, leave, sitting out, window close
|
||||
(bounded to 1.5 s inside the graceful leave) and at `finished`; seeking
|
||||
back from the end sends Playing again. Reports carry `PositionTicks`
|
||||
(see "Watched rule"), `IsPaused` and mpv's `aid`/`sid` as Jellyfin
|
||||
(see "Watched on Jellyfin"), `IsPaused` and mpv's `aid`/`sid` as Jellyfin
|
||||
stream indices (`jellyfinIndexForMpvTrack`, `SubtitleStreamIndex -1` =
|
||||
off). They go out in order on one chain; failures are logged, one quiet
|
||||
toast per session, playback is never touched; a 401 marks the account
|
||||
expired and ends reporting.
|
||||
- **Watched rule** (`WatchSession` in `packages/core/lib/src/watch_session.dart`,
|
||||
pure, meant for any tracker): each opening samples the position every
|
||||
tick; only normal playback counts (forward, at most 2x speed + 1.5 s,
|
||||
samples at most 10 s apart; seeks, skips and pauses add nothing). The
|
||||
union of the watched stretches is the coverage (rewatching counts once),
|
||||
alongside the plain watched time, first / last / furthest position and
|
||||
start / end time. At a stop: played before this opening -> keep played;
|
||||
coverage >= 70 % of the runtime, or the ending reached (furthest position
|
||||
past the Outro / ED start or into the last 15 %, at most 4 min, whichever
|
||||
is earlier) with coverage >= 40 % -> played (someone who watched that
|
||||
far and stops is done with it); coverage under max(2 min, 5 %) (at most a quarter of a short
|
||||
item) -> a peek; else part way, resuming at the last position. An
|
||||
unknown runtime never makes it played. Jellyfin decides played from
|
||||
- **Watched on Jellyfin**: the watch of the item is the session's (see
|
||||
"Watch tracking"); `JellyfinProgressReporter` reads its verdict and
|
||||
never samples itself. Jellyfin decides played from
|
||||
positions alone (10.11): Playing sets a resumable item unplayed, a
|
||||
Progress or Stopped position past "max resume" (90 %) marks it played,
|
||||
one in between keeps a resume point. So `JellyfinProgressReporter`
|
||||
one in between keeps a resume point. So the reporter
|
||||
(played before = `UserData.Played` read at open with
|
||||
`/UserItems/{id}/UserData`):
|
||||
`/UserItems/{id}/UserData`, handed to the watch with the resume point):
|
||||
- played (before, or by the rule at an earlier stop of this opening):
|
||||
`UserPlayedItems` right after Playing, Progress without
|
||||
`PositionTicks` (leaves the saved position and played state), Stopped
|
||||
@@ -1089,22 +1222,10 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
||||
there is one); from `nextPromptStart` (the ending's
|
||||
start when one is known, see "Skip rules", else the last 2 min of the
|
||||
runtime) a floating "Next episode" pill shows while the controls are up
|
||||
(see "Player controls"). Auto-advance still waits for `finished`. Any
|
||||
of them swaps to that episode's ref with the clicker's account. Guests
|
||||
(relay, blocked) see none of it.
|
||||
- **Auto-advance** (Settings, on by default): at `finished` with a known
|
||||
next episode, every client with the setting on shows a 10 s countdown
|
||||
card (Cancel / Play now; without the setting, a "Next" card with Play
|
||||
next). Exactly one client swaps: `autoAdvanceSwapper`, the lowest member
|
||||
id among present members (`in` or `joining`) whose `jf` for that
|
||||
server is true. Only what
|
||||
every client sees in `state` decides it (who put the item up is not in
|
||||
`state`; a late joiner never saw that swap), so clients agree without a
|
||||
wire change. Cancel is local: it stops the countdown on that screen, and
|
||||
the swapper's Cancel stops the swap; a seek by anyone ends `finished`
|
||||
and with it every countdown. Only while Up next is empty and its
|
||||
continuation was not cleared (see "Up next"); a queued item replaces
|
||||
a countdown already running.
|
||||
(see "Player controls"). Any of them puts that episode up with the
|
||||
clicker's account (through Up next when it is the continuation there).
|
||||
At the end, the next episode continues the item by itself (see "What's
|
||||
next"). Guests (relay, blocked) see none of it.
|
||||
- **Tracks**: the controls' Audio and Subtitles buttons (or ⚙ when
|
||||
hidden from the bar) list `PlaybackController.tracks`. A pick is applied, reported, and saved as a `TrackChoice`
|
||||
(language, title, codec, channels, forced/HI: from the Jellyfin streams
|
||||
@@ -1117,6 +1238,43 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
||||
Link goes through `mediaForLink`, a Jellyfin pick as a ref, a file
|
||||
through `ServerIsolate.setFile` (host) or the relay (`swapFile`).
|
||||
|
||||
## What's next
|
||||
|
||||
One system decides which item plays next and when, and has it prepared
|
||||
ahead. The server holds the decision (`SyncEngine.whatsNext`, on every
|
||||
tick and queue change; wire in [PROTOCOL.md](PROTOCOL.md), "Queue"); each
|
||||
app's `QueueController` (`lib/src/queue/`) adds to the one list, puts an
|
||||
item up when the server asks it, shows the countdown and keeps the next
|
||||
item ready ("Getting ready" under "Up next").
|
||||
|
||||
- **One list**: the queue, Up next's items, then at most one `auto`
|
||||
item, the continuation of the media playing (`for` its `mediaId`).
|
||||
Everyone sees it; any swap that does not put it up drops it.
|
||||
- **The continuation**: added by the app that put the media up (or, when
|
||||
that member is not `in`, the first member who is: core's
|
||||
`mayAddContinuation`), when it knows what comes next and the setting
|
||||
"Play the next episode automatically" is on there: a Jellyfin episode
|
||||
played with its own account continues with the series' next one
|
||||
(`adjacentEpisodes`), a file this app put up with the next episode of
|
||||
its show (`LocalNextFinder`: the library the Files page saw this run,
|
||||
else the file's own folder scanned and grouped; `LocalShow.nextAfter`,
|
||||
so the numbers skipped on the way are said in its second line, "skips
|
||||
66–107"). It goes up like any item of its kind (a Jellyfin ref, the
|
||||
adder's file), with a picture, and adds no feed line. A `cancel`
|
||||
(Clear, or Cancel on the end card) drops it for everyone, and no app
|
||||
adds it again for that media (the feed's `advance-cancel`).
|
||||
- **When**: with nothing up, the first item is asked for at once. When
|
||||
the media finishes with an item first, the server's countdown starts
|
||||
(`state.advance`, 10 s, the same on every screen); a new first item
|
||||
takes it over with the same end; a seek, `cancel` (shared) or a swap
|
||||
ends it. At its end the server sets `queuePlay` and the app that puts
|
||||
the item up (`queuePerformer`) swaps it in. It holds while that member
|
||||
is `gone` (in its grace) or nobody is around, or while the item's warm
|
||||
relay waits for a `gone` source, and asks once they are back. An item
|
||||
asked for that nobody here can put up (its adder left, nobody has its
|
||||
Jellyfin server) is skipped by the server (feed `queue-skip`, actor
|
||||
`server`), and the countdown goes on with the next one.
|
||||
|
||||
## Up next
|
||||
|
||||
The queue: what plays after the current media. The server holds the list
|
||||
@@ -1139,27 +1297,28 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
|
||||
and a file go up as a `key` only: the app keeps the URL or path in
|
||||
memory under it and nothing else leaves it. The adder gets a toast
|
||||
"Added … to Up next"; everyone else an activity-log line.
|
||||
- **Who puts an item up** (`queuePerformer`, from `state` alone, so all
|
||||
clients agree): a link passed on or a file, only its adder (`gone`:
|
||||
- **Who puts an item up** (core's `queuePerformer`, from `state` alone,
|
||||
so the server and every app agree): a link passed on or a file, only its adder (`gone`:
|
||||
the item waits for it through its grace; left for good: skipped, "Bob
|
||||
left"); a Jellyfin item, its adder when it plays that server with its
|
||||
own account, else the lowest present member id that does (`jf`, which
|
||||
lists every working account; nobody: skipped); a link everyone opens
|
||||
or the server fetches, its adder when present, else the lowest present
|
||||
member id (present: `in` or `joining`, `isPresent` in
|
||||
`session/party.dart`). A skip is a `remove` with the reason from the
|
||||
lowest member id that is not `gone` (`queueJanitor`); the feed's
|
||||
`queue-skip` toasts on every screen.
|
||||
- **Advancing**: at `finished` with Up next waiting, every screen shows
|
||||
`UpNextCard` (picture, title, who added it and how; with auto-advance on
|
||||
a 10 s countdown with Cancel, else Play now only); at zero the app that
|
||||
puts the first item up does, with a swap naming it (`swap.queueId`), so
|
||||
member id (present: `in` or `joining`, `isPresentMember`). An item asked
|
||||
for that nobody can put up is skipped by the server (see "What's
|
||||
next"); an item whose swap fails is taken off by the app that tried,
|
||||
with the reason; the feed's `queue-skip` toasts on every screen.
|
||||
- **Advancing**: at `finished` with something next, every screen shows
|
||||
`UpNextCard` (picture, title, who added it and how; the server's
|
||||
countdown with Cancel while `state.advance` runs, "Waiting for Bob"
|
||||
when it ran out without the one who puts it up, else Play now only;
|
||||
see "What's next"). The app that puts the item up does when the server
|
||||
asks (`state.queuePlay`), with a swap naming it (`swap.queueId`), so
|
||||
the server takes it off in the same step and a second swap of it is
|
||||
ignored. Cancel is per screen like the next-episode countdown. "Play
|
||||
now" anywhere (a row, the card, the Up next button's ⏭, `>` / Shift+N) swaps
|
||||
directly from the app that can, else sends `play` and that app reacts
|
||||
to `state.queuePlay`. With nothing up at all, the first item goes up as
|
||||
soon as it is added. The swap goes through the usual paths
|
||||
ignored. "Play now" anywhere (a row, the card, the Up next button's ⏭,
|
||||
`>` / Shift+N) swaps directly from the app that can, else sends `play`
|
||||
and that app reacts to `state.queuePlay`. With nothing up at all, the
|
||||
server asks for the first item as soon as it is added. The swap goes through the usual paths
|
||||
(`swapPicked`, `swapJellyfin`; a server-fetched item through
|
||||
`swapQueued`, without the URL, its picture as the art), so relays, art
|
||||
and refusals work as for any swap; when it fails the item is skipped
|
||||
@@ -1168,14 +1327,15 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
|
||||
out comes back first; any other queued swap leaves the swapper out.
|
||||
- **Getting ready** (`QueueController._warm`): every app keeps the one
|
||||
item that plays next ready all the time, its own copy, so a skip, Play
|
||||
now or the end of the media starts it at once. That is the first Up
|
||||
next item, else, with Up next empty, the first "Continues with"
|
||||
episode (on the apps that have it); while sitting out only the probe
|
||||
now or the end of the media starts it at once. That is the queue's
|
||||
first item (Up next's, else the continuation, which is in the queue
|
||||
for everyone); while sitting out only the probe
|
||||
of a link this app puts up (its swap reads it). Per
|
||||
kind: a Jellyfin item is resolved with this app's account
|
||||
(`resolveJellyfinRef`: PlaybackInfo and the item) into
|
||||
`SessionCoordinator.warmJellyfin`, which `_playJellyfin` takes instead
|
||||
of resolving when the same ref goes up within 35 minutes and the
|
||||
`SessionCoordinator.warmJellyfin`, which the media pipeline's resolve
|
||||
(`openJellyfin`) takes instead of asking again when the same ref goes
|
||||
up within 35 minutes and the
|
||||
account is unchanged; without an account it is the server's `/j` URL
|
||||
when `state.relayNext` says the server's login passes it on (see
|
||||
"Jellyfin relay"). A link everyone opens is that link; the app that
|
||||
@@ -1184,35 +1344,36 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
|
||||
read it from there). A link passed on "through me" and a file on
|
||||
their adder's app (the link, the path; not the host's own file, which
|
||||
plays from `/m`), which also feeds the server's warm relay of it
|
||||
(`QueueController._feedWarm`, `SessionCoordinator.feedWarm`; a
|
||||
(`QueueController._feedWarm`, `RelayFeeder.feedWarm`; a
|
||||
Jellyfin item's from the member who puts it up, with its resolved
|
||||
stream, when the server asks for one); everyone else, and everyone
|
||||
for a server-fetched link, preloads that warm relay's URL
|
||||
(`SessionCoordinator.warmRelayUrl`) once `queue[0].media.relay.live`
|
||||
(`MediaPipeline.warmRelayUrl`) once `queue[0].media.relay.live`
|
||||
says bytes can flow (see "Relay"). At the swap the adder's app skips
|
||||
re-reading the link's sizes (its warm source has them) and that
|
||||
source carries on as the media's. It
|
||||
is resolved as soon as it is first there, again every 30 minutes, and
|
||||
dropped when it stops being first.
|
||||
**Preload**: once resolved, the stream is opened in a second, hidden
|
||||
mpv player (`PlaybackController.preload`, through
|
||||
`SessionCoordinator.preloadMedia`): paused at the start, muted, with
|
||||
its own video output when the session view is up (media_kit starts a
|
||||
player with `vid=no`; the output decodes the first frame), the same
|
||||
pins, hwdec and yt-dlp hook as the session player but a 48 MiB forward
|
||||
and 8 MiB back cache (`preloadBytes`), mpv log at `error`. One at a
|
||||
time; it waits until the media playing has started here
|
||||
(`SessionCoordinator.settledMediaId`: opened, failed or nothing to
|
||||
**Preload**: once resolved, the stream is opened early by the media
|
||||
pipeline (`MediaPipeline.prepare`, a page through the same resolve as
|
||||
any open) in a second, hidden player (the host's `preload` role):
|
||||
paused at the start, muted, with its own video output when the
|
||||
session view is up (media_kit starts a player with `vid=no`; the
|
||||
output decodes the first frame), the same pins and hwdec as the
|
||||
session player but a 48 MiB forward and 8 MiB back cache
|
||||
(`preloadAheadBytes`), mpv log at `error`. One at a time; it waits
|
||||
until the media playing has started here
|
||||
(`MediaPipeline.settledMediaId`: opened, failed or nothing to
|
||||
open), so it never competes with that open and a preload a swap is
|
||||
about to take is never replaced first; a removed, reordered or replaced
|
||||
first item swaps or drops it then, a manual swap to something else
|
||||
keeps it (still next). A link is opened again at the 30-minute refresh
|
||||
(stream URLs expire); a failed preload is not retried until then and
|
||||
changes nothing (the usual open follows). When the coordinator opens
|
||||
the same URL and headers (`openUrl`), the preload is promoted instead
|
||||
of a fresh player: it becomes the session player through the same
|
||||
hand-over (see "Player lifecycle" under "Media"), unmuted and with the
|
||||
full cache pins. Its buffer comes along,
|
||||
changes nothing (the usual open follows). When the pipeline opens the
|
||||
same URL and headers, the preload is promoted instead of a fresh
|
||||
player: it becomes the session player through the same hand-over (see
|
||||
"Media pipeline" under "Media"), unmuted and with the full cache pins
|
||||
(`PlayerHost.promote`), its subtitles added. Its buffer comes along,
|
||||
so the report sent right after the open already passes the gate: the
|
||||
start no longer waits for anyone's open. A promoted player is no
|
||||
longer the first mpv of the process, so ffmpeg's HTTP status reaches
|
||||
@@ -1220,33 +1381,33 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
|
||||
errors themselves are still detected; see "Media"). Off with env
|
||||
`SYNC_RELAY_PRELOAD=0`. Measured (`queue_switch_e2e_test`, a fake
|
||||
Jellyfin and link host answering after 120 ms at 24 Mbit/s, two
|
||||
account holders and a `/j` guest): ⏭ to the server playing 59 to
|
||||
86 ms for the next episode and 35 ms for a direct link, against about
|
||||
1.5 s cold; at 300 ms and 12 Mbit/s 52 and 103 ms against about 2.9 s.
|
||||
Relayed, from the warm relay (same setup, loopback server): a link
|
||||
passed on 21 to 22 ms, a server-fetched link 35 to 37 ms, against
|
||||
account holders and a `/j` guest): ⏭ to the server playing 74 to
|
||||
87 ms for the next episode and 35 ms for a direct link, against about
|
||||
1.6 to 1.8 s cold; at 300 ms and 12 Mbit/s 52 and 103 ms against about
|
||||
2.9 s. Relayed, from the warm relay (same setup, loopback server): a
|
||||
link passed on 36 to 38 ms, a server-fetched link 46 to 53 ms, against
|
||||
about 1.6 s cold.
|
||||
`SwitchTrace` (env `SYNC_RELAY_TRACE_SWITCH=1` prints it) times each
|
||||
phase of a media change: state, resolved, open, player (a fresh
|
||||
player is up; see "Player lifecycle" under "Media"), opened or
|
||||
player is up; see "Media pipeline" under "Media"), opened or
|
||||
preloaded, buffered, playing.
|
||||
The row of the item shows a dot on its picture's corner (a tooltip
|
||||
says which): hollow waiting, pulsing getting ready (resolving, then
|
||||
preloading its first 4 s), filled ready, red failed (tried again every
|
||||
2 minutes).
|
||||
- **Continues with**: not queued, computed here like the next episode:
|
||||
with an own-account Jellyfin episode playing, the next three episodes
|
||||
of the series (`/Shows/{id}/Episodes`, cached), greyed under Up next,
|
||||
"+" adds one. "Clear" hides them for the media playing and keeps its
|
||||
next-episode countdown from starting.
|
||||
- **Continues with**: the continuation (the `auto` item, see "What's
|
||||
next"), greyed under Up next for everyone, ▶ plays it now; "Clear"
|
||||
drops it for everyone (`cancel`). Under it, on an app playing a
|
||||
Jellyfin episode with its own account, the series' further episodes
|
||||
(`/Shows/{id}/Episodes`, cached), "+" adds one to Up next.
|
||||
- **UI**: `UpNextButton` is one of the player's top-bar controls, before
|
||||
the window controls (see "Player overlay" under Desktop UI), in the look
|
||||
of the skin's buttons (`UpNextLook`); it shows and hides with the controls,
|
||||
which an open popover holds up: the first item's picture (`QueueThumb`,
|
||||
the rows' faint box and icon until it arrives), "UP NEXT" over its
|
||||
title, a +n count, ⏭ plays it now; without Up next the continuation
|
||||
(the episode's image, "NEXT · S1 E8" over its name, ⏭ = next episode);
|
||||
nothing when both are empty. Resting on it 600 ms (popover closed)
|
||||
(its picture, "NEXT · S1E8" (its second line) over its name, ⏭ plays
|
||||
it); nothing when both are empty. Resting on it 600 ms (popover closed)
|
||||
shows `UpNextPreview` under it, right edges aligned, in `AppGlass`:
|
||||
the picture large, title, second line, "length · added by (avatar)
|
||||
name · how" (an episode: series, length, overview), how ready it is
|
||||
@@ -1352,7 +1513,54 @@ show or movie, `ProviderIds` in the item fields; from a pick's lines,
|
||||
title). Swaps (`swapFile`, `swapJellyfin`, `swapLink`) and Up next adds
|
||||
carry it; Up next names a file item by it. Consumers: receivers' now
|
||||
playing title and episode line (`refTitle`), the swapper's art (by the
|
||||
AniList id); later the watch tracking keys on it.
|
||||
AniList id), the watch tracking's records (see "Watch tracking").
|
||||
|
||||
## Watch tracking
|
||||
|
||||
What this device watched, by D3: watched means at least as good as
|
||||
Jellyfin's own, and a peek, skipping around or a screenshot visit never
|
||||
counts. The truth is split: the device holds its watch history, Jellyfin
|
||||
its played state and resume point (reported now), AniList its list
|
||||
(later).
|
||||
|
||||
- **The rule** (`WatchSession` in `packages/core/lib/src/watch_session.dart`,
|
||||
pure): one per opening, sampled every second with the position,
|
||||
whether the media plays here (the server's transport is playing and
|
||||
this player plays it) and whether this viewer is In (its presence in
|
||||
`state`). The basis is time In: wall time it played while this viewer
|
||||
was `in`. Time sat out (Away), joining, gone, paused or held never
|
||||
counts, nor does a jump (skipping around adds only what was played);
|
||||
samples more than 10 s apart add nothing. A resume carries the earlier
|
||||
watch: when what played starts within 60 s of the resume point, the
|
||||
time up to it counts too. At a stop: played before -> keep played;
|
||||
time In >= 70 % of the runtime, or the ending reached (furthest
|
||||
position while In past the Outro / ED start or into the last 15 %, at
|
||||
most 4 min, whichever is earlier) with time In >= 40 % -> played;
|
||||
under max(2 min, 5 %) (at most a quarter of a short item) -> a peek;
|
||||
else part way, resuming at the last position seen while In. An unknown
|
||||
runtime never makes it played.
|
||||
- **The tracker** (`WatchTracker`, `lib/src/watch/`, made by the
|
||||
composition root): one watch per media of the server (`mediaId`), fed
|
||||
by the coordinator's 1 s tick, also through drops (the player goes
|
||||
on); a reconnect or a server restart never ends it while the media
|
||||
plays on (it follows the media to its new `mediaId`, and goes on while
|
||||
a restarted server waits for it, see "Server restart"); another media
|
||||
or leaving closes it, the end of the media
|
||||
(`finished`) records it as it stands (a seek back goes on with it).
|
||||
Each record (`WatchRecord`: key, title, `media.ref`, verdict, time In,
|
||||
runtime) goes out on `records`, where AniList progress will listen,
|
||||
and to the device's history. Keys (`WatchKey`): a file this app put up
|
||||
by its path, a Jellyfin item by its ids, a link this app put up or
|
||||
everyone opens by its URL, else name and size (someone else's file).
|
||||
- **The history** (`WatchHistory`, Hive box `watch_history`): files and
|
||||
links only (Jellyfin keeps its own): played, or a resume point (a part
|
||||
way watch of an item played before keeps it played); a peek changes
|
||||
nothing. Played before (and the resume point) for the next opening
|
||||
come from it; the Files page's "Next" follows the newest watched
|
||||
episode of a show, else the last one opened (Recent).
|
||||
- **Jellyfin**: the reporter reads the same watch (see "Jellyfin in a
|
||||
session", "Watched on Jellyfin"); Jellyfin's played state and resume
|
||||
point read at open become the watch's "before".
|
||||
|
||||
## App events and platform ports
|
||||
|
||||
@@ -1369,8 +1577,10 @@ Jellyfin item with its own account, so the relay is not its concern),
|
||||
that moves by the minute. `Status.line` is the player's pill (most
|
||||
pressing first: reconnecting with Cancel, unstable, refused or replaced
|
||||
with Retry, connecting, your player's fault or error with Reload, the
|
||||
server getting the video, the relay's source dropped / left / not
|
||||
passing it on yet, finished, someone loading, someone who cannot play
|
||||
server getting the video, everyone held for the relay's source
|
||||
("Waiting for <name>": `state.hold.reason` `source`, a viewer's buffer
|
||||
ran dry while it is gone; for everyone, own-account viewers too), the
|
||||
relay's source dropped / left / not passing it on yet, finished, someone loading, someone who cannot play
|
||||
it, you not ready, who everyone waits for; nothing while it plays and
|
||||
everyone is fine, and only connection lines while you sit out), each
|
||||
with a `StatusLevel` (ok and info never colour anything, warn amber,
|
||||
@@ -1466,8 +1676,12 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
|
||||
- `FolderAccess` (`DesktopFolders`): folder and file dialogs
|
||||
(file_picker), the Files scan, folder exists, file stat, showing a
|
||||
folder in the file manager. Paths for now.
|
||||
- `LinkResolver` (`AppYtDlp`): the app's yt-dlp, link probes, mpv's raw
|
||||
options, warm-up.
|
||||
- `LinkResolver` (`AppYtDlp`): the app's yt-dlp, link probes (what a
|
||||
page plays, see "yt-dlp"), whether this device resolves pages
|
||||
(`resolvesLinks`, the media plan's capability), warm-up.
|
||||
- `PlayerHost` (`MediaKitHost`): the media players ("Player host" under
|
||||
"Media"); Files' thumbnails make theirs with it too
|
||||
(`thumbnailPlayers`, set in `main`).
|
||||
- `SystemNotifications` (`DesktopNotifications`,
|
||||
`platform/desktop/desktop_notifications.dart`): `show(title, body, tag,
|
||||
payload)` and `onClick(payload)`, through flutter_local_notifications'
|
||||
@@ -1481,8 +1695,7 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
|
||||
(`AppDirs`, "Desktop UI"), and the copy of the data from before the
|
||||
rename; resolved in `main` before the composition root exists, so not a
|
||||
`Ports` field.
|
||||
Still direct: the player (`PlaybackController`, media_kit, and its
|
||||
`AppYtDlp.instance.mpvRawOptions`), the updater's restart
|
||||
Still direct: the updater's restart
|
||||
(`Process.start`), the session's own file reads for what it plays or
|
||||
relays (`QueueController`, thumbnails, cookies import), app-private
|
||||
caches.
|
||||
@@ -1694,7 +1907,8 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
servers), player (the shared rows below, the controls' choices, custom
|
||||
shader files),
|
||||
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
|
||||
`AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds
|
||||
`AnimeFlowClient.fromAccounts`, "Play the next episode automatically":
|
||||
whether this app adds the continuation, see "What's next"), notifications & sounds
|
||||
(what pops up, chat pop-ups, joins and leaves while sitting out or in
|
||||
the background), updates, shortcuts, about.
|
||||
- **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one
|
||||
@@ -1744,7 +1958,13 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
snaps to the nearest corner with the shared curve). A mode change fades
|
||||
the layer out, moves it while invisible and fades it in (with the tabs
|
||||
when the full player comes or goes), so the video is never mounted twice
|
||||
nor remounted. A tab sends the full player to mini; × on the mini sits
|
||||
nor remounted. The mini has no control skin: its `Video` gets an empty
|
||||
controls builder (never null: media_kit keeps the last controls on
|
||||
null, which left the full player's bars under the mini). Hovered it
|
||||
shows the title with expand and ×, the status line, the full player's
|
||||
play button (`controlIcon`/`togglePlay`: ready in a server) with the
|
||||
time, and a 4 px bar (track, cached, played) that seeks; otherwise a
|
||||
3 px played line along the bottom edge. A tab sends the full player to mini; × on the mini sits
|
||||
out (the player unloads and shows the join-back card, `SitOutCard`, over
|
||||
the still mounted `Video`); × on that card closes the player, the strip
|
||||
brings it back, and joining back with it closed shows the mini. Fullscreen
|
||||
@@ -1811,8 +2031,8 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
16 px from the bottom while they are hidden (`AnimatedPositioned`,
|
||||
the shell's `ease` / `easeTime`); in it the status pill centred
|
||||
(moved left only as far as it must to clear the right end, never
|
||||
overlapping it) and at the right end the end card (`UpNextCard`, else
|
||||
`EndOfEpisodeCard`, which stay up) or the action pills (Skip OP / ED /
|
||||
overlapping it) and at the right end the end card (`UpNextCard`, which
|
||||
stays up) or the action pills (Skip OP / ED /
|
||||
1:30 over Next episode, `PillGate`). The Jellyfin blocked card sits in
|
||||
the middle. Two surfaces: cards and popovers use `AppGlass`
|
||||
(`widgets/app_menu.dart`, the menus' recipe: blur, dark tint, hairline
|
||||
@@ -1947,8 +2167,8 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
|
||||
volume, skip offer, render mode; the shell's fullscreen, chat open and
|
||||
unread count) and what they do (`togglePlay` = ready, as Space;
|
||||
`seek` through `player.seek`, the user path; `nudge`, `skipForward`,
|
||||
`skip`, `stepChapter`, `pickChapter`; `next` = Up next's first item,
|
||||
else the next episode, `previous`; volume set / step (flashes) / mute;
|
||||
`skip`, `stepChapter`, `pickChapter`; `next` = the queue's first item
|
||||
(Up next's, else the continuation), else the next episode, `previous`; volume set / step (flashes) / mute;
|
||||
`pickTrack`, `pickShader`; time display; fullscreen, chat, change
|
||||
video, collapse, leave, reload, mark everyone ready, link). The
|
||||
player keys call the same actions. `ControlsSnapshot` (computed) is
|
||||
@@ -1990,7 +2210,7 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
|
||||
glass capsules (`MinimalCapsule`, the pills' tint and hairline;
|
||||
`MinimalButton` rounded squares, the play button a filled accent
|
||||
circle) above `MinimalTimeline`, a 36 px band across the bottom: the
|
||||
played part tinted in the theme's primary, the position a white line,
|
||||
played part tinted in the theme's primary (40 %), the position a white line,
|
||||
the part not downloaded yet (from `buffered`, or the position when
|
||||
that is behind, to the end) in thin stripes, chapter starts as dots on
|
||||
the top edge with a dark cut through the band (a press within reach of
|
||||
@@ -2000,7 +2220,11 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
|
||||
not reach the end). Hover: a faint line and a glass tip with the time
|
||||
(the chapter title above it). A press seeks, every drag move seeks,
|
||||
after release the line stays at the target until the position catches
|
||||
up (at most 2 s). Hidden, it shrinks (120 ms) to a 3 px progress line.
|
||||
up (at most 2 s). Hidden, it shrinks (120 ms) to a 3 px progress line
|
||||
that is the band itself, smaller: the same dark band, the same played
|
||||
tint (40 % of the accent), so growing or shrinking never changes colour;
|
||||
the stripes are stronger on the line (a few pixels of each show) so the
|
||||
part not downloaded still reads; chapter cuts are a light dark line.
|
||||
We know only the end of mpv's cache, so a dropped back buffer is not
|
||||
shown.
|
||||
- **Settings** (`AppSettings`, `model/controls_prefs.dart`, all live):
|
||||
@@ -2016,8 +2240,9 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
|
||||
`shown`) or the mouse is on it (which holds the controls up too), and
|
||||
Skip OP / Skip 1:30 also for 5 s when an offer of a new kind appears
|
||||
(Skip ED and Next episode never without the controls).
|
||||
- Volume: mpv's `volume-max` is pinned to `PlaybackController.maxVolume`
|
||||
(150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the
|
||||
- Volume: mpv's `volume-max` is pinned to 150 per player by the host
|
||||
(`PlaybackController.maxVolume`); the volume is the controller's and
|
||||
goes to every next player; `VolumeBar` runs 0..150 with a tick at 100 and the
|
||||
part above in amber; the wheel and ↑ / ↓ step 5 % (`volumeStep`),
|
||||
clamped. The wheel works anywhere on the full player (`VolumeWheel`
|
||||
around the player's stack): `WheelSteps` makes a mouse notch one step
|
||||
@@ -2040,10 +2265,11 @@ Alt+F4, and Update and restart): the window position is saved and the
|
||||
window hides at once. Then every mpv player goes, before the engine does
|
||||
(a live one calls its wakeup callback into a dead isolate): the session
|
||||
player, the preload and the player still retiring
|
||||
(`PlaybackController.disposeAll`, 1.5 s each, via
|
||||
(`MediaPipeline.disposeAll` → `PlayerHost.disposeAll`, 1.5 s each, via
|
||||
`SessionCoordinator.releasePlayers`, which keeps the position for the
|
||||
Jellyfin stop report), the Files thumbnail jobs (`cancelAllThumbnails`)
|
||||
and the sound engine (500 ms), side by side; on Windows 300 ms more,
|
||||
and the sound engine (500 ms), side by side, then anything the host
|
||||
still holds (the host owns the disposal order); on Windows 300 ms more,
|
||||
since media_kit_video frees a video output on a thread of its own after
|
||||
dispose returns, and an output still up when the engine stops blocks the
|
||||
plugin's teardown (`~VideoOutput` waits on a texture callback that never
|
||||
@@ -2067,7 +2293,9 @@ app kills itself (`Process.killPid`, no exit handlers; it only gets there
|
||||
if the close did not go through); the runners end the process 15 s after
|
||||
the first close request (Linux: a `delete-event` handler connected before
|
||||
window_manager's; Windows: the first `WM_CLOSE`), and Windows also 3 s
|
||||
after the window is destroyed, while the engine and plugins go.
|
||||
after the window is destroyed, while the engine and plugins go (only
|
||||
with an engine: `Win32Window::Create` runs `OnDestroy` before the window
|
||||
exists, and a timer armed there ended every start at 3 s).
|
||||
|
||||
## Logging
|
||||
|
||||
|
||||
+77
-21
@@ -6,7 +6,10 @@ Layout and rules: [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
Needs the Flutter SDK (beta channel, Dart 3.13+) and, for the Linux desktop
|
||||
build, the usual GTK toolchain (clang, cmake, ninja, pkg-config, libgtk-3-dev).
|
||||
mpv libraries come from `media_kit_libs_video`. `flutter_soloud` (the app's
|
||||
mpv libraries come from `media_kit_libs_video`. `media_kit_video` is the patched copy in
|
||||
`third_party/media_kit_video` (see its `starsync/README.md`); test video
|
||||
output changes under X11 (`GDK_BACKEND=x11`, ideally a real X11 session)
|
||||
and Wayland. `flutter_soloud` (the app's
|
||||
sounds) compiles SoLoud and miniaudio from source in its build hook with
|
||||
that C++ toolchain (MSVC on Windows); it needs no audio dev headers (miniaudio
|
||||
loads ALSA / PulseAudio / JACK at run time) and, with `no_xiph_libs` set in
|
||||
@@ -78,16 +81,21 @@ and `e2e_check` (simulated viewers against a live server).
|
||||
Three suites, run from each package's folder:
|
||||
|
||||
- `cd packages/core && dart test` (a few seconds): protocol round-trips,
|
||||
what a player opens for a page (`YtDlpPlay`) and the cookie notes of a
|
||||
failed resolve against a stand-in yt-dlp (`cookies_test`, D11),
|
||||
the sync engine (clock, gate, presence and grace, correction, end of
|
||||
file), the `PresencePolicy` table, queue model, the library's L1
|
||||
file), the `PresencePolicy` table, queue model and what's next (who
|
||||
puts an item up, the countdown on a fake clock), the library's L1
|
||||
(release and folder names, `groupLocal` over a real anime folder's
|
||||
names: shows across folders, holes, extras, a movie),
|
||||
relay pieces, updater, logging redaction, watch rule, yt-dlp and
|
||||
relay pieces, updater, logging redaction, the watched rule (time In),
|
||||
yt-dlp and
|
||||
Jellyfin client parsing against captured fixtures.
|
||||
- `cd packages/server && dart test` (well under a minute): live servers
|
||||
on loopback: auth and version refusal, identity, takeover and grace
|
||||
(`presence_test` on a fake clock), media and relay byte serving with
|
||||
source drops, Jellyfin relay against a fake, queue, session log,
|
||||
source drops, Jellyfin relay against a fake, queue (and the shared
|
||||
countdown on a fake clock), session log,
|
||||
isolate `serve`, sharing (`share_test`: a listener attached and detached
|
||||
under the same members and Up next, the host key standing in for the
|
||||
password).
|
||||
@@ -95,7 +103,10 @@ Three suites, run from each package's folder:
|
||||
controls, settings, schedule), pure app logic (`StateDiff`, the library's L2 matching against a
|
||||
fake AniList (`show_matcher_test`), `media.ref` producers,
|
||||
shader list, sound engine with a fake backend, invite addresses, the
|
||||
version gate), `singletons_test` (the `.instance` count, see
|
||||
version gate, the media plan's table with the Jellyfin relay rule and
|
||||
the "Waiting for" status (`media_plan_test`), watch tracking and the
|
||||
device history (`watch_tracker_test`), what continues a file of a
|
||||
local show (`queue_logic_test`)), `singletons_test` (the `.instance` count, see
|
||||
ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e`
|
||||
tests plus `coord_presence`. The whole suite takes a couple of minutes;
|
||||
run it only before a restructure step lands, otherwise only the files
|
||||
@@ -108,21 +119,40 @@ else is offline: Jellyfin and link hosts are local fakes.
|
||||
|
||||
Real-mpv tests: `sync_playback_e2e`, `coord_presence` (drops through a
|
||||
`FlakyProxy`: a half-open link and return, 10 flaps, `replaced`, `bye`,
|
||||
a dead or restarted server), `video_reload_e2e`,
|
||||
a dead or restarted server; a restarted server that came back empty gets
|
||||
what played back at its position, a link everyone opens and a file
|
||||
passed on, nothing reopened, the watch and Up next carrying on), `video_reload_e2e` (a fresh player from the
|
||||
host, the old one retired, leave releases them all),
|
||||
`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `chapters_mpv_e2e`,
|
||||
`ytdl_headers_e2e`, `relay_e2e` (a dedicated server with no file of its
|
||||
`ytdl_headers_e2e` (a page through the app's own resolve against a
|
||||
stand-in yt-dlp and a Referer-checking CDN: the EDL, the headers and
|
||||
cookies, subtitles without danmaku, a 403, the D11 error),
|
||||
`media_switch_e2e` (commands during a switch wait and apply once, no mpv
|
||||
errors), `relay_e2e` (a dedicated server with no file of its
|
||||
own: a guest's file, links passed on or fetched by the server, a late
|
||||
source, a source that drops and resumes), `jellyfin_e2e`,
|
||||
`queue_switch_e2e` (times Up next switches against a delayed, rate-capped
|
||||
fake Jellyfin and link host: an episode, a direct link, a link passed on
|
||||
and a server-fetched one; `WT_SWITCH_PRELOAD=0` for the cold path,
|
||||
source, a source that drops and resumes; a seek past what a slow source
|
||||
has sent, timed; a source that drops while a viewer seeks past
|
||||
the buffer and another joins: no seek loop, "Waiting for", all resume
|
||||
(D6)), `jellyfin_e2e` (account holders report what they watched, one
|
||||
who sat out is not marked watched, the next episode continues for
|
||||
everyone), `whats_next_e2e` (a file of a show continued by its next
|
||||
episode with the gap said, the server's countdown on both screens, a
|
||||
shared cancel, the watch history), `queue_switch_e2e` (times Up next switches
|
||||
through the media pipeline against a delayed, rate-capped fake Jellyfin
|
||||
and link host: an episode, a direct link, a link passed on and a
|
||||
server-fetched one, each prepared and promoted; no mpv "error running
|
||||
command" or "file cache" lines; `WT_SWITCH_PRELOAD=0` for the cold path,
|
||||
`WT_SWITCH_DELAY_MS` / `WT_SWITCH_RATE_KBS` for the link). The sync,
|
||||
reload and end-of-file ones expect `~/Videos/chiyo.mp4`; the others
|
||||
generate media with ffmpeg into `WT_TEST_TMP` (default: system temp; all
|
||||
desktop tests that write files use it). Headless mpv (no video output
|
||||
attached) won't open video-only files, so test media needs an audio
|
||||
track. Tests always log media_kit "S/W rendering" because they have no
|
||||
display; that says nothing about the app. `sound_device_e2e` opens the
|
||||
display; that says nothing about the app. A headless player has no video
|
||||
track selected (`vid=no`), and an mkv's cues list only the video, so
|
||||
seek tests over a relay or HTTP use an mp4 with its index first
|
||||
(`+faststart`); setting `vid=auto` on a headless player crashes mpv when
|
||||
it is disposed. `sound_device_e2e` opens the
|
||||
real audio device (flutter_soloud) and plays every sound of every preset
|
||||
quietly (`WT_SFX_VOLUME`, default 0.1).
|
||||
|
||||
@@ -160,12 +190,12 @@ in [ROADMAP.md](ROADMAP.md)):
|
||||
|
||||
| Scenario | Covered today | Missing, added in |
|
||||
|---|---|---|
|
||||
| Drop and reconnect while sharing a relay | server `flaky_link_test` (blackhole: `gone` with ready, queue item and relay kept, the same member back; reset), `sync_wave_test` (takeover by client id, `replaced`, `stale`), `presence_test` (grace on a fake clock, restart), `relay_test` "the source drops (D6)" (`sourceGone`, buffer served, same `info` continues, another 4002, a second channel 4003, leave ends it); desktop `relay_e2e` "the source drops uncleanly and comes back" (gone past the server's silence, the relay kept, the source re-dials and continues), `coord_presence` (a half-open drop while playing: same member, no reopen, the state kept; 10 flaps: at most one lost/back sound; `replaced` is terminal; a restarted server gets the ready again), `connection_test` (the hello's id, `seq`, caps, `feedAfter`) | |
|
||||
| Drop and reconnect while sharing a relay | server `flaky_link_test` (blackhole: `gone` with ready, queue item and relay kept, the same member back; reset), `sync_wave_test` (takeover by client id, `replaced`, `stale`), `presence_test` (grace on a fake clock, restart), `relay_test` "the source drops (D6)" (`sourceGone`, buffer served, same `info` continues, another 4002, a second channel 4003, leave ends it); desktop `relay_e2e` "the source drops uncleanly and comes back" (gone past the server's silence, the relay kept, the source re-dials and continues), `coord_presence` (a half-open drop while playing: same member, no reopen, the state kept; 10 flaps: at most one lost/back sound; `replaced` is terminal; a restarted server gets the ready again, and what played if it came back empty), server `queue_test` (the first restore offer wins), `connection_test` (the hello's id, `seq`, caps, `feedAfter`) | |
|
||||
| Join mid-playback | core `sync_engine_test` "D2: joiners" (never changes the transport while playing, at most 15 s while held, joining ends on error or timeout), `presence_test` (the table); server `relay_test` "a late joiner gets its own /r/ URL"; desktop `coord_presence` (the returning member plays on in sync) | |
|
||||
| Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (the host sat out through `presence` on your shared server), `notice_table_test` (the feed actions one-to-one, matched by id; sitting out gets only chat, nudges, "starting now", Up next and the chosen joins and leaves; alone no social sounds), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out) | step 6: not tracked as watched (P7) |
|
||||
| Swap, also while someone is Away or gone | core `sync_engine_test` "swap" (a gone member stays out of the gate, comes back joining), "away" (a swap leaves it away), `queue_test` (a swap takes its item, drops the `auto` one); server `queue_test` (a gone adder's item waits), `relay_test` (a swap drops the buffer); desktop `sync_playback_e2e` | step 5: no commands reach a player mid-switch (P15) |
|
||||
| Up next advance | core `queue_test` (skip, play request timeout, `auto` and `cancel`); server `queue_test`, `relay_test` "warm relay"; desktop `jellyfin_session_test` (auto-advance swapper, end card countdown), `queue_switch_e2e` | step 5: preparing it through the media pipeline; step 6: the countdown as server state (`state.advance`) |
|
||||
| Close the app cleanly, incl. `bye` | server `lifecycle_test` (a close keeps the member `gone`, `bye` leaves at once, server stop closes sockets); desktop `server_isolate_test` (stop frees the port; Stop sharing tells guests with 4016 and keeps you and Up next; a friend's server and back keeps yours), `coord_presence` (Stop sharing and your server's stop release their ports; leave sends `bye` and others see "left" at once, leaving for a restart keeps the member `gone`) | step 5: the Windows close smoke job (P32) |
|
||||
| Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (the host sat out through `presence` on your shared server), `notice_table_test` (the feed actions one-to-one, matched by id; sitting out gets only chat, nudges, "starting now", Up next and the chosen joins and leaves; alone no social sounds), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out); core `watch_session_test`, desktop `watch_tracker_test`, `jellyfin_session_test` "sat out", `jellyfin_e2e` (sat out: not tracked as watched, P7) | |
|
||||
| Swap, also while someone is Away or gone | core `sync_engine_test` "swap" (a gone member stays out of the gate, comes back joining), "away" (a swap leaves it away), `queue_test` (a swap takes its item, drops the `auto` one); server `queue_test` (a gone adder's item waits), `relay_test` (a swap drops the buffer); desktop `sync_playback_e2e`, `media_switch_e2e` (no command reaches a player mid-switch) | |
|
||||
| Up next advance | core `queue_test` (skip, play request timeout, `auto` and `cancel`; the countdown: start, follow the first item, seek, cancel, hold for a gone performer or source, skip what nobody can put up), `whats_next_test`; server `queue_test` (the countdown and cancel shared, a skip by the server); `relay_test` "warm relay"; desktop `jellyfin_session_test` (end card), `jellyfin_e2e` and `whats_next_e2e` (the continuation and the countdown end to end), `queue_switch_e2e` (prepared and promoted through the media pipeline) | |
|
||||
| Close the app cleanly, incl. `bye` | server `lifecycle_test` (a close keeps the member `gone`, `bye` leaves at once, server stop closes sockets); desktop `server_isolate_test` (stop frees the port; Stop sharing tells guests with 4016 and keeps you and Up next; a friend's server and back keeps yours), `coord_presence` (Stop sharing and your server's stop release their ports; leave sends `bye` and others see "left" at once, leaving for a restart keeps the member `gone`), `video_reload_e2e` (leave releases every player); the close smoke run on the built app (`tool/smoke_windows.ps1` in CI, `tool/smoke_linux.sh` by hand) | |
|
||||
|
||||
A test file whose first line is `// restructure: rewrite in step N` pins
|
||||
behaviour that step of the restructure ([ROADMAP.md](ROADMAP.md)) changes;
|
||||
@@ -174,7 +204,22 @@ before.
|
||||
|
||||
CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus
|
||||
`dart compile exe` of the dedicated server (fails if Flutter creeps into
|
||||
`packages/server`). Desktop tests are not in CI.
|
||||
`packages/server`). Desktop tests are not in CI. The Windows job runs the
|
||||
updater tests, the release build and the close smoke run on that build
|
||||
(`tool/smoke_windows.ps1`, P32): the app starts with
|
||||
`STARSYNC_SMOKE=<clip>` (a 5 s clip from ffmpeg, else a WAV the script
|
||||
writes) under its own profile (`STARSYNC_PROFILE=smoke`), opens the clip
|
||||
in a player with a video output through the media pipeline
|
||||
(`platform/smoke.dart`), plays it, stays up 10 s from its start and
|
||||
closes the normal way; the steps go to `STARSYNC_SMOKE_OUT` (`start`,
|
||||
`opened <ms> render=<mode>`, `closing`, `quitting` just before the
|
||||
window closes, or `skip` / `fail` with why). It fails when the app ends
|
||||
by itself before `closing` or never reaches `quitting`, does not exit
|
||||
within 45 s or leaves a `starsync` process, skips with a warning
|
||||
when the runner cannot bring the app or its video output up, and prints
|
||||
the `smoke:`, `close:` and `mpv: render` lines of the app log.
|
||||
`tool/smoke_linux.sh` is the same on Linux (needs a display; after
|
||||
`flutter build linux --release`).
|
||||
|
||||
Traps:
|
||||
- Never pump media_kit's `Video` in `flutter test`: the runner hangs (kill
|
||||
@@ -207,7 +252,8 @@ User pushes tags; CI does the rest.
|
||||
2. Review, then `git push --follow-tags`.
|
||||
3. `.gitea/workflows/release.yml` runs one job per platform: Linux builds
|
||||
with `tool/package_linux.sh`, Windows (runner `yuuka-windows`, host mode,
|
||||
PowerShell only) with `tool/package_windows.ps1`. Each then runs
|
||||
PowerShell only; `tool/setup_flutter_windows.ps1` also adds Visual
|
||||
Studio's C++ ATL component once, which the notifications plugin needs) with `tool/package_windows.ps1`. Each then runs
|
||||
`dart tool/release_publish.dart vX.Y.Z <archive>`, which creates the
|
||||
release if the other job hasn't yet and uploads (or replaces) its asset.
|
||||
By hand: package, then the same `dart tool/release_publish.dart` line.
|
||||
@@ -258,6 +304,14 @@ in the same file; a dedicated server prints its folder at start
|
||||
(`--log-dir`). Users send theirs with Settings → About → "Copy
|
||||
diagnostics".
|
||||
|
||||
A Windows friend without a terminal habit: `tool/windows_report.cmd`
|
||||
(one file: a cmd header that runs the PowerShell below it). Dropped into
|
||||
the Starsync folder and double-clicked, it collects the app's logs,
|
||||
Windows' crash events (1000/1001/1026), that install's file list (and
|
||||
any other found on Desktop, Downloads, Documents, AppData, Program
|
||||
Files) and a 20 s test start with its exit code, zips them to the
|
||||
Desktop and copies the zip (Ctrl+V into Discord).
|
||||
|
||||
Start with the session log when there is one: `session-YYYY-MM-DD.log`
|
||||
in the server's folder (the hosting app's logs folder, or the dedicated
|
||||
server's `--log-dir`), every member's lines and the server's merged in
|
||||
@@ -302,8 +356,10 @@ is off), not software rendering.
|
||||
- `web_socket_channel` 3.x: `channel.ready` errors are unhandled on refused
|
||||
connections; swallow them and report through the stream's `onError`.
|
||||
Streams are single-subscription: one listener per socket.
|
||||
- media_kit has no public `Player.setProperty`; the code reaches mpv through a
|
||||
dynamic call on `player.platform`. Cache size goes through
|
||||
- media_kit has no public `Player.setProperty`; the player host
|
||||
(`MediaKitHost`) reaches mpv through a dynamic call on
|
||||
`player.platform`, and nothing outside it imports media_kit except the
|
||||
`Video` widget glue. Cache size goes through
|
||||
`PlayerConfiguration(bufferSize:)`.
|
||||
- Hot restart with any media_kit `Player` alive (session, thumbnails)
|
||||
used to SIGSEGV on mpv's `core` thread: each mpv handle's wakeup callback
|
||||
|
||||
+12
-1
@@ -78,7 +78,13 @@ brings you back.
|
||||
6. **Swap.** Anyone can change the video by pasting another link. Everybody
|
||||
stops at 0 and the gate runs again for everyone there; ready flags stay
|
||||
as they were (a setting unreadies you instead).
|
||||
7. **Leave.** Closing the app or Leave says goodbye and drops you from the
|
||||
7. **What's next.** Up next is one list on the server for everyone, and
|
||||
an episode you put up is followed by its next one. When a video ends
|
||||
with something next, the server counts down 10 s on every screen;
|
||||
anyone can cancel it for everyone (a seek stops it too). At zero the
|
||||
app that has what plays next puts it up; if that person is away for a
|
||||
moment, the countdown waits for them.
|
||||
8. **Leave.** Closing the app or Leave says goodbye and drops you from the
|
||||
server; the others keep going. If you vanish without a goodbye, after 15 s
|
||||
of silence you are "gone": you stop blocking but keep your place
|
||||
(ready flag, sitting out, Up next items, the video you pass on) for
|
||||
@@ -90,6 +96,11 @@ anyone, and sit anyone out (the host too). Someone sitting out stays
|
||||
connected and in the chat, but their app loads nothing and nobody waits
|
||||
for them until they join back.
|
||||
|
||||
Watched or not is counted by the time you were there while it played:
|
||||
sitting out, skipping around or a quick look never counts as watching.
|
||||
Jellyfin hears it for your account; files and links are remembered on
|
||||
your computer.
|
||||
|
||||
## Where video comes from
|
||||
|
||||
- **Link**: you paste an http(s) URL. By default your computer opens it and
|
||||
|
||||
+32
-12
@@ -98,6 +98,7 @@ yet; the restructure step named next to them does.
|
||||
{"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}}
|
||||
{"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}}
|
||||
{"t":"swap","media":{...},"queueId":"q3"} puts up an Up next item
|
||||
{"t":"swap","media":{...},"restore":{"position":512.3,"transport":"playing"}} puts back what played (see below)
|
||||
{"t":"report","position":123.1,"buffered":6.2,"eventId":41,
|
||||
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true},"error":"Not allowed (403)"}
|
||||
{"t":"ping","c":1767225600000}
|
||||
@@ -205,6 +206,13 @@ Field notes:
|
||||
every member's `ready` and puts every member in the set (`inSet`; who
|
||||
counts is up to presence); clients that want to be unready for the new
|
||||
media send `ready` themselves (gate: ARCHITECTURE.md, "Ready gate").
|
||||
- `swap.restore`: what played before the server restarted, offered back
|
||||
by a member that still holds it (`position` seconds, `transport`
|
||||
`playing`, `held` or `finished` as it was). The server takes it only
|
||||
while it has no media; any later one is ignored, so the first offer
|
||||
wins. It is a swap otherwise (relays, art, the gate), held at
|
||||
`position` (`finished`: at the end), its feed `swap` line with
|
||||
`reason` `restore`.
|
||||
- Frames before `welcome` (other than `hello`) are ignored; unparsable or
|
||||
unknown frames are ignored silently. Frames
|
||||
from a socket another hello took over are ignored.
|
||||
@@ -282,8 +290,8 @@ Field notes:
|
||||
`source` (the same while the relay's source is `gone`), `start` (nobody
|
||||
counts for the gate: someone has to ready up). `members`: the member
|
||||
ids the gate waits for (empty for `start`).
|
||||
- `state.advance` (**reserved**, step 6): the shared countdown to Up next
|
||||
(see "Queue").
|
||||
- `state.advance` (omitted when none runs): the shared countdown to Up
|
||||
next's first item at the end of the media (see "Queue").
|
||||
- `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms
|
||||
(the clock `serverTime` uses). With `t3` the local receive time, the offset
|
||||
is `s - (c + t3) / 2`, accurate to half the round trip.
|
||||
@@ -299,9 +307,10 @@ Field notes:
|
||||
the actor set itself; `reason` `idle`/`minimised`/`bye`, empty for
|
||||
manual), `in` (back from away), `swap`, `seek`, `pause`, `resume`,
|
||||
`finished`, `chat`, `rename`, `avatar`, `queue-add`, `queue-remove`,
|
||||
`queue-skip`, `advance-cancel` (`target` the dropped continuation's
|
||||
title, if any), `fetch-fail` (the server could not fetch the current
|
||||
media, `target` its name). `reason` carries `auto`/`manual` for ready.
|
||||
`queue-skip` (actor `server` when the server skipped it), `advance-cancel`
|
||||
(`target` the dropped continuation's title, if any), `fetch-fail` (the server could not fetch the current
|
||||
media, `target` its name). `reason` carries `auto`/`manual` for ready,
|
||||
`restore` for a swap that put back what played (`swap.restore`).
|
||||
`rename`: `actor` is the new name, `target` the old one. `avatar`: the
|
||||
actor changed only the picture. `queue-*`: `target` is the item's
|
||||
title, `reason` (skip) why it could not play (see "Queue").
|
||||
@@ -623,7 +632,7 @@ state "queue":[<item>,...], "queuePlay":"q3"
|
||||
<item> = {"id":"q3","media":{<swap media>},"how":"direct"|"server"|"through"|"file"|"jellyfin",
|
||||
"title":"...","sub":"...","duration":213,"thumb":"<hash>",
|
||||
"by":"m2","byName":"Bob","key":"<adder's handle>","auto":true?,"for":2?}
|
||||
state "advance":{"id":"q3","at":"<server time>","secs":10} reserved
|
||||
state "advance":{"id":"q3","at":"<server time>","secs":10}
|
||||
```
|
||||
|
||||
- `add`: `item` without `id`, `by`, `byName`, `thumb` (the server sets
|
||||
@@ -634,7 +643,8 @@ state "advance":{"id":"q3","at":"<server time>","secs":10} reserved
|
||||
item is queued. `title` and `sub` are cleaned like media names and cut
|
||||
to 200 characters (an empty title becomes the media's name); `duration`
|
||||
(seconds, omitted when unknown) is informational. At most 100 items;
|
||||
over that the sender gets `err` `Up next is full (100 items)`.
|
||||
over that the sender gets `err` `Up next is full (100 items)`. With
|
||||
nothing up, the first item is asked for at once (`queuePlay`).
|
||||
- `how` and what `media` must carry:
|
||||
- `direct`: an http(s) `url`; everyone opens the link.
|
||||
- `server`: an http(s) `url` and `relay` `{"fetch":true}`, on a server
|
||||
@@ -654,7 +664,10 @@ state "advance":{"id":"q3","at":"<server time>","secs":10} reserved
|
||||
- `play`: moves `id` first and sets `state.queuePlay` to it: a request for
|
||||
the member who can put it up to do so now. `skip` is `play` of the
|
||||
first item. `queuePlay` ends with any swap, when the item is removed,
|
||||
or after 30 s.
|
||||
or after 30 s. An item asked for that nobody here can put up (its
|
||||
adder left for good, nobody has its Jellyfin server; the rule is
|
||||
core's `queuePerformer`, ARCHITECTURE "What's next") is taken off by the
|
||||
server: feed `queue-skip`, actor `server`, `reason` why.
|
||||
- `auto` (with `for`, the `mediaId` it continues): the continuation (the
|
||||
next episode). Only for the current media (else `err` `bad`), and only
|
||||
from the member who put it up (`media.swapBy`) or, when that member is
|
||||
@@ -662,12 +675,19 @@ state "advance":{"id":"q3","at":"<server time>","secs":10} reserved
|
||||
one: a newer one replaces it; it stays after the other items (an `add`
|
||||
goes before it, `next` puts an item first, `move` never passes it, it
|
||||
does not move itself); any swap that does not put it up drops it.
|
||||
Adding it gives no feed line.
|
||||
- `cancel`: drops the `auto` item and the countdown for everyone (feed
|
||||
`advance-cancel`); nothing to drop, nothing happens.
|
||||
- `state.advance` (**reserved**, step 6): the shared countdown when the
|
||||
media finishes and an item is first; at `at` + `secs` the server would
|
||||
set `queuePlay` to `id`, holding while its performer or source is
|
||||
`gone`. The server never starts one yet; `cancel` and any swap clear it.
|
||||
- `state.advance`: the shared countdown, started when the media finishes
|
||||
with an item first (or an item comes first while it is finished);
|
||||
`secs` is 10, `at` its start (server clock). A new first item takes it
|
||||
over with the same end. At `at` + `secs` the server sets `queuePlay` to
|
||||
`id` and the countdown is over; while the member who puts the item up
|
||||
is not around (its adder `gone`, nobody present) or the item's warm
|
||||
relay waits for a `gone` source, it holds, and asks once they are
|
||||
back. A seek (the end of `finished`), an empty queue, `cancel` and any
|
||||
swap clear it; after `cancel` it does not start again for that media,
|
||||
nor again for the item it already asked for.
|
||||
- `swap.queueId`: the swap puts that item up and takes it off the queue
|
||||
in the same step. A swap naming an item that is no longer queued is
|
||||
ignored (someone put it up first). For a `server` item the swap may
|
||||
|
||||
+108
-62
@@ -35,19 +35,7 @@ Problems are numbered; delete a line when its fix ships.
|
||||
- D11 Login-only sites: error texts say which cookies were used.
|
||||
|
||||
**Problems:**
|
||||
- P2 Viewers of a relay whose source is gone: mpv loops on failed seeks
|
||||
past what the server buffered (viewer-side recovery, step 5).
|
||||
- P7 Sat-out people get tracked as watched on Jellyfin.
|
||||
- P9 The watched rule (position coverage) misses real watches (D3).
|
||||
- P15 Commands reach a player mid-switch (`seek 90.0`, `event:5` errors).
|
||||
- P17 Preload, warm relay and the fresh player per video are layered on
|
||||
the player instead of being one media pipeline.
|
||||
- P18 Bilibili "no video formats" with browser cookies set; the hint
|
||||
wording misleads (D11).
|
||||
- P25 ssh to Gitea blocked through the proxy (pushes go over https).
|
||||
- P28 Log noise: "Failed to create file cache"; an idle relay channel for
|
||||
Jellyfin items everyone has an account for.
|
||||
- P29 Relay start delay after a seek into an unbuffered part.
|
||||
- P30 Shaders don't work on Windows. Fix in 1.2.1-dev.3 (list read-back
|
||||
check; Windows hwdec `auto-copy`): a Windows user must confirm shaders
|
||||
stay on and the EGL-surface error is gone.
|
||||
@@ -56,11 +44,20 @@ Problems are numbered; delete a line when its fix ships.
|
||||
`watch-together 2>&1 | grep VideoOutput` from a terminal if software.
|
||||
- P32 The app freezes on exit, mostly reported on Windows. 1.2.1-dev.3:
|
||||
orderly close, Linux `_exit` (0 crashes in 41 closes vs 10/10 before),
|
||||
Windows normal close + TerminateProcess + watchdogs, untested on
|
||||
Windows; if it still hangs: Event Viewer 1000/1002, a Task Manager dump,
|
||||
Windows normal close + TerminateProcess + watchdogs; the player host
|
||||
now owns the disposal order. CI's Windows job runs a close smoke run on
|
||||
every build (`tool/smoke_windows.ps1`); its first runs on the runner
|
||||
must show whether it passes or skips there (no interactive desktop). If
|
||||
a real close still hangs: Event Viewer 1000/1002, a Task Manager dump,
|
||||
the `close:` log lines.
|
||||
- P33 No volume readout on wheel: likely fixed by the controls rework
|
||||
(1.2.1-dev.1; a test covers both skins); confirm in real use.
|
||||
- P35 System notifications in a burst hit KDE Plasma's limit
|
||||
(`ExcessNotificationGeneration`, 3 refused within 3 s after a swap while
|
||||
the window was in the background): throttle or merge them app side.
|
||||
- P36 After a reconnect the app re-dials the warm relay of an Up next
|
||||
item that was already played ("relay channel refused: not the source of
|
||||
Up next item q8"): harmless, the feeder should end with its item.
|
||||
|
||||
**Restructure** (in order; reviewed against the code). Truth lives in
|
||||
three places: the server (members, presence, gate, transport, queue incl.
|
||||
@@ -68,24 +65,13 @@ the automatic continuation and countdown, relay state), the device
|
||||
(client id, library and folders, match corrections, watch history,
|
||||
settings, notification prefs, what this app is sourcing) and the
|
||||
integrations (Jellyfin played/resume, AniList list).
|
||||
5. Media pipeline + player host port: plan → resolve → open → ready,
|
||||
commands queued during a switch, viewer-side recovery when a source
|
||||
drops (no mpv seek loops, play what's buffered), resolver and source
|
||||
chosen by capability; a Windows CI smoke job (start, open, close
|
||||
within N s, no orphan process). Fixes P2 P15 P17 P18 P28 P29, P30–P32;
|
||||
D11.
|
||||
6. What's next (decides which/when, asks the pipeline to prepare;
|
||||
continuation and countdown as server state, shared cancel) + Watch
|
||||
tracking (time In from the presence history, device history, Jellyfin
|
||||
now, AniList later). Fixes P7 P9; D3.
|
||||
7. Release 1.3.0: publish the manifest (protocol 16 makes it a required
|
||||
update for 1.2.x) and update the VPS dedicated server in the same
|
||||
window; check a 1.2.x app gets a clear "update the app" message.
|
||||
Released as 1.3.0. Left: check that a 1.2.x app gets a clear "update
|
||||
the app" message and updates (Windows: the first real use, backlog 5).
|
||||
Composition root (in place, ARCHITECTURE "Composition root"): the
|
||||
remaining `.instance` singletons move into `AppGraph` as the steps touch
|
||||
them; `singletons_test` holds the count (today: AppSettings 55,
|
||||
them; `singletons_test` holds the count (today: AppSettings 49,
|
||||
JellyfinAccounts 10, UpdateController 8, Profile 8, FolderStore 6,
|
||||
AppYtDlp 4, RecentLinks 3).
|
||||
AppYtDlp 2, RecentLinks 3).
|
||||
Not in the restructure: P25 (ops).
|
||||
|
||||
|
||||
@@ -126,6 +112,56 @@ your server leaves them there without you, unwarned; a join refused
|
||||
after dialling (wrong password) costs your own member there (`bye`, back
|
||||
as a new member). Your server writes its session log even when you are
|
||||
alone.
|
||||
Left of step 6 (what's next, watch tracking): a human check, on Linux
|
||||
now, of the server's countdown and the end card in real use (Cancel
|
||||
from another screen, a seek stopping it, "Waiting for" while the one who
|
||||
puts it up is gone), of a file continuation on the real folder (Bleach
|
||||
"skips 66–107" across its folders, SAO S3 into its OP/ED folder, the
|
||||
last episode of a show) and of Jellyfin's played state and resume point
|
||||
under time In against a real Jellyfin (a full watch, one sat out, a
|
||||
resume), and of a dev server restarted mid-episode (what played comes
|
||||
back where it was for everyone, a Jellyfin item passed on to a guest
|
||||
re-fed: only files and links are covered by tests). Not built: AniList
|
||||
progress (the tracker's `records` are its seam); nothing resumes a file
|
||||
or link at the history's resume point (it only keeps it); no watched
|
||||
marks on Files' episode rows (only "Next" follows the history) and no
|
||||
way to see or clear the history. The continuation of a file comes only
|
||||
from the app that put it up (only it has the file). "Play the next episode
|
||||
automatically" is the setting of whoever puts an episode up; Up next's
|
||||
own items count down whatever it says. A play request the performer
|
||||
does not act on ends after 30 s without a new countdown for that item
|
||||
(Play now remains). A warm relay waiting for a `gone` source holds the
|
||||
countdown even when the one who puts the item up is there. After a
|
||||
server restart, a relayed file or link whose member is not back, or a
|
||||
Jellyfin item nobody back has an account for, cannot be put back (the
|
||||
apps let go after 30 s); a server-fetched Up next item is lost (its
|
||||
address stayed with the old server).
|
||||
Left of step 5 (media pipeline): a human check, on Linux now and on
|
||||
Windows at 1.3.0, of a page link in "Everyone fetches it" through the
|
||||
app's own resolve (YouTube's 1080p video + audio EDL, Bilibili with its
|
||||
Referer and cookies, X's HLS) and of its subtitles, and of the D11 text
|
||||
with browser cookies (Bilibili without a login there); a relay viewer
|
||||
during a real source drop (the bytes run out, "Waiting for <name>", the
|
||||
return); the first Windows CI runs of the close smoke step. Not built:
|
||||
a viewer cannot tell what the server's relay buffer holds, so a seek
|
||||
past its own mpv buffer while the source is away is tried once and then
|
||||
waits for the source even when the server had it; `Unresolvable` (a
|
||||
device without yt-dlp) only says who could pass the link on, nothing
|
||||
routes it there by itself (Android); a page's stream URLs come from the
|
||||
probe cache (10 minutes, 35 for Up next's first item) and are resolved
|
||||
again only at Reload video and the 30-minute refresh; a format whose
|
||||
CDN wants per-track headers that differ gets the first track's (as
|
||||
mpv's hook did); Bilibili's danmaku is dropped, not shown. The Jellyfin
|
||||
item this member put up re-feeds its relay after an app restart (the
|
||||
state still names it the source).
|
||||
Relay start (P29's rest): a seek into what the source has not sent
|
||||
plays after about 2.8 s in the e2e; a human check of a real session
|
||||
on dev.8+. The source's in-flight window still starts at 256 KiB and
|
||||
grows with the measured rate, so the speed climbs from low after a
|
||||
seek or a new link; the start of a freshly put-up link was never
|
||||
measured on its own. Levers if it still shows: start the window from
|
||||
the last rate, prefetch from the source as soon as a link goes up,
|
||||
a smaller gate buffer when only the relay is behind.
|
||||
Left of step 4 (library): a human check of the Files library on the
|
||||
real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders
|
||||
with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the
|
||||
@@ -137,9 +173,7 @@ swapper's bytes (now chosen by the ref's AniList id). Relay logins are
|
||||
sent at Invite: an account linked or signed in again later relays only
|
||||
after Stop sharing and Invite. "Season N" without a name walks AniList's
|
||||
sequels, which can land on the wrong cour where AniList splits seasons;
|
||||
Wrong show? fixes it. The next episode of a local show lives on its page
|
||||
(from Recent); no countdown or "Continues with" for files yet (backlog
|
||||
2). The `library` box keeps every AniList entry seen (no pruning);
|
||||
Wrong show? fixes it. The `library` box keeps every AniList entry seen (no pruning);
|
||||
AniList's episode titles exist only where it lists streaming episodes.
|
||||
Left of step 2: system notifications need a human check (Linux: the
|
||||
notification shows and a click brings the window up, also on Wayland,
|
||||
@@ -165,13 +199,8 @@ Opened in step 1: [PROTOCOL.md](PROTOCOL.md) is proto 16 (version
|
||||
ranges and the refusal frame, error codes and close codes, lasting members by client id with takeover and `seq`, grace and
|
||||
`bye`, presence and `PresencePolicy`, `state.hold`, feed ids, `seq` and
|
||||
replay, relay `sourceGone` and source resume, lasting `queue[].by`,
|
||||
`auto` items and `cancel`, `media.ref`, `members[].app`/`caps`), server,
|
||||
`auto` items, `cancel` and `state.advance`, `media.ref`, `members[].app`/`caps`), server,
|
||||
core and app. Still open on the wire, frozen at the 1.3.0 release:
|
||||
- `state.advance` (step 6): the server starts the countdown when the
|
||||
media finishes and an item is first, sets `queuePlay` at its end and
|
||||
holds it while the item's performer or source is `gone`. Today it is
|
||||
parsed and broadcast but never started; `auto` items and `cancel` are
|
||||
live.
|
||||
- `hello.client` optionally derived per server (HMAC of an install
|
||||
secret and the dialled host:port).
|
||||
- TLS: `/health.tls` is always false; `wss://` only with a CA-valid
|
||||
@@ -180,21 +209,32 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
|
||||
|
||||
## Backlog
|
||||
|
||||
0. **media_kit fork evaluation** (after the restructure): upstream
|
||||
media-kit releases stalled at media_kit_video 2.0.1 (Dec 2025) though
|
||||
main still gets fixes; `Predidit/media-kit` is active (138 ahead, 146
|
||||
behind upstream) with native D3D11 on Windows (no ANGLE: may fix P30
|
||||
and the dxva2-egl error), a bundled libmpv on Linux, triple-buffered
|
||||
Linux rendering, patched FFmpeg. Check: Windows shaders, whether its
|
||||
it plays `edl://` URLs with `http-header-fields` (pages resolve in
|
||||
the app since step 5, so mpv's Lua and ytdl_hook are not needed), offline
|
||||
CI builds (it downloads binaries in a build hook), and the upstream
|
||||
fixes it lacks. Until then we vendor upstream 2.0.1 with the X11/EGL
|
||||
patch in `third_party/media_kit_video`.
|
||||
Upstream isn't merging outside work (20 open PRs, no release since
|
||||
Dec 2025): no upstream PRs. Independent of the fork: the Windows
|
||||
libmpv is a 2023 build fetched by `media_kit_libs_windows_video`;
|
||||
vendoring that package with a current mpv-dev build (and checksum)
|
||||
updates Windows' mpv on its own.
|
||||
|
||||
1. **Auto-skip OP/ED** (undecided): who decides and how (shared for the
|
||||
server, personal, per show) is open. Constraint: a skip is a seek and
|
||||
seeks are shared, and only real ranges (segments, OP/ED chapters) should
|
||||
ever be skipped.
|
||||
|
||||
2. **Files**: the next episode of a local show (the library's
|
||||
`nextAfter`, gaps included) at the end of one, with the countdown, and
|
||||
under Up next's "Continues with". Tracking for file series comes with
|
||||
step 6.
|
||||
|
||||
3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
|
||||
bundled Windows libmpv has the ytdl hook (Lua) and plays `edl://` URLs
|
||||
through media_kit; if not, resolve in the app and open the relay
|
||||
tracks' formats. YouTube: yt-dlp wants a JS runtime (deno) for full
|
||||
extraction. Formats that need yt-dlp's cookies.
|
||||
2. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
|
||||
bundled Windows libmpv plays the app's `edl://` URLs (video + audio
|
||||
with headers) through media_kit. YouTube: yt-dlp wants a JS runtime
|
||||
(deno) for full extraction.
|
||||
- Live streams through the relay (today refused: the sync model needs a
|
||||
fixed duration and seeking).
|
||||
- `tool/relay_e2e.dart`: a two-track case (bytes and pieces) over a
|
||||
@@ -208,15 +248,19 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
|
||||
- Thumbnails and scrubbing previews of a relayed media with tracks
|
||||
should open the video track's URL alone, not the EDL.
|
||||
|
||||
4. **AniList account**: sign in (OAuth via a localhost callback), "My list"
|
||||
3. **AniList account**: sign in (OAuth via a localhost callback), "My list"
|
||||
filter on the Schedule from the user's AniList lists. This is the
|
||||
Schedule's personal tracking; AnimeFlow has no per-user follow.
|
||||
**AniList browser** (wanted next once 1.3.0 holds up): view shows and
|
||||
browse older ones (search, seasons, details), and request one from
|
||||
there on AnimeFlow; later on Sonarr too, which is what a public
|
||||
release of the app would use.
|
||||
|
||||
5. **Ops**: Check shaders on Windows; run the relay e2e "the source drops
|
||||
4. **Ops**: Check shaders on Windows; run the relay e2e "the source drops
|
||||
uncleanly and comes back" a few times under the full suite (its
|
||||
predecessor failed about half the time there).
|
||||
|
||||
6. **Windows: first real use.** Check the rename at 1.3.0: a 1.2.x
|
||||
5. **Windows: first real use.** Check the rename at 1.3.0: a 1.2.x
|
||||
install updates and restarts (`watch_together.exe`, a copy of
|
||||
`starsync.exe`), the data in `%APPDATA%\foxyz\Starsync` came over from
|
||||
`%APPDATA%\com.example\watch_together` and Documents, the next update
|
||||
@@ -226,7 +270,7 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
|
||||
fullscreen controls, hosting (firewall prompt), in-app update from one
|
||||
release to the next. The rest of the desktop tests are not in CI.
|
||||
|
||||
7. **Relay follow-ups** (see ARCHITECTURE "Relay"). First real use: owner's
|
||||
6. **Relay follow-ups** (see ARCHITECTURE "Relay"). First real use: owner's
|
||||
PC -> VPS -> friend, Jellyfin and a file. Then:
|
||||
- Source takeover: when the source leaves, another member with an
|
||||
account on that Jellyfin (or the same file: name, size, hash) takes
|
||||
@@ -247,15 +291,21 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
|
||||
fetch of member-resolved links (the member need not upload or stay)
|
||||
is worth building.
|
||||
|
||||
7. **Tray** (like Discord): close to the tray instead of quitting, so
|
||||
the app stays on the server and its system notifications keep coming;
|
||||
a tray menu (Open, Quit; maybe Leave the server). Quit stays the
|
||||
orderly shutdown (P32). Linux trays vary (StatusNotifierItem; GNOME
|
||||
shows none without an extension), so close-to-tray needs a fallback
|
||||
to quitting where no tray exists.
|
||||
|
||||
8. **Jellyfin** (epic, below).
|
||||
|
||||
9. **Smaller items**
|
||||
- Video output robustness: if media_kit's hardware path regresses to
|
||||
software (see ARCHITECTURE, Media: render mode), vendor a
|
||||
media_kit_video fix that builds the EGL display from GDK's native
|
||||
display. The video freeze after GPU idle is not explained by this;
|
||||
next suspect is media_kit's context switching on the raster thread
|
||||
with NVIDIA.
|
||||
- The video freeze after GPU idle: next suspect is media_kit's context
|
||||
switching on the raster thread with NVIDIA.
|
||||
- Timelines: draw mpv's real cached ranges (`demuxer-cache-state`
|
||||
`seekable-ranges`, read through media_kit's `observeProperty`), not
|
||||
only the end of the cache ahead, like uosc.
|
||||
- Link conveniences: a link swap in session updates the remembered media;
|
||||
show the open link with an open-in-browser button.
|
||||
- Time UI: remaining time, scrubber preview.
|
||||
@@ -277,10 +327,6 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
|
||||
|
||||
What is left (the rest is in [ARCHITECTURE.md](ARCHITECTURE.md), "Jellyfin
|
||||
in a session"):
|
||||
- Auto-advance cancel for everyone (the next episode and Up next): today
|
||||
Cancel is per screen (the designated swapper's Cancel stops the swap; a
|
||||
seek stops all). The wire has it (`queue` `cancel`, `state.advance`);
|
||||
the app uses it in step 6.
|
||||
- Sidecar (external) subtitle streams of Jellyfin items: only embedded
|
||||
tracks are mapped to mpv today.
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export 'src/media_source.dart';
|
||||
export 'src/sync_engine.dart';
|
||||
export 'src/updater.dart';
|
||||
export 'src/watch_session.dart';
|
||||
export 'src/whats_next.dart';
|
||||
export 'src/jellyfin/client.dart';
|
||||
export 'src/jellyfin/client_info.dart';
|
||||
export 'src/jellyfin/http.dart';
|
||||
|
||||
@@ -9,7 +9,7 @@ abstract class RelayConstants {
|
||||
static const int protoMax = 16;
|
||||
|
||||
// Relay (`/r/...`): a member's bytes through the server's buffer.
|
||||
static const int relaySliceBytes = 1 << 20;
|
||||
static const int relaySliceBytes = 256 * 1024;
|
||||
static const int relayFrameBytes = 64 * 1024;
|
||||
/// Defaults for the buffer; they mirror a viewer's mpv buffer (150 s /
|
||||
/// 256 MiB ahead, 64 MiB back), so whatever a viewer may seek to soon is
|
||||
@@ -92,10 +92,12 @@ abstract class RelayConstants {
|
||||
/// Up next (`queue`): at most this many items; a title or second line
|
||||
/// is cut to [maxQueueTextLength] characters; an item's picture is
|
||||
/// capped like a poster ([maxPosterBytes]). A play request nobody acts on
|
||||
/// ends after [queuePlayTimeoutSecs].
|
||||
/// ends after [queuePlayTimeoutSecs]. The countdown to the first item at
|
||||
/// the end of the media (`state.advance`) runs [advanceSecs].
|
||||
static const int maxQueueItems = 100;
|
||||
static const int maxQueueTextLength = 200;
|
||||
static const int queuePlayTimeoutSecs = 30;
|
||||
static const int advanceSecs = 10;
|
||||
|
||||
/// Diagnostics sharing (`/l/...`, ARCHITECTURE "Logging"): a member's
|
||||
/// batch carries at most [diagBatchBytes] of lines (gzip on the wire, at
|
||||
|
||||
@@ -164,6 +164,45 @@ Map<String, List<NetscapeCookie>> cookiesBySite(Iterable<NetscapeCookie> cookies
|
||||
return {for (final k in m.keys.toList()..sort()) k: m[k]!};
|
||||
}
|
||||
|
||||
/// [parseCookies], or none for a file without any.
|
||||
List<NetscapeCookie> parseCookiesOrEmpty(String text) {
|
||||
try {
|
||||
return parseCookies(text);
|
||||
} on FormatException {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// "Firefox", "Chrome", "Firefox (work)": a browser and its profile.
|
||||
String browserLabel(CookieBrowser b) {
|
||||
final name = b.browser.isEmpty ? b.browser : '${b.browser[0].toUpperCase()}${b.browser.substring(1)}';
|
||||
return b.profile.isEmpty ? name : '$name (${b.profile})';
|
||||
}
|
||||
|
||||
/// The cookies part of a sign-in error (D11): which cookies were used and
|
||||
/// how many of them are for [site] ([count] -1: unknown; [read]: what
|
||||
/// reading a browser's gave).
|
||||
String cookieNoteText(CookieSource? source, String site, int count, {({int count, String? error})? read}) {
|
||||
switch (source) {
|
||||
case null:
|
||||
return 'no cookies were used';
|
||||
case CookieFile():
|
||||
if (count < 0) return 'used the cookies file';
|
||||
return count == 0
|
||||
? 'used the cookies file, which has none for $site'
|
||||
: 'used the cookies file ($count for $site: signed out or expired?)';
|
||||
case CookieBrowser():
|
||||
final who = "${browserLabel(source)}'s cookies";
|
||||
final error = read?.error;
|
||||
if (error != null) return "used $who, but yt-dlp couldn't read them: $error";
|
||||
final n = read?.count ?? count;
|
||||
if (n < 0) return 'used $who';
|
||||
return n == 0
|
||||
? 'used $who, which have none for $site: sign in to $site there'
|
||||
: 'used $who ($n for $site: that account may not see it)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Sites ticked by default after an import: those yt-dlp is usually given
|
||||
/// cookies for.
|
||||
const suggestedCookieSites = {'bilibili.com', 'youtube.com'};
|
||||
@@ -233,17 +272,6 @@ class YtDlpCookies {
|
||||
null => const [],
|
||||
};
|
||||
|
||||
/// The same as mpv's `ytdl-raw-options` value ('' for none); each value
|
||||
/// length-quoted (`%N%`) so commas and '=' in paths survive.
|
||||
String mpvRawOptions([CookieSource? source]) {
|
||||
String q(String v) => '%${utf8.encode(v).length}%$v';
|
||||
return switch (source ?? read(count: false)) {
|
||||
CookieFile(:final path) => 'cookies=${q(path)}',
|
||||
CookieBrowser b => 'cookies-from-browser=${q(b.spec)}',
|
||||
null => '',
|
||||
};
|
||||
}
|
||||
|
||||
/// Keeps [cookies] as the private copy, replacing any browser setting.
|
||||
Future<CookieFile> saveFile(List<NetscapeCookie> cookies) async {
|
||||
await Directory(dir).create(recursive: true);
|
||||
|
||||
@@ -95,6 +95,22 @@ final _unreachable = RegExp(
|
||||
r'network is unreachable|no route to host|name or service not known|getaddrinfo|unable to download webpage',
|
||||
caseSensitive: false);
|
||||
|
||||
/// yt-dlp's [error] (plain words or raw) may be about signing in: worth
|
||||
/// saying which cookies were used (D11).
|
||||
bool mayNeedSignIn(String error) {
|
||||
final t = playErrorText(error);
|
||||
return t == needsCookiesText || t == noFormatsText;
|
||||
}
|
||||
|
||||
/// [plain] ([playErrorText]) with the cookies [note] (D11), for the two
|
||||
/// texts about signing in; anything else as is.
|
||||
String withCookieNote(String plain, String? note) {
|
||||
if (note == null || note.isEmpty) return plain;
|
||||
if (plain == needsCookiesText) return 'This site needs cookies ($note) — Settings → Player → yt-dlp → Cookies';
|
||||
if (plain == noFormatsText) return 'No video found: it may need signing in ($note)';
|
||||
return plain;
|
||||
}
|
||||
|
||||
/// Why a link or media cannot be played, in plain words, from the raw
|
||||
/// error of yt-dlp, ffmpeg/mpv or the link probe; anything it does not
|
||||
/// know comes back as is (one line, at most 100 characters).
|
||||
@@ -131,6 +147,10 @@ class LinkProbe {
|
||||
final String? pageTitle;
|
||||
/// Why yt-dlp could not read the page.
|
||||
final String? ytDlpError;
|
||||
|
||||
/// Which cookies that run used ([YtDlp.cookieNote]), when it may be about
|
||||
/// signing in.
|
||||
final String? cookies;
|
||||
/// The HTTP status of the probe's GET; 0 when unknown.
|
||||
final int status;
|
||||
const LinkProbe.file(this.url, this.size)
|
||||
@@ -138,8 +158,9 @@ class LinkProbe {
|
||||
info = null,
|
||||
pageTitle = null,
|
||||
ytDlpError = null,
|
||||
cookies = null,
|
||||
status = 0;
|
||||
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError, this.status = 0})
|
||||
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError, this.cookies, this.status = 0})
|
||||
: isPage = true,
|
||||
size = -1;
|
||||
|
||||
@@ -152,7 +173,7 @@ class LinkProbe {
|
||||
// A 4xx says more than yt-dlp's words it has no plain text for.
|
||||
final known = err != null && _plainTexts.contains(err);
|
||||
if (!known && status >= 400 && status < 500) return playErrorText('HTTP Error $status');
|
||||
return err;
|
||||
return err == null ? null : withCookieNote(err, cookies);
|
||||
}
|
||||
|
||||
/// The name to show: yt-dlp's title, the page's, else the host; null
|
||||
@@ -219,12 +240,13 @@ Future<LinkProbe> _probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async
|
||||
} catch (e) {
|
||||
getError = e;
|
||||
}
|
||||
String? ytErr;
|
||||
String? ytErr, cookies;
|
||||
if (ytDlp != null) {
|
||||
try {
|
||||
return LinkProbe.page(url, info: await ytDlp.resolve(url.toString()), status: status);
|
||||
} catch (e) {
|
||||
ytErr = '$e';
|
||||
if (e is YtDlpException) cookies = e.cookies;
|
||||
}
|
||||
}
|
||||
if (getError != null) {
|
||||
@@ -238,7 +260,8 @@ Future<LinkProbe> _probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async
|
||||
body = await _readText(await req.close().timeout(const Duration(seconds: 15)));
|
||||
} catch (_) {}
|
||||
}
|
||||
return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr, status: status);
|
||||
return LinkProbe.page(url,
|
||||
pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr, cookies: cookies, status: status);
|
||||
} finally {
|
||||
if (client == null) c.close(force: true);
|
||||
}
|
||||
|
||||
@@ -836,16 +836,42 @@ class SeekIntent {
|
||||
}
|
||||
}
|
||||
|
||||
/// `swap.restore`: what played before the server restarted, offered back
|
||||
/// by an app that still holds it: at [position] (seconds), [transport]
|
||||
/// as it was (`playing`, `held` or `finished`). The server takes it only
|
||||
/// while it has no media (the first offer wins, later ones are ignored).
|
||||
class SwapRestore {
|
||||
final double position;
|
||||
final String transport;
|
||||
const SwapRestore({required this.position, this.transport = 'held'});
|
||||
|
||||
Map<String, Object?> toJson() => {'position': position, 'transport': transport};
|
||||
|
||||
static SwapRestore? fromJson(Object? j) {
|
||||
if (j is! Map) return null;
|
||||
final t = j['transport'];
|
||||
return SwapRestore(
|
||||
position: finiteDouble(j['position'], 'restore.position'),
|
||||
transport: t == 'playing' || t == 'finished' ? t as String : 'held');
|
||||
}
|
||||
}
|
||||
|
||||
class SwapIntent {
|
||||
final HelloMedia media;
|
||||
/// The Up next item this swap puts up (`queue[].id`); the server takes
|
||||
/// it out of the queue with the swap, and ignores the swap when it is no
|
||||
/// longer queued (someone else put it up first).
|
||||
final String? queueId;
|
||||
const SwapIntent({required this.media, this.queueId});
|
||||
/// Puts back what played before a server restart ([SwapRestore]).
|
||||
final SwapRestore? restore;
|
||||
const SwapIntent({required this.media, this.queueId, this.restore});
|
||||
|
||||
Map<String, Object?> toJson() =>
|
||||
{'t': 'swap', 'media': media.toJson(), if (queueId != null) 'queueId': queueId};
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'swap',
|
||||
'media': media.toJson(),
|
||||
if (queueId != null) 'queueId': queueId,
|
||||
if (restore != null) 'restore': restore!.toJson(),
|
||||
};
|
||||
|
||||
factory SwapIntent.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'swap') throw const ProtocolException('not a swap intent');
|
||||
@@ -854,7 +880,8 @@ class SwapIntent {
|
||||
final queueId = json['queueId'];
|
||||
return SwapIntent(
|
||||
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
|
||||
queueId: queueId is String && queueId.isNotEmpty ? queueId : null);
|
||||
queueId: queueId is String && queueId.isNotEmpty ? queueId : null,
|
||||
restore: SwapRestore.fromJson(json['restore']));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1147,11 +1174,11 @@ class HoldInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// `state.advance` (reserved, step 6): the shared countdown to Up next's
|
||||
/// first item when the media finishes. At [at] + [secs] (server clock) the
|
||||
/// server sets `queuePlay` to [id]; `{"t":"queue","op":"cancel"}` clears
|
||||
/// it for everyone. The server parses and broadcasts it but never starts
|
||||
/// one yet.
|
||||
/// `state.advance`: the shared countdown to Up next's first item when the
|
||||
/// media finishes. At [at] + [secs] (server clock) the server sets
|
||||
/// `queuePlay` to [id] (holding while its performer or its relay's source
|
||||
/// is `gone`); `{"t":"queue","op":"cancel"}` and a seek clear it for
|
||||
/// everyone (see `SyncEngine.whatsNext`).
|
||||
class QueueAdvance {
|
||||
final String id;
|
||||
final DateTime at;
|
||||
@@ -1211,7 +1238,7 @@ class StateBroadcast {
|
||||
final String? queuePlay;
|
||||
/// Why it is held ([HoldInfo]); null unless `held` with media up.
|
||||
final HoldInfo? hold;
|
||||
/// The shared countdown to Up next (reserved, [QueueAdvance]).
|
||||
/// The shared countdown to Up next's first item ([QueueAdvance]); null when none runs.
|
||||
final QueueAdvance? advance;
|
||||
const StateBroadcast({
|
||||
required this.eventId,
|
||||
|
||||
@@ -2,9 +2,9 @@ import 'dart:typed_data';
|
||||
|
||||
import 'constants.dart';
|
||||
import 'errors.dart';
|
||||
import 'presence.dart';
|
||||
import 'protocol.dart';
|
||||
import 'sync_engine.dart';
|
||||
import 'whats_next.dart';
|
||||
|
||||
// Up next: what plays after the current media, held by the server for
|
||||
// everyone (see PROTOCOL.md "Queue"). Wire types, and [EngineQueue], the
|
||||
@@ -342,14 +342,15 @@ typedef QueueRefusal = ({ErrCode code, String message});
|
||||
|
||||
/// The `queue` messages applied to the engine's [SyncEngine.queue], with
|
||||
/// their feed lines (`queue-add`, `queue-remove`, `queue-skip`,
|
||||
/// `advance-cancel`). Each returns whether the queue changed;
|
||||
/// [applyQueueAdd] a refusal for the sender instead (null when added).
|
||||
/// `advance-cancel`), then what's next ([SyncEngine.whatsNext]). Each
|
||||
/// returns whether the queue changed; [applyQueueAdd] a refusal for the
|
||||
/// sender instead (null when added).
|
||||
extension EngineQueueOps on SyncEngine {
|
||||
/// [item] is already cleaned by the server (see `cleanQueueAdd`). An
|
||||
/// [QueueItem.auto] item must continue the current media
|
||||
/// ([QueueItem.forMedia] = [SyncEngine.mediaId]) and come from who put
|
||||
/// it up ([SyncEngine.swapBy]) or, when that member is not `in`, from the
|
||||
/// first member who is.
|
||||
/// it up or, when that member is not `in`, from the first member who is
|
||||
/// ([mayAddContinuation]); it adds no feed line (it is no news).
|
||||
QueueRefusal? applyQueueAdd(String token, QueueItem item,
|
||||
{bool next = false, String? privateUrl, Uint8List? thumb}) {
|
||||
final member = members[token];
|
||||
@@ -358,24 +359,27 @@ extension EngineQueueOps on SyncEngine {
|
||||
if (media == null || item.forMedia != mediaId) {
|
||||
return (code: ErrCode.bad, message: 'bad message: an auto item must name the current media');
|
||||
}
|
||||
final swapper = members[swapBy];
|
||||
final firstIn = members.values.where((m) => m.presence == Presence.watching).firstOrNull;
|
||||
final allowed = token == swapBy ||
|
||||
((swapper == null || swapper.presence != Presence.watching) && identical(firstIn, member));
|
||||
if (!allowed) {
|
||||
final views = [for (final m in members.values) m.view(inGate: false)];
|
||||
if (!mayAddContinuation(member.id, members[swapBy]?.id, views)) {
|
||||
return (code: ErrCode.forbidden, message: 'only who put this media up adds what continues it');
|
||||
}
|
||||
}
|
||||
final added = queue.add(item.copyWith(by: member.id, byName: member.name),
|
||||
next: next, privateUrl: privateUrl, thumb: thumb);
|
||||
if (added == null) return (code: ErrCode.full, message: 'Up next is full (${RelayConstants.maxQueueItems} items)');
|
||||
if (!added.auto) {
|
||||
feed.add(FeedMessage(
|
||||
actor: member.name, actorId: member.id, action: 'queue-add', target: added.title, at: now));
|
||||
}
|
||||
whatsNext();
|
||||
return null;
|
||||
}
|
||||
|
||||
bool applyQueueMove(String token, String id, int to) =>
|
||||
members.containsKey(token) && queue.move(id, to);
|
||||
bool applyQueueMove(String token, String id, int to) {
|
||||
if (!members.containsKey(token) || !queue.move(id, to)) return false;
|
||||
whatsNext();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// [why] set: skipped because it cannot play (`queue-skip`, with the
|
||||
/// reason), else removed by hand (`queue-remove`).
|
||||
@@ -391,6 +395,7 @@ extension EngineQueueOps on SyncEngine {
|
||||
target: gone.title,
|
||||
reason: why,
|
||||
at: now));
|
||||
whatsNext();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -399,17 +404,20 @@ extension EngineQueueOps on SyncEngine {
|
||||
if (!members.containsKey(token)) return false;
|
||||
final target = id.isEmpty ? (queue.isEmpty ? null : queue.items.first.id) : id;
|
||||
if (target == null || queue.play == target) return false;
|
||||
return queue.requestPlay(target, now);
|
||||
if (!queue.requestPlay(target, now)) return false;
|
||||
whatsNext();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// `cancel`: drops the continuation ([EngineQueue.auto]) and the
|
||||
/// countdown ([SyncEngine.advance]) for everyone (feed `advance-cancel`).
|
||||
/// countdown ([SyncEngine.advance]) for everyone (feed `advance-cancel`);
|
||||
/// no countdown starts again for this media.
|
||||
bool applyQueueCancel(String token) {
|
||||
final member = members[token];
|
||||
if (member == null) return false;
|
||||
final dropped = queue.dropAuto();
|
||||
final hadAdvance = advance != null;
|
||||
advance = null;
|
||||
cancelAdvance();
|
||||
if (dropped == null && !hadAdvance) return false;
|
||||
feed.add(FeedMessage(
|
||||
actor: member.name, actorId: member.id, action: 'advance-cancel', target: dropped?.title, at: now));
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'constants.dart';
|
||||
import 'presence.dart';
|
||||
import 'protocol.dart';
|
||||
import 'queue.dart';
|
||||
import 'whats_next.dart';
|
||||
|
||||
typedef SyncClock = DateTime Function();
|
||||
|
||||
@@ -245,9 +246,19 @@ class SyncEngine {
|
||||
/// Up next ([EngineQueue]): in `state.queue`, edited by `queue`
|
||||
/// messages, taken from by swaps that name an item.
|
||||
final EngineQueue queue = EngineQueue();
|
||||
/// The shared countdown to Up next (`state.advance`); reserved: only
|
||||
/// `cancel` touches it today.
|
||||
/// The shared countdown to Up next's first item at the end of the media
|
||||
/// (`state.advance`, see [whatsNext]).
|
||||
QueueAdvance? advance;
|
||||
/// The relay of Up next item `queueId` waits for its source, who is
|
||||
/// `gone` (set by the server: the engine does not hold relays). The
|
||||
/// countdown holds while it does.
|
||||
bool Function(String queueId)? itemSourceGone;
|
||||
// The mediaId whose countdown was cancelled (`cancel`): it does not
|
||||
// start again for that media.
|
||||
int _advanceOffFor = -1;
|
||||
// The item last asked to play for the current media: not counted down
|
||||
// again.
|
||||
String? _advancedId;
|
||||
/// A member went `gone` (its socket closed or fell silent): the server
|
||||
/// closes the socket and marks a relay it serves.
|
||||
void Function(EngineMember member)? onGone;
|
||||
@@ -316,12 +327,97 @@ class SyncEngine {
|
||||
at: now);
|
||||
|
||||
/// Heartbeat (1 s): silence, joining timeouts, gone notices, grace,
|
||||
/// a stale play request, end of media.
|
||||
/// a stale play request, end of media, what's next.
|
||||
void tick() {
|
||||
sweepSilent();
|
||||
sweepPresence();
|
||||
queue.tick(now);
|
||||
evaluate();
|
||||
whatsNext();
|
||||
}
|
||||
|
||||
/// What's next (PROTOCOL.md "Queue"), on every tick and queue change:
|
||||
/// - a play request (`queuePlay`) nobody here can serve is skipped
|
||||
/// (feed `queue-skip`, actor `server`, with why: [queuePerformer]);
|
||||
/// - with nothing up, the first item is asked to play at once;
|
||||
/// - at the end of the media with an item first, the countdown
|
||||
/// ([advance], [RelayConstants.advanceSecs]) starts (not again for a
|
||||
/// media whose countdown was cancelled, nor for the item it already
|
||||
/// asked for); it follows the first item when that changes, keeping
|
||||
/// its end; it ends with the end of `finished` (a seek), an empty
|
||||
/// queue, `cancel` or a swap. At its end the first item is asked to
|
||||
/// play (`queuePlay`) and the countdown is over, unless nobody can
|
||||
/// put it up (skipped) or its performer is not around or its relay's
|
||||
/// source is `gone` ([itemSourceGone]): then it holds until they are
|
||||
/// back.
|
||||
/// Returns whether anything changed.
|
||||
bool whatsNext() {
|
||||
var changed = false;
|
||||
final views = [for (final m in members.values) m.view(inGate: false)];
|
||||
final play = queue.play;
|
||||
if (play != null) {
|
||||
final item = queue.item(play);
|
||||
final why = item == null ? null : queuePerformer(item, views).why;
|
||||
if (item != null && why != null) {
|
||||
_skipQueued(item, why);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
final first = queue.items.firstOrNull;
|
||||
if (media == null) {
|
||||
if (first != null && queue.play == null) changed = _due(first, views) || changed;
|
||||
return changed;
|
||||
}
|
||||
var a = advance;
|
||||
if (a != null && (transport != Transport.finished || first == null || queue.play != null)) {
|
||||
advance = a = null;
|
||||
changed = true;
|
||||
}
|
||||
if (transport != Transport.finished || first == null || queue.play != null) return changed;
|
||||
if (a == null) {
|
||||
if (_advanceOffFor == mediaId || _advancedId == first.id) return changed;
|
||||
advance = a = QueueAdvance(id: first.id, at: now, secs: RelayConstants.advanceSecs);
|
||||
changed = true;
|
||||
} else if (a.id != first.id) {
|
||||
advance = a = QueueAdvance(id: first.id, at: a.at, secs: a.secs);
|
||||
changed = true;
|
||||
}
|
||||
if (now.isBefore(a.at.add(Duration(seconds: a.secs)))) return changed;
|
||||
return _due(first, views) || changed;
|
||||
}
|
||||
|
||||
/// [item] should play now: asked for (`queuePlay`), skipped when nobody
|
||||
/// can put it up, or held. Returns whether anything changed.
|
||||
bool _due(QueueItem item, List<MemberView> views) {
|
||||
final perf = queuePerformer(item, views);
|
||||
final why = perf.why;
|
||||
if (why != null) {
|
||||
_skipQueued(item, why);
|
||||
return true;
|
||||
}
|
||||
if (perf.id == null || (itemSourceGone?.call(item.id) ?? false)) return false;
|
||||
queue.requestPlay(item.id, now);
|
||||
advance = null;
|
||||
_advancedId = item.id;
|
||||
return true;
|
||||
}
|
||||
|
||||
void _skipQueued(QueueItem item, String why) {
|
||||
queue.remove(item.id);
|
||||
// The countdown goes on with the next item (asked for on the next
|
||||
// tick when its time is up).
|
||||
final a = advance, first = queue.items.firstOrNull;
|
||||
if (a != null && a.id == item.id) {
|
||||
advance = first == null ? null : QueueAdvance(id: first.id, at: a.at, secs: a.secs);
|
||||
}
|
||||
feed.add(FeedMessage(
|
||||
actor: 'server', actorId: 'server', action: 'queue-skip', target: item.title, reason: why, at: now));
|
||||
}
|
||||
|
||||
/// `cancel` for the current media: no countdown for it any more.
|
||||
void cancelAdvance() {
|
||||
advance = null;
|
||||
_advanceOffFor = mediaId;
|
||||
}
|
||||
|
||||
/// Joining: loads in the background until its reports hold the media
|
||||
@@ -617,6 +713,8 @@ class SyncEngine {
|
||||
_seekSeq = seq;
|
||||
_seekEventId = eventId;
|
||||
feed.add(_line('seek', actor: member));
|
||||
// A seek stops the countdown to Up next.
|
||||
advance = null;
|
||||
// Seeking back from the end: held, and resumes once the gate passes.
|
||||
if (transport == Transport.finished) {
|
||||
transport = Transport.held;
|
||||
@@ -627,11 +725,15 @@ class SyncEngine {
|
||||
/// Returns false (no state change) for media with non-finite numbers.
|
||||
/// [queueId]: the Up next item this swap puts up; it leaves the queue
|
||||
/// (any other swap drops the continuation item, [EngineQueue.auto]).
|
||||
bool applySwap(String actorToken, HelloMedia next, {String? queueId}) {
|
||||
/// [restore]: what played before a restart, put back where it was (the
|
||||
/// server takes it only while nothing is up; see `swap.restore`): held
|
||||
/// at its position (the gate starts it again), or finished.
|
||||
bool applySwap(String actorToken, HelloMedia next, {String? queueId, SwapRestore? restore}) {
|
||||
if (!next.duration.isFinite || next.size < 0) return false;
|
||||
final actor = members[actorToken];
|
||||
queue.swapped(queueId);
|
||||
advance = null;
|
||||
_advancedId = null;
|
||||
mediaId++;
|
||||
media = next;
|
||||
swapBy = actorToken;
|
||||
@@ -648,7 +750,15 @@ class SyncEngine {
|
||||
member.inSet = true;
|
||||
member.buffered = 0;
|
||||
}
|
||||
feed.add(_line('swap', actor: actor));
|
||||
if (restore != null) {
|
||||
final d = next.duration;
|
||||
position = restore.position < 0 ? 0 : (d > 0 && restore.position > d ? d : restore.position);
|
||||
if (restore.transport == 'finished' && d > 0) {
|
||||
position = d;
|
||||
transport = Transport.finished;
|
||||
}
|
||||
}
|
||||
feed.add(_line('swap', actor: actor, reason: restore == null ? '' : 'restore'));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -715,6 +825,7 @@ class SyncEngine {
|
||||
eventId++;
|
||||
transport = Transport.finished;
|
||||
feed.add(_line('finished'));
|
||||
whatsNext();
|
||||
return;
|
||||
}
|
||||
final blocked =
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
// What one viewer actually watched of one opened item, and the rule that
|
||||
// turns it into "watched", "barely looked" or "stopped part way". Pure, so
|
||||
// any tracker (Jellyfin today) applies the same rule. See ARCHITECTURE.md
|
||||
// "Watched rule".
|
||||
// turns it into "watched", "barely looked" or "stopped part way" (D3).
|
||||
// Pure, so every tracker (the device history, Jellyfin, AniList later)
|
||||
// applies the same rule. See ARCHITECTURE.md "Watch tracking".
|
||||
|
||||
/// What a [WatchSession] amounts to when it ends.
|
||||
enum WatchOutcome {
|
||||
@@ -14,7 +14,8 @@ enum WatchOutcome {
|
||||
/// whatever happened, with no resume point.
|
||||
keepPlayed,
|
||||
|
||||
/// Barely watched (a peek, a screenshot): leave the item as it was.
|
||||
/// Barely watched (a peek, a screenshot visit, skipping around): leave
|
||||
/// the item as it was.
|
||||
peek,
|
||||
|
||||
/// Part way: resume from [WatchVerdict.resumeAt].
|
||||
@@ -28,108 +29,105 @@ class WatchVerdict {
|
||||
final double? resumeAt;
|
||||
const WatchVerdict(this.outcome, {this.resumeAt});
|
||||
|
||||
/// It counts as watched (played now, or played before).
|
||||
bool get watched => outcome == WatchOutcome.played || outcome == WatchOutcome.keepPlayed;
|
||||
|
||||
@override
|
||||
String toString() => 'WatchVerdict(${outcome.name}${resumeAt == null ? '' : ', $resumeAt'})';
|
||||
}
|
||||
|
||||
/// One opening of one item on this client. Fed the player position about
|
||||
/// once a second ([sample]); only normal playback counts as watched: the
|
||||
/// stretch between two samples while playing, when the position moved
|
||||
/// forward no faster than [maxRate] (plus [slack]) and the samples are at
|
||||
/// most [maxGap] apart. Seeks, skips and pauses add nothing. [covered] is the union of the watched stretches,
|
||||
/// so rewatching a part does not count twice; [watched] is the plain sum.
|
||||
/// One opening of one item on this app. Fed about once a second
|
||||
/// ([sample]) with the player's position, whether the media plays here
|
||||
/// (the server's transport is playing and this player plays it) and
|
||||
/// whether this viewer is In (present: `in`, from the presence the server
|
||||
/// reports for it). The basis is [timeIn]: wall time the media played
|
||||
/// while this viewer was In. Time sat out (Away), joining, gone, paused or
|
||||
/// held never counts, nor does a jump: skipping around adds only the time
|
||||
/// actually played. Two samples further apart than [maxGap] add nothing
|
||||
/// (the app stalled). A resume carries the earlier watch: when what played
|
||||
/// here starts at about the resume point ([resumeBefore], 60 s either
|
||||
/// way) and nothing before it played, the time up to it counts as watched
|
||||
/// before ([credited]).
|
||||
class WatchSession {
|
||||
/// Played before this opening (Jellyfin `UserData.Played`).
|
||||
final bool playedBefore;
|
||||
/// Played before this opening (Jellyfin `UserData.Played`, the device's
|
||||
/// watch history); set once known (Jellyfin answers after the open).
|
||||
bool playedBefore;
|
||||
|
||||
/// The resume point before this opening, seconds (0 = none).
|
||||
final double resumeBefore;
|
||||
double resumeBefore;
|
||||
|
||||
final double maxRate;
|
||||
final double slack;
|
||||
final double maxGap;
|
||||
|
||||
WatchSession(
|
||||
{this.playedBefore = false, this.resumeBefore = 0, this.maxRate = 2, this.slack = 1.5, this.maxGap = 10});
|
||||
WatchSession({this.playedBefore = false, this.resumeBefore = 0, this.maxGap = 10});
|
||||
|
||||
DateTime? startedAt;
|
||||
DateTime? endedAt;
|
||||
double? firstPosition;
|
||||
double? lastPosition;
|
||||
|
||||
/// The furthest position seen, reached by playing or by a jump.
|
||||
/// The furthest position seen while In, reached by playing or by a jump.
|
||||
double furthest = 0;
|
||||
|
||||
/// Seconds of normal playback, rewatches included.
|
||||
double watched = 0;
|
||||
/// Seconds the media played while this viewer was In.
|
||||
double timeIn = 0;
|
||||
|
||||
final _spans = <(double, double)>[];
|
||||
({double pos, bool playing, DateTime at})? _prev;
|
||||
/// The lowest position it played from while In; null before any.
|
||||
double? playedFrom;
|
||||
|
||||
/// Seconds of the item covered by normal playback (rewatches once).
|
||||
double get covered => _spans.fold(0.0, (s, e) => s + e.$2 - e.$1);
|
||||
/// [timeIn] plus the time before the resume point when this opening
|
||||
/// continued from it.
|
||||
double get credited {
|
||||
final from = playedFrom;
|
||||
final resumed = resumeBefore > 0 && from != null && (from - resumeBefore).abs() <= 60;
|
||||
return timeIn + (resumed ? resumeBefore : 0);
|
||||
}
|
||||
|
||||
/// The watched stretches, sorted and merged.
|
||||
List<(double, double)> get spans => List.unmodifiable(_spans);
|
||||
({DateTime at, double pos, bool counting})? _prev;
|
||||
|
||||
void sample(double position, {required bool playing, required DateTime at}) {
|
||||
void sample(double position, {required bool playing, required bool present, required DateTime at}) {
|
||||
final prev = _prev;
|
||||
_prev = (pos: position, playing: playing, at: at);
|
||||
_prev = (at: at, pos: position, counting: playing && present);
|
||||
startedAt ??= at;
|
||||
endedAt = at;
|
||||
if (present) {
|
||||
firstPosition ??= position;
|
||||
lastPosition = position;
|
||||
furthest = math.max(furthest, position);
|
||||
if (prev == null || !prev.playing) return;
|
||||
}
|
||||
if (prev == null || !prev.counting) return;
|
||||
final dt = at.difference(prev.at).inMilliseconds / 1000;
|
||||
final dp = position - prev.pos;
|
||||
if (dt <= 0 || dt > maxGap || dp <= 0 || dp > dt * maxRate + slack) return;
|
||||
watched += dp;
|
||||
_add(prev.pos, position);
|
||||
}
|
||||
|
||||
void _add(double a, double b) {
|
||||
final out = <(double, double)>[];
|
||||
var (lo, hi) = (a, b);
|
||||
for (final (s, e) in _spans) {
|
||||
if (e < lo || s > hi) {
|
||||
out.add((s, e));
|
||||
} else {
|
||||
lo = math.min(lo, s);
|
||||
hi = math.max(hi, e);
|
||||
}
|
||||
}
|
||||
out
|
||||
..add((lo, hi))
|
||||
..sort((x, y) => x.$1.compareTo(y.$1));
|
||||
_spans
|
||||
..clear()
|
||||
..addAll(out);
|
||||
if (dt <= 0 || dt > maxGap) return;
|
||||
timeIn += dt;
|
||||
final from = playedFrom;
|
||||
playedFrom = from == null ? prev.pos : math.min(from, prev.pos);
|
||||
}
|
||||
|
||||
/// The rule, for an item [runtime] seconds long whose ending starts at
|
||||
/// [endingStart] (an Outro segment or ED chapter; null = the real end):
|
||||
/// - played before: [WatchOutcome.keepPlayed];
|
||||
/// - [covered] >= 70 % of the runtime, or the ending was reached
|
||||
/// ([furthest]) with [covered] >= 40 %: [WatchOutcome.played]. The
|
||||
/// - [credited] time In >= 70 % of the runtime, or the ending was
|
||||
/// reached ([furthest]) with it >= 40 %: [WatchOutcome.played]. The
|
||||
/// ending is the earlier of [endingStart] and the last 15 % (at most
|
||||
/// 4 min): someone who watched that far and stops is done with it;
|
||||
/// - [covered] under max(2 min, 5 %) (at most a quarter of a short
|
||||
/// 4 min): someone who watched that far and stops is done with it.
|
||||
/// Any watch Jellyfin's own rule counts (a position past 90 %, reached
|
||||
/// by playing) counts here too, also with the opening and ending
|
||||
/// skipped;
|
||||
/// - [credited] under max(2 min, 5 %) (at most a quarter of a short
|
||||
/// item): [WatchOutcome.peek];
|
||||
/// - else [WatchOutcome.partial] at [lastPosition].
|
||||
/// An unknown runtime (0) never makes it played.
|
||||
WatchVerdict verdict({required double runtime, double? endingStart}) {
|
||||
if (playedBefore) return const WatchVerdict(WatchOutcome.keepPlayed);
|
||||
final c = covered;
|
||||
final t = credited;
|
||||
if (runtime > 0) {
|
||||
final tail = runtime - math.min(240.0, 0.15 * runtime);
|
||||
final end = endingStart != null && endingStart > 0 && endingStart < tail ? endingStart : tail;
|
||||
if (c >= 0.7 * runtime || (furthest >= end && c >= 0.4 * runtime)) {
|
||||
if (t >= 0.7 * runtime || (furthest >= end && t >= 0.4 * runtime)) {
|
||||
return const WatchVerdict(WatchOutcome.played);
|
||||
}
|
||||
}
|
||||
final peekBelow = runtime > 0 ? math.min(math.max(120.0, 0.05 * runtime), 0.25 * runtime) : 120.0;
|
||||
if (c < peekBelow) return const WatchVerdict(WatchOutcome.peek);
|
||||
if (t < peekBelow) return const WatchVerdict(WatchOutcome.peek);
|
||||
return WatchVerdict(WatchOutcome.partial, resumeAt: lastPosition ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'presence.dart';
|
||||
import 'protocol.dart';
|
||||
import 'queue.dart';
|
||||
|
||||
// What's next, the rules every side reads the same way (PROTOCOL.md
|
||||
// "Queue", ARCHITECTURE.md "What's next"): who puts an Up next item up.
|
||||
// Pure and only on what `state` carries, so the server (which holds the
|
||||
// countdown and skips what nobody can play) and every app agree without
|
||||
// a wire message.
|
||||
|
||||
/// Who puts an Up next item up: [id] a member id; null with [why] when
|
||||
/// nobody here can (the server skips the item), null without [why] when
|
||||
/// nobody is around right now (it waits).
|
||||
typedef QueuePerformer = ({String? id, String? why});
|
||||
|
||||
/// Connected and watching (`in` or `joining`): can do things for the
|
||||
/// party now (put an Up next item up).
|
||||
bool isPresentMember(MemberView m) => m.presence == Presence.watching || m.presence == Presence.joining;
|
||||
|
||||
/// - A link passed on by its adder, or the adder's file: only the adder
|
||||
/// (their app has the file, the cookies). Gone (disconnected, in its
|
||||
/// grace): it waits for them; left for good: skipped.
|
||||
/// - Jellyfin: the adder when it plays that server with its own account,
|
||||
/// else the lowest member id that does (`jf[serverId] == true`,
|
||||
/// present). Nobody: skipped.
|
||||
/// - A link everyone opens, or one the server fetches: the adder when
|
||||
/// present, else the lowest present member id; nobody present: it waits.
|
||||
QueuePerformer queuePerformer(QueueItem item, List<MemberView> members) {
|
||||
MemberView? adder;
|
||||
for (final m in members) {
|
||||
if (m.id == item.by) adder = m;
|
||||
}
|
||||
if (item.how.adderOnly) {
|
||||
if (adder == null) return (id: null, why: '${item.byName.isEmpty ? 'who added it' : item.byName} left');
|
||||
if (adder.presence == Presence.gone) return (id: null, why: null);
|
||||
return (id: adder.id, why: null);
|
||||
}
|
||||
final ref = item.media.jellyfin;
|
||||
if (item.how == QueueHow.jellyfin && ref != null) {
|
||||
bool can(MemberView m) => isPresentMember(m) && m.jf[ref.serverId] == true;
|
||||
if (adder != null && can(adder)) return (id: adder.id, why: null);
|
||||
final id = lowestMemberId(members.where(can));
|
||||
return id == null ? (id: null, why: 'nobody here has that Jellyfin server') : (id: id, why: null);
|
||||
}
|
||||
if (adder != null && isPresentMember(adder)) return (id: adder.id, why: null);
|
||||
return (id: lowestMemberId(members.where(isPresentMember)), why: null);
|
||||
}
|
||||
|
||||
/// The lowest member id of [members] ("m2" before "m10"), or null.
|
||||
String? lowestMemberId(Iterable<MemberView> members) {
|
||||
final ids = [for (final m in members) m.id]..sort(compareMemberIds);
|
||||
return ids.isEmpty ? null : ids.first;
|
||||
}
|
||||
|
||||
/// "m2" before "m10": numeric suffix order, then plain text.
|
||||
int compareMemberIds(String a, String b) {
|
||||
final na = int.tryParse(a.replaceFirst(RegExp(r'^\D+'), ''));
|
||||
final nb = int.tryParse(b.replaceFirst(RegExp(r'^\D+'), ''));
|
||||
if (na != null && nb != null && na != nb) return na.compareTo(nb);
|
||||
return a.compareTo(b);
|
||||
}
|
||||
|
||||
/// Who may add the continuation ([QueueItem.auto]) of the current media:
|
||||
/// who put it up ([swapBy], a member id), or, when that member is not
|
||||
/// `in`, the first member (in [members] order, the join order) who is.
|
||||
bool mayAddContinuation(String memberId, String? swapBy, List<MemberView> members) {
|
||||
if (memberId.isEmpty) return false;
|
||||
if (memberId == swapBy) return true;
|
||||
final swapper = members.where((m) => m.id == swapBy).firstOrNull;
|
||||
if (swapper != null && swapper.presence == Presence.watching) return false;
|
||||
return members.where((m) => m.presence == Presence.watching).firstOrNull?.id == memberId;
|
||||
}
|
||||
|
||||
/// Whole seconds left of [advance] at [now] (server clock), at least 0.
|
||||
int advanceSecondsLeft(QueueAdvance advance, DateTime now) {
|
||||
final end = advance.at.add(Duration(seconds: advance.secs));
|
||||
final ms = end.difference(now).inMilliseconds;
|
||||
return ms <= 0 ? 0 : (ms / 1000).ceil();
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import 'package:crypto/crypto.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'cookies.dart';
|
||||
import 'link_probe.dart' show mayNeedSignIn;
|
||||
import 'logging.dart';
|
||||
import 'relay.dart' show mpvEdl;
|
||||
|
||||
// yt-dlp: turns a web page (YouTube, Twitter/X, ...) into media URLs. The
|
||||
// app and the server each keep their own copy, the official standalone
|
||||
@@ -19,7 +21,11 @@ final _log = createLogger('yt-dlp');
|
||||
|
||||
class YtDlpException implements Exception {
|
||||
final String message;
|
||||
const YtDlpException(this.message);
|
||||
|
||||
/// Which cookies the run used and what they hold for the site, when the
|
||||
/// failure may be about signing in ([YtDlp.cookieNote], D11).
|
||||
final String? cookies;
|
||||
const YtDlpException(this.message, {this.cookies});
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -121,6 +127,10 @@ class YtDlpInfo {
|
||||
/// Every relayable format by id, to find [tracks] again after a
|
||||
/// re-resolve.
|
||||
final Map<String, YtDlpFormat> formats;
|
||||
|
||||
/// What a player opens for this page ([playFormat]); null when yt-dlp
|
||||
/// picked nothing a player can open.
|
||||
final YtDlpPlay? play;
|
||||
const YtDlpInfo({
|
||||
required this.title,
|
||||
this.uploader = '',
|
||||
@@ -131,6 +141,7 @@ class YtDlpInfo {
|
||||
this.isLive = false,
|
||||
this.tracks = const [],
|
||||
this.formats = const {},
|
||||
this.play,
|
||||
});
|
||||
|
||||
/// One line for the log: extractor, duration, live, and each picked
|
||||
@@ -163,6 +174,7 @@ class YtDlpInfo {
|
||||
duration: j['duration'] is num ? (j['duration'] as num).toDouble() : null,
|
||||
thumbnail: str('thumbnail').isEmpty ? null : str('thumbnail'),
|
||||
isLive: isLive,
|
||||
play: YtDlpPlay.parse(j),
|
||||
tracks: isLive ? const [] : pickTracks(formats),
|
||||
formats: {
|
||||
for (final f in formats)
|
||||
@@ -341,6 +353,100 @@ Map<String, String> parseSha256Sums(String text) => {
|
||||
m.group(2)!: m.group(1)!.toLowerCase(),
|
||||
};
|
||||
|
||||
/// The formats a player opens for a page: the best video up to 1080p with
|
||||
/// the best audio, else the best single file (yt-dlp's `-f`).
|
||||
const playFormat = 'bv*[height<=1080]+ba/b';
|
||||
|
||||
/// The subtitles a player gets: every language but Bilibili's danmaku.
|
||||
const playSubLangs = 'all,-danmaku';
|
||||
|
||||
/// A page's subtitle track for a player (`requested_subtitles`).
|
||||
class YtDlpSubtitle {
|
||||
final String lang;
|
||||
final String name;
|
||||
final String url;
|
||||
final String ext;
|
||||
const YtDlpSubtitle(this.lang, this.url, {this.name = '', this.ext = ''});
|
||||
}
|
||||
|
||||
/// What a player opens for a page, as yt-dlp resolved it ([playFormat]):
|
||||
/// one file, or a video and an audio stream played together, with the
|
||||
/// headers its CDN wants (the first format's `http_headers`, plus its
|
||||
/// `cookies` as a Cookie header) and the subtitles ([playSubLangs]).
|
||||
/// URLs are bound to the resolving machine (its address, its cookies).
|
||||
class YtDlpPlay {
|
||||
final List<String> urls;
|
||||
final Map<String, String> headers;
|
||||
final List<YtDlpSubtitle> subtitles;
|
||||
const YtDlpPlay(this.urls, {this.headers = const {}, this.subtitles = const []});
|
||||
|
||||
/// The URL mpv opens: the file, or an EDL of the streams.
|
||||
String get mpvUrl => urls.length == 1 ? urls.single : mpvEdl(urls);
|
||||
|
||||
/// From `yt-dlp -J`: its `requested_formats`, else the top-level format;
|
||||
/// null when none of them is something a player can open.
|
||||
static YtDlpPlay? parse(Map<String, Object?> j) {
|
||||
final picked = j['requested_formats'] is List
|
||||
? [for (final f in j['requested_formats'] as List) if (f is Map) Map<String, Object?>.from(f)]
|
||||
: [if (j['url'] is String) j];
|
||||
if (picked.isEmpty) return null;
|
||||
final urls = <String>[];
|
||||
for (final f in picked) {
|
||||
final url = _playUrl(f);
|
||||
if (url == null) return null;
|
||||
urls.add(url);
|
||||
}
|
||||
final first = picked.first;
|
||||
final headers = <String, String>{
|
||||
if (first['http_headers'] is Map)
|
||||
for (final e in (first['http_headers'] as Map).entries)
|
||||
if (e.key is String && e.value is String) e.key as String: e.value as String,
|
||||
};
|
||||
final cookie = cookieHeaderOf(first['cookies']);
|
||||
if (cookie.isNotEmpty) headers['Cookie'] = cookie;
|
||||
final subs = <YtDlpSubtitle>[];
|
||||
final requested = j['requested_subtitles'];
|
||||
if (requested is Map) {
|
||||
for (final e in requested.entries) {
|
||||
final lang = '${e.key}', v = e.value;
|
||||
if (lang == 'danmaku' || v is! Map || v['url'] is! String) continue;
|
||||
final ext = v['ext'] is String ? v['ext'] as String : '';
|
||||
if (ext == 'xml' || ext == 'json') continue;
|
||||
subs.add(YtDlpSubtitle(lang, v['url'] as String,
|
||||
name: v['name'] is String ? v['name'] as String : '', ext: ext));
|
||||
}
|
||||
}
|
||||
return YtDlpPlay(urls, headers: headers, subtitles: subs);
|
||||
}
|
||||
|
||||
static String? _playUrl(Map<String, Object?> f) {
|
||||
final protocol = f['protocol'] is String ? f['protocol'] as String : 'https';
|
||||
if (protocol.startsWith('http_dash')) {
|
||||
final manifest = f['manifest_url'];
|
||||
return manifest is String && manifest.startsWith('http') ? manifest : null;
|
||||
}
|
||||
final url = f['url'];
|
||||
if (url is! String || !(url.startsWith('http://') || url.startsWith('https://'))) return null;
|
||||
return const {'http', 'https', 'm3u8', 'm3u8_native'}.contains(protocol) ? url : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// A Cookie header from yt-dlp's `cookies` field (`name=value; Domain=...;
|
||||
/// Path=/; name2=value2; ...`): the name=value pairs, attributes dropped.
|
||||
String cookieHeaderOf(Object? cookies) {
|
||||
if (cookies is! String || cookies.trim().isEmpty) return '';
|
||||
const attributes = {'domain', 'path', 'expires', 'max-age', 'secure', 'httponly', 'samesite', 'comment', 'version'};
|
||||
final pairs = <String>[];
|
||||
for (final part in cookies.split(';')) {
|
||||
final t = part.trim();
|
||||
final i = t.indexOf('=');
|
||||
final name = (i < 0 ? t : t.substring(0, i)).trim();
|
||||
if (i <= 0 || attributes.contains(name.toLowerCase())) continue;
|
||||
pairs.add('$name=${t.substring(i + 1).trim()}');
|
||||
}
|
||||
return pairs.join('; ');
|
||||
}
|
||||
|
||||
/// The last `ERROR:` line of yt-dlp's stderr without its prefixes
|
||||
/// (`ERROR: [twitter] 123: No video` -> `No video`), else the last line.
|
||||
String ytDlpErrorText(String stderr) {
|
||||
@@ -602,7 +708,11 @@ class YtDlp {
|
||||
final e = await err;
|
||||
_log.warning('resolve ${logUrl(url)}: exit $code after ${sw.elapsedMilliseconds} ms; stderr tail:\n'
|
||||
'${_tail(e)}');
|
||||
throw YtDlpException(ytDlpErrorText(e));
|
||||
final text = ytDlpErrorText(e);
|
||||
// A site that hides the video from guests: say which cookies went.
|
||||
final note = mayNeedSignIn(text) ? await cookieNote(url) : null;
|
||||
if (note != null) _log.info('resolve ${logUrl(url)}: $note');
|
||||
throw YtDlpException(text, cookies: note);
|
||||
}
|
||||
try {
|
||||
final j = jsonDecode(await out);
|
||||
@@ -616,6 +726,59 @@ class YtDlp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which cookies a run for [url] uses and what they hold for its site
|
||||
/// ([cookieSite]), in plain words (D11): none; the private file and how
|
||||
/// many of its cookies are for the site; a browser, and how many cookies
|
||||
/// for the site yt-dlp could read from it (one run of yt-dlp that writes
|
||||
/// the browser's cookies to a private file in [dir], counted and deleted
|
||||
/// at once; values are never logged).
|
||||
Future<String> cookieNote(String url) async {
|
||||
final site = cookieSite(Uri.tryParse(url)?.host ?? '');
|
||||
final source = cookies.read(count: false);
|
||||
switch (source) {
|
||||
case null:
|
||||
return 'no cookies were used';
|
||||
case CookieFile(:final path):
|
||||
try {
|
||||
final n = parseCookies(await File(path).readAsString()).where((c) => cookieSite(c.host) == site).length;
|
||||
return cookieNoteText(source, site, n);
|
||||
} catch (_) {
|
||||
return cookieNoteText(source, site, -1);
|
||||
}
|
||||
case CookieBrowser():
|
||||
return cookieNoteText(source, site, -1, read: await _browserCookies(source, site));
|
||||
}
|
||||
}
|
||||
|
||||
/// How many cookies for [site] yt-dlp reads from [browser], or why it
|
||||
/// cannot.
|
||||
Future<({int count, String? error})> _browserCookies(CookieBrowser browser, String site) async {
|
||||
final exe = await ensure();
|
||||
if (exe == null) return (count: -1, error: lastError ?? 'yt-dlp is not available');
|
||||
final jar = File(p.join(dir, '.cookie-check-$pid.txt'));
|
||||
try {
|
||||
// Private before yt-dlp writes into it (it keeps the mode).
|
||||
await jar.writeAsString('# Netscape HTTP Cookie File\n');
|
||||
if (!Platform.isWindows) await Process.run('chmod', ['600', jar.path]);
|
||||
// No URL: yt-dlp loads the browser's cookies, saves the jar and stops.
|
||||
final r = await Process.run(
|
||||
exe, ['--ignore-config', '--cookies-from-browser', browser.spec, '--cookies', jar.path])
|
||||
.timeout(const Duration(seconds: 30));
|
||||
final cookies = parseCookiesOrEmpty(await jar.readAsString());
|
||||
final err = '${r.stderr}';
|
||||
if (cookies.isEmpty && err.contains('ERROR:') && !err.contains('provide at least one URL')) {
|
||||
return (count: -1, error: ytDlpErrorText(err));
|
||||
}
|
||||
return (count: cookies.where((c) => cookieSite(c.host) == site).length, error: null);
|
||||
} on TimeoutException {
|
||||
return (count: -1, error: 'yt-dlp took longer than 30 s reading them');
|
||||
} catch (e) {
|
||||
return (count: -1, error: '$e');
|
||||
} finally {
|
||||
if (await jar.exists()) await jar.delete().catchError((_) => jar);
|
||||
}
|
||||
}
|
||||
|
||||
/// [args] for the log: the cookie source named, not its path or spec.
|
||||
static String _argsForLog(List<String> args) {
|
||||
final out = <String>[];
|
||||
@@ -640,8 +803,14 @@ class YtDlp {
|
||||
}
|
||||
|
||||
/// The command line [resolve] runs.
|
||||
List<String> resolveArgs(String url) =>
|
||||
['-J', '--no-playlist', '--no-warnings', '--ignore-config', ...cookies.args(), '--', url];
|
||||
List<String> resolveArgs(String url) => [
|
||||
'-J', '--no-playlist', '--no-warnings', '--ignore-config',
|
||||
// What a player opens ([YtDlpPlay]): yt-dlp's own pick at most
|
||||
// 1080p, and the page's subtitles but Bilibili's danmaku (an XML
|
||||
// comment feed, not subtitles).
|
||||
'-f', playFormat, '--write-subs', '--sub-langs', playSubLangs,
|
||||
...cookies.args(), '--', url,
|
||||
];
|
||||
|
||||
Future<void> close() => _changes.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: sync_relay_core
|
||||
description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter.
|
||||
version: 1.2.0
|
||||
version: 1.3.2
|
||||
environment:
|
||||
sdk: ^3.0.0
|
||||
dependencies:
|
||||
|
||||
@@ -30,26 +30,93 @@ void main() {
|
||||
expect(again.map((c) => c.toLine()), cookies.map((c) => c.toLine()));
|
||||
});
|
||||
|
||||
test('kept sites become a private file yt-dlp and mpv are pointed at', () async {
|
||||
test('kept sites become a private file yt-dlp is pointed at', () async {
|
||||
final dir = await Directory.systemTemp.createTemp('cookies_test');
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
final src = File('${dir.path}/export.json')..writeAsStringSync(_json);
|
||||
final y = YtDlp('${dir.path}/yt-dlp, a=b');
|
||||
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '--', 'U']);
|
||||
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '-f', playFormat, '--write-subs', '--sub-langs', playSubLangs, '--', 'U']);
|
||||
|
||||
final kept = await y.cookies.import(src.path, sites: {'bilibili.com'});
|
||||
expect([kept.count, ...kept.sites], [1, 'bilibili.com']);
|
||||
final path = y.cookies.filePath;
|
||||
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '--cookies', path, '--', 'U']);
|
||||
expect(y.cookies.mpvRawOptions(), 'cookies=%${path.length}%$path');
|
||||
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '-f', playFormat, '--write-subs', '--sub-langs', playSubLangs, '--cookies', path, '--', 'U']);
|
||||
if (!Platform.isWindows) expect(File(path).statSync().modeString(), 'rw-------');
|
||||
|
||||
await y.cookies.saveBrowser('firefox', 'work');
|
||||
expect(y.resolveArgs('U').sublist(4, 6), ['--cookies-from-browser', 'firefox:work']);
|
||||
expect(y.resolveArgs('U').sublist(9, 11), ['--cookies-from-browser', 'firefox:work']);
|
||||
expect(File(path).existsSync(), isFalse);
|
||||
expect(y.cookies.mpvRawOptions(), 'cookies-from-browser=%12%firefox:work');
|
||||
|
||||
await y.cookies.remove();
|
||||
expect(y.cookies.read(), isNull);
|
||||
});
|
||||
|
||||
// D11: a sign-in error says which cookies were used and what they hold
|
||||
// for the site.
|
||||
group('cookie notes', () {
|
||||
test('the texts', () {
|
||||
expect(cookieNoteText(null, 'bilibili.com', -1), 'no cookies were used');
|
||||
const file = CookieFile('/x', 3, ['youtube.com']);
|
||||
expect(cookieNoteText(file, 'bilibili.com', 0), 'used the cookies file, which has none for bilibili.com');
|
||||
expect(cookieNoteText(file, 'bilibili.com', 4),
|
||||
'used the cookies file (4 for bilibili.com: signed out or expired?)');
|
||||
const ff = CookieBrowser('firefox', 'work');
|
||||
expect(cookieNoteText(ff, 'bilibili.com', -1, read: (count: 0, error: null)),
|
||||
"used Firefox (work)'s cookies, which have none for bilibili.com: sign in to bilibili.com there");
|
||||
expect(cookieNoteText(ff, 'bilibili.com', -1, read: (count: 12, error: null)),
|
||||
"used Firefox (work)'s cookies (12 for bilibili.com: that account may not see it)");
|
||||
expect(cookieNoteText(const CookieBrowser('chrome'), 'x.com', -1, read: (count: -1, error: 'locked')),
|
||||
"used Chrome's cookies, but yt-dlp couldn't read them: locked");
|
||||
expect(withCookieNote(noFormatsText, 'no cookies were used'),
|
||||
'No video found: it may need signing in (no cookies were used)');
|
||||
expect(withCookieNote(needsCookiesText, 'x'), 'This site needs cookies (x) — Settings → Player → yt-dlp → Cookies');
|
||||
expect(withCookieNote(notFoundText, 'x'), notFoundText);
|
||||
expect(mayNeedSignIn('No video formats found!'), isTrue);
|
||||
expect(mayNeedSignIn('HTTP Error 404'), isFalse);
|
||||
});
|
||||
|
||||
test("a failed resolve names the file's cookies for the site; a browser's are counted by yt-dlp",
|
||||
() async {
|
||||
final dir = await Directory.systemTemp.createTemp('cookie_note');
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
// A stand-in yt-dlp: a version, a resolve that finds no formats, and
|
||||
// the browser read (no URL) that writes the jar it is given.
|
||||
final fake = File('${dir.path}/yt-dlp');
|
||||
await fake.writeAsString(r'''#!/bin/sh
|
||||
case "$1" in --version) echo 2099.01.01; exit 0;; esac
|
||||
jar=""; prev=""
|
||||
for a in "$@"; do [ "$prev" = "--cookies" ] && jar="$a"; prev="$a"; done
|
||||
case "$*" in *-J*) echo "ERROR: [BiliBili] BV1: No video formats found!" >&2; exit 1;; esac
|
||||
printf '# Netscape HTTP Cookie File\n.bilibili.com\tTRUE\t/\tTRUE\t2000000000\tSESSDATA\tx\n.bilibili.com\tTRUE\t/\tFALSE\t2000000000\tbuvid3\ty\n.other.org\tTRUE\t/\tFALSE\t0\ta\tb\n' > "$jar"
|
||||
echo "Extracted 3 cookies from firefox" >&2
|
||||
echo "yt-dlp: error: You must provide at least one URL." >&2; exit 2
|
||||
''');
|
||||
await Process.run('chmod', ['+x', fake.path]);
|
||||
final y = YtDlp(dir.path, updateEvery: const Duration(days: 9999));
|
||||
await File('${dir.path}/last-check').writeAsString(DateTime.now().toIso8601String());
|
||||
const page = 'https://www.bilibili.com/video/BV1';
|
||||
|
||||
Future<YtDlpException> fail() async {
|
||||
try {
|
||||
await y.resolve(page);
|
||||
} on YtDlpException catch (e) {
|
||||
return e;
|
||||
}
|
||||
throw StateError('resolved');
|
||||
}
|
||||
|
||||
expect((await fail()).cookies, 'no cookies were used');
|
||||
await y.cookies.saveFile(parseCookies('.youtube.com\tTRUE\t/\tFALSE\t0\tSID\tv\n'));
|
||||
expect((await fail()).cookies, 'used the cookies file, which has none for bilibili.com');
|
||||
await y.cookies.saveBrowser('firefox');
|
||||
final e = await fail();
|
||||
expect(e.message, contains('No video formats found'));
|
||||
expect(e.cookies, "used Firefox's cookies (2 for bilibili.com: that account may not see it)");
|
||||
expect(dir.listSync().map((f) => f.path).where((p) => p.contains('cookie-check')), isEmpty,
|
||||
reason: 'the browser jar is deleted at once');
|
||||
// The probe carries it into the plain words.
|
||||
expect(LinkProbe.page(Uri.parse(page), ytDlpError: e.message, cookies: e.cookies).problem,
|
||||
"No video found: it may need signing in (used Firefox's cookies (2 for bilibili.com: that account may not see it))");
|
||||
}, skip: Platform.isWindows ? 'the stand-in yt-dlp is a shell script' : false);
|
||||
});
|
||||
}
|
||||
@@ -316,7 +316,7 @@ void main() {
|
||||
expect(r.copyWith(sourceGone: false).toJson().containsKey('sourceGoneSince'), isFalse);
|
||||
});
|
||||
|
||||
test('state carries hold and the reserved advance', () {
|
||||
test('state carries hold and advance', () {
|
||||
final at = DateTime.utc(2026);
|
||||
final s = StateBroadcast(
|
||||
eventId: 1,
|
||||
|
||||
@@ -70,6 +70,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('skip asks for the first item; a play request times out', () {
|
||||
engine.applySwap('a', const HelloMedia(name: 'up', size: 0, duration: 0, url: 'https://x/up'));
|
||||
expect(engine.applyQueuePlay('a', ''), isFalse, reason: 'nothing queued');
|
||||
engine.applyQueueAdd('a', link('one'));
|
||||
expect(engine.applyQueuePlay('a', ''), isTrue);
|
||||
@@ -171,4 +172,132 @@ void main() {
|
||||
expect(engine.media!.name, 'Ep02');
|
||||
});
|
||||
});
|
||||
|
||||
group("what's next (the server's countdown)", () {
|
||||
QueueItem file(String title, {String key = 'abcdefgh12'}) => QueueItem(
|
||||
media: HelloMedia(name: title, size: 3, duration: 0), how: QueueHow.file, title: title, key: key);
|
||||
|
||||
// Both keep talking (pings), so nobody goes silent.
|
||||
void after(int secs) {
|
||||
t = t.add(Duration(seconds: secs));
|
||||
engine
|
||||
..touch('a')
|
||||
..touch('b')
|
||||
..tick();
|
||||
}
|
||||
|
||||
/// Ep01 (20 s) up by alice, both ready and buffered, played to its end.
|
||||
void finish() {
|
||||
engine.applySwap('a', const HelloMedia(name: 'Ep01', size: 0, duration: 20, url: 'https://example.com/1'));
|
||||
for (final tok in ['a', 'b']) {
|
||||
engine.applyReady(tok, true);
|
||||
engine.applyReport(tok, 0, 100, engine.eventId, mediaId: engine.mediaId);
|
||||
}
|
||||
expect(engine.transport, Transport.playing);
|
||||
after(21);
|
||||
expect(engine.transport, Transport.finished);
|
||||
}
|
||||
|
||||
|
||||
test('nothing up: the first item is asked for at once', () {
|
||||
engine.applyQueueAdd('b', link('one'));
|
||||
expect(engine.queue.play, idOf('one'));
|
||||
expect(engine.advance, isNull);
|
||||
});
|
||||
|
||||
test('at the end with an item first: 10 s for everyone, then queuePlay', () {
|
||||
finish();
|
||||
expect(engine.advance, isNull, reason: 'nothing queued, nothing counts down');
|
||||
engine.applyQueueAdd('b', link('two'));
|
||||
final a = engine.advance!;
|
||||
expect([a.id, a.secs], [idOf('two'), RelayConstants.advanceSecs]);
|
||||
expect(a.at, t);
|
||||
expect(engine.broadcast().advance!.id, a.id, reason: 'in every state');
|
||||
expect(advanceSecondsLeft(a, t.add(const Duration(milliseconds: 2500))), 8);
|
||||
after(5);
|
||||
expect(engine.queue.play, isNull);
|
||||
// A new first item takes the countdown over, its end unchanged.
|
||||
engine.applyQueueAdd('a', link('first'), next: true);
|
||||
expect([engine.advance!.id, engine.advance!.at], [idOf('first'), a.at]);
|
||||
after(5);
|
||||
expect(engine.queue.play, idOf('first'));
|
||||
expect(engine.advance, isNull, reason: 'over once it asked');
|
||||
after(1);
|
||||
expect(engine.advance, isNull, reason: 'not counted down again while the request waits');
|
||||
});
|
||||
|
||||
test('a seek stops it; cancel stops it for this media and drops the continuation', () {
|
||||
engine.applyQueueAdd('b', link('two'));
|
||||
finish();
|
||||
expect(engine.advance, isNotNull);
|
||||
engine.applySeek('b', 5);
|
||||
expect(engine.advance, isNull);
|
||||
expect(engine.transport, isNot(Transport.finished));
|
||||
// Played to the end again: a fresh countdown.
|
||||
after(16);
|
||||
expect(engine.transport, Transport.finished);
|
||||
expect(engine.advance, isNotNull);
|
||||
engine.applyQueueAdd('a', link('Ep02').copyWith(auto: true, forMedia: engine.mediaId));
|
||||
engine.feed.drain();
|
||||
expect(engine.applyQueueCancel('b'), isTrue);
|
||||
expect(engine.advance, isNull);
|
||||
expect(titles(), ['two']);
|
||||
expect(engine.feed.drain().single.action, 'advance-cancel');
|
||||
after(30);
|
||||
expect((engine.advance, engine.queue.play), (null, null), reason: 'cancelled for this media');
|
||||
});
|
||||
|
||||
test("holds while its performer is gone, plays once they're back", () {
|
||||
engine.applyQueueAdd('b', file('bobs'));
|
||||
finish();
|
||||
engine.applyGone('b');
|
||||
after(12);
|
||||
expect(engine.queue.play, isNull, reason: 'held: only bob has the file');
|
||||
expect(engine.advance, isNotNull);
|
||||
engine.applyTakeover('b', seq: 1);
|
||||
after(1);
|
||||
expect(engine.queue.play, idOf('bobs'));
|
||||
});
|
||||
|
||||
test("holds while the item's relay source is gone", () {
|
||||
var sourceGone = true;
|
||||
engine.itemSourceGone = (_) => sourceGone;
|
||||
engine.applyQueueAdd('b', link('two'));
|
||||
finish();
|
||||
after(12);
|
||||
expect(engine.queue.play, isNull);
|
||||
sourceGone = false;
|
||||
after(1);
|
||||
expect(engine.queue.play, idOf('two'));
|
||||
});
|
||||
|
||||
test('what nobody here can put up is skipped by the server', () {
|
||||
engine.applyQueueAdd('b', file('bobs'));
|
||||
engine.applyQueueAdd('a', link('next'));
|
||||
finish();
|
||||
engine.applyLeave('b', reason: 'bye');
|
||||
engine.feed.drain();
|
||||
after(11);
|
||||
expect(titles(), ['next']);
|
||||
final skip = engine.feed.drain().firstWhere((f) => f.action == 'queue-skip');
|
||||
expect([skip.actorId, skip.target, skip.reason], ['server', 'bobs', 'bob left']);
|
||||
expect(engine.advance!.id, idOf('next'), reason: 'the countdown goes on with the next one');
|
||||
after(1);
|
||||
expect(engine.queue.play, idOf('next'));
|
||||
// A play request for a Jellyfin item nobody here can open: skipped too.
|
||||
engine.applyQueueAdd('a', QueueItem(
|
||||
media: const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i')),
|
||||
how: QueueHow.jellyfin,
|
||||
title: 'ep'));
|
||||
engine.applyQueuePlay('a', idOf('ep'));
|
||||
expect(titles().contains('ep'), isFalse);
|
||||
});
|
||||
|
||||
test('the continuation adds no feed line', () {
|
||||
engine.applySwap('a', const HelloMedia(name: 'Ep01', size: 0, duration: 0, url: 'https://example.com/1'));
|
||||
engine.feed.drain();
|
||||
engine.applyQueueAdd('a', link('Ep02').copyWith(auto: true, forMedia: engine.mediaId));
|
||||
expect(engine.feed.drain().where((f) => f.action == 'queue-add'), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -388,6 +388,22 @@ void main() {
|
||||
group('swap', () {
|
||||
const ep2 = HelloMedia(name: 'Ep02.mkv', size: 9, duration: 90.0);
|
||||
|
||||
test('a restore (after a restart) puts it back at its position, held; finished stays finished', () {
|
||||
join('a', 'alice');
|
||||
engine.applySwap('a', ep2, restore: const SwapRestore(position: 42.5, transport: 'playing'));
|
||||
expect((engine.positionAt(), engine.transport), (42.5, Transport.held));
|
||||
expect(engine.feed.drain().last.reason, 'restore');
|
||||
engine.applyReady('a', true);
|
||||
buffer('a', 4.0);
|
||||
expect(engine.transport, Transport.playing, reason: 'the gate starts it again');
|
||||
engine.applySwap('a', ep2, restore: const SwapRestore(position: 90, transport: 'finished'));
|
||||
expect((engine.positionAt(), engine.transport), (90.0, Transport.finished));
|
||||
final rt = SwapIntent.fromJson(
|
||||
const SwapIntent(media: ep2, restore: SwapRestore(position: 3, transport: 'playing')).toJson());
|
||||
expect((rt.restore!.position, rt.restore!.transport), (3.0, 'playing'));
|
||||
expect(SwapIntent.fromJson(const SwapIntent(media: ep2).toJson()).restore, isNull);
|
||||
});
|
||||
|
||||
test('swap bumps mediaId, holds, keeps ready, fresh members in set', () {
|
||||
join('a', 'alice');
|
||||
engine.applyReady('a', true);
|
||||
|
||||
@@ -1,102 +1,151 @@
|
||||
// restructure: rewrite in step 6 (D3: the watched rule moves from position coverage to time present)
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Plays [from]..[to] at normal speed, one sample a second, starting at
|
||||
/// wall second [t]; returns the wall second after.
|
||||
int play(WatchSession w, int t, double from, double to) {
|
||||
var pos = from;
|
||||
w.sample(pos, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
|
||||
while (pos < to) {
|
||||
t++;
|
||||
pos = pos + 1 > to ? to : pos + 1;
|
||||
w.sample(pos, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
|
||||
final _t0 = DateTime(2026, 1, 1);
|
||||
|
||||
/// One sample a second for [secs] seconds from wall second [t], the
|
||||
/// position moving with the wall clock from [from] while [playing];
|
||||
/// returns the wall second after.
|
||||
int run(WatchSession w, int t, double from, int secs, {bool playing = true, bool present = true}) {
|
||||
for (var i = 0; i <= secs; i++) {
|
||||
w.sample(from + (playing ? i : 0), playing: playing, present: present, at: _t0.add(Duration(seconds: t + i)));
|
||||
}
|
||||
return t + 1;
|
||||
return t + secs + 1;
|
||||
}
|
||||
|
||||
void main() {
|
||||
const ep = 1440.0; // 24 min
|
||||
const ed = 1350.0; // the ending starts 1.5 min before the end
|
||||
|
||||
test('normal playback counts; seeks, skips and pauses do not', () {
|
||||
group('time In', () {
|
||||
test('counts the time it played while In; pauses, holds and jumps add nothing', () {
|
||||
final w = WatchSession();
|
||||
var t = play(w, 0, 0, 60);
|
||||
// A jump ahead between two samples: not watched.
|
||||
w.sample(600, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
|
||||
t = play(w, t + 1, 600, 630);
|
||||
// Paused: the position does not move, nothing counts.
|
||||
w.sample(630, playing: false, at: DateTime(2026, 1, 1).add(Duration(seconds: t + 30)));
|
||||
expect(w.covered, closeTo(90, 1));
|
||||
expect(w.spans, hasLength(2));
|
||||
expect(w.firstPosition, 0);
|
||||
expect(w.lastPosition, 630);
|
||||
var t = run(w, 0, 0, 60);
|
||||
// Held (the server waits for someone): the clock runs, nothing counts.
|
||||
t = run(w, t, 60, 30, playing: false);
|
||||
// A jump ahead: only the time actually played after it.
|
||||
t = run(w, t, 600, 30);
|
||||
expect(w.timeIn, closeTo(90, 2));
|
||||
expect(w.furthest, 630);
|
||||
expect(w.firstPosition, 0);
|
||||
});
|
||||
|
||||
test('rewatching a part counts once in coverage, twice in watched time', () {
|
||||
test('sat out (Away) or joining: not counted, though the media plays on (P7)', () {
|
||||
final w = WatchSession();
|
||||
var t = play(w, 0, 0, 300);
|
||||
t = play(w, t, 100, 200); // went back for a moment
|
||||
expect(w.covered, closeTo(300, 1));
|
||||
expect(w.watched, closeTo(400, 1));
|
||||
var t = run(w, 0, 0, 120); // in for two minutes
|
||||
t = run(w, t, 120, 1200, present: false); // sat out for the rest
|
||||
expect(w.timeIn, closeTo(120, 2));
|
||||
expect(w.verdict(runtime: ep).outcome, WatchOutcome.partial);
|
||||
expect(w.furthest, closeTo(120, 1), reason: 'positions while away are not this viewer\'s');
|
||||
});
|
||||
|
||||
test('played: 70 % covered, or the ending (or the last 15 %) reached after 40 %', () {
|
||||
test('a stall longer than the gap adds nothing', () {
|
||||
final w = WatchSession();
|
||||
w.sample(0, playing: true, present: true, at: _t0);
|
||||
w.sample(30, playing: true, present: true, at: _t0.add(const Duration(seconds: 30)));
|
||||
expect(w.timeIn, 0);
|
||||
});
|
||||
|
||||
test('drift corrections and small seeks while watching do not cost watched time (P9)', () {
|
||||
final w = WatchSession();
|
||||
var pos = 0.0;
|
||||
for (var i = 0; i <= 1200; i++) {
|
||||
// Every 7 s the player is corrected half a second back or ahead.
|
||||
if (i % 7 == 0) pos += i % 14 == 0 ? 0.5 : -0.5;
|
||||
w.sample(pos, playing: true, present: true, at: _t0.add(Duration(seconds: i)));
|
||||
pos += 1;
|
||||
}
|
||||
expect(w.timeIn, closeTo(1200, 1));
|
||||
expect(w.verdict(runtime: ep).outcome, WatchOutcome.played);
|
||||
});
|
||||
});
|
||||
|
||||
group('verdict (D3)', () {
|
||||
test('played: 70 % of the runtime In, or the ending (or the last 15 %) reached after 40 %', () {
|
||||
final full = WatchSession();
|
||||
play(full, 0, 0, 1200); // 83 %
|
||||
run(full, 0, 0, 1200); // 83 %
|
||||
expect(full.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
|
||||
|
||||
// The opening and the ending skipped, stopped at the ED: Jellyfin's
|
||||
// own rule (past 90 %) would count it too.
|
||||
final skipped = WatchSession();
|
||||
final t = play(skipped, 0, 0, 800); // 56 %, then skipped to the ED
|
||||
play(skipped, t, ed, ed + 5);
|
||||
var t = run(skipped, 0, 0, 90);
|
||||
t = run(skipped, t, 180, 1170); // to the ED start
|
||||
expect(skipped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
|
||||
// Without a known ending the last 15 % (at most 4 min) counts.
|
||||
expect(skipped.verdict(runtime: ep).outcome, WatchOutcome.played);
|
||||
|
||||
// Watched with the opening skipped, stopped 3 min before the end.
|
||||
final early = WatchSession();
|
||||
final u = play(early, 0, 0, 90);
|
||||
play(early, u, 180, ep - 180);
|
||||
expect(early.verdict(runtime: ep).outcome, WatchOutcome.played);
|
||||
final half = WatchSession();
|
||||
t = run(half, 0, 0, 800); // 56 %, then a skip to the ED
|
||||
run(half, t, ed, 5);
|
||||
expect(half.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
|
||||
});
|
||||
|
||||
test('skipping around or a screenshot visit never counts', () {
|
||||
// Through the whole episode in 30 s jumps, 2 s at each stop.
|
||||
final hops = WatchSession();
|
||||
var t = 0;
|
||||
for (var p = 0.0; p < ep; p += 30) {
|
||||
t = run(hops, t, p, 2);
|
||||
}
|
||||
expect(hops.furthest, greaterThan(ed));
|
||||
expect(hops.verdict(runtime: ep, endingStart: ed).watched, isFalse);
|
||||
|
||||
final shot = WatchSession();
|
||||
run(shot, 0, 700, 20);
|
||||
expect(shot.verdict(runtime: ep).outcome, WatchOutcome.peek);
|
||||
|
||||
// A quarter watched, then a jump to the end: part way, not played.
|
||||
final jumped = WatchSession();
|
||||
play(jumped, 0, 0, 400); // 28 %, then jumped to the end
|
||||
jumped.sample(ep, playing: false, at: DateTime(2026, 1, 2));
|
||||
t = run(jumped, 0, 0, 400);
|
||||
run(jumped, t, ep - 5, 5);
|
||||
expect(jumped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.partial);
|
||||
});
|
||||
|
||||
test('played before: stays played however little was watched', () {
|
||||
final w = WatchSession(playedBefore: true);
|
||||
play(w, 0, 700, 730); // checking a moment
|
||||
run(w, 0, 700, 30);
|
||||
expect(w.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.keepPlayed);
|
||||
expect(w.verdict(runtime: ep).watched, isTrue);
|
||||
});
|
||||
|
||||
test('a resume continues the earlier watch', () {
|
||||
final resumed = WatchSession(resumeBefore: 600);
|
||||
run(resumed, 0, 590, 800); // from the resume point to the end
|
||||
expect(resumed.credited, closeTo(1400, 2));
|
||||
expect(resumed.verdict(runtime: ep).outcome, WatchOutcome.played);
|
||||
|
||||
// Played from the start again: the earlier part is not counted twice.
|
||||
final again = WatchSession(resumeBefore: 600);
|
||||
run(again, 0, 0, 300);
|
||||
expect(again.credited, closeTo(300, 2));
|
||||
});
|
||||
|
||||
test('a peek leaves nothing; part way resumes at the last position', () {
|
||||
final peek = WatchSession(resumeBefore: 300);
|
||||
play(peek, 0, 900, 990); // 90 s
|
||||
run(peek, 0, 900, 90);
|
||||
expect(peek.verdict(runtime: ep).outcome, WatchOutcome.peek);
|
||||
|
||||
final movie = WatchSession();
|
||||
play(movie, 0, 0, 300); // 5 min of 2 h: under 5 %
|
||||
run(movie, 0, 0, 300); // 5 min of 2 h: under 5 %
|
||||
expect(movie.verdict(runtime: 7200).outcome, WatchOutcome.peek);
|
||||
|
||||
final part = WatchSession();
|
||||
final t = play(part, 0, 0, 500);
|
||||
part.sample(450, playing: false, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
|
||||
final t = run(part, 0, 0, 500);
|
||||
part.sample(450, playing: false, present: true, at: _t0.add(Duration(seconds: t)));
|
||||
final v = part.verdict(runtime: ep);
|
||||
expect(v.outcome, WatchOutcome.partial);
|
||||
expect(v.resumeAt, 450);
|
||||
|
||||
// A 3 min clip: half of it is not a peek.
|
||||
final clip = WatchSession();
|
||||
play(clip, 0, 0, 90);
|
||||
run(clip, 0, 0, 90);
|
||||
expect(clip.verdict(runtime: 180).outcome, WatchOutcome.partial);
|
||||
});
|
||||
|
||||
test('an unknown runtime never makes it played', () {
|
||||
final w = WatchSession();
|
||||
play(w, 0, 0, 1000);
|
||||
run(w, 0, 0, 1000);
|
||||
expect(w.verdict(runtime: 0).outcome, WatchOutcome.partial);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
MemberView member(String id,
|
||||
{Presence presence = Presence.watching, Map<String, bool> jf = const {}, String name = ''}) =>
|
||||
MemberView(
|
||||
id: id, name: name.isEmpty ? id : name, ready: false, inSet: true, buffered: 0, presence: presence, jf: jf);
|
||||
|
||||
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
|
||||
id: 'q1',
|
||||
media: how == QueueHow.jellyfin
|
||||
? const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i'))
|
||||
: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
|
||||
how: how,
|
||||
title: 'x',
|
||||
by: by,
|
||||
byName: byName,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('queuePerformer: who puts an item up (the server and every app read it the same)', () {
|
||||
test('a link passed on or a file: only its adder; gone waits for it, left means skipped', () {
|
||||
for (final how in [QueueHow.through, QueueHow.file]) {
|
||||
expect(queuePerformer(item(how), [member('m1'), member('m2', presence: Presence.away)]).id, 'm2',
|
||||
reason: 'sitting out, it comes back to put its own item up');
|
||||
final waits = queuePerformer(item(how), [member('m1'), member('m2', presence: Presence.gone)]);
|
||||
expect((waits.id, waits.why), (null, null), reason: 'a gone adder may come back within its grace');
|
||||
final left = queuePerformer(item(how), [member('m1'), member('m3')]);
|
||||
expect((left.id, left.why), (null, 'Bob left'));
|
||||
}
|
||||
});
|
||||
|
||||
test('Jellyfin: the adder with access, else the lowest member with access, else skipped', () {
|
||||
final access = {'S': true};
|
||||
expect(queuePerformer(item(QueueHow.jellyfin), [member('m1', jf: access), member('m2', jf: access)]).id, 'm2');
|
||||
expect(
|
||||
queuePerformer(item(QueueHow.jellyfin), [member('m10', jf: access), member('m3', jf: access), member('m2')])
|
||||
.id,
|
||||
'm3');
|
||||
expect(
|
||||
queuePerformer(
|
||||
item(QueueHow.jellyfin), [member('m3', jf: access, presence: Presence.away), member('m2')])
|
||||
.why,
|
||||
'nobody here has that Jellyfin server');
|
||||
});
|
||||
|
||||
test('a link everyone opens or the server fetches: the adder, else the lowest present member', () {
|
||||
for (final how in [QueueHow.direct, QueueHow.server]) {
|
||||
expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2');
|
||||
expect(
|
||||
queuePerformer(item(how), [member('m4'), member('m2', presence: Presence.gone), member('m3')]).id, 'm3');
|
||||
final nobody = queuePerformer(item(how), [member('m2', presence: Presence.away)]);
|
||||
expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip');
|
||||
}
|
||||
});
|
||||
|
||||
test('member ids order by number: m2 before m10', () {
|
||||
expect(compareMemberIds('m9', 'm10'), lessThan(0));
|
||||
expect(lowestMemberId([member('m10'), member('m9')]), 'm9');
|
||||
expect(lowestMemberId(const []), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('the continuation: who put the media up, else the first member in', () {
|
||||
final members = [member('m1', presence: Presence.away), member('m2'), member('m3')];
|
||||
expect(mayAddContinuation('m1', 'm1', members), isTrue, reason: 'the swapper itself');
|
||||
expect(mayAddContinuation('m2', 'm1', members), isTrue, reason: 'the swapper is away: the first in');
|
||||
expect(mayAddContinuation('m3', 'm1', members), isFalse);
|
||||
expect(mayAddContinuation('m2', 'm3', members), isFalse, reason: 'the swapper is in');
|
||||
expect(mayAddContinuation('m2', null, members), isTrue, reason: 'the media the server started with');
|
||||
expect(mayAddContinuation('', 'm1', members), isFalse);
|
||||
});
|
||||
|
||||
test('seconds left of a countdown, on the server clock', () {
|
||||
final at = DateTime.utc(2026, 1, 1);
|
||||
final a = QueueAdvance(id: 'q1', at: at, secs: 10);
|
||||
expect(advanceSecondsLeft(a, at), 10);
|
||||
expect(advanceSecondsLeft(a, at.add(const Duration(milliseconds: 100))), 10);
|
||||
expect(advanceSecondsLeft(a, at.add(const Duration(seconds: 9, milliseconds: 1))), 1);
|
||||
expect(advanceSecondsLeft(a, at.add(const Duration(seconds: 12))), 0);
|
||||
});
|
||||
}
|
||||
@@ -261,4 +261,46 @@ void main() {
|
||||
expect(pick(['avc1.640033', 'vp09.00.40.08', 'av01.0.08M.08']), 'v2');
|
||||
expect(pick(['avc1.640033', 'vp09.00.40.08']), 'v1');
|
||||
});
|
||||
|
||||
test('what a player opens: yt-dlp\'s pick, an EDL for video + audio, headers with the cookies, subtitles but danmaku', () {
|
||||
final split = YtDlpInfo.parse({
|
||||
'title': 'Page',
|
||||
'formats': <Object>[],
|
||||
'requested_formats': [
|
||||
{
|
||||
'format_id': 'v', 'url': 'https://cdn/v.m4s', 'protocol': 'https',
|
||||
'http_headers': {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA'},
|
||||
'cookies': 'SESSDATA=abc; Domain=.bilibili.com; Path=/; Secure; Expires=1999999999; buvid3=xyz; Domain=.bilibili.com',
|
||||
},
|
||||
{'format_id': 'a', 'url': 'https://cdn/a.m4s', 'protocol': 'https', 'http_headers': {'Referer': 'other'}},
|
||||
],
|
||||
'requested_subtitles': {
|
||||
'zh-CN': {'url': 'https://subs/zh.vtt', 'ext': 'vtt', 'name': 'Chinese'},
|
||||
'danmaku': {'url': 'https://comment/1.xml', 'ext': 'xml'},
|
||||
},
|
||||
}).play!;
|
||||
expect(split.urls, ['https://cdn/v.m4s', 'https://cdn/a.m4s']);
|
||||
expect(split.mpvUrl, mpvEdl(['https://cdn/v.m4s', 'https://cdn/a.m4s']));
|
||||
expect(split.headers, {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA', 'Cookie': 'SESSDATA=abc; buvid3=xyz'});
|
||||
expect([for (final s in split.subtitles) '${s.lang} ${s.name} ${s.url}'], ['zh-CN Chinese https://subs/zh.vtt']);
|
||||
|
||||
final single = YtDlpInfo.parse({
|
||||
'title': 'Tweet', 'url': 'https://video.twimg.com/v.mp4', 'protocol': 'https', 'ext': 'mp4',
|
||||
'http_headers': {'User-Agent': 'UA'},
|
||||
}).play!;
|
||||
expect(single.mpvUrl, 'https://video.twimg.com/v.mp4');
|
||||
expect(single.headers, {'User-Agent': 'UA'});
|
||||
|
||||
final hls = YtDlpInfo.parse({
|
||||
'title': 'Live', 'is_live': true,
|
||||
'requested_formats': [{'url': 'https://x/live.m3u8', 'protocol': 'm3u8_native'}],
|
||||
}).play!;
|
||||
expect(hls.mpvUrl, 'https://x/live.m3u8', reason: 'a live stream plays, it is only not relayed');
|
||||
expect(YtDlpInfo.parse({'title': 'DASH', 'requested_formats': [{'url': 'https://x/f', 'protocol': 'http_dash_segments', 'manifest_url': 'https://x/m.mpd'}]}).play?.mpvUrl,
|
||||
'https://x/m.mpd');
|
||||
expect(YtDlpInfo.parse({'title': 'none', 'formats': <Object>[]}).play, isNull);
|
||||
expect(YtDlpInfo.parse({'title': 'rtmp', 'url': 'rtmp://x/y', 'protocol': 'rtmp'}).play, isNull);
|
||||
expect(cookieHeaderOf(null), '');
|
||||
expect(cookieHeaderOf('a=1; Path=/; HttpOnly; b=2=3'), 'a=1; b=2=3');
|
||||
});
|
||||
}
|
||||
@@ -112,14 +112,41 @@ class ChannelRelayFeed extends RelayFeed {
|
||||
class _Pull {
|
||||
final int id, start, end;
|
||||
int pos;
|
||||
final buf = BytesBuilder(copy: false);
|
||||
// The slice being filled: its chunks so far (readers take them as they
|
||||
// come, see [RelayBuffer.read]).
|
||||
final parts = <Uint8List>[];
|
||||
int filled = 0;
|
||||
_Pull(this.id, this.start, this.end) : pos = start;
|
||||
|
||||
/// Bytes of the slice being filled from [from] (offset in the slice).
|
||||
Uint8List? partFrom(int from) {
|
||||
if (from >= filled) return null;
|
||||
final out = BytesBuilder(copy: false);
|
||||
var at = 0;
|
||||
for (final p in parts) {
|
||||
final end = at + p.length;
|
||||
if (end > from) out.add(at >= from ? p : Uint8List.sublistView(p, from - at));
|
||||
at = end;
|
||||
}
|
||||
return out.takeBytes();
|
||||
}
|
||||
|
||||
Uint8List takeSlice() {
|
||||
final out = BytesBuilder(copy: false);
|
||||
for (final p in parts) {
|
||||
out.add(p);
|
||||
}
|
||||
parts.clear();
|
||||
filled = 0;
|
||||
return out.takeBytes();
|
||||
}
|
||||
}
|
||||
|
||||
class _Viewer {
|
||||
int pos = 0;
|
||||
int open = 0; // requests in flight
|
||||
int newest = 0; // sequence of its newest request
|
||||
(int slice, int req)? blocking; // where its newest request waits
|
||||
DateTime lastActive = DateTime.now();
|
||||
}
|
||||
|
||||
@@ -134,9 +161,11 @@ class _Viewer {
|
||||
/// slowest and [ahead] past the furthest viewer. Evicted by distance from
|
||||
/// the viewers, never LRU.
|
||||
/// - Filled only while someone watches: a viewer blocked on a slice comes
|
||||
/// first (oldest wait first); then read-ahead.
|
||||
/// first (oldest wait first); then read-ahead. A blocked viewer reads the
|
||||
/// slice being filled as its bytes arrive, not once it is whole.
|
||||
/// - Only a viewer's newest request moves its position or asks the feed
|
||||
/// for anything; older ones still get bytes once they are buffered.
|
||||
/// for anything; older ones still get bytes once they are buffered, and
|
||||
/// stop holding the feed once superseded (a seek elsewhere).
|
||||
/// Everything lives in memory: dropping the buffer is the cleanup.
|
||||
class RelayBuffer implements TrackBuffer {
|
||||
/// Set by the relay when a media's tracks share the budget.
|
||||
@@ -166,6 +195,8 @@ class RelayBuffer implements TrackBuffer {
|
||||
final _slices = <int, Uint8List>{};
|
||||
final _waiters = <int, List<Completer<void>>>{};
|
||||
final _blocked = <int, int>{}; // slice -> wait start (us)
|
||||
// The driving requests waiting on each blocked slice.
|
||||
final _blockedBy = <int, Set<int>>{};
|
||||
final _viewers = <String, _Viewer>{};
|
||||
@override
|
||||
FeedClock clock = FeedClock();
|
||||
@@ -256,21 +287,22 @@ class RelayBuffer implements TrackBuffer {
|
||||
_endPull(pl);
|
||||
return;
|
||||
}
|
||||
pl.buf.add(Uint8List.sublistView(data, off, off + take));
|
||||
pl.parts.add(Uint8List.sublistView(data, off, off + take));
|
||||
pl.filled += take;
|
||||
off += take;
|
||||
pl.pos += take;
|
||||
if (pl.pos == _sliceEnd(s) + 1) {
|
||||
_slices[s] = pl.buf.takeBytes();
|
||||
final done = pl.pos == _sliceEnd(s) + 1;
|
||||
if (done) _slices[s] = pl.takeSlice();
|
||||
// Whole, or more of it: whoever waits on it reads on.
|
||||
for (final c in _waiters.remove(s) ?? const <Completer<void>>[]) {
|
||||
c.complete();
|
||||
}
|
||||
if (_shouldStop(pl, s)) {
|
||||
if (done && _shouldStop(pl, s)) {
|
||||
_endPull(pl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void feedEnd(int id) {
|
||||
@@ -405,25 +437,54 @@ class RelayBuffer implements TrackBuffer {
|
||||
await _kick.future.timeout(const Duration(seconds: 5), onTimeout: () {});
|
||||
}
|
||||
|
||||
Future<bool> _waitSlice(int s, bool drives) async {
|
||||
/// The bytes of slice [s] from [from] (offset in the slice) as soon as
|
||||
/// there are any: the whole slice's rest, or what the pull filling it has
|
||||
/// so far. Null when they did not come within [waitLimit]. [drives]: the
|
||||
/// request is its viewer's newest ([driving] says while it still is), so
|
||||
/// it may move the feed.
|
||||
Future<Uint8List?> _waitBytes(_Viewer v, int s, int from, int req, bool Function() driving) async {
|
||||
final deadline = DateTime.now().add(waitLimit);
|
||||
while (!_slices.containsKey(s)) {
|
||||
if (_disposed || DateTime.now().isAfter(deadline)) return false;
|
||||
if (drives) {
|
||||
try {
|
||||
while (true) {
|
||||
final whole = _slices[s];
|
||||
if (whole != null) return from == 0 ? whole : Uint8List.sublistView(whole, from);
|
||||
final pl = _pull;
|
||||
if (pl != null && pl.pos ~/ slice == s && pl.start <= s * slice) {
|
||||
final part = pl.partFrom(from);
|
||||
if (part != null) return part;
|
||||
}
|
||||
if (_disposed || DateTime.now().isAfter(deadline)) return null;
|
||||
if (driving()) {
|
||||
_blocked.putIfAbsent(s, () => DateTime.now().microsecondsSinceEpoch);
|
||||
_blockedBy.putIfAbsent(s, () => {}).add(req);
|
||||
v.blocking = (s, req);
|
||||
// A pull nobody waits on gives way at once; one serving another
|
||||
// blocked viewer hands over at its next slice boundary.
|
||||
final pl = _pull;
|
||||
final at = pl == null ? -1 : pl.pos ~/ slice;
|
||||
if (pl != null && at != s && !_blocked.containsKey(at)) _endPull(pl);
|
||||
_wake();
|
||||
} else {
|
||||
_unblock(s, req);
|
||||
}
|
||||
final c = Completer<void>();
|
||||
_waiters.putIfAbsent(s, () => []).add(c);
|
||||
await c.future.timeout(const Duration(seconds: 2), onTimeout: () {});
|
||||
}
|
||||
} finally {
|
||||
_unblock(s, req);
|
||||
}
|
||||
}
|
||||
|
||||
// [req] no longer waits on slice [s]; the slice stops counting as
|
||||
// blocked once nobody driving does.
|
||||
void _unblock(int s, int req) {
|
||||
final by = _blockedBy[s];
|
||||
if (by == null || !by.remove(req)) return;
|
||||
if (by.isEmpty) {
|
||||
_blockedBy.remove(s);
|
||||
_blocked.remove(s);
|
||||
return true;
|
||||
_wake();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- viewers
|
||||
@@ -444,6 +505,10 @@ class RelayBuffer implements TrackBuffer {
|
||||
final tail = start >= size - pin;
|
||||
final me = ++_reqSeq;
|
||||
if (!tail) {
|
||||
// A seek: the request it supersedes stops holding the feed at once.
|
||||
final was = v.blocking;
|
||||
if (was != null) _unblock(was.$1, was.$2);
|
||||
v.blocking = null;
|
||||
v.newest = me;
|
||||
v.pos = start;
|
||||
}
|
||||
@@ -454,16 +519,14 @@ class RelayBuffer implements TrackBuffer {
|
||||
var off = start;
|
||||
while (off <= end) {
|
||||
final s = off ~/ slice;
|
||||
if (!await _waitSlice(s, tail || v.newest == me)) {
|
||||
throw StateError('relay: bytes at $off did not arrive');
|
||||
}
|
||||
final data = _slices[s];
|
||||
if (data == null) continue; // evicted between wake and read
|
||||
final from = off - s * slice, to = min(data.length, end - s * slice + 1);
|
||||
yield Uint8List.sublistView(data, from, to);
|
||||
bytesToViewers += to - from;
|
||||
onSent?.call(to - from);
|
||||
off += to - from;
|
||||
final data = await _waitBytes(v, s, off - s * slice, me, () => tail || v.newest == me);
|
||||
if (data == null) throw StateError('relay: bytes at $off did not arrive');
|
||||
final n = min(data.length, end - off + 1);
|
||||
if (n <= 0) continue;
|
||||
yield n == data.length ? data : Uint8List.sublistView(data, 0, n);
|
||||
bytesToViewers += n;
|
||||
onSent?.call(n);
|
||||
off += n;
|
||||
v.lastActive = DateTime.now();
|
||||
if (!tail && v.newest == me) v.pos = off;
|
||||
_wake();
|
||||
|
||||
@@ -50,7 +50,17 @@ class ServerHub {
|
||||
engine = engine ?? SyncEngine() {
|
||||
this.engine
|
||||
..onGone = _memberGone
|
||||
..onLeft = _memberLeft;
|
||||
..onLeft = _memberLeft
|
||||
..itemSourceGone = _warmSourceGone;
|
||||
}
|
||||
|
||||
/// The warm relay of Up next item [queueId] waits for a member source
|
||||
/// that is `gone`: the countdown holds (see `SyncEngine.whatsNext`).
|
||||
bool _warmSourceGone(String queueId) {
|
||||
final w = warm;
|
||||
if (w == null || w.queueId != queueId || w.fetch || w.sourceToken == null) return false;
|
||||
final src = engine.members[w.sourceToken];
|
||||
return src == null || src.presence == Presence.gone;
|
||||
}
|
||||
|
||||
static String _randomId() {
|
||||
@@ -698,8 +708,16 @@ void mountSyncRoute(
|
||||
final swap = SwapIntent.fromJson(decoded);
|
||||
var parsed = swap.media;
|
||||
final queueId = swap.queueId;
|
||||
final restore = swap.restore;
|
||||
// What played before a restart, offered back: the first
|
||||
// offer wins (see `swap.restore`).
|
||||
if (restore != null && hub.engine.media != null) {
|
||||
_hubLog.info('restore by ${hub.who(token)} ignored: media is up already');
|
||||
return;
|
||||
}
|
||||
_hubLog.info('swap by ${hub.who(token)}: "${parsed.name}" ${_swapKind(parsed)}'
|
||||
'${queueId == null ? '' : ' (Up next $queueId)'}');
|
||||
'${queueId == null ? '' : ' (Up next $queueId)'}'
|
||||
'${restore == null ? '' : ' (restored at ${restore.position.toStringAsFixed(1)} s, ${restore.transport})'}');
|
||||
if (queueId != null) {
|
||||
// Already put up by someone else: nothing to do.
|
||||
if (!hub.engine.queue.has(queueId)) return;
|
||||
@@ -737,7 +755,7 @@ void mountSyncRoute(
|
||||
: promoted != null
|
||||
? hub.promotedRelay(promoted, queueId!)
|
||||
: hub.relayFor(token, const MediaRelay()));
|
||||
if (media == null || !hub.engine.applySwap(token, media, queueId: queueId)) {
|
||||
if (media == null || !hub.engine.applySwap(token, media, queueId: queueId, restore: restore)) {
|
||||
promoted?.dispose();
|
||||
wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap');
|
||||
return;
|
||||
@@ -785,7 +803,7 @@ void mountSyncRoute(
|
||||
duration: parsed.duration,
|
||||
ref: parsed.ref,
|
||||
relay: promoted != null ? hub.promotedRelay(promoted, queueId!) : hub.relayFor(token, relayReq));
|
||||
if (!hub.engine.applySwap(token, media, queueId: queueId)) {
|
||||
if (!hub.engine.applySwap(token, media, queueId: queueId, restore: restore)) {
|
||||
promoted?.dispose();
|
||||
wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap');
|
||||
return;
|
||||
@@ -827,7 +845,7 @@ void mountSyncRoute(
|
||||
wsErr(ws, ErrCode.bad, 'swap needs a url or the hosted file', forType: 'swap');
|
||||
return;
|
||||
}
|
||||
if (!hub.engine.applySwap(token, intent.media, queueId: queueId)) {
|
||||
if (!hub.engine.applySwap(token, intent.media, queueId: queueId, restore: restore)) {
|
||||
wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: sync_relay_server
|
||||
description: Pure-Dart sync + media server. Embeddable, no Flutter.
|
||||
publish_to: none
|
||||
version: 1.2.0
|
||||
version: 1.3.2
|
||||
environment:
|
||||
sdk: ^3.0.0
|
||||
dependencies:
|
||||
|
||||
@@ -72,9 +72,10 @@ void main() {
|
||||
final clients = <_Client>[];
|
||||
|
||||
setUp(() async {
|
||||
// Short limits so the demo runs in a couple of seconds.
|
||||
// Short limits so the demo runs in a few seconds; silence well above
|
||||
// a busy CI runner's stalls (600 ms marked a reporting client gone).
|
||||
server = SyncServer(AppConfig(port: 0, secret: 'shh', name: 'test'),
|
||||
liveness: const Liveness(silence: Duration(milliseconds: 600)));
|
||||
liveness: const Liveness(silence: Duration(milliseconds: 1500)));
|
||||
await server.start();
|
||||
proxy = await FlakyProxy.start(server.boundPort);
|
||||
});
|
||||
@@ -106,7 +107,7 @@ void main() {
|
||||
.toJson());
|
||||
c.send(const SwapIntent(media: HelloMedia(name: 'mine.mkv', size: 100, duration: 0, relay: MediaRelay())).toJson());
|
||||
await until(() => engine.queue.length == 1 && server.hub.relay != null && member()!.ready, 'ready, queued, relayed');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1200));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 2000));
|
||||
expect(member()!.presence, isNot(Presence.gone), reason: 'reports through the proxy keep it present');
|
||||
|
||||
proxy.last!.blackhole();
|
||||
|
||||
@@ -141,4 +141,110 @@ void main() {
|
||||
expect(back.queue.single.by, aliceId, reason: 'the same member: its items are its own again');
|
||||
expect(back.members.where((m) => m.name == 'alice'), hasLength(1));
|
||||
});
|
||||
|
||||
test('after a restart, the first offer of what played wins; later ones are ignored', () async {
|
||||
final a = await _Peer.join(server.boundPort, 'alice');
|
||||
final b = await _Peer.join(server.boundPort, 'bob');
|
||||
const media = HelloMedia(name: 'v', size: 0, duration: 600, url: 'https://example.com/v.mp4');
|
||||
a.send(const SwapIntent(media: media, restore: SwapRestore(position: 120.5, transport: 'playing')).toJson());
|
||||
final back = await b.state((s) => s.media != null);
|
||||
expect((back.position, back.transport), (120.5, 'held'));
|
||||
final line = await b.until((m) => m['t'] == 'feed' && m['action'] == 'swap');
|
||||
expect(line['reason'], 'restore');
|
||||
b.send(const SwapIntent(
|
||||
media: HelloMedia(name: 'w', size: 0, duration: 0, url: 'https://example.com/w.mp4'),
|
||||
restore: SwapRestore(position: 3))
|
||||
.toJson());
|
||||
a.msgs.clear();
|
||||
final later = await a.state((s) => true);
|
||||
expect((later.mediaId, later.media?['name'], later.position), (back.mediaId, 'v', 120.5));
|
||||
});
|
||||
|
||||
group('the countdown (state.advance), on a fake clock', () {
|
||||
late DateTime t;
|
||||
late SyncServer clocked;
|
||||
|
||||
setUp(() async {
|
||||
t = DateTime.utc(2026, 1, 1);
|
||||
clocked = SyncServer(const AppConfig(port: 0, secret: 's', name: 't'), clock: () => t);
|
||||
await clocked.start();
|
||||
});
|
||||
|
||||
tearDown(() => clocked.stop());
|
||||
|
||||
/// Moves the clock on; everyone keeps talking; one heartbeat.
|
||||
void step(int secs) {
|
||||
t = t.add(Duration(seconds: secs));
|
||||
final engine = clocked.hub.engine;
|
||||
for (final token in engine.members.keys) {
|
||||
engine.touch(token);
|
||||
}
|
||||
clocked.hub.heartbeat();
|
||||
}
|
||||
|
||||
/// Alice and bob in; a 20 s video up, both ready with a full buffer,
|
||||
/// then played to its end; [queue] what bob adds first.
|
||||
Future<(_Peer, _Peer)> playToEnd(List<QueueItem> queue) async {
|
||||
final a = await _Peer.join(clocked.boundPort, 'alice');
|
||||
final b = await _Peer.join(clocked.boundPort, 'bob');
|
||||
a.send(const SwapIntent(media: HelloMedia(name: 'Ep01', size: 0, duration: 20, url: 'https://example.com/1.mp4'))
|
||||
.toJson());
|
||||
final up = await b.state((s) => s.media != null);
|
||||
for (final p in [a, b]) {
|
||||
p.send(const ReadyIntent(ready: true).toJson());
|
||||
p.send(ReportMessage(position: 0, buffered: 100, eventId: up.eventId + 1, mediaId: up.mediaId).toJson());
|
||||
}
|
||||
await b.state((s) => s.transport == 'playing');
|
||||
for (final q in queue) {
|
||||
b.send(QueueIntent.add(q).toJson());
|
||||
}
|
||||
await b.state((s) => s.queue.length == queue.length);
|
||||
step(21);
|
||||
return (a, b);
|
||||
}
|
||||
|
||||
const next = QueueItem(
|
||||
media: HelloMedia(name: 'n', size: 0, duration: 0, url: 'https://example.com/n.mp4'),
|
||||
how: QueueHow.direct,
|
||||
title: 'Next');
|
||||
|
||||
test('everyone sees the same countdown; at its end the first item is asked for', () async {
|
||||
final (a, b) = await playToEnd([next]);
|
||||
final s = await a.state((s) => s.advance != null);
|
||||
expect(s.transport, 'finished');
|
||||
expect([s.advance!.id, s.advance!.secs], [s.queue.first.id, RelayConstants.advanceSecs]);
|
||||
expect((await b.state((s) => s.advance != null)).advance!.at, s.advance!.at);
|
||||
step(5);
|
||||
expect((await a.state((s) => true)).queuePlay, isNull);
|
||||
step(5);
|
||||
final due = await a.state((s) => s.queuePlay != null);
|
||||
expect(due.queuePlay, s.queue.first.id);
|
||||
expect(due.advance, isNull);
|
||||
});
|
||||
|
||||
test('cancel is shared: the countdown stops for everyone', () async {
|
||||
final (a, b) = await playToEnd([next]);
|
||||
await a.state((s) => s.advance != null);
|
||||
b.send(const QueueIntent.cancel().toJson());
|
||||
await a.state((s) => s.advance == null);
|
||||
final line = await a.until((m) => m['t'] == 'feed' && m['action'] == 'advance-cancel');
|
||||
expect(line['actor'], 'bob');
|
||||
step(15);
|
||||
expect(clocked.hub.engine.queue.play, isNull);
|
||||
});
|
||||
|
||||
test('an item whose adder left is skipped by the server, with why', () async {
|
||||
final (a, b) = await playToEnd([
|
||||
const QueueItem(
|
||||
media: HelloMedia(name: 'f.mkv', size: 10, duration: 0), how: QueueHow.file, title: 'Bobs', key: 'k1234567890'),
|
||||
]);
|
||||
await a.state((s) => s.advance != null);
|
||||
b.send(const ByeMessage().toJson());
|
||||
await a.until((m) => m['t'] == 'feed' && m['action'] == 'leave');
|
||||
step(10);
|
||||
final skip = await a.until((m) => m['t'] == 'feed' && m['action'] == 'queue-skip');
|
||||
expect([skip['actorId'], skip['target'], skip['reason']], ['server', 'Bobs', 'bob left']);
|
||||
expect((await a.state((s) => true)).queue, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
## 2.0.1
|
||||
|
||||
- fix: flutter 3.38.x crash
|
||||
- fix(linux): flutter 3.38.x h/w render
|
||||
|
||||
## 2.0.0
|
||||
|
||||
- feat: flutter 3.38.x support
|
||||
- **BREAKING CHANGE**: remove `screen_brightness` and `volume_controller` dependencies
|
||||
|
||||
## 1.3.1
|
||||
|
||||
- fix(windows): notify `VideoOutput.Resize` on platform thread
|
||||
- fix(linux): notify `VideoOutput.Resize` on platform thread
|
||||
- fix(android): `VideoController` implementation
|
||||
- fix(android): remove deprecated API usage
|
||||
|
||||
## 1.3.0
|
||||
|
||||
- **REFACTOR**: screen_brightness -> screen_brightness_platform_interface.
|
||||
- **REFACTOR**(android): simplify AndroidVideoController implementation.
|
||||
- **REFACTOR**(android): VideoOutput SurfaceTextureEntry -> SurfaceProducer migration.
|
||||
- **REFACTOR**: use setProperty API in NativeVideoController.
|
||||
- **FIX**: improve responsiveness of showing controls on mobile.
|
||||
- **FIX**: bump web to 1.1.0.
|
||||
- **FIX**: cast to JSObject.
|
||||
- **FIX**: not call super.didChangeAppLifecycleState(state);.
|
||||
- **FIX**: not call super.didChangeAppLifecycleState(state);.
|
||||
- **FIX**: wakelock print.
|
||||
- **FIX**: subtitles not shifting on controls show/hide.
|
||||
- **FIX**: set width/height from VideoParams in NativeVideoController.
|
||||
- **FIX**: seek inside onPointerMove.
|
||||
- **FIX**(windows): automatic IDXGIAdapter selection on windows 10 or greater.
|
||||
- **FIX**(android): waitUntilFirstFrameRenderedCompleter.
|
||||
- **FIX**(android): --hwdec=auto-safe as default.
|
||||
- **FIX**: dispose ValueNotifier(s) in PlatformVideoController.
|
||||
- **FIX**: fullscreen.
|
||||
- **FIX**: long press video speed reset issue.
|
||||
- **FIX**: wrong value in brightness builder callback.
|
||||
- **FIX**: Use a Scaffold as the outermost widget on fullscreen video pages.
|
||||
- **FEAT**: upgrade volume_controller dependency and refactor related code.
|
||||
- **FEAT**: seek on double tap custom duration support.
|
||||
|
||||
## 1.2.5
|
||||
- fix: subtitleView not being updated
|
||||
- fix: mobile double tap areas hidden but still mounted
|
||||
- fix: mobile center click is delayed
|
||||
|
||||
- feat: desktop video controls mouse hides with controls
|
||||
- feat: desktop on tap pause and play
|
||||
- feat: desktop on double tap fullscreen
|
||||
- fix: windows crash by @Airyzz [#900](https://github.com/media-kit/media-kit/pull/900)
|
||||
- feat: auto pass changes to fullscreen without having to call .update
|
||||
|
||||
## 1.2.4
|
||||
|
||||
- fix: web compile error
|
||||
|
||||
## 1.2.3
|
||||
|
||||
- feat: `VideoState.update` & `VideoViewParameters`
|
||||
|
||||
## 1.2.2
|
||||
|
||||
- fix: override `setState` & check `mounted` in `MaterialVideoControls` & `MaterialDesktopVideoControls`
|
||||
|
||||
## 1.2.1
|
||||
|
||||
- fix(android): clear `android.view.Surface` before playback
|
||||
- feat: `MaterialVideoControlsThemeData.seekBarAlignment`
|
||||
|
||||
## 1.2.0
|
||||
|
||||
- fix: `MaterialVideoControls` layout
|
||||
|
||||
## 1.1.9
|
||||
|
||||
- fix: unmount `CircularProgressIndicator` buffering indicator if invisible
|
||||
- fix: pass all video attributes to fullscreen route
|
||||
- fix: prevent controls from hiding during seek
|
||||
- fix: hide last video's frame upon `Player.open`
|
||||
- fix: `ThemeData.copyWith` override
|
||||
- fix(android): `hwdec=auto-safe` w/ `enableHardwareAcceleration=true`
|
||||
- fix(windows): fullscreen for non-primary monitors
|
||||
- fix(darwin): `mpv_render_context_free` call
|
||||
- fix(darwin): memory leaks
|
||||
- fix(ios): fix `disposeMPV`
|
||||
- feat: `VideoControllerConfiguration.androidAttachSurfaceAfterVideoParameters`
|
||||
- feat: center the seek-bar within its parent `Container` for improved tap area
|
||||
- feat: `backdropColor` argument in `MaterialVideoControlsThemeData`
|
||||
|
||||
## 1.1.8
|
||||
|
||||
- fix: pass all `*VideoControlsTheme`(s) to fullscreen `context`
|
||||
|
||||
## 1.1.7
|
||||
|
||||
- fix: add `await` to `maybePop` when exiting fullscreen
|
||||
- fix: `MaterialVideoControls`/`MaterialDesktopVideoControls` seekbar glitch
|
||||
- fix(android): S/W rendering fallback
|
||||
- fix(android): create fresh `android.view.Surface` for every video output
|
||||
|
||||
## 1.1.6
|
||||
|
||||
- fix: programmatic fullscreen API
|
||||
- fix(android): pause upon entering fullscreen
|
||||
- fix(android): `waitUntilFirstFrameRenderedNotify` in `FlutterFragmentActivity`
|
||||
|
||||
## 1.1.5
|
||||
|
||||
- fix(android): `waitUntilFirstFrameRenderedNotify` fallback & release-mode
|
||||
|
||||
## 1.1.4
|
||||
|
||||
- feat: `Video` `resumeUponEnteringForegroundMode`
|
||||
- feat(android): `waitUntilFirstFrameRenderedNotify` implementation
|
||||
|
||||
## 1.1.3
|
||||
|
||||
- feat(android): `VideoControllerConfiguration.scale`
|
||||
- fix(android): use `hwdec=auto`
|
||||
- fix(android): `SurfaceTexture.setDefaultBufferSize` & render race
|
||||
|
||||
## 1.1.2
|
||||
|
||||
- fix(windows): memory leak in `GetVideoWidth`/`GetVideoHeight`
|
||||
- fix(linux): `GThread*` leak in S/W render & `video_output_get_(width|height)`
|
||||
- fix(linux): H/W support for multiple videos
|
||||
- build(darwin): bump `mpv` headers to `0.36.0`
|
||||
- build(darwin): use symlinks for `FRAMEWORK_SEARCH_PATHS`, `media_kit_libs_*** >= 1.1.0`
|
||||
- fix(darwin): remove black screen when switching videos ([#332](https://github.com/media-kit/media-kit/issues/332))
|
||||
- feat: `Video`: expose `onEnterFullscreen` & `onExitFullscreen`
|
||||
- feat: feat: `visibleOnMount` `MaterialVideoControls`/`MaterialDesktopVideoControls`
|
||||
- fix: display `bufferingIndicatorBuilder` even if controls are hidden
|
||||
|
||||
## 1.1.1
|
||||
|
||||
- chore: `try`/`catch` native calls to hide stray logs
|
||||
- fix: `MaterialDesktopVideoControls`: do not add `onTapUp` callback if `toggleFullscreenOnDoublePress` is disabled
|
||||
|
||||
## 1.1.0
|
||||
|
||||
- feat: `SubtitleView`, `SubtitleViewConfiguration`
|
||||
- feat: `shiftSubtitlesOnControlsVisibilityChange` in `MaterialVideoControls` & `MaterialDesktopVideoControls`
|
||||
- feat: apply rotation from metadata to video output
|
||||
- feat: improve wakelock behavior
|
||||
- feat: `pauseUponEnteringBackgroundMode`
|
||||
- fix: `bufferingIndicatorBuilder` padding in `MaterialVideoControls` & `MaterialDesktopVideoControls`
|
||||
- fix(windows): maintain aspect ratio in s/w rendering pixel-buffer size clamping
|
||||
- fix(linux): maintain aspect ratio in s/w rendering pixel-buffer size clamping
|
||||
- perf(android): use `hwdec=mediacodec` w/ `enableHardwareAcceleration`
|
||||
- deps: migrate [`package:wakelock_plus`](https://pub.dev/packages/wakelock_plus)
|
||||
|
||||
## 1.0.2
|
||||
|
||||
- fix(video/macos): fix fullscreen support
|
||||
|
||||
## 1.0.1
|
||||
|
||||
- fix: synchronize `VideoController` constructor
|
||||
- fix: `fullscreen` video controls theme data not being applied
|
||||
|
||||
## 1.0.0
|
||||
|
||||
- feat: web support
|
||||
- feat: fullscreen API
|
||||
- feat: acquire wakelock
|
||||
- feat: support for AGP 8.0
|
||||
- feat: pre-built video controls
|
||||
- feat: `controls` argument in `Video` widget
|
||||
- feat: `AdaptiveVideoControls`, `MaterialVideoControls`, `MaterialDesktopVideoControls` & `NoVideoControls`
|
||||
|
||||
## 0.0.12
|
||||
|
||||
- fix(android): improve `Texture` resize handling
|
||||
|
||||
## 0.0.11
|
||||
|
||||
- fix(android): improve `Texture` resize handling
|
||||
|
||||
## 0.0.10
|
||||
|
||||
- feat: `VideoControllerConfiguration`
|
||||
- feat: `VideoController.waitUntilFirstFrameRendered`
|
||||
- refactor: clean-up package structure
|
||||
- refactor: remove `VideoController.dispose`
|
||||
- refactor: `VideoController.create` -> `VideoController` constructor
|
||||
- fix(android): add `av1` to `hwdec-codecs`
|
||||
- fix(android): use `--vo=gpu` + `--hwdec=mediacodec-copy` /w `enableHardwareAcceleration`
|
||||
|
||||
## 0.0.9
|
||||
|
||||
- fix(android): revert to `--vo=mediacodec_embed` in `enableHardwareAcceleration`
|
||||
|
||||
## 0.0.8
|
||||
|
||||
- fix(android): subtitle rendering
|
||||
- fix(android): video rendering inside emulators (#149)
|
||||
- fix(android): video rendering with `enableHardwareAcceleration: false`
|
||||
|
||||
## 0.0.7
|
||||
|
||||
- fix(linux): VAAPI hardware acceleration
|
||||
- perf(windows): `VideoOutput::Resize`: delete texture objects in background
|
||||
|
||||
## 0.0.6
|
||||
|
||||
- fix(windows): synchronize texture object deletion in on unregister _v.i.z_ `VideoOutput::Resize` or `VideoOutput::~VideoOutput`
|
||||
|
||||
## 0.0.5
|
||||
|
||||
- Android support
|
||||
- feat: `VideoController.setSize`
|
||||
- fix: set `vo` to `libmpv` before creating render context
|
||||
- refactor: `VideoController.create` takes `Player` reference instead of `handle`
|
||||
|
||||
## 0.0.4
|
||||
|
||||
- fix: use `mkdir` instead of `.gitkeep`
|
||||
|
||||
## 0.0.3
|
||||
|
||||
- fix: add `.framework` & `.xcframework` for all libs
|
||||
|
||||
## 0.0.2
|
||||
|
||||
- macOS support:
|
||||
- Hardware: MPV_RENDER_API_TYPE_OPENGL + pixel buffer + METAL
|
||||
- Software: MPV_RENDER_API_TYPE_SW + pixel buffer
|
||||
- iOS support:
|
||||
- Hardware: MPV_RENDER_API_TYPE_OPENGL + pixel buffer
|
||||
- Software: MPV_RENDER_API_TYPE_SW + pixel buffer
|
||||
- fix(windows): use `TextureRegistrar::UnregisterTexture` release callback to free texture resources
|
||||
- fix(windows): synchronize texture unregister & release on frame dimensions change
|
||||
- feat: `aspectRatio` parameter for `Video` widget
|
||||
|
||||
## 0.0.1
|
||||
|
||||
- Initial release
|
||||
- Windows support:
|
||||
- Hardware: MPV_RENDER_API_TYPE_OPENGL + ANGLE + DirectX 11
|
||||
- Software: MPV_RENDER_API_TYPE_SW + pixel buffer
|
||||
- GNU/Linux support:
|
||||
- Hardware: MPV_RENDER_API_TYPE_OPENGL + GDK/GL
|
||||
- Software: MPV_RENDER_API_TYPE_SW + pixel buffer
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 & onwards Hitesh Kumar Saini <saini123hitesh@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# [package:media_kit_video](https://github.com/media-kit/media-kit)
|
||||
|
||||
[](https://discord.gg/h7qf2R9n57) [](https://github.com/media-kit/media-kit/actions/workflows/ci.yml)
|
||||
|
||||
Native implementation for video playback in [package:media_kit](https://pub.dev/packages/media_kit).
|
||||
|
||||
## License
|
||||
|
||||
Copyright © 2021 & onwards, Hitesh Kumar Saini <<saini123hitesh@gmail.com>>
|
||||
|
||||
This project & the work under this repository is governed by MIT license that can be found in the [LICENSE](./LICENSE) file.
|
||||
@@ -0,0 +1,8 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
analyzer:
|
||||
errors:
|
||||
dangling_library_doc_comments: ignore
|
||||
unintended_html_in_doc_comment: ignore
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,38 @@
|
||||
group 'com.alexmercerind.media_kit_video'
|
||||
version '1.0'
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
android {
|
||||
namespace 'com.alexmercerind.media_kit_video'
|
||||
|
||||
compileSdkVersion 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 16
|
||||
consumerProguardFiles 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
-keepclassmembers class io.flutter.embedding.engine.FlutterEngine {
|
||||
private io.flutter.embedding.engine.FlutterJNI flutterJNI;
|
||||
}
|
||||
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user