Protocol 12: Up next (a shared queue) and where the video comes from
Up next: a server-side queue anyone edits by item id (add at end or next, move, remove, play now, skip); each item says who added it and how it plays. At the real end the first item swaps in after the end card's countdown, put up by whoever can (its adder for through-me links and files, someone with access for Jellyfin, else anyone); items that can't play are skipped with the reason. The next item's link is resolved ahead. Greyed "Continues with" episodes below. A chip with the controls opens it as a popover (leaf_widgets, local path dependency for now); "Add to queue" next to Put it up everywhere. Delivery: the server measures the source's rate, the media's bitrate and each member's read rate; the members card shows a source line (who passes it on, through what, keeping up or not, source left) and each member's route and buffer. A slow source is told once per media. A failed server fetch is announced to everyone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
95cd668cfb
commit
6b10cae4e6
50 files changed
+4239
-157
No files matched your search
@@ -158,6 +158,32 @@ folder to Files, or fill Link).
|
||||
Put it up and the player opens. Not in a server, the buttons say Play
|
||||
and it plays for you alone ([Watch solo](#getting-started)).
|
||||
|
||||
### Up next (the queue)
|
||||
|
||||
In a server (solo too), anything can wait its turn instead of going up
|
||||
now: **Add to queue** on the Link page (in the way you picked), or
|
||||
right-click a Jellyfin card or episode, a video in Files or a recent link
|
||||
for **Add to queue** / **Play next**; the Jellyfin details panel has a
|
||||
queue button (right-click it to play next). A link is checked first, like
|
||||
Put it up. Everyone shares one Up next and anyone can change it; what
|
||||
you added stays when you leave.
|
||||
|
||||
In the player, **Up next · title** sits top right with the controls: ▶
|
||||
plays it now, the title opens the queue: drag rows to reorder, hover a
|
||||
row to play it now (▶) or remove it (×), paste a link in the field or
|
||||
drop a link or a video on it to add. 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.
|
||||
|
||||
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.
|
||||
|
||||
## Dedicated server
|
||||
|
||||
For an always-on box (no display needed): the release tarball also contains
|
||||
@@ -302,7 +328,7 @@ Watching:
|
||||
- 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
|
||||
session). They show "via host" in the member list (click the avatars). When the app hosts,
|
||||
session). They show "via server" in the member list (click the avatars). When the app hosts,
|
||||
or a dedicated server has its own login (`--jellyfin <file>`, see
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server
|
||||
fetches it itself instead.
|
||||
@@ -362,6 +388,18 @@ streams or DRM-protected video (use "Everyone fetches it" for live).
|
||||
Your upload has to beat the video's bitrate: a 25-minute 2 GB episode is
|
||||
about 11 Mbit/s.
|
||||
|
||||
The member list (click the avatars) shows where the video comes from on
|
||||
top: who passes it on, or that the server fetches it, everyone plays the
|
||||
link, or everyone streams it from Jellyfin, with how many watch through
|
||||
the server and, for a relay, how fast it arrives against what the video
|
||||
needs (the bar's mark; past it is keeping up). It turns amber when the
|
||||
source is too slow or reconnecting, red when it left (the others play
|
||||
out what the server already has), and whoever passes it on is told once
|
||||
when their upload is too slow. Each member's line says how they get it
|
||||
("via server" with their speed, or "direct") after a dot for their
|
||||
buffer: green enough to play on, amber low, red empty. A red dot on one
|
||||
member while the source keeps up is their own connection.
|
||||
|
||||
## Video quality and shaders
|
||||
|
||||
Each viewer can run mpv GLSL shaders on their own picture; nobody else is
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../shell/motion.dart';
|
||||
import '../../widgets/app_menu.dart';
|
||||
import '../folder_store.dart';
|
||||
import '../name_parser.dart';
|
||||
import '../scanner.dart';
|
||||
@@ -54,6 +55,8 @@ class FilesPage extends StatefulWidget {
|
||||
final bool relayed;
|
||||
/// Plays [path] in the session; returns an error text, or null.
|
||||
final Future<String?> Function(String path) onPlay;
|
||||
/// Adds [path] to Up next (right-click on a video); null: no server.
|
||||
final Future<String?> Function(String path, {bool next})? onAddToQueue;
|
||||
final String? nowPlayingPath;
|
||||
/// Defaults to [thumbnailFor].
|
||||
final ThumbnailLoader? thumbnails;
|
||||
@@ -70,6 +73,7 @@ class FilesPage extends StatefulWidget {
|
||||
this.solo = false,
|
||||
required this.relayed,
|
||||
required this.onPlay,
|
||||
this.onAddToQueue,
|
||||
this.nowPlayingPath,
|
||||
this.thumbnails,
|
||||
this.scan,
|
||||
@@ -238,6 +242,35 @@ class _FilesPageState extends State<FilesPage> {
|
||||
|
||||
VoidCallback _tapOf(String path) => () => _play(path);
|
||||
|
||||
/// Right-click on a video ([context]: its tile): Put it up, Add to
|
||||
/// queue, Play next.
|
||||
Future<void> _menu(BuildContext context, String path) async {
|
||||
final add = widget.onAddToQueue;
|
||||
if (add == null) return;
|
||||
final pick = await showAppMenu<int>(context, items: [
|
||||
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
|
||||
const AppMenuItem(value: 1, label: 'Add to queue'),
|
||||
const AppMenuItem(value: 2, label: 'Play next'),
|
||||
]);
|
||||
if (pick == null || !mounted) return;
|
||||
if (pick == 0) return _play(path);
|
||||
String? error;
|
||||
try {
|
||||
error = await add(path, next: pick == 2);
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
if (error != null) _showError(error);
|
||||
}
|
||||
|
||||
/// [child] with the right-click menu (when there is a server).
|
||||
Widget _menuable(String path, Widget child) => widget.onAddToQueue == null
|
||||
? child
|
||||
: KeyedSubtree(
|
||||
key: ValueKey('menu:$path'),
|
||||
child: Builder(
|
||||
builder: (context) => GestureDetector(onSecondaryTap: () => _menu(context, path), child: child)));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
@@ -468,14 +501,16 @@ class _FilesPageState extends State<FilesPage> {
|
||||
itemCount: recent.length,
|
||||
itemBuilder: (context, i) {
|
||||
final path = recent[i];
|
||||
return _RecentRow(
|
||||
key: ValueKey(path),
|
||||
path: path,
|
||||
state: _stateOf(path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(path),
|
||||
onRemove: () => _store.removeRecent(path),
|
||||
);
|
||||
return _menuable(
|
||||
path,
|
||||
_RecentRow(
|
||||
key: ValueKey(path),
|
||||
path: path,
|
||||
state: _stateOf(path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(path),
|
||||
onRemove: () => _store.removeRecent(path),
|
||||
));
|
||||
},
|
||||
),
|
||||
];
|
||||
@@ -582,16 +617,18 @@ class _FilesPageState extends State<FilesPage> {
|
||||
onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)),
|
||||
);
|
||||
}
|
||||
return FileRow(
|
||||
key: ValueKey(v.path),
|
||||
path: v.path,
|
||||
title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label,
|
||||
subtitle: v.name,
|
||||
size: formatSize(v.size),
|
||||
state: _stateOf(v.path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(v.path),
|
||||
);
|
||||
return _menuable(
|
||||
v.path,
|
||||
FileRow(
|
||||
key: ValueKey(v.path),
|
||||
path: v.path,
|
||||
title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label,
|
||||
subtitle: v.name,
|
||||
size: formatSize(v.size),
|
||||
state: _stateOf(v.path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(v.path),
|
||||
));
|
||||
},
|
||||
),
|
||||
// Everything else (movies, clips) as a thumbnail grid.
|
||||
@@ -620,13 +657,15 @@ class _FilesPageState extends State<FilesPage> {
|
||||
itemCount: loose.length,
|
||||
itemBuilder: (context, i) {
|
||||
final v = loose[i];
|
||||
return VideoCard(
|
||||
key: ValueKey(v.path),
|
||||
video: v,
|
||||
state: _stateOf(v.path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(v.path),
|
||||
);
|
||||
return _menuable(
|
||||
v.path,
|
||||
VideoCard(
|
||||
key: ValueKey(v.path),
|
||||
video: v,
|
||||
state: _stateOf(v.path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(v.path),
|
||||
));
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'accounts.dart';
|
||||
import 'format.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
|
||||
/// One level of the library browser's back stack.
|
||||
sealed class BrowseRoute {
|
||||
@@ -274,6 +275,9 @@ class BrowseScope extends InheritedWidget {
|
||||
final ValueChanged<JellyfinItem> onSelect;
|
||||
/// Double-click: put it up (a series: its next episode).
|
||||
final ValueChanged<JellyfinItem> onPutUp;
|
||||
/// Adds it to Up next (with next: right after what plays); null: no
|
||||
/// server, the cards have no menu.
|
||||
final void Function(JellyfinItem item, {bool next})? onAddToQueue;
|
||||
/// Home shows the library chips itself (the Library tab has them in its
|
||||
/// header instead).
|
||||
final bool libraryStrip;
|
||||
@@ -287,6 +291,7 @@ class BrowseScope extends InheritedWidget {
|
||||
required this.selectedItemId,
|
||||
required this.onSelect,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
this.libraryStrip = true,
|
||||
required super.child,
|
||||
});
|
||||
@@ -296,6 +301,26 @@ class BrowseScope extends InheritedWidget {
|
||||
|
||||
void onOpen(BrowseRoute route) => controller.open(route);
|
||||
|
||||
/// Right-click on a playable card ([context]: the card): Put it up, Add
|
||||
/// to queue, Play next. Nothing without a server.
|
||||
Future<void> cardMenu(BuildContext context, JellyfinItem item) async {
|
||||
final queue = onAddToQueue;
|
||||
if (queue == null || !isSelectable(item)) return;
|
||||
final pick = await showAppMenu<int>(context, items: const [
|
||||
AppMenuItem(value: 0, label: 'Put it up'),
|
||||
AppMenuItem(value: 1, label: 'Add to queue'),
|
||||
AppMenuItem(value: 2, label: 'Play next'),
|
||||
]);
|
||||
switch (pick) {
|
||||
case 0:
|
||||
onPutUp(item);
|
||||
case 1:
|
||||
queue(item);
|
||||
case 2:
|
||||
queue(item, next: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached browse call on this server.
|
||||
Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) =>
|
||||
accounts.cached(account.serverId, key, () => fn(client));
|
||||
|
||||
@@ -30,6 +30,8 @@ class JellyfinBrowser extends StatefulWidget {
|
||||
final JellyfinSelection? selected;
|
||||
final ValueChanged<JellyfinSelection> onSelect;
|
||||
final ValueChanged<JellyfinSelection> onPutUp;
|
||||
/// Right-click on a card: Add to queue / Play next; null: no server.
|
||||
final void Function(JellyfinSelection item, {bool next})? onAddToQueue;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
final Duration searchDebounce;
|
||||
/// Opens here instead of the remembered location (the series of the
|
||||
@@ -43,6 +45,7 @@ class JellyfinBrowser extends StatefulWidget {
|
||||
required this.accounts,
|
||||
required this.onSelect,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
this.controller,
|
||||
this.selected,
|
||||
this.onOpenIntegrations,
|
||||
@@ -221,6 +224,10 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
||||
selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null,
|
||||
onSelect: (item) => widget.onSelect(JellyfinSelection(account.serverId, item.id, item)),
|
||||
onPutUp: (item) => widget.onPutUp(JellyfinSelection(account.serverId, item.id, item)),
|
||||
onAddToQueue: widget.onAddToQueue == null
|
||||
? null
|
||||
: (item, {next = false}) =>
|
||||
widget.onAddToQueue!(JellyfinSelection(account.serverId, item.id, item), next: next),
|
||||
libraryStrip: widget.libraryStrip,
|
||||
child: Focus(
|
||||
onKeyEvent: _onKey,
|
||||
|
||||
@@ -186,6 +186,7 @@ class _MediaCardState extends State<MediaCard> {
|
||||
focusColor: Colors.transparent,
|
||||
hoverColor: Colors.transparent,
|
||||
onTap: () => _tap(scope),
|
||||
onSecondaryTap: () => scope.cardMenu(context, item),
|
||||
onHover: (h) => setState(() => _hover = h),
|
||||
onFocusChange: (f) => setState(() => _focus = f),
|
||||
child: Column(
|
||||
|
||||
@@ -45,12 +45,16 @@ class JellyfinDetailsPanel extends StatefulWidget {
|
||||
final bool solo;
|
||||
/// Puts [item] (playable) up; returns an error text, or null when sent.
|
||||
final Future<String?> Function(JellyfinSelection item) onPutUp;
|
||||
/// Adds [item] to Up next (with next: right after what plays); null: no
|
||||
/// server, no queue actions.
|
||||
final Future<String?> Function(JellyfinSelection item, {bool next})? onAddToQueue;
|
||||
final VoidCallback onClose;
|
||||
const JellyfinDetailsPanel({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.selection,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
required this.onClose,
|
||||
this.solo = false,
|
||||
});
|
||||
@@ -123,6 +127,46 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _queue(JellyfinItem target, {bool next = false}) async {
|
||||
final add = widget.onAddToQueue;
|
||||
if (add == null || _busy) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
String? error;
|
||||
try {
|
||||
error = await add(JellyfinSelection(_serverId, target.id, target), next: next);
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
|
||||
/// Right-click on an episode ([context]: its row): Put it up, Add to
|
||||
/// queue, Play next.
|
||||
Future<void> _episodeMenu(BuildContext context, JellyfinItem e) async {
|
||||
if (widget.onAddToQueue == null) return;
|
||||
setState(() => _targetId = e.id);
|
||||
final pick = await showAppMenu<int>(context, items: [
|
||||
AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'),
|
||||
const AppMenuItem(value: 1, label: 'Add to queue'),
|
||||
const AppMenuItem(value: 2, label: 'Play next'),
|
||||
]);
|
||||
switch (pick) {
|
||||
case 0:
|
||||
await _putUp(e);
|
||||
case 1:
|
||||
await _queue(e);
|
||||
case 2:
|
||||
await _queue(e, next: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a watched-state change, then lets every row reload.
|
||||
Future<void> _change(Future<void> Function(JellyfinClient c) action) async {
|
||||
final c = _client;
|
||||
@@ -308,6 +352,16 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
||||
: const Icon(Icons.play_arrow),
|
||||
label: Text(label, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (playable != null && widget.onAddToQueue != null)
|
||||
GestureDetector(
|
||||
onSecondaryTap: _busy ? null : () => _queue(playable, next: true),
|
||||
child: IconButton(
|
||||
key: const ValueKey('details-add-queue'),
|
||||
onPressed: _busy ? null : () => _queue(playable),
|
||||
tooltip: 'Add to queue (right-click: play next)',
|
||||
icon: const Icon(Icons.playlist_add_rounded),
|
||||
),
|
||||
),
|
||||
if (playable != null)
|
||||
IconButton(
|
||||
key: const ValueKey('details-watched'),
|
||||
@@ -413,10 +467,12 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
||||
return Material(
|
||||
color: isTarget ? scheme.primary.withValues(alpha: 0.10) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
// The row's own context: its menu opens at the row.
|
||||
child: Builder(builder: (context) => InkWell(
|
||||
key: ValueKey('details-episode-${e.id}'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => _tapEpisode(e),
|
||||
onSecondaryTap: () => _episodeMenu(context, e),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 4),
|
||||
child: Row(
|
||||
@@ -461,7 +517,7 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ class JellyfinPickerSource extends StatefulWidget {
|
||||
final JellyfinSelection? selected;
|
||||
final ValueChanged<JellyfinSelection> onSelect;
|
||||
final ValueChanged<JellyfinSelection> onPutUp;
|
||||
final void Function(JellyfinSelection item, {bool next})? onAddToQueue;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
final BrowseStart? start;
|
||||
final bool libraryStrip;
|
||||
@@ -25,6 +26,7 @@ class JellyfinPickerSource extends StatefulWidget {
|
||||
required this.accounts,
|
||||
required this.onSelect,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
this.controller,
|
||||
this.selected,
|
||||
this.onOpenIntegrations,
|
||||
@@ -61,6 +63,7 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
|
||||
selected: widget.selected,
|
||||
onSelect: widget.onSelect,
|
||||
onPutUp: widget.onPutUp,
|
||||
onAddToQueue: widget.onAddToQueue,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
start: widget.start,
|
||||
libraryStrip: widget.libraryStrip,
|
||||
|
||||
@@ -375,6 +375,7 @@ class _EpisodeTileState extends State<EpisodeTile> {
|
||||
key: ValueKey('episode-${e.id}'),
|
||||
focusColor: Colors.transparent,
|
||||
onTap: () => _tap(scope),
|
||||
onSecondaryTap: () => scope.cardMenu(context, widget.episode),
|
||||
onHover: (h) => setState(() => _hover = h),
|
||||
onFocusChange: (f) => setState(() => _focus = f),
|
||||
child: Padding(
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../files/name_parser.dart';
|
||||
import '../files/thumbnails.dart';
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/format.dart' show pickFor;
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../screens/home/picked_media.dart';
|
||||
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 getting that item ready shortly before.
|
||||
|
||||
/// 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.
|
||||
class UpNextCountdown {
|
||||
final int mediaId;
|
||||
final QueueItem item;
|
||||
final int? secondsLeft;
|
||||
const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft});
|
||||
}
|
||||
|
||||
/// The first item's readiness, on the app that puts it up.
|
||||
enum WarmState { idle, warming, ready, failed }
|
||||
|
||||
class QueueController {
|
||||
final SessionCoordinator coord;
|
||||
QueueController(this.coord);
|
||||
|
||||
/// Queued items' pictures by hash, from `/i/`.
|
||||
late final PictureCache thumbs = PictureCache(coord.sync,
|
||||
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>[]);
|
||||
|
||||
final countdown = signal<UpNextCountdown?>(null);
|
||||
|
||||
/// The first item's id and how ready it is here (only on the app that
|
||||
/// puts it up; null elsewhere).
|
||||
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).
|
||||
final continuation = signal<List<EpisodeLink>>(const []);
|
||||
|
||||
/// The mediaId whose continuation was cleared here.
|
||||
final _hiddenFor = signal(-1);
|
||||
|
||||
/// The queue popover is open: the chip stays up.
|
||||
final popoverOpen = signal(false);
|
||||
|
||||
// What adder-only items stand for, by their key (a link passed on from
|
||||
// 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;
|
||||
String _continuationFor = '';
|
||||
final _random = Random.secure();
|
||||
|
||||
String get _me => coord.sync.memberId;
|
||||
|
||||
void attach() {
|
||||
if (_attached) return;
|
||||
_attached = true;
|
||||
coord.sync.states.listen(_onState);
|
||||
coord.sync.feeds.listen(_onFeed);
|
||||
effect(() {
|
||||
final next = coord.nextEpisode.value;
|
||||
final plan = coord.jellyfinPlan.value;
|
||||
unawaited(_loadContinuation(next, plan));
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- adding
|
||||
|
||||
/// Adds [pick] to Up next (at the end, or with [next] right after what
|
||||
/// plays). A link is probed first and refused like Put it up when it
|
||||
/// will not play. Returns an error text, or null when sent.
|
||||
Future<String?> add(PickedMedia pick, {bool next = false}) async {
|
||||
coord.markInput();
|
||||
if (coord.serverState.peek() == null) return 'not in a server';
|
||||
if (items.peek().length >= RelayConstants.maxQueueItems) {
|
||||
return 'Up next is full (${RelayConstants.maxQueueItems} items)';
|
||||
}
|
||||
final ({QueueItem item, Uint8List? thumb}) built;
|
||||
try {
|
||||
final b = await _build(pick);
|
||||
if (b is String) return b;
|
||||
built = b as ({QueueItem item, Uint8List? thumb});
|
||||
} catch (e) {
|
||||
return "couldn't add it: $e";
|
||||
}
|
||||
final item = built.item;
|
||||
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
|
||||
final thumb = built.thumb;
|
||||
coord.sync.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb)));
|
||||
coord.showToast('Added ${item.title} to Up next');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// A continuation episode becomes an Up next item.
|
||||
Future<String?> promote(EpisodeLink e) => add(e.pick);
|
||||
|
||||
/// The item, or an error text.
|
||||
Future<Object> _build(PickedMedia pick) async {
|
||||
switch (pick) {
|
||||
case JellyfinMedia():
|
||||
final accounts = coord.jellyfin;
|
||||
if (accounts.account(pick.serverId) == null) return 'that Jellyfin server is not linked any more';
|
||||
if (accounts.statusOf(pick.serverId) == JellyfinAccountStatus.expired) {
|
||||
return 'Jellyfin sign-in expired — reconnect it in Settings → Jellyfin & integrations';
|
||||
}
|
||||
final client = accounts.clientFor(pick.serverId);
|
||||
final thumb = client == null ? null : await _jellyfinThumb(client, pick.itemId);
|
||||
return (
|
||||
item: QueueItem(
|
||||
media: mediaForPick(pick),
|
||||
how: QueueHow.jellyfin,
|
||||
title: pick.title,
|
||||
sub: pick.subtitle,
|
||||
duration: (pick.runtime?.inMilliseconds ?? 0) / 1000,
|
||||
),
|
||||
thumb: thumb,
|
||||
);
|
||||
case FileMedia(:final path):
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return 'file not found — check the path';
|
||||
final name = pick.label;
|
||||
final parsed = parseReleaseName(name);
|
||||
final shot = await thumbnailFor(path);
|
||||
Uint8List? thumb;
|
||||
if (shot != null) {
|
||||
try {
|
||||
final bytes = await File(shot.imagePath).readAsBytes();
|
||||
if (bytes.length <= RelayConstants.maxPosterBytes) thumb = bytes;
|
||||
} catch (_) {}
|
||||
}
|
||||
final ep = [
|
||||
if (parsed.season != null) 'S${parsed.season}',
|
||||
if (parsed.episode != null) 'E${parsed.episode}',
|
||||
].join();
|
||||
return (
|
||||
item: QueueItem(
|
||||
media: HelloMedia(name: name, size: await file.length(), duration: 0),
|
||||
how: QueueHow.file,
|
||||
title: parsed.series.trim().isEmpty ? name : parsed.series.trim(),
|
||||
sub: ep,
|
||||
duration: (shot?.duration.inMilliseconds ?? 0) / 1000,
|
||||
key: _newKey(),
|
||||
),
|
||||
thumb: thumb,
|
||||
);
|
||||
case LinkMedia(:final url, :final anyway):
|
||||
// Alone, a link plays direct: there is nobody to pass it on to.
|
||||
final relay = coord.solo ? LinkRelay.direct : pick.relay;
|
||||
final linked = await mediaForLink(coord.jellyfin, url);
|
||||
final ref = linked.jellyfin;
|
||||
if (ref != null) {
|
||||
return (
|
||||
item: QueueItem(
|
||||
media: HelloMedia(name: linked.name, size: 0, duration: 0, jellyfin: ref),
|
||||
how: QueueHow.jellyfin,
|
||||
title: ref.title.isEmpty ? linked.name : ref.title,
|
||||
sub: ref.subtitle ?? '',
|
||||
duration: ref.runtimeSecs ?? 0),
|
||||
thumb: null,
|
||||
);
|
||||
}
|
||||
LinkProbe? probe;
|
||||
String? probeError;
|
||||
try {
|
||||
probe = await coord.ytDlp.probe(url).timeout(const Duration(seconds: 45));
|
||||
} catch (e) {
|
||||
probeError = '$e';
|
||||
}
|
||||
final problem = probe == null ? playErrorText(probeError ?? '') : probe.problem;
|
||||
if (problem != null && !anyway) return problem;
|
||||
if (relay == LinkRelay.me) {
|
||||
if (probe == null) return problem ?? "couldn't reach the site";
|
||||
final relayProblem = probe.relayProblem;
|
||||
if (relayProblem != null) return relayProblem;
|
||||
}
|
||||
if (relay == LinkRelay.server && !coord.serverHasPassword) return 'server fetch needs a server password';
|
||||
if (relay == LinkRelay.server && probe != null && probe.isPage && probe.info != null) {
|
||||
final relayProblem = probe.relayProblem;
|
||||
if (relayProblem != null) return relayProblem;
|
||||
}
|
||||
final uri = Uri.tryParse(url) ?? Uri();
|
||||
final info = probe?.info;
|
||||
final title = probe?.title ?? linked.name;
|
||||
final site = info == null
|
||||
? uri.host
|
||||
: (info.uploader.isNotEmpty ? info.uploader : (info.extractor.isNotEmpty ? info.extractor : uri.host));
|
||||
final thumbUrl = info?.thumbnail == null ? null : Uri.tryParse(info!.thumbnail!);
|
||||
final thumb = thumbUrl == null ? null : (await thumbnailArt(thumbUrl))?.poster;
|
||||
final how = switch (relay) {
|
||||
LinkRelay.direct => QueueHow.direct,
|
||||
LinkRelay.server => QueueHow.server,
|
||||
LinkRelay.me => QueueHow.through,
|
||||
};
|
||||
return (
|
||||
item: QueueItem(
|
||||
media: HelloMedia(
|
||||
name: title,
|
||||
size: 0,
|
||||
duration: 0,
|
||||
url: how == QueueHow.through ? null : url,
|
||||
relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null),
|
||||
how: how,
|
||||
title: title,
|
||||
sub: site,
|
||||
duration: info?.duration ?? 0,
|
||||
key: how == QueueHow.through ? _newKey() : '',
|
||||
),
|
||||
thumb: thumb,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _newKey() => [for (var i = 0; i < 12; i++) _random.nextInt(256).toRadixString(16).padLeft(2, '0')].join();
|
||||
|
||||
/// A wide picture of a Jellyfin item: an episode's own still, else the
|
||||
/// item's (or series') thumb or backdrop, else its poster.
|
||||
Future<Uint8List?> _jellyfinThumb(JellyfinClient client, String itemId) async {
|
||||
try {
|
||||
final item = await client.item(itemId).timeout(const Duration(seconds: 10));
|
||||
final ref = (item.kind == JellyfinItemKind.episode ? item.ownImage(JellyfinImageType.primary) : null) ??
|
||||
item.image(JellyfinImageType.thumb) ??
|
||||
item.image(JellyfinImageType.backdrop) ??
|
||||
item.image(JellyfinImageType.primary);
|
||||
if (ref == null) return null;
|
||||
return await fetchImage(
|
||||
client.imageUrl(ref, maxWidth: 480, quality: 80), client.authHeaders, RelayConstants.maxPosterBytes);
|
||||
} catch (e) {
|
||||
debugPrint('queue: no Jellyfin picture for $itemId: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ editing
|
||||
|
||||
void move(String id, int to) {
|
||||
coord.markInput();
|
||||
coord.sync.sendQueue(QueueIntent.move(id, to));
|
||||
}
|
||||
|
||||
void remove(String id) {
|
||||
coord.markInput();
|
||||
coord.sync.sendQueue(QueueIntent.remove(id));
|
||||
}
|
||||
|
||||
/// Plays [id] now: this app puts it up when it is the one that can,
|
||||
/// else it asks the server, and the app that can puts it up.
|
||||
void playNow(String id) {
|
||||
coord.markInput();
|
||||
final r = coord.serverState.peek();
|
||||
final item = r?.queue.where((q) => q.id == id).firstOrNull;
|
||||
if (r == null || item == null) return;
|
||||
if (queuePerformer(item, r.members).id == _me) {
|
||||
_perform(item);
|
||||
} else {
|
||||
coord.sync.sendQueue(QueueIntent.play(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the first Up next item now.
|
||||
void skip() {
|
||||
final first = items.peek().firstOrNull;
|
||||
if (first != null) playNow(first.id);
|
||||
}
|
||||
|
||||
/// Cancel on the end-of-media card: no countdown here for this media.
|
||||
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);
|
||||
}
|
||||
|
||||
/// "Clear": hides the continuation of what plays now (and its next
|
||||
/// episode does not come up by itself).
|
||||
void clearContinuation() {
|
||||
final id = coord.serverState.peek()?.mediaId;
|
||||
if (id != null) _hiddenFor.value = id;
|
||||
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance();
|
||||
}
|
||||
|
||||
/// 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.
|
||||
late final Computed<List<EpisodeLink>> shownContinuation = computed(() {
|
||||
final id = coord.serverState.value?.mediaId;
|
||||
return id != null && _hiddenFor.value == id ? const <EpisodeLink>[] : continuation.value;
|
||||
});
|
||||
|
||||
// --------------------------------------------------------- per state
|
||||
|
||||
void _onState(StateBroadcast r) {
|
||||
_prune(r);
|
||||
final queue = r.queue;
|
||||
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;
|
||||
}
|
||||
_warm(r);
|
||||
}
|
||||
|
||||
void _onFeed(FeedMessage f) {
|
||||
if (f.action == 'queue-skip') {
|
||||
coord.showToast('Skipped ${f.target ?? 'an item'}${f.reason.isEmpty ? '' : ': ${f.reason}'}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets what no longer matters: performing marks after a minute (a
|
||||
/// swap that never landed may be tried again), local picks no longer
|
||||
/// queued.
|
||||
void _prune(StateBroadcast r) {
|
||||
final now = DateTime.now();
|
||||
_performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1));
|
||||
final keys = {for (final q in r.queue) q.key};
|
||||
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
|
||||
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.sync.sendQueue(QueueIntent.remove(item.id, why: why));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (perf.id == _me) _perform(item);
|
||||
}
|
||||
|
||||
/// The media finished with Up next waiting: the card, and with
|
||||
/// auto-advance on the countdown, for its first item.
|
||||
void _atEnd(StateBroadcast r) {
|
||||
// Up next goes before the next episode, even one already counting.
|
||||
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance();
|
||||
final head = r.queue.first;
|
||||
final perf = queuePerformer(head, r.members);
|
||||
if (perf.id == null && perf.why != null) {
|
||||
// Taken off by [_bringUp]'s rule; the next state shows the next one.
|
||||
_bringUp(head, r);
|
||||
return;
|
||||
}
|
||||
final c = countdown.peek();
|
||||
if (c != null && c.mediaId == r.mediaId) {
|
||||
if (c.item.id != head.id || c.item.thumb != head.thumb) {
|
||||
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: head, secondsLeft: c.secondsLeft);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final auto = AppSettings.instance.autoAdvance.peek() && _cancelledFor != r.mediaId;
|
||||
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: auto ? autoAdvanceSecs : null);
|
||||
_timer?.cancel();
|
||||
_timer = auto ? Timer.periodic(const Duration(seconds: 1), (_) => _tick()) : null;
|
||||
}
|
||||
|
||||
void _tick() {
|
||||
final c = countdown.peek();
|
||||
final r = coord.serverState.peek();
|
||||
final left = c?.secondsLeft;
|
||||
if (c == null || left == null || r == null || r.mediaId != c.mediaId || r.transport != 'finished') {
|
||||
_stopCountdown();
|
||||
return;
|
||||
}
|
||||
if (left > 1) {
|
||||
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item, secondsLeft: left - 1);
|
||||
return;
|
||||
}
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_cancelledFor = c.mediaId;
|
||||
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
|
||||
// Decided on the latest state: only the app that can puts it up.
|
||||
final head = r.queue.firstOrNull;
|
||||
if (head != null && queuePerformer(head, r.members).id == _me) _perform(head);
|
||||
}
|
||||
|
||||
void _stopCountdown() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
countdown.value = null;
|
||||
}
|
||||
|
||||
/// Puts [item] up from here (once per item while it is in flight).
|
||||
/// When it cannot play after all, it is taken off with the reason.
|
||||
void _perform(QueueItem item) {
|
||||
if (_performing.containsKey(item.id)) return;
|
||||
_performing[item.id] = DateTime.now();
|
||||
unawaited(() async {
|
||||
String? error;
|
||||
try {
|
||||
error = await _swapTo(item);
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
if (error != null) {
|
||||
debugPrint('queue: ${item.id} cannot go up: $error');
|
||||
coord.sync.sendQueue(QueueIntent.remove(item.id, why: "couldn't play it ($error)"));
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
Future<String?> _swapTo(QueueItem item) async {
|
||||
switch (item.how) {
|
||||
case QueueHow.jellyfin:
|
||||
final ref = item.media.jellyfin;
|
||||
final pick = ref == null ? null : pickForRef(coord.jellyfin, ref);
|
||||
if (pick == null) return 'no Jellyfin account here for it';
|
||||
coord.swapJellyfin(pick, queueId: item.id);
|
||||
return null;
|
||||
case QueueHow.direct:
|
||||
final url = item.media.url;
|
||||
if (url == null) return 'no link';
|
||||
// Each viewer opens it: one computer's probe does not decide.
|
||||
return coord.swapLink(url, relay: LinkRelay.direct, anyway: true, queueId: item.id);
|
||||
case QueueHow.server:
|
||||
coord.swapQueued(HelloMedia(name: item.title, size: 0, duration: 0, relay: const MediaRelay(fetch: true)),
|
||||
item.id,
|
||||
art: thumbs.bytesFor(item.thumb));
|
||||
return null;
|
||||
case QueueHow.through:
|
||||
case QueueHow.file:
|
||||
final local = _local[item.key];
|
||||
if (local == null) return "it isn't on this computer any more";
|
||||
// Only this app can pass it on, and a member sitting out has
|
||||
// nothing open: back first.
|
||||
if (coord.selfOut.peek()) coord.joinBack();
|
||||
return coord.swapPicked(local.pick, queueId: item.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ warming
|
||||
|
||||
/// Near the end of what plays (or once it is buffered to its end), the
|
||||
/// app that will put the first item up resolves its link, so the swap
|
||||
/// does not wait for yt-dlp. A Jellyfin item, a file or a link the
|
||||
/// server fetches has nothing to get ready here.
|
||||
void _warm(StateBroadcast r) {
|
||||
final head = r.queue.firstOrNull;
|
||||
if (head == null || queuePerformer(head, r.members).id != _me) {
|
||||
if (warm.peek() != null) warm.value = null;
|
||||
return;
|
||||
}
|
||||
final w = warm.peek();
|
||||
if (w == null || w.id != head.id) warm.value = (id: head.id, state: WarmState.idle);
|
||||
if (warm.peek()!.state != WarmState.idle) return;
|
||||
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||
final position = coord.playback.positionSecs;
|
||||
final due = r.media == null ||
|
||||
queueWarmDue(
|
||||
transport: r.transport,
|
||||
position: position,
|
||||
duration: duration,
|
||||
bufferedTo: position + coord.playback.bufferedSecsAhead);
|
||||
if (!due) return;
|
||||
final local = _local[head.key]?.pick;
|
||||
final url = switch (head.how) {
|
||||
QueueHow.direct => head.media.url,
|
||||
QueueHow.through => local is LinkMedia ? local.url : null,
|
||||
_ => null,
|
||||
};
|
||||
if (url == null) {
|
||||
warm.value = (id: head.id, state: WarmState.ready);
|
||||
return;
|
||||
}
|
||||
warm.value = (id: head.id, state: WarmState.warming);
|
||||
// The probe is kept a few minutes: the swap reads it from there.
|
||||
unawaited(coord.ytDlp.probe(url).then((p) => p.problem == null, onError: (_) => false).then((ok) {
|
||||
if (warm.peek()?.id != head.id) return;
|
||||
warm.value = (id: head.id, state: ok ? WarmState.ready : WarmState.failed);
|
||||
}));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- continuation
|
||||
|
||||
/// The next few episodes after the one playing (own account only).
|
||||
Future<void> _loadContinuation(EpisodeLink? next, JellyfinPlan? plan) async {
|
||||
if (next == null || plan is! OwnPlay) {
|
||||
_continuationFor = '';
|
||||
if (continuation.peek().isNotEmpty) continuation.value = const [];
|
||||
return;
|
||||
}
|
||||
final key = '${plan.account.serverId}/${next.item.id}';
|
||||
if (key == _continuationFor) return;
|
||||
_continuationFor = key;
|
||||
continuation.value = [next];
|
||||
final seriesId = next.item.seriesId;
|
||||
if (seriesId == null) return;
|
||||
try {
|
||||
final list = await coord.jellyfin.cached(
|
||||
plan.account.serverId, 'episodes:$seriesId', () => plan.client.episodes(seriesId));
|
||||
if (_continuationFor != key) return;
|
||||
final more = [next];
|
||||
var at = next.item;
|
||||
while (more.length < 4) {
|
||||
final after = episodeAfter(list, at.id);
|
||||
if (after == null) break;
|
||||
more.add(EpisodeLink(after, pickFor(plan.account, after)));
|
||||
at = after;
|
||||
}
|
||||
continuation.value = more;
|
||||
} catch (e) {
|
||||
debugPrint('queue: continuation of $seriesId: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/session_media.dart' show compareMemberIds;
|
||||
|
||||
// Who puts an Up next item up, and when to get the first one ready. 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: skipped.
|
||||
/// - Jellyfin: the adder when it plays that server with its own account,
|
||||
/// else the lowest member id that does (`jf[serverId] == true`, not
|
||||
/// stale, not sitting out). Nobody: skipped.
|
||||
/// - A link everyone opens, or one the server fetches: the adder when
|
||||
/// present (not stale or sitting out), else the lowest such 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) => !m.stale && !m.out;
|
||||
if (item.how.adderOnly) {
|
||||
if (adder == null) return (id: null, why: '${_name(item)} left');
|
||||
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 not stale.
|
||||
String? queueJanitor(List<MemberView> members) => autoAdvanceLowest(members.where((m) => !m.stale));
|
||||
|
||||
/// 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);
|
||||
return switch (item.how) {
|
||||
QueueHow.through => 'through $who',
|
||||
QueueHow.file => mine ? 'your file' : "$who's file",
|
||||
QueueHow.server => 'server fetches',
|
||||
QueueHow.direct => 'everyone fetches',
|
||||
QueueHow.jellyfin => 'Jellyfin',
|
||||
};
|
||||
}
|
||||
|
||||
/// Seconds before the end at which the first Up next item is got ready.
|
||||
const queueWarmLeadSecs = 90.0;
|
||||
|
||||
/// Whether to get the next item ready now: the current media is in its
|
||||
/// last [queueWarmLeadSecs], is buffered to its end, or has finished.
|
||||
bool queueWarmDue({
|
||||
required String transport,
|
||||
required double position,
|
||||
required double duration,
|
||||
required double bufferedTo,
|
||||
}) {
|
||||
if (transport == 'finished') return true;
|
||||
if (duration <= 0) return false;
|
||||
return duration - position <= queueWarmLeadSecs || bufferedTo >= duration - 1;
|
||||
}
|
||||
|
||||
/// "1:23:45" / "23:45" for [secs]; empty when unknown.
|
||||
String queueClock(double secs) {
|
||||
if (secs <= 0) return '';
|
||||
final s = secs.round();
|
||||
final h = s ~/ 3600, m = (s % 3600) ~/ 60, r = s % 60;
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return h > 0 ? '$h:${two(m)}:${two(r)}' : '$m:${two(r)}';
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -122,3 +122,7 @@ final class JellyfinMedia extends PickedMedia {
|
||||
@override
|
||||
String toString() => 'JellyfinMedia($serverId/$itemId "$title")';
|
||||
}
|
||||
|
||||
/// Adds a pick to Up next (with [next]: right after what plays); returns
|
||||
/// an error text, or null when sent. Null where there is no server.
|
||||
typedef AddToQueue = Future<String?> Function(PickedMedia media, {bool next});
|
||||
@@ -165,25 +165,3 @@ class JellyfinBlockedCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small member-list badge: plays the Jellyfin item through the host.
|
||||
class ViaHostBadge extends StatelessWidget {
|
||||
const ViaHostBadge({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: 'No Jellyfin account here: watches through the host',
|
||||
child: Container(
|
||||
key: const ValueKey('via-host'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white10,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: Colors.white24),
|
||||
),
|
||||
child: const Text('via host', style: TextStyle(fontSize: 10, color: Colors.white70)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/session_media.dart';
|
||||
|
||||
// Who fetches the current media and how it spreads, from the state alone:
|
||||
// the members card's source line and each member's route and buffer.
|
||||
|
||||
/// "4.1 MB/s", "850 kB/s".
|
||||
String formatRate(int bytesPerSec) {
|
||||
if (bytesPerSec >= 1000000) {
|
||||
final mb = bytesPerSec / 1e6;
|
||||
return '${mb.toStringAsFixed(mb >= 10 ? 0 : 1)} MB/s';
|
||||
}
|
||||
return '${(bytesPerSec / 1000).round()} kB/s';
|
||||
}
|
||||
|
||||
/// How the current media reaches the party.
|
||||
enum DeliveryKind {
|
||||
/// A link every app opens itself.
|
||||
link,
|
||||
/// The hosting app's file, read by everyone from its server.
|
||||
hostFile,
|
||||
/// A file the dedicated server was started with.
|
||||
serverFile,
|
||||
/// A member's machine passes it on through the server.
|
||||
memberRelay,
|
||||
/// The server reads the link itself.
|
||||
serverFetch,
|
||||
/// Jellyfin: own accounts, the server's login for the rest.
|
||||
jellyfin,
|
||||
}
|
||||
|
||||
/// How a member gets the bytes.
|
||||
enum DeliveryRoute {
|
||||
/// It serves them (a relay source, the host's file).
|
||||
source,
|
||||
/// It reads them from the server.
|
||||
server,
|
||||
/// It fetches them itself (a link, its own Jellyfin account).
|
||||
direct,
|
||||
}
|
||||
|
||||
enum BufferHealth { good, low, starving }
|
||||
|
||||
/// The relay source's condition.
|
||||
enum SourceState {
|
||||
ok,
|
||||
/// Measured slower than the media's bitrate.
|
||||
slow,
|
||||
/// Its channel is down (or the server has not read the link yet).
|
||||
waiting,
|
||||
/// It left the server.
|
||||
left,
|
||||
}
|
||||
|
||||
/// Members' names by id, kept after they leave: "Foxyz left" needs the
|
||||
/// name the state no longer has.
|
||||
class MemberNames {
|
||||
final _names = <String, String>{};
|
||||
|
||||
void observe(StateBroadcast r) {
|
||||
if (_names.length > 256) _names.clear();
|
||||
for (final m in r.members) {
|
||||
_names[m.id] = m.name;
|
||||
}
|
||||
}
|
||||
|
||||
String? operator [](String id) => _names[id];
|
||||
}
|
||||
|
||||
/// The member [m]'s route and buffer.
|
||||
class MemberDelivery {
|
||||
/// Null when unknown (a Jellyfin member that has not reported yet).
|
||||
final DeliveryRoute? route;
|
||||
final DeliveryKind kind;
|
||||
/// Bytes per second the server sends it lately.
|
||||
final int rate;
|
||||
/// Null when it does not count: sitting out, reconnecting, cannot play.
|
||||
final BufferHealth? health;
|
||||
final double buffered;
|
||||
/// The media is a Jellyfin item (direct = its own account).
|
||||
final bool jellyfin;
|
||||
const MemberDelivery({
|
||||
required this.route,
|
||||
required this.kind,
|
||||
this.jellyfin = false,
|
||||
required this.rate,
|
||||
required this.health,
|
||||
required this.buffered,
|
||||
});
|
||||
|
||||
/// "via server · 3.2 MB/s", "direct", "passes it on"; empty when unknown.
|
||||
String get short => switch (route) {
|
||||
DeliveryRoute.source => kind == DeliveryKind.hostFile ? 'hosts the file' : 'passes it on',
|
||||
DeliveryRoute.server => rate > 0 ? 'via server · ${formatRate(rate)}' : 'via server',
|
||||
DeliveryRoute.direct => jellyfin ? 'own Jellyfin account' : 'direct',
|
||||
null => '',
|
||||
};
|
||||
|
||||
/// [short] and the buffer: "via server · 3.2 MB/s · 45 s buffered".
|
||||
String get detail => [
|
||||
if (short.isNotEmpty) short,
|
||||
if (health != null) health == BufferHealth.starving ? 'buffer empty' : '${buffered.round()} s buffered',
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
/// The current media's path for everyone: what the members card's source
|
||||
/// line says.
|
||||
class Delivery {
|
||||
final DeliveryKind kind;
|
||||
/// The member serving it ([DeliveryKind.memberRelay], [DeliveryKind.hostFile]); empty otherwise.
|
||||
final String sourceId;
|
||||
final String sourceName;
|
||||
final bool sourceIsSelf;
|
||||
final SourceState state;
|
||||
/// `media.relay.rate` / `need` (0 unknown).
|
||||
final int rate, need;
|
||||
/// Members (not sitting out, not the source) reading from the server,
|
||||
/// and fetching it themselves.
|
||||
final int viaServer, direct;
|
||||
const Delivery({
|
||||
required this.kind,
|
||||
this.sourceId = '',
|
||||
this.sourceName = '',
|
||||
this.sourceIsSelf = false,
|
||||
this.state = SourceState.ok,
|
||||
this.rate = 0,
|
||||
this.need = 0,
|
||||
this.viaServer = 0,
|
||||
this.direct = 0,
|
||||
});
|
||||
|
||||
bool get relayed => kind == DeliveryKind.memberRelay || kind == DeliveryKind.serverFetch;
|
||||
|
||||
/// Rate against bitrate, both known: 1 = exactly what the media needs.
|
||||
double? get headroom => rate > 0 && need > 0 ? rate / need : null;
|
||||
|
||||
String get title {
|
||||
final who = sourceIsSelf ? 'You' : sourceName;
|
||||
switch (kind) {
|
||||
case DeliveryKind.link:
|
||||
return 'Everyone plays the link directly';
|
||||
case DeliveryKind.serverFile:
|
||||
return 'The server has the file';
|
||||
case DeliveryKind.hostFile:
|
||||
return sourceIsSelf ? 'You host the file' : '$who hosts the file';
|
||||
case DeliveryKind.jellyfin:
|
||||
return viaServer == 0 ? 'Everyone streams it from Jellyfin' : 'From Jellyfin';
|
||||
case DeliveryKind.serverFetch:
|
||||
return switch (state) {
|
||||
SourceState.waiting => 'The server is getting the video…',
|
||||
SourceState.slow => 'The server fetches it slower than it plays',
|
||||
_ => 'The server fetches it',
|
||||
};
|
||||
case DeliveryKind.memberRelay:
|
||||
return switch (state) {
|
||||
SourceState.left => '$sourceName left: playing what the server has',
|
||||
SourceState.waiting => sourceIsSelf ? 'Reconnecting you to the server…' : 'Waiting for $who to reconnect',
|
||||
SourceState.slow => sourceIsSelf ? 'Your upload is slower than the video' : "$who's upload is slower than the video",
|
||||
SourceState.ok => sourceIsSelf ? 'You pass it on through the server' : '$who passes it on through the server',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// "3 through the server · 4.1 MB/s, needs 2.3 MB/s"; empty when
|
||||
/// there is nothing to add.
|
||||
String get detail {
|
||||
final parts = <String>[
|
||||
if (direct > 0 && (kind == DeliveryKind.jellyfin || kind == DeliveryKind.memberRelay))
|
||||
'$direct with their own account',
|
||||
if (viaServer > 0 && kind != DeliveryKind.link) '$viaServer through the server',
|
||||
if (rate > 0 && state != SourceState.left) need > 0 ? '${formatRate(rate)}, needs ${formatRate(need)}' : formatRate(rate),
|
||||
];
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/// The hover text: what the numbers mean.
|
||||
String get explain => switch (kind) {
|
||||
DeliveryKind.memberRelay || DeliveryKind.serverFetch => [
|
||||
if (rate > 0)
|
||||
'${kind == DeliveryKind.serverFetch ? 'The server gets' : sourceIsSelf ? 'You send' : '$sourceName sends'} '
|
||||
'${formatRate(rate)} while the server asks for more.',
|
||||
if (need > 0) 'The video averages ${formatRate(need)}.',
|
||||
if (state == SourceState.slow) 'Slower than the video: the others will pause to buffer.',
|
||||
if (rate == 0) 'The speed shows once bytes have flowed for a few seconds.',
|
||||
].join(' '),
|
||||
DeliveryKind.hostFile || DeliveryKind.serverFile => 'Everyone else reads the file from the server.',
|
||||
DeliveryKind.jellyfin => 'Members with an account on that Jellyfin stream it themselves; '
|
||||
'the rest through the server.',
|
||||
DeliveryKind.link => 'Each app opens the link itself; the server only keeps time.',
|
||||
};
|
||||
}
|
||||
|
||||
/// The current media's [Delivery]; null with nothing up or nobody else in
|
||||
/// the server. [names] remembers a source that left.
|
||||
Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? names}) {
|
||||
final media = r.media;
|
||||
if (media == null || r.members.length < 2) return null;
|
||||
final rel = media['relay'];
|
||||
final relay = rel is Map ? MediaRelay.fromJson(Map<String, Object?>.from(rel)) : null;
|
||||
final ref = refOfStateMedia(media);
|
||||
final url = media['url'];
|
||||
final kind = relay != null
|
||||
? (relay.fetch ? DeliveryKind.serverFetch : DeliveryKind.memberRelay)
|
||||
: ref != null
|
||||
? DeliveryKind.jellyfin
|
||||
: url is String && url.isNotEmpty
|
||||
? DeliveryKind.link
|
||||
: r.members.any((m) => m.host)
|
||||
? DeliveryKind.hostFile
|
||||
: DeliveryKind.serverFile;
|
||||
final sourceId = switch (kind) {
|
||||
DeliveryKind.memberRelay => relay!.source,
|
||||
DeliveryKind.hostFile => r.members.firstWhere((m) => m.host).id,
|
||||
_ => '',
|
||||
};
|
||||
final source = r.members.where((m) => m.id == sourceId).firstOrNull;
|
||||
var via = 0, direct = 0;
|
||||
for (final m in r.members) {
|
||||
if (m.out || m.id == sourceId) continue;
|
||||
switch (routeOf(m, kind, sourceId, ref, r.relay)) {
|
||||
case DeliveryRoute.server:
|
||||
via++;
|
||||
case DeliveryRoute.direct:
|
||||
direct++;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A Jellyfin item that everyone streams with their own account: the
|
||||
// relay stands by, nothing goes through it.
|
||||
if (kind == DeliveryKind.memberRelay && ref != null && via == 0 && source != null) {
|
||||
return Delivery(kind: DeliveryKind.jellyfin, direct: direct);
|
||||
}
|
||||
final state = relay == null
|
||||
? SourceState.ok
|
||||
: kind == DeliveryKind.memberRelay && source == null
|
||||
? SourceState.left
|
||||
: !relay.live
|
||||
? SourceState.waiting
|
||||
: relay.slow
|
||||
? SourceState.slow
|
||||
: SourceState.ok;
|
||||
return Delivery(
|
||||
kind: kind,
|
||||
sourceId: sourceId,
|
||||
sourceName: source?.name ?? names?[sourceId] ?? 'The member passing it on',
|
||||
sourceIsSelf: sourceId.isNotEmpty && sourceId == selfId,
|
||||
state: state,
|
||||
rate: relay?.rate ?? 0,
|
||||
need: relay?.need ?? 0,
|
||||
viaServer: via,
|
||||
direct: direct,
|
||||
);
|
||||
}
|
||||
|
||||
/// How [m] gets media of [kind] served by [sourceId]. Jellyfin: by its
|
||||
/// last `report.jf` for [ref]'s server ([jellyfinRelay]: the server can
|
||||
/// relay it); null before it reported.
|
||||
DeliveryRoute? routeOf(MemberView m, DeliveryKind kind, String sourceId, JellyfinRef? ref, bool jellyfinRelay) {
|
||||
if (sourceId.isNotEmpty && m.id == sourceId) return DeliveryRoute.source;
|
||||
switch (kind) {
|
||||
case DeliveryKind.link:
|
||||
return DeliveryRoute.direct;
|
||||
case DeliveryKind.hostFile:
|
||||
case DeliveryKind.serverFile:
|
||||
case DeliveryKind.serverFetch:
|
||||
return DeliveryRoute.server;
|
||||
case DeliveryKind.memberRelay:
|
||||
case DeliveryKind.jellyfin:
|
||||
final own = ref == null ? null : m.jf[ref.serverId];
|
||||
if (own == true) return DeliveryRoute.direct;
|
||||
if (kind == DeliveryKind.memberRelay) return DeliveryRoute.server;
|
||||
return own == false && jellyfinRelay ? DeliveryRoute.server : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// [m]'s route and buffer for [d] (from [deliveryOf] on [r]).
|
||||
MemberDelivery memberDeliveryOf(MemberView m, StateBroadcast r, Delivery d) {
|
||||
final counts = !m.out && !m.stale && m.error.isEmpty;
|
||||
final need = r.bufferNeed;
|
||||
final ref = refOfStateMedia(r.media);
|
||||
final health = !counts
|
||||
? null
|
||||
: m.buffered >= need
|
||||
? BufferHealth.good
|
||||
: r.transport == 'playing' && m.buffered < 1
|
||||
? BufferHealth.starving
|
||||
: BufferHealth.low;
|
||||
return MemberDelivery(
|
||||
route: m.out ? null : routeOf(m, d.kind, d.sourceId, ref, r.relay),
|
||||
kind: d.kind,
|
||||
jellyfin: ref != null,
|
||||
rate: m.rate,
|
||||
health: health,
|
||||
buffered: m.buffered,
|
||||
);
|
||||
}
|
||||
|
||||
/// What the source is told once per media when it is too slow; null
|
||||
/// while it keeps up (or this member is not the source).
|
||||
String? slowSourceWarning(StateBroadcast r, String selfId) {
|
||||
final rel = r.media?['relay'];
|
||||
if (rel is! Map || selfId.isEmpty) return null;
|
||||
final relay = MediaRelay.fromJson(Map<String, Object?>.from(rel));
|
||||
if (relay.source != selfId || !relay.slow) return null;
|
||||
return 'Your upload (${formatRate(relay.rate)}) is slower than this video needs '
|
||||
'(${formatRate(relay.need)}): the others will pause to buffer now and then.';
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
@@ -23,6 +24,8 @@ import '../sync/avatar_cache.dart';
|
||||
import '../sync/state_diff.dart';
|
||||
import '../sync/sync_client.dart';
|
||||
import '../sync/user_seek.dart';
|
||||
import '../queue/queue_controller.dart';
|
||||
import 'delivery.dart';
|
||||
import 'health.dart';
|
||||
import 'idle.dart';
|
||||
import 'media_art.dart';
|
||||
@@ -168,6 +171,10 @@ class SessionCoordinator {
|
||||
/// The app's yt-dlp and link probes (tests pass their own).
|
||||
final AppYtDlp ytDlp;
|
||||
final SyncClient sync = SyncClient();
|
||||
/// Up next: the server's queue, adding to it, and putting items up.
|
||||
late final QueueController queue = QueueController(this);
|
||||
/// The server has a password (it fetches links only then).
|
||||
bool get serverHasPassword => (_params?.secret ?? '').isNotEmpty;
|
||||
/// Member pictures by `avatarHash`, fetched from the server.
|
||||
late final AvatarCache avatars = AvatarCache(sync);
|
||||
/// The current media's poster and banner by `state.media.art` hash.
|
||||
@@ -282,7 +289,7 @@ class SessionCoordinator {
|
||||
// This client's last swap, until a state shows it landed (a newer
|
||||
// mediaId than [after] with this member as `swapBy`): its art goes up then.
|
||||
// [link]: the web link it came from (a relayed one has no `url`).
|
||||
({HelloMedia media, int after, String? link})? _pendingArt;
|
||||
({HelloMedia media, int after, String? link, Uint8List? art})? _pendingArt;
|
||||
AniListClient? _aniList;
|
||||
// What this member serves for media [$1]; survives a reconnect (the
|
||||
// state then looks new again).
|
||||
@@ -291,6 +298,10 @@ class SessionCoordinator {
|
||||
int _relaySourceMediaId = -1;
|
||||
/// This member serves the current media's bytes through the server.
|
||||
final relaying = signal<bool>(false);
|
||||
/// Names by member id, kept after they leave (a relay source that left).
|
||||
final memberNames = MemberNames();
|
||||
// The media this member was told its upload is too slow for.
|
||||
int _slowWarnedFor = -1;
|
||||
/// What this client put up last in this connection (a solo session
|
||||
/// carries it over when it becomes a hosted server).
|
||||
PickedMedia? get lastPick => _lastPick;
|
||||
@@ -390,6 +401,17 @@ class SessionCoordinator {
|
||||
_memberErrors = now;
|
||||
}
|
||||
|
||||
/// Remembers names, and tells this member once per media when it
|
||||
/// passes the video on slower than the video plays.
|
||||
void _warnSlowSource(StateBroadcast r) {
|
||||
memberNames.observe(r);
|
||||
if (_slowWarnedFor == r.mediaId) return;
|
||||
final warning = slowSourceWarning(r, sync.memberId);
|
||||
if (warning == null) return;
|
||||
_slowWarnedFor = r.mediaId;
|
||||
_pushToast(warning);
|
||||
}
|
||||
|
||||
bool _reloadingVideo = false;
|
||||
|
||||
/// "Reload video" (button, Ctrl+R, status menu): fresh
|
||||
@@ -502,6 +524,7 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
void _subscribe() {
|
||||
queue.attach();
|
||||
_stateSub ??= sync.states.listen(_onState);
|
||||
_feedSub ??= sync.feeds.listen(_onFeed);
|
||||
_nudgeSub ??= sync.nudges.listen(_onNudge);
|
||||
@@ -576,8 +599,9 @@ class SessionCoordinator {
|
||||
jellyfinPlan.peek() is! OwnPlay;
|
||||
if (waiting) {
|
||||
final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name;
|
||||
final gone = memberNames[relay.source] ?? 'the member who put this up';
|
||||
health.value = HealthStatus(HealthSeverity.warn,
|
||||
who == null ? 'the member who put this up left' : 'waiting for $who to pass the video on');
|
||||
who == null ? '$gone left: playing what the server has' : 'waiting for $who to pass the video on');
|
||||
_relayWaitShown = true;
|
||||
} else if (_relayWaitShown) {
|
||||
_relayWaitShown = false;
|
||||
@@ -652,6 +676,7 @@ class SessionCoordinator {
|
||||
_lastStateAt = DateTime.now().toUtc();
|
||||
serverState.value = r;
|
||||
_toastMemberErrors(r);
|
||||
_warnSlowSource(r);
|
||||
_checkPlayerError(r.mediaId);
|
||||
_maybeSendArt(r);
|
||||
_queuedState = r;
|
||||
@@ -919,6 +944,10 @@ class SessionCoordinator {
|
||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor)
|
||||
];
|
||||
}
|
||||
// The server gave up fetching what everyone waits for.
|
||||
if (f.action == 'fetch-fail' && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) {
|
||||
health.value = const HealthStatus(HealthSeverity.error, "the server couldn't fetch this video");
|
||||
}
|
||||
final selfName = selfMember.peek()?.name;
|
||||
// Sat out by someone else: say who (the feed names members by name).
|
||||
if (f.action == 'out' && f.target != null && f.target == selfName && f.actor != selfName) {
|
||||
@@ -1259,12 +1288,23 @@ class SessionCoordinator {
|
||||
sync.sendNudge(memberId);
|
||||
}
|
||||
|
||||
void _swap(HelloMedia media, {String? link}) {
|
||||
// Putting something up means watching it: back first.
|
||||
/// [queueId]: the Up next item this puts up (the server takes it off
|
||||
/// the queue); [art]: a picture to send as its art (a queued item's).
|
||||
void _swap(HelloMedia media, {String? link, String? queueId, Uint8List? art}) {
|
||||
// Putting something up means watching it: back first. Not for Up next
|
||||
// coming up by itself: whoever puts it up may be sitting out.
|
||||
final self = selfMember.peek();
|
||||
if (self != null && self.out) sync.sendOut(self.id, false);
|
||||
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url);
|
||||
sync.sendSwap(media);
|
||||
if (self != null && self.out && queueId == null) sync.sendOut(self.id, false);
|
||||
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url, art: art);
|
||||
sync.sendSwap(media, queueId: queueId);
|
||||
}
|
||||
|
||||
/// An Up next item put up as queued, for one this app cannot build
|
||||
/// itself (a server-fetched link: the server has the URL); [art] goes up
|
||||
/// as its art.
|
||||
void swapQueued(HelloMedia media, String queueId, {Uint8List? art}) {
|
||||
markInput();
|
||||
_swap(media, queueId: queueId, art: art);
|
||||
}
|
||||
|
||||
void _maybeSendArt(StateBroadcast r) {
|
||||
@@ -1272,17 +1312,19 @@ class SessionCoordinator {
|
||||
if (pending == null || r.mediaId <= pending.after) return;
|
||||
if (sync.memberId.isEmpty || r.media?['swapBy'] != sync.memberId) return;
|
||||
_pendingArt = null;
|
||||
unawaited(_sendArt(r.mediaId, pending.media, pending.link));
|
||||
unawaited(_sendArt(r.mediaId, pending.media, pending.link, given: pending.art));
|
||||
}
|
||||
|
||||
/// Fetches the art of [media] (this client's swap, now [mediaId], from
|
||||
/// web [link] if any) and sends it; quietly nothing on any failure.
|
||||
Future<void> _sendArt(int mediaId, HelloMedia media, String? link) async {
|
||||
Future<void> _sendArt(int mediaId, HelloMedia media, String? link, {Uint8List? given}) async {
|
||||
try {
|
||||
final ref = media.jellyfin;
|
||||
final client = ref == null ? null : jellyfin.clientFor(ref.serverId);
|
||||
final ArtBytes? bytes;
|
||||
if (ref != null && client != null) {
|
||||
if (given != null) {
|
||||
bytes = (poster: given, banner: given);
|
||||
} else if (ref != null && client != null) {
|
||||
bytes = await jellyfinArt(client, ref.itemId);
|
||||
} else if (ref == null && (link ?? '').isNotEmpty) {
|
||||
// A web link is not a show: never guess. A page's thumbnail is
|
||||
@@ -1312,13 +1354,14 @@ class SessionCoordinator {
|
||||
/// probe's [LinkProbe.problem], or unreachable) is refused with the
|
||||
/// reason in plain words unless [anyway]. Returns an error text, or null
|
||||
/// when sent.
|
||||
Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct, bool anyway = false}) async {
|
||||
Future<String?> swapLink(String url,
|
||||
{LinkRelay relay = LinkRelay.direct, bool anyway = false, String? queueId}) async {
|
||||
markInput();
|
||||
_lastPick = LinkMedia(url, relay: relay, anyway: anyway);
|
||||
final media = await mediaForLink(jellyfin, url);
|
||||
if (media.jellyfin != null) {
|
||||
_pendingOrigin = _JellyfinOrigin(url);
|
||||
_swap(media.copyWith(relay: const MediaRelay()));
|
||||
_swap(media.copyWith(relay: const MediaRelay()), queueId: queueId);
|
||||
return null;
|
||||
}
|
||||
LinkProbe? probe;
|
||||
@@ -1334,7 +1377,7 @@ class SessionCoordinator {
|
||||
final uri = Uri.tryParse(url) ?? Uri();
|
||||
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
|
||||
if (relay == LinkRelay.direct) {
|
||||
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url));
|
||||
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url), queueId: queueId);
|
||||
return null;
|
||||
}
|
||||
if (relay == LinkRelay.me) {
|
||||
@@ -1366,7 +1409,7 @@ class SessionCoordinator {
|
||||
}
|
||||
_pendingOrigin = _LinkOrigin(url, probe, y);
|
||||
_swap(HelloMedia(
|
||||
name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)), link: url);
|
||||
name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)), link: url, queueId: queueId);
|
||||
return null;
|
||||
}
|
||||
// The server reads it with its own yt-dlp; only a page this app knows
|
||||
@@ -1376,7 +1419,8 @@ class SessionCoordinator {
|
||||
return probe.relayProblem;
|
||||
}
|
||||
_swap(HelloMedia(
|
||||
name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true)));
|
||||
name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true)),
|
||||
queueId: queueId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1390,43 +1434,44 @@ class SessionCoordinator {
|
||||
|
||||
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
|
||||
/// relayed by this member for viewers without an account.
|
||||
void swapJellyfin(JellyfinMedia media) {
|
||||
void swapJellyfin(JellyfinMedia media, {String? queueId}) {
|
||||
markInput();
|
||||
_lastPick = media;
|
||||
_pendingOrigin = const _JellyfinOrigin(null);
|
||||
_swap(mediaForPick(media).copyWith(relay: const MediaRelay()));
|
||||
_swap(mediaForPick(media).copyWith(relay: const MediaRelay()), queueId: queueId);
|
||||
}
|
||||
|
||||
/// A file on this computer. The host's app serves it itself; anyone
|
||||
/// else's goes through the server's relay. Returns an error text, or null
|
||||
/// when sent.
|
||||
Future<String?> swapFile(String path) async {
|
||||
Future<String?> swapFile(String path, {String? queueId}) async {
|
||||
markInput();
|
||||
_lastPick = FileMedia(path);
|
||||
final host = hostFile;
|
||||
if (host != null) {
|
||||
final file = await host(path);
|
||||
if (file == null) return 'file not found — check the path';
|
||||
_swap(HelloMedia(name: file.name, size: file.size, duration: 0));
|
||||
_swap(HelloMedia(name: file.name, size: file.size, duration: 0), queueId: queueId);
|
||||
return null;
|
||||
}
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return 'file not found — check the path';
|
||||
_pendingOrigin = _FileOrigin(path);
|
||||
_swap(HelloMedia(
|
||||
name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay()));
|
||||
name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay()),
|
||||
queueId: queueId);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Any picker selection.
|
||||
Future<String?> swapPicked(PickedMedia media) async {
|
||||
/// Any picker selection; [queueId] when it is an Up next item.
|
||||
Future<String?> swapPicked(PickedMedia media, {String? queueId}) async {
|
||||
switch (media) {
|
||||
case LinkMedia(:final url, :final relay, :final anyway):
|
||||
return swapLink(url, relay: relay, anyway: anyway);
|
||||
return swapLink(url, relay: relay, anyway: anyway, queueId: queueId);
|
||||
case JellyfinMedia():
|
||||
swapJellyfin(media);
|
||||
swapJellyfin(media, queueId: queueId);
|
||||
case FileMedia(:final path):
|
||||
return swapFile(path);
|
||||
return swapFile(path, queueId: queueId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1541,11 +1586,18 @@ class SessionCoordinator {
|
||||
return relayUrlFor(_wsHost, _wsPort, sync.token, ref.itemId);
|
||||
}
|
||||
|
||||
/// `report.jf`: every linked account that works (so a queued Jellyfin
|
||||
/// item finds someone to put it up while other media plays), then how
|
||||
/// the current item plays here.
|
||||
Map<String, bool>? _jfAccessFor(StateBroadcast r) {
|
||||
final linked = {
|
||||
for (final a in jellyfin.accounts)
|
||||
if (jellyfin.statusOf(a.serverId) != JellyfinAccountStatus.expired) a.serverId: true,
|
||||
};
|
||||
final ref = _jfRef;
|
||||
final access = jellyfinPlan.peek()?.jfAccess;
|
||||
if (ref == null || access == null || r.mediaId != _jfMediaId) return null;
|
||||
return {ref.serverId: access};
|
||||
if (ref == null || access == null || r.mediaId != _jfMediaId) return linked.isEmpty ? null : linked;
|
||||
return {...linked, ref.serverId: access};
|
||||
}
|
||||
|
||||
/// Resolves [ref] for this viewer and opens it: own account (direct
|
||||
@@ -1720,7 +1772,9 @@ class SessionCoordinator {
|
||||
}
|
||||
nextPromptVisible.value = false;
|
||||
final next = nextEpisode.peek();
|
||||
// Up next goes first; "Clear" in it hides the next episode here.
|
||||
if (next == null ||
|
||||
!queue.continuationShown(r.mediaId) ||
|
||||
!AppSettings.instance.autoAdvance.peek() ||
|
||||
_advanceDoneFor == r.mediaId ||
|
||||
autoAdvance.peek()?.mediaId == r.mediaId) {
|
||||
|
||||
@@ -420,7 +420,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
// --------------------------------------------------------------- handoff
|
||||
|
||||
/// Nothing (a dialog, a popover) is over the shell.
|
||||
bool get _onTop => !_handoffOpen && (ModalRoute.of(context)?.isCurrent ?? true);
|
||||
bool get _onTop => !_handoffOpen && handoffHolds == 0 && (ModalRoute.of(context)?.isCurrent ?? true);
|
||||
|
||||
/// Ctrl+V outside a text field (a focused field pastes on its own).
|
||||
bool _onKey(KeyEvent e) {
|
||||
@@ -673,6 +673,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
),
|
||||
jellyfinStart: _libraryStart,
|
||||
onPutUp: _putUp,
|
||||
// Up next needs a server (a solo session has one).
|
||||
onAddToQueue: online ? coord.queue.add : null,
|
||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||
),
|
||||
ShellTab.schedule => SchedulePage(
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/profile.dart';
|
||||
import '../session/delivery.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'motion.dart';
|
||||
@@ -22,6 +23,9 @@ class AvatarInfo {
|
||||
/// Why their player cannot open the media (`members[].error`): an error
|
||||
/// mark; empty when it can.
|
||||
final String error;
|
||||
/// How it gets the media and its buffer ([MemberDelivery.detail]), for
|
||||
/// the hover; empty when unknown.
|
||||
final String delivery;
|
||||
const AvatarInfo({
|
||||
this.id = '',
|
||||
required this.name,
|
||||
@@ -35,6 +39,7 @@ class AvatarInfo {
|
||||
this.self = false,
|
||||
this.out = false,
|
||||
this.error = '',
|
||||
this.delivery = '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,6 +108,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
||||
final self = m.id.isNotEmpty && m.id == coord.sync.memberId;
|
||||
final relay = r == null ? null : stateMediaRelay(r);
|
||||
final playing = r?.media != null;
|
||||
final delivery = r == null ? null : deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames);
|
||||
return AvatarInfo(
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
@@ -118,6 +124,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
||||
self: self,
|
||||
out: m.out,
|
||||
error: m.error,
|
||||
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,9 +311,12 @@ class _AvatarStackState extends State<AvatarStack> {
|
||||
left: i * step,
|
||||
top: 0,
|
||||
child: Tooltip(
|
||||
message: visible[i].error.isEmpty
|
||||
? visible[i].name
|
||||
: "${visible[i].name} can't play this: ${visible[i].error}",
|
||||
message: [
|
||||
visible[i].error.isEmpty
|
||||
? visible[i].name
|
||||
: "${visible[i].name} can't play this: ${visible[i].error}",
|
||||
if (visible[i].delivery.isNotEmpty) visible[i].delivery,
|
||||
].join('\n'),
|
||||
child: StatusAvatar(info: visible[i], size: size),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -237,6 +237,15 @@ class ActivityRow extends StatelessWidget {
|
||||
return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out');
|
||||
case 'back':
|
||||
return (Icons.login, Colors.green, 'joined back');
|
||||
case 'queue-add':
|
||||
return (Icons.playlist_add_rounded, scheme.primary, 'added ${f.target ?? ''} to Up next');
|
||||
case 'queue-remove':
|
||||
return (Icons.playlist_remove_rounded, Colors.grey, 'removed ${f.target ?? ''} from Up next');
|
||||
case 'queue-skip':
|
||||
final why = f.reason.isEmpty ? '' : ' · ${f.reason}';
|
||||
return (Icons.redo_rounded, Colors.orange, 'skipped ${f.target ?? ''}$why');
|
||||
case 'fetch-fail':
|
||||
return (Icons.cloud_off, Colors.red, "couldn't fetch ${f.target ?? 'the video'}");
|
||||
default:
|
||||
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
|
||||
return (Icons.info_outline, Colors.grey, '${f.action}$target');
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../session/delivery.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
// The members card's view of [Delivery]: one source line on top, a buffer
|
||||
// dot on each member. Nothing of it sits on the picture.
|
||||
|
||||
Color _tone(SourceState s, ColorScheme scheme) => switch (s) {
|
||||
SourceState.ok => scheme.primary,
|
||||
SourceState.slow || SourceState.waiting => Colors.amber,
|
||||
SourceState.left => Colors.redAccent.shade200,
|
||||
};
|
||||
|
||||
IconData _icon(Delivery d) => switch (d.state) {
|
||||
SourceState.left => Icons.link_off_rounded,
|
||||
SourceState.waiting => Icons.hourglass_top_rounded,
|
||||
_ => switch (d.kind) {
|
||||
DeliveryKind.memberRelay => Icons.cell_tower,
|
||||
DeliveryKind.serverFetch => Icons.cloud_download_outlined,
|
||||
DeliveryKind.hostFile => Icons.folder_shared_outlined,
|
||||
DeliveryKind.serverFile => Icons.dns_outlined,
|
||||
DeliveryKind.link => Icons.public,
|
||||
DeliveryKind.jellyfin => Icons.video_library_outlined,
|
||||
},
|
||||
};
|
||||
|
||||
/// Where the current media comes from and how it reaches the others:
|
||||
/// "Foxyz passes it on through the server", "3 through the server ·
|
||||
/// 4.1 MB/s, needs 2.3 MB/s" and a bar of the source's speed against the
|
||||
/// video's (past the mark: keeping up). Amber when slow or waiting, red
|
||||
/// when the source left.
|
||||
class SourceLine extends StatelessWidget {
|
||||
final Delivery delivery;
|
||||
const SourceLine({super.key, required this.delivery});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final d = delivery;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final tone = _tone(d.state, scheme);
|
||||
final detail = d.detail;
|
||||
final headroom = d.state == SourceState.left ? null : d.headroom;
|
||||
return Tooltip(
|
||||
message: d.explain,
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
child: Container(
|
||||
key: const ValueKey('source-line'),
|
||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 6),
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 12, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: d.state == SourceState.ok ? scheme.surfaceContainerHigh : tone.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 1),
|
||||
child: Icon(_icon(d), size: 18, color: tone),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(d.title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
if (detail.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(detail, style: TextStyle(fontSize: 11.5, color: scheme.onSurfaceVariant)),
|
||||
],
|
||||
if (headroom != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
_HeadroomBar(headroom: headroom, slow: d.state == SourceState.slow),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The source's speed on a scale to twice the video's bitrate; the mark
|
||||
/// in the middle is what the video needs.
|
||||
class _HeadroomBar extends StatelessWidget {
|
||||
final double headroom;
|
||||
final bool slow;
|
||||
const _HeadroomBar({required this.headroom, required this.slow});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final fill = (headroom / 2).clamp(0.04, 1.0);
|
||||
final color = slow ? Colors.amber : Colors.greenAccent.shade400;
|
||||
return SizedBox(
|
||||
key: const ValueKey('headroom-bar'),
|
||||
height: 4,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, box) => Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
width: box.maxWidth * fill,
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2)),
|
||||
),
|
||||
Positioned(
|
||||
left: box.maxWidth / 2 - 0.75,
|
||||
top: -1,
|
||||
bottom: -1,
|
||||
child: Container(width: 1.5, color: scheme.onSurface.withValues(alpha: 0.7)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A member's buffer at a glance: green enough to play on, amber low,
|
||||
/// red empty while playing.
|
||||
Color healthColor(BufferHealth h) => switch (h) {
|
||||
BufferHealth.good => Colors.greenAccent.shade400,
|
||||
BufferHealth.low => Colors.amber,
|
||||
BufferHealth.starving => Colors.redAccent.shade200,
|
||||
};
|
||||
|
||||
/// The members card's subtitle: [status] ("ready"), then how the member
|
||||
/// gets the media (the source's own pill says it serves it), after a dot
|
||||
/// coloured by its buffer; the hover says how much it has buffered.
|
||||
class DeliveryStatus extends StatelessWidget {
|
||||
final String status;
|
||||
final MemberDelivery? delivery;
|
||||
const DeliveryStatus({super.key, required this.status, this.delivery});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final d = delivery;
|
||||
final route = d == null || d.route == DeliveryRoute.source ? '' : d.short;
|
||||
final text = Text([status, if (route.isNotEmpty) route].join(' · '),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis);
|
||||
if (d == null) return text;
|
||||
final health = d.health;
|
||||
return Tooltip(
|
||||
message: d.detail,
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
child: Row(
|
||||
children: [
|
||||
if (health != null) ...[
|
||||
AnimatedContainer(
|
||||
key: const ValueKey('health-dot'),
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(color: healthColor(health), shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Flexible(child: text),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,11 @@ import '../files/ui/file_tiles.dart' show baseName;
|
||||
// field: what it is ([classifyHandoff], pure) and the small card that asks
|
||||
// how to open it ([HandoffCard]). The shell wires both.
|
||||
|
||||
/// Open overlays over the shell that take drops and pastes themselves
|
||||
/// (the Up next popover, which is not a route): the shell's own handoff
|
||||
/// stays off while any is open.
|
||||
int handoffHolds = 0;
|
||||
|
||||
/// What was dropped or pasted.
|
||||
sealed class Handoff {
|
||||
const Handoff();
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, pa
|
||||
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../state/connect_prefs.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
@@ -217,12 +218,15 @@ class LibraryLinkSource extends StatefulWidget {
|
||||
final bool solo;
|
||||
/// Puts the link up; returns an error text, or null when sent.
|
||||
final Future<String?> Function(LinkMedia media) onPutUp;
|
||||
/// Adds the link to Up next in the chosen mode; null: no server.
|
||||
final AddToQueue? onAddToQueue;
|
||||
final RecentLinks recents;
|
||||
final LinkProber probe;
|
||||
const LibraryLinkSource({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
required this.recents,
|
||||
this.solo = false,
|
||||
this.probe = defaultLinkProber,
|
||||
@@ -328,6 +332,49 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
});
|
||||
}
|
||||
|
||||
/// "Add to queue" (or, from a recent card's menu, [url] and [next]):
|
||||
/// the link in the chosen mode; the probe refuses what will not play.
|
||||
Future<void> _queue({String? url, bool next = false}) async {
|
||||
final add = widget.onAddToQueue;
|
||||
final link = url ?? _c.link.text.trim();
|
||||
if (add == null || link.isEmpty || _busy) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
_canForce = false;
|
||||
});
|
||||
String? error;
|
||||
try {
|
||||
error = await add(LinkMedia(link, relay: widget.solo ? LinkRelay.direct : _c.linkRelay), next: next);
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
|
||||
/// Right-click on a recent link: put it up or queue it.
|
||||
Future<void> _recentMenu(BuildContext context, RecentLink l) async {
|
||||
final pick = await showAppMenu<String>(context, items: [
|
||||
AppMenuItem(value: 'up', label: widget.solo ? 'Play' : 'Put it up'),
|
||||
if (widget.onAddToQueue != null) ...const [
|
||||
AppMenuItem(value: 'queue', label: 'Add to queue'),
|
||||
AppMenuItem(value: 'next', label: 'Play next'),
|
||||
],
|
||||
]);
|
||||
if (pick == null || !mounted) return;
|
||||
_c.link.text = l.url;
|
||||
switch (pick) {
|
||||
case 'up':
|
||||
await _go();
|
||||
case 'queue' || 'next':
|
||||
await _queue(url: l.url, next: pick == 'next');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _modeCard((LinkRelay, IconData, String, String) m, {bool compact = false}) => _ModeCard(
|
||||
key: ValueKey('link-mode-${m.$1.name}'),
|
||||
icon: m.$2,
|
||||
@@ -372,6 +419,22 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
);
|
||||
final queue = widget.onAddToQueue == null
|
||||
? null
|
||||
: Tooltip(
|
||||
message: 'Plays after what is on now (right-click a recent link for Play next)',
|
||||
waitDuration: const Duration(milliseconds: 600),
|
||||
child: OutlinedButton.icon(
|
||||
key: const ValueKey('link-add-queue'),
|
||||
onPressed: url.isEmpty || _busy ? null : () => _queue(),
|
||||
icon: const Icon(Icons.playlist_add_rounded),
|
||||
label: const Text('Add to queue'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 56),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
key: const ValueKey('media-link'),
|
||||
@@ -408,8 +471,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
field,
|
||||
const SizedBox(height: 10),
|
||||
button,
|
||||
if (queue != null) ...[const SizedBox(height: 8), queue],
|
||||
])
|
||||
: Row(children: [Expanded(child: field), const SizedBox(width: 12), button]),
|
||||
: Row(children: [
|
||||
Expanded(child: field),
|
||||
const SizedBox(width: 12),
|
||||
button,
|
||||
if (queue != null) ...[const SizedBox(width: 8), queue],
|
||||
]),
|
||||
),
|
||||
if (solo || _error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
@@ -510,7 +579,8 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 32),
|
||||
child: _RecentGrid(recents: recents, onPick: (l) => _c.link.text = l.url),
|
||||
child: _RecentGrid(
|
||||
recents: recents, onPick: (l) => _c.link.text = l.url, onMenu: _recentMenu),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -655,7 +725,9 @@ class _ModeCard extends StatelessWidget {
|
||||
class _RecentGrid extends StatelessWidget {
|
||||
final List<RecentLink> recents;
|
||||
final ValueChanged<RecentLink> onPick;
|
||||
const _RecentGrid({required this.recents, required this.onPick});
|
||||
/// Right-click: Put it up / Add to queue / Play next.
|
||||
final void Function(BuildContext context, RecentLink link) onMenu;
|
||||
const _RecentGrid({required this.recents, required this.onPick, required this.onMenu});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -680,9 +752,11 @@ class _RecentGrid extends StatelessWidget {
|
||||
child: Tooltip(
|
||||
message: l.url,
|
||||
waitDuration: const Duration(milliseconds: 600),
|
||||
child: InkWell(
|
||||
// The card's own context: the menu opens at the card.
|
||||
child: Builder(builder: (context) => InkWell(
|
||||
key: ValueKey('recent-link-${l.url}'),
|
||||
onTap: () => onPick(l),
|
||||
onSecondaryTap: () => onMenu(context, l),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -709,7 +783,7 @@ class _RecentGrid extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -78,6 +78,8 @@ class LibraryTab extends StatefulWidget {
|
||||
final BrowseStart? jellyfinStart;
|
||||
/// Puts the pick up; returns an error text, or null when sent.
|
||||
final Future<String?> Function(PickedMedia media) onPutUp;
|
||||
/// Adds a pick to Up next; null without a server (no queue actions).
|
||||
final AddToQueue? onAddToQueue;
|
||||
final VoidCallback? onPutUpDone;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
/// Defaults to [RecentLinks.instance].
|
||||
@@ -97,6 +99,7 @@ class LibraryTab extends StatefulWidget {
|
||||
required this.solo,
|
||||
required this.isHost,
|
||||
required this.onPutUp,
|
||||
this.onAddToQueue,
|
||||
this.onPutUpDone,
|
||||
this.jellyfinStart,
|
||||
this.onOpenIntegrations,
|
||||
@@ -164,6 +167,13 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
|
||||
/// A Jellyfin card or episode: a series goes up as its next episode.
|
||||
Future<String?> _putUpJellyfin(JellyfinSelection sel) async {
|
||||
final pick = await _jellyfinPick(sel);
|
||||
return pick is JellyfinMedia ? _put(pick) : pick as String;
|
||||
}
|
||||
|
||||
/// What a Jellyfin card stands for (a series: its next episode), or an
|
||||
/// error text.
|
||||
Future<Object> _jellyfinPick(JellyfinSelection sel) async {
|
||||
final account = widget.accounts.account(sel.serverId);
|
||||
final client = widget.accounts.clientFor(sel.serverId);
|
||||
if (account == null || client == null) return 'that Jellyfin server is not linked any more';
|
||||
@@ -175,7 +185,23 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
return e.message;
|
||||
}
|
||||
if (item == null) return 'nothing to play in there';
|
||||
return _put(pickFor(account, item));
|
||||
return pickFor(account, item);
|
||||
}
|
||||
|
||||
/// "Add to queue" on a Jellyfin card, episode or the details panel.
|
||||
Future<String?> _queueJellyfin(JellyfinSelection sel, {bool next = false}) async {
|
||||
final add = widget.onAddToQueue;
|
||||
if (add == null) return null;
|
||||
final pick = await _jellyfinPick(sel);
|
||||
return pick is JellyfinMedia ? add(pick, next: next) : pick as String;
|
||||
}
|
||||
|
||||
/// A card's menu: errors as a snack bar.
|
||||
Future<void> _cardQueue(JellyfinSelection sel, {bool next = false}) async {
|
||||
final error = await _queueJellyfin(sel, next: next);
|
||||
if (error != null && mounted) {
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Double-click on a card: put it up, errors as a snack bar.
|
||||
@@ -235,6 +261,7 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
selected: _c.selected,
|
||||
onSelect: _c.select,
|
||||
onPutUp: _doublePutUp,
|
||||
onAddToQueue: widget.onAddToQueue == null ? null : _cardQueue,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
start: widget.jellyfinStart,
|
||||
libraryStrip: false,
|
||||
@@ -251,6 +278,7 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
recents: widget.recentLinks ?? RecentLinks.instance,
|
||||
probe: widget.probe,
|
||||
onPutUp: _put,
|
||||
onAddToQueue: widget.onAddToQueue,
|
||||
),
|
||||
MediaKind.file => _files(context),
|
||||
};
|
||||
@@ -282,6 +310,7 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
selection: shown,
|
||||
solo: widget.solo,
|
||||
onPutUp: _putUpJellyfin,
|
||||
onAddToQueue: widget.onAddToQueue == null ? null : _queueJellyfin,
|
||||
onClose: () => _c.select(null),
|
||||
),
|
||||
));
|
||||
@@ -348,7 +377,13 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
if (b != null) {
|
||||
return b(context, solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
|
||||
}
|
||||
return FilesPage(solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
|
||||
final add = widget.onAddToQueue;
|
||||
return FilesPage(
|
||||
solo: widget.solo,
|
||||
relayed: relayed,
|
||||
onPlay: play,
|
||||
onAddToQueue: add == null ? null : (path, {next = false}) => add(FileMedia(path), next: next),
|
||||
nowPlayingPath: now);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ import 'package:sync_relay_core/core.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
|
||||
import '../session/delivery.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
import 'delivery_line.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// The shell's tabs, in strip order.
|
||||
@@ -35,10 +36,6 @@ const stripAvatarSize = 42.0;
|
||||
/// so the tabs keep their room.
|
||||
const stripCompactBelow = 1100.0;
|
||||
|
||||
/// The member plays the current Jellyfin item through the host's relay.
|
||||
bool viaHostOf(MemberView m, JellyfinRef? ref) =>
|
||||
ref != null && m.jf[ref.serverId] == false;
|
||||
|
||||
/// The 60 px strip over everything: your avatar (profile), the tabs, and
|
||||
/// the party: offline Join a friend / Host, solo a "Solo" chip, Invite
|
||||
/// friends and Stop, online the avatars (members card), server and
|
||||
@@ -483,6 +480,9 @@ class MembersCard extends StatelessWidget {
|
||||
final address = hosting
|
||||
? null
|
||||
: '${session.lastForm.host}:${session.lastForm.port}';
|
||||
final delivery = r == null
|
||||
? null
|
||||
: deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -494,11 +494,12 @@ class MembersCard extends StatelessWidget {
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (delivery != null) SourceLine(delivery: delivery),
|
||||
for (final m in r?.members ?? const <MemberView>[])
|
||||
_MemberTile(
|
||||
member: m,
|
||||
info: memberAvatarInfo(m, r, coord),
|
||||
viaHost: viaHostOf(m, coord.currentRef.value),
|
||||
delivery: delivery == null ? null : memberDeliveryOf(m, r!, delivery),
|
||||
),
|
||||
if (r == null)
|
||||
Padding(
|
||||
@@ -540,11 +541,12 @@ class MembersCard extends StatelessWidget {
|
||||
class _MemberTile extends StatelessWidget {
|
||||
final MemberView member;
|
||||
final AvatarInfo info;
|
||||
final bool viaHost;
|
||||
/// How it gets the media and its buffer; null with nothing up or alone.
|
||||
final MemberDelivery? delivery;
|
||||
const _MemberTile({
|
||||
required this.member,
|
||||
required this.info,
|
||||
required this.viaHost,
|
||||
this.delivery,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -575,10 +577,9 @@ class _MemberTile extends StatelessWidget {
|
||||
if (info.host) const Pill('host', Colors.amber),
|
||||
if (info.relaying)
|
||||
const Pill('passing the video on', Colors.lightBlueAccent),
|
||||
if (viaHost) const ViaHostBadge(),
|
||||
],
|
||||
),
|
||||
subtitle: Text(status),
|
||||
subtitle: DeliveryStatus(status: status, delivery: delivery),
|
||||
trailing: AppMenuButton<String>(
|
||||
tooltip: 'More',
|
||||
onSelected: (v) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../controls/uosc/buttons.dart';
|
||||
import '../controls/uosc/controls.dart';
|
||||
import '../controls/uosc/timeline.dart';
|
||||
import '../controls/volume.dart';
|
||||
import '../queue/queue_ui.dart';
|
||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||
import '../screens/session/jellyfin_widgets.dart';
|
||||
import '../screens/session/player_controls.dart';
|
||||
@@ -200,6 +201,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
|
||||
final shift = HardwareKeyboard.instance.isShiftPressed;
|
||||
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
|
||||
// Up next first, then the next episode.
|
||||
if (coord.queue.items.peek().isNotEmpty) {
|
||||
_lastKeyAt = now;
|
||||
coord.queue.skip();
|
||||
return true;
|
||||
}
|
||||
if (coord.nextEpisode.peek() == null) return false;
|
||||
_lastKeyAt = now;
|
||||
coord.playNextEpisode();
|
||||
@@ -287,6 +294,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_status(),
|
||||
_progressLine(),
|
||||
_jellyfinOverlay(),
|
||||
_upNextChip(),
|
||||
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
|
||||
] else
|
||||
_miniOverlay(),
|
||||
@@ -678,6 +686,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
final blocked = coord.jellyfinBlocked.value;
|
||||
final next = coord.nextEpisode.value;
|
||||
final countdown = coord.autoAdvance.value;
|
||||
// Up next goes before the next episode.
|
||||
final upNext = coord.queue.countdown.value;
|
||||
final finished = coord.serverState.value?.transport == 'finished';
|
||||
final pill = coord.nextPromptVisible.value;
|
||||
final SkipOffer? skip = coord.skipOffer.value;
|
||||
@@ -692,7 +702,16 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
onRetry: coord.retryJellyfin,
|
||||
),
|
||||
),
|
||||
if (next != null && (finished || countdown != null))
|
||||
if (upNext != null && finished)
|
||||
Positioned(
|
||||
right: 24,
|
||||
bottom: 138,
|
||||
child: UpNextCard(queue: coord.queue, countdown: upNext),
|
||||
)
|
||||
// "Clear" in Up next hides the next episode for this media.
|
||||
else if (next != null &&
|
||||
(finished || countdown != null) &&
|
||||
coord.queue.shownContinuation.value.isNotEmpty)
|
||||
Positioned(
|
||||
right: 24,
|
||||
bottom: 138,
|
||||
@@ -737,6 +756,24 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
}
|
||||
|
||||
/// "Up next · <title>" top right, with the controls (and while its
|
||||
/// popover is open); under the party in fullscreen.
|
||||
Widget _upNextChip() {
|
||||
final queue = _coord.queue;
|
||||
return Positioned(
|
||||
right: 16,
|
||||
top: widget.fullscreen ? _barHeight + 60 : _topBarHeight + 8,
|
||||
child: SignalBuilder(
|
||||
builder: (context) => PillGate(
|
||||
key: const ValueKey('up-next-gate'),
|
||||
kind: 'up-next',
|
||||
controlsShown: queue.popoverOpen.value ? const _AlwaysOn() : _controlsShown,
|
||||
child: UpNextChip(queue: queue, onPlayNextEpisode: _coord.playNextEpisode),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Fullscreen hides the strip: while paused the party stays in a corner.
|
||||
Widget _pausedParty() => Positioned(
|
||||
right: 16,
|
||||
@@ -908,6 +945,17 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Always up (a pill whose popover is open).
|
||||
class _AlwaysOn implements ValueListenable<bool> {
|
||||
const _AlwaysOn();
|
||||
@override
|
||||
bool get value => true;
|
||||
@override
|
||||
void addListener(VoidCallback listener) {}
|
||||
@override
|
||||
void removeListener(VoidCallback listener) {}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
||||
@@ -208,8 +208,13 @@ class SyncClient {
|
||||
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
|
||||
}
|
||||
|
||||
void sendSwap(HelloMedia media) {
|
||||
_send(SwapIntent(media: media).toJson());
|
||||
void sendSwap(HelloMedia media, {String? queueId}) {
|
||||
_send(SwapIntent(media: media, queueId: queueId).toJson());
|
||||
}
|
||||
|
||||
/// One change to Up next (see [QueueIntent]).
|
||||
void sendQueue(QueueIntent intent) {
|
||||
_send(intent.toJson());
|
||||
}
|
||||
|
||||
void sendReport({
|
||||
|
||||
@@ -342,6 +342,13 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.0"
|
||||
leaf_widgets:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../../leaf_widgets"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -777,14 +784,14 @@ packages:
|
||||
path: "../../packages/core"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.1.1"
|
||||
version: "1.1.2"
|
||||
sync_relay_server:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/server"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.1.1"
|
||||
version: "1.1.2"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -30,6 +30,11 @@ dependencies:
|
||||
git:
|
||||
url: https://git.senkochan.moe/Foxyz/fox_utils.git
|
||||
ref: v0.1.9
|
||||
# LeafPopover (the Up next popover). A local experiment: it must become a
|
||||
# git dependency (like fox_utils) before a CI release, which cannot see
|
||||
# this path.
|
||||
leaf_widgets:
|
||||
path: ../../../leaf_widgets
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/session/delivery.dart';
|
||||
import 'package:watch_together/src/shell/delivery_line.dart';
|
||||
|
||||
MemberView _m(String id, String name, {double buffered = 30, bool host = false, int rate = 0}) =>
|
||||
MemberView(id: id, name: name, ready: true, inSet: true, buffered: buffered, stale: false, host: host, rate: rate);
|
||||
|
||||
StateBroadcast _state(Map<String, Object?>? media, List<MemberView> members, {String transport = 'playing'}) =>
|
||||
StateBroadcast(
|
||||
eventId: 1,
|
||||
transport: transport,
|
||||
position: 10,
|
||||
serverTime: DateTime.now().toUtc(),
|
||||
mediaId: 2,
|
||||
media: media,
|
||||
members: members,
|
||||
);
|
||||
|
||||
Map<String, Object?> _relayed(MediaRelay relay) =>
|
||||
{'name': 'x.mkv', 'size': 0, 'duration': 1000, 'relay': relay.toJson()};
|
||||
|
||||
void main() {
|
||||
final foxyz = _m('m1', 'Foxyz');
|
||||
final bob = _m('m2', 'Bob', rate: 3200000);
|
||||
final ann = _m('m3', 'Ann');
|
||||
|
||||
test('a member relay: who passes it on, how many read it, how fast', () {
|
||||
final r = _state(
|
||||
_relayed(const MediaRelay(source: 'm1', live: true, rate: 4100000, need: 2300000)), [foxyz, bob, ann]);
|
||||
final d = deliveryOf(r, selfId: 'm2')!;
|
||||
expect(d.kind, DeliveryKind.memberRelay);
|
||||
expect(d.state, SourceState.ok);
|
||||
expect(d.title, 'Foxyz passes it on through the server');
|
||||
expect(d.detail, '2 through the server · 4.1 MB/s, needs 2.3 MB/s');
|
||||
expect(d.headroom, closeTo(4.1 / 2.3, 0.01));
|
||||
expect(deliveryOf(r, selfId: 'm1')!.title, 'You pass it on through the server');
|
||||
final mb = memberDeliveryOf(bob, r, d);
|
||||
expect(mb.route, DeliveryRoute.server);
|
||||
expect(mb.detail, 'via server · 3.2 MB/s · 30 s buffered');
|
||||
expect(memberDeliveryOf(foxyz, r, d).route, DeliveryRoute.source);
|
||||
});
|
||||
|
||||
test('slow source: amber for everyone, the warning for the source only', () {
|
||||
final r = _state(
|
||||
_relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000, need: 2300000)), [foxyz, bob]);
|
||||
expect(deliveryOf(r, selfId: 'm2')!.state, SourceState.slow);
|
||||
expect(deliveryOf(r, selfId: 'm2')!.title, "Foxyz's upload is slower than the video");
|
||||
expect(slowSourceWarning(r, 'm1'), contains('Your upload (1.2 MB/s) is slower than this video needs (2.3 MB/s)'));
|
||||
expect(slowSourceWarning(r, 'm2'), isNull);
|
||||
// Bitrate unknown: rates without a comparison, no warning.
|
||||
final unknown = _state(_relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000)), [foxyz, bob]);
|
||||
expect(deliveryOf(unknown, selfId: 'm2')!.state, SourceState.ok);
|
||||
expect(deliveryOf(unknown, selfId: 'm2')!.detail, '1 through the server · 1.2 MB/s');
|
||||
expect(slowSourceWarning(unknown, 'm1'), isNull);
|
||||
});
|
||||
|
||||
test('the source left: named from before, no rates', () {
|
||||
final names = MemberNames()..observe(_state(null, [foxyz, bob]));
|
||||
final r = _state(_relayed(const MediaRelay(source: 'm1', rate: 4100000, need: 2300000)), [bob, ann]);
|
||||
final d = deliveryOf(r, selfId: 'm2', names: names)!;
|
||||
expect(d.state, SourceState.left);
|
||||
expect(d.title, 'Foxyz left: playing what the server has');
|
||||
expect(d.detail, '2 through the server');
|
||||
});
|
||||
|
||||
test('channel down: waiting', () {
|
||||
final r = _state(_relayed(const MediaRelay(source: 'm1')), [foxyz, bob]);
|
||||
expect(deliveryOf(r, selfId: 'm2')!.title, 'Waiting for Foxyz to reconnect');
|
||||
});
|
||||
|
||||
test('links are direct, the host file is the host\'s, solo shows nothing', () {
|
||||
final link = _state({'name': 'v', 'url': 'https://example.com/v.mp4'}, [foxyz, bob]);
|
||||
final d = deliveryOf(link, selfId: 'm2')!;
|
||||
expect(d.title, 'Everyone plays the link directly');
|
||||
expect(d.detail, isEmpty);
|
||||
expect(memberDeliveryOf(bob, link, d).short, 'direct');
|
||||
final file = _state({'name': 'f.mkv'}, [_m('m1', 'Foxyz', host: true), bob]);
|
||||
expect(deliveryOf(file, selfId: 'm2')!.title, 'Foxyz hosts the file');
|
||||
expect(deliveryOf(_state({'name': 'f.mkv'}, [bob]), selfId: 'm2'), isNull);
|
||||
expect(deliveryOf(_state(null, [foxyz, bob]), selfId: 'm2'), isNull);
|
||||
});
|
||||
|
||||
test('Jellyfin: own accounts direct, the rest via server', () {
|
||||
const ref = {'serverId': 'srv', 'itemId': '0123456789abcdef0123456789abcdef', 'title': 'Pilot'};
|
||||
MemberView jf(String id, String name, bool own) =>
|
||||
MemberView(id: id, name: name, ready: true, inSet: true, buffered: 30, stale: false, jf: {'srv': own});
|
||||
final all = [jf('m1', 'Foxyz', true), jf('m2', 'Bob', true)];
|
||||
final media = {'name': 'Pilot', 'jellyfin': ref, 'relay': const MediaRelay(source: 'm1', live: true).toJson()};
|
||||
final d = deliveryOf(_state(media, all), selfId: 'm2')!;
|
||||
expect(d.title, 'Everyone streams it from Jellyfin', reason: 'the relay stands by');
|
||||
final mixed = _state(media, [...all, jf('m3', 'Guest', false)]);
|
||||
final m = deliveryOf(mixed, selfId: 'm2')!;
|
||||
expect(m.kind, DeliveryKind.memberRelay);
|
||||
expect(m.detail, '1 with their own account · 1 through the server');
|
||||
expect(memberDeliveryOf(jf('m2', 'Bob', true), mixed, m).short, 'own Jellyfin account');
|
||||
});
|
||||
|
||||
test('buffer health: enough, low, empty while playing', () {
|
||||
final r = _state({'name': 'v', 'url': 'https://e/v', 'duration': 1000}, [foxyz, bob]);
|
||||
final d = deliveryOf(r, selfId: 'm2')!;
|
||||
expect(memberDeliveryOf(_m('a', 'A', buffered: 30), r, d).health, BufferHealth.good);
|
||||
expect(memberDeliveryOf(_m('a', 'A', buffered: 2), r, d).health, BufferHealth.low);
|
||||
expect(memberDeliveryOf(_m('a', 'A', buffered: 0), r, d).health, BufferHealth.starving);
|
||||
final held = _state(r.media, [foxyz, bob], transport: 'held');
|
||||
expect(memberDeliveryOf(_m('a', 'A', buffered: 0), held, d).health, BufferHealth.low);
|
||||
});
|
||||
|
||||
test('rates read as MB/s or kB/s', () {
|
||||
expect(formatRate(4100000), '4.1 MB/s');
|
||||
expect(formatRate(12000000), '12 MB/s');
|
||||
expect(formatRate(850000), '850 kB/s');
|
||||
});
|
||||
|
||||
testWidgets('the source line and a member\'s status fit the members card', (tester) async {
|
||||
final r = _state(
|
||||
_relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000, need: 2300000)), [foxyz, bob, ann]);
|
||||
final d = deliveryOf(r, selfId: 'm2')!;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 340,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
SourceLine(delivery: d),
|
||||
DeliveryStatus(status: 'ready', delivery: memberDeliveryOf(bob, r, d)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text("Foxyz's upload is slower than the video"), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('headroom-bar')), findsOneWidget);
|
||||
expect(find.text('ready · via server · 3.2 MB/s'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('health-dot')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -12,9 +12,9 @@ import 'package:watch_together/src/jellyfin/session_media.dart';
|
||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||
import 'package:watch_together/src/screens/home/media_picker.dart';
|
||||
import 'package:watch_together/src/screens/session/jellyfin_widgets.dart';
|
||||
import 'package:watch_together/src/session/delivery.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart' show AutoAdvance;
|
||||
import 'package:watch_together/src/shell/library_tab.dart' show putUpJellyfinMedia;
|
||||
import 'package:watch_together/src/shell/party_strip.dart' show viaHostOf;
|
||||
|
||||
import 'jellyfin_accounts_test.dart';
|
||||
import 'jellyfin_browser_test.dart' show app, setSize, settle;
|
||||
@@ -341,11 +341,13 @@ void main() {
|
||||
expect(compareMemberIds('m9', 'm10'), lessThan(0));
|
||||
});
|
||||
|
||||
test('member badge: via host only for relay viewers of the current item', () {
|
||||
expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: false}), episodeRef), isTrue);
|
||||
expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: true}), episodeRef), isFalse);
|
||||
expect(viaHostOf(member('m1', 'G'), episodeRef), isFalse);
|
||||
expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: false}), null), isFalse);
|
||||
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);
|
||||
expect(route(member('m1', 'G', jf: {fixtureServerId: false})), DeliveryRoute.server);
|
||||
expect(route(member('m1', 'G', jf: {fixtureServerId: false}), relay: false), isNull);
|
||||
expect(route(member('m1', 'G', jf: {fixtureServerId: true})), DeliveryRoute.direct);
|
||||
expect(route(member('m1', 'G')), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/queue/queue_logic.dart';
|
||||
|
||||
MemberView member(String id,
|
||||
{bool stale = false, bool out = false, Map<String, bool> jf = const {}, String name = ''}) =>
|
||||
MemberView(
|
||||
id: id, name: name.isEmpty ? id : name, ready: false, inSet: true, buffered: 0, stale: stale, out: out, 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', () {
|
||||
test('a link passed on or a file: only its adder; gone means skipped', () {
|
||||
for (final how in [QueueHow.through, QueueHow.file]) {
|
||||
expect(queuePerformer(item(how), [member('m1'), member('m2', out: true)]).id, 'm2');
|
||||
final gone = queuePerformer(item(how), [member('m1'), member('m3')]);
|
||||
expect(gone.id, isNull);
|
||||
expect(gone.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, out: 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', stale: true), member('m3')]).id, 'm3');
|
||||
final nobody = queuePerformer(item(how), [member('m2', out: 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 stale', () {
|
||||
expect(queueJanitor([member('m9'), member('m1', stale: true), member('m2')]), 'm2');
|
||||
});
|
||||
|
||||
test('warming starts in the last 90 s, once buffered to the end, or at the end', () {
|
||||
bool due(double pos, {double buffered = 0, String t = 'playing'}) =>
|
||||
queueWarmDue(transport: t, position: pos, duration: 1000, bufferedTo: pos + buffered);
|
||||
expect(due(100), isFalse);
|
||||
expect(due(911), isTrue);
|
||||
expect(due(100, buffered: 900), isTrue);
|
||||
expect(due(0, t: 'finished'), isTrue);
|
||||
expect(queueWarmDue(transport: 'playing', position: 5, duration: 0, bufferedTo: 5), isFalse);
|
||||
});
|
||||
|
||||
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(queueClock(3725), '1:02:05');
|
||||
expect(queueClock(65), '1:05');
|
||||
expect(queueClock(0), '');
|
||||
});
|
||||
}
|
||||
+110
-6
@@ -571,7 +571,28 @@ How bytes move:
|
||||
token after a resume); a different size (or piece count and roles)
|
||||
drops that track's buffer.
|
||||
`state.media.relay.live` follows the channel, and viewers show "waiting
|
||||
for <name> to pass the video on" while it is down.
|
||||
for <name> to pass the video on" while it is down, "<name> left:
|
||||
playing what the server has" once the source left the server.
|
||||
- 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
|
||||
shared by its tracks, run by each buffer's pull), so a full buffer with
|
||||
an idle source does not read as a slow one; smoothed over about 4 s of
|
||||
that time (`SourceRate`). Its first-byte wait counts, so it reads a bit
|
||||
under the uplink on short pulls. The need is the tracks' sizes over the
|
||||
reported duration (pieces: their bytes per second once a minute of
|
||||
segments is in). Each member's rate is its `bytesServed` change over
|
||||
5 s (`ReadRates`), which follows what its player asks for, not what its
|
||||
link could take. Two significant digits keep `state` from changing with
|
||||
every byte. The app (`session/delivery.dart`) derives the rest from the
|
||||
state: the kind (link, host's file, server's file, member relay, server
|
||||
fetch, Jellyfin), each member's route (source; via server: relayed,
|
||||
`/m`, or Jellyfin `jf` false; direct: links, Jellyfin `jf` true) and
|
||||
buffer health (enough for `bufferNeed`, low, empty while playing). The
|
||||
members card (`shell/delivery_line.dart`) shows it as a source line and
|
||||
a dot per member; nothing goes on the picture. The source gets one toast
|
||||
per media while `rate` < `need`. Not shown: a direct member's speed
|
||||
(the server never sees its bytes).
|
||||
|
||||
Measured (mpv 0.41 viewers, real server and source, a shaped link
|
||||
between source and server; `tool/relay_e2e.dart`, 2-minute 1080p test
|
||||
@@ -727,8 +748,8 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
||||
here for <server>, and nobody is passing it on" with a button that
|
||||
opens Integrations over the session; linking (or reconnecting) the
|
||||
account retries by itself. A 401 marks the account expired. The member
|
||||
list shows "via host" for members whose `jf` is false for the current
|
||||
item. A reconnect to the same item with `OwnPlay` keeps playing as is.
|
||||
list shows "via server" for members whose `jf` is false for the current
|
||||
item (see "Relay", Stats). A reconnect to the same item with `OwnPlay` keeps playing as is.
|
||||
- **Progress** (`JellyfinProgressReporter`, `OwnPlay` only): Playing once the
|
||||
file is open and mpv has picked its tracks; Progress every 10 s and at
|
||||
once on pause, resume, a seek (position more than 3 s from where playback
|
||||
@@ -758,7 +779,9 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
||||
`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.
|
||||
and with it every countdown. Only while Up next is empty and its
|
||||
continuation was not cleared (see "Up next"); a queued item replaces
|
||||
a countdown already running.
|
||||
- **Tracks**: the controls' Audio and Subtitles buttons list
|
||||
`PlaybackController.tracks`. A pick is applied, reported, and saved as a `TrackChoice`
|
||||
(language, title, codec, channels, forced/HI: from the Jellyfin streams
|
||||
@@ -771,6 +794,86 @@ 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`).
|
||||
|
||||
## Up next
|
||||
|
||||
The queue: what plays after the current media. The server holds the list
|
||||
(`EngineQueue` in `packages/core/lib/src/queue.dart`, in memory, part of
|
||||
`state`; wire in [PROTOCOL.md](PROTOCOL.md), "Queue"), any member edits
|
||||
it by item id (ops never clash), items outlive their adder. The app side
|
||||
is `lib/src/queue/`: `QueueController` (owned by the coordinator, fed by
|
||||
every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
|
||||
`queue_ui.dart`.
|
||||
|
||||
- **Adding** (`QueueController.add`, from "Add to queue" anywhere or the
|
||||
popover): a link is probed like a swap (`AppYtDlp.probe`) and refused
|
||||
when it will not play, in the chosen mode ("Through me" also needs
|
||||
something a relay can read, "Server fetches it" a password on the
|
||||
server; alone it is direct). The item carries the title, a second line
|
||||
(uploader or site, the show and episode, a file's parsed episode), the
|
||||
length when known, and a picture (a page's thumbnail, a Jellyfin
|
||||
episode still or backdrop, a file's frame from `thumbnailFor`; JPEG,
|
||||
under the poster cap) sent as `thumb`. A link passed on "through me"
|
||||
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:
|
||||
skipped, "Bob left"); a Jellyfin item, its adder when it plays that
|
||||
server with its own account, else the lowest 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 active member id; for these, stale or sitting out counts as
|
||||
away (an adder who is stale is waited for until it is gone). A skip is
|
||||
a `remove` with the reason from the lowest member id that is not stale
|
||||
(`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
|
||||
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 chip'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
|
||||
(`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
|
||||
with the reason. A swap without `queueId` (a manual one) leaves the
|
||||
queue alone. A member who puts its own link or file up while sitting
|
||||
out comes back first; any other queued swap leaves the swapper out.
|
||||
- **Getting ready** (`queueWarmDue`): in the last 90 s of the current
|
||||
media, once it is buffered to its end, or at `finished`, the app that
|
||||
will put the first item up resolves its link (`AppYtDlp.probe`, kept 10
|
||||
minutes, which the swap then reads), so the swap does not wait for
|
||||
yt-dlp. A Jellyfin item, a file or a server-fetched link has nothing to
|
||||
get ready there. Only the first item, only that app, nothing on the
|
||||
server. Its row shows a dot: hollow waiting, pulsing getting ready,
|
||||
filled ready, red failed (tried again when it comes up).
|
||||
- **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.
|
||||
- **UI**: `UpNextChip` top right under the top bar (under the party in
|
||||
fullscreen), shown with the controls like the pills (`PillGate`) and
|
||||
while its popover is open: "Up next · title", a +n count, ▶ plays it
|
||||
now; without Up next the continuation ("Next · S1E8 …", ▶ = next
|
||||
episode); nothing when both are empty. Its title opens the queue as a
|
||||
`LeafPopover` (package `leaf_widgets`, an overlay rather than a route):
|
||||
frosted glass like the app menus; rows with a 16:9 picture, title, second
|
||||
line, "length · added by (avatar) name · how", a drag handle
|
||||
(`ReorderableListView`, reordered here at once and sent as `move`), ▶
|
||||
and × floating in on hover; "Continues with"; a "Paste a link to add"
|
||||
field (focused on open, so the player's keys and Space rest while it
|
||||
is up; Esc closes) and a drop zone for links and video files. Being no
|
||||
route, it holds the shell's own drop and paste handling off while open
|
||||
(`handoffHolds`). "Add to queue" elsewhere: the Link page's button
|
||||
next to Put it up (in the chosen mode), a right-click menu on recent
|
||||
links, Jellyfin cards, episodes and details-panel episodes, and Files
|
||||
videos (Put it up / Add to queue / Play next), and a button in the
|
||||
Jellyfin details panel (right-click: play next). None of it offline.
|
||||
|
||||
## Desktop UI
|
||||
|
||||
One window, `AppShell` (`lib/src/shell/`), whatever the connection state
|
||||
@@ -802,8 +905,9 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
|
||||
dialog; no avatars or chat), online the party: `AvatarStack` (host first, then
|
||||
ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and
|
||||
"ready n/m" (hidden under 1100 px), chat with an unread badge, and Leave
|
||||
(asks first). The stack opens the members card (status, host / relay
|
||||
pills, "via host", "sitting out"; per member force ready, Nudge and Sit
|
||||
(asks first). The stack opens the members card (the source line, then
|
||||
per member status, route and buffer dot (see "Relay", Stats), host /
|
||||
relay pills, "sitting out"; per member force ready, Nudge and Sit
|
||||
out (anyone, the host too), your own row Sit out / Join back; "Copy invite
|
||||
address": the dialled address when joined; hosting, `inviteAddresses`
|
||||
of `NetworkInterface.list` (IPv4, no loopback or link-local, private
|
||||
|
||||
+87
-12
@@ -1,4 +1,4 @@
|
||||
# Protocol (proto = 11)
|
||||
# Protocol (proto = 12)
|
||||
|
||||
Wire format only. Behavior (gate, clock, timeouts) is in
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
|
||||
@@ -30,7 +30,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||
## Client to server
|
||||
|
||||
```
|
||||
{"t":"hello","proto":11,"secret":"","user":{"name":"","avatar":""},
|
||||
{"t":"hello","proto":12,"secret":"","user":{"name":"","avatar":""},
|
||||
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
|
||||
"resume":"<token>"}
|
||||
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
|
||||
@@ -39,6 +39,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||
{"t":"swap","media":{"name":"","size":0,"duration":0,"url":"..."}}
|
||||
{"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":"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}
|
||||
@@ -47,6 +48,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||
{"t":"nudge","target":"<memberId>"}
|
||||
{"t":"profile","name":"...","avatar":"<base64>"}
|
||||
{"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"}
|
||||
{"t":"queue","op":"add"|"move"|"remove"|"play"|"skip",...} see "Queue"
|
||||
```
|
||||
|
||||
Field notes:
|
||||
@@ -74,7 +76,10 @@ Field notes:
|
||||
- `report.jf` (optional): Jellyfin access per `ServerId`, `true` = this
|
||||
client plays the item with its own account, `false` = through the relay.
|
||||
Sticky: a report without `jf` keeps the last one. At most 8 entries, keys
|
||||
up to 64 characters; others are dropped.
|
||||
up to 64 characters; others are dropped. The reference client lists
|
||||
every server it has a working account on as `true`, then the current
|
||||
item's server as it plays it (so a queued Jellyfin item finds someone
|
||||
to put it up while other media plays).
|
||||
- `report.error` (optional, omitted when empty): short plain text saying
|
||||
why this client's player cannot open the media `report.mediaId` (e.g.
|
||||
`Not allowed (403)`). The client sends it in every report while that
|
||||
@@ -127,8 +132,8 @@ Field notes:
|
||||
"serverTime":"<ISO-8601 UTC>","mediaId":1,
|
||||
"media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent,
|
||||
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
|
||||
"host"?,"out"?,"error"?}],
|
||||
"seekBy":"m1","seekSeq":3,"relay":true}
|
||||
"host"?,"out"?,"error"?,"rate"?}],
|
||||
"seekBy":"m1","seekSeq":3,"relay":true,"queue":[<item>]?,"queuePlay":"q3"?}
|
||||
{"t":"pong","c":1767225600000,"s":1767225600012}
|
||||
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
|
||||
{"t":"chat","text":"...","from":"<name>"}
|
||||
@@ -161,17 +166,23 @@ Field notes:
|
||||
server's `hostKey` (see `hello.hostKey`). `members[].out` (omitted when
|
||||
false): the member sits out (see `out`). `members[].error` (omitted when
|
||||
empty): the member's player cannot open the current media (see
|
||||
`report.error`).
|
||||
`report.error`). `members[].rate` (omitted when 0): bytes per second
|
||||
the server sent this member over the last 5 s (`/m`, `/j` and `/r`
|
||||
together), two significant digits, under 10 kB/s counts as 0. Members
|
||||
that fetch the media themselves read nothing from the server.
|
||||
- `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.
|
||||
- `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`,
|
||||
`force-play`, `force-hold`, `out`, `back`, `join`, `leave`, `stale`, `swap`,
|
||||
`seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`. `target`
|
||||
`seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`,
|
||||
`queue-add`, `queue-remove`, `queue-skip`, `fetch-fail` (proto 12: the
|
||||
server could not fetch the current media, `target` its name). `target`
|
||||
and `reason` are optional; `reason` carries `auto`/`manual` for ready and
|
||||
`silent too long` or `no signal` for leave/stale. `rename`: `actor` is the
|
||||
new name, `target` the old one. `avatar`: the actor changed only the
|
||||
picture.
|
||||
picture. `queue-*`: `target` is the item's title, `reason` (skip) why it
|
||||
could not play (see "Queue").
|
||||
- Chat is delivered twice: a `chat` frame with the text and a textless
|
||||
`feed` with `action:"chat"`. Render the `chat` frame only; the reference
|
||||
client drops the textless feed entry.
|
||||
@@ -207,8 +218,9 @@ state media.art = {"poster":"<hash>","banner":"<hash>"}
|
||||
- `media.art` carries hashes like `avatarHash`; a key is omitted when that
|
||||
picture is absent, `art` when both are. The same picture as poster and
|
||||
banner (one hash) is a web video's thumbnail.
|
||||
- `GET /i/<sid>/<token>/<hash>`: as `/a/` (see "Avatars"); 404 when the
|
||||
current media has no such art.
|
||||
- `GET /i/<sid>/<token>/<hash>`: as `/a/` (see "Avatars"); also the
|
||||
pictures of queued items (`queue[].thumb`). 404 when neither the current
|
||||
media nor a queued item has it.
|
||||
|
||||
## Jellyfin media
|
||||
|
||||
@@ -257,6 +269,7 @@ swap media.relay = {} the swapper's machine serves the bytes
|
||||
media.relay = {"fetch":true} the server reads media.url itself
|
||||
state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
|
||||
media.relay = {"fetch":true,"live":true,"tracks":[{"kind":"video"},{"kind":"audio"}]}
|
||||
media.relay = {"source":"m2","live":true,"rate":4100000,"need":2300000}
|
||||
```
|
||||
|
||||
Tracks: a relayed media is one file, or tracks that viewers play as one
|
||||
@@ -286,6 +299,15 @@ anything else means one file.
|
||||
channel is connected, or the server could read the link). `tracks`: as
|
||||
the swap named them, or, with `fetch`, set once the server has read the
|
||||
link (together with `live`).
|
||||
- `state.media.relay.rate` (omitted while unknown): bytes per second the
|
||||
source (with `fetch`, the server's own fetch) delivers while the buffer
|
||||
is waiting on it, smoothed over about 4 s of that time and present once
|
||||
2 s of it are measured; it holds while the buffer is full and the
|
||||
source idles. `need` (omitted while unknown): the media's average bytes
|
||||
per second, the tracks' sizes over `media.duration` (a pieces track:
|
||||
its segments in so far, after a minute of them). Both two significant
|
||||
digits, updated once a second. `rate` under `need` means the source
|
||||
cannot keep up; clients tell the source once per media.
|
||||
- `welcome.mediaUrl` for relayed media without `jellyfin`: this member's
|
||||
`/r/` URL; absent for the source itself (it plays its own copy) and for
|
||||
a fetched link the server has not read yet (clients open it on `live`).
|
||||
@@ -360,6 +382,59 @@ is a fresh `hello` (with `resume` = the old token), after which the client
|
||||
re-sends its prior ready flag. If the old member was still there (unclean
|
||||
drop), `resume` takes it over and the token and media URL stay the same.
|
||||
|
||||
## Reserved, not implemented
|
||||
## Queue (proto 12)
|
||||
|
||||
Server-held queue (`queue-add`, `queue-skip`) would auto-advance like a swap.
|
||||
Up next: what plays after the current media, held by the server for
|
||||
everyone, in memory (a restart loses it). Any member edits it; items stay
|
||||
when their adder leaves. Behavior (who puts an item up, the countdown,
|
||||
skipping) is in [ARCHITECTURE.md](ARCHITECTURE.md), "Up next".
|
||||
|
||||
```
|
||||
client {"t":"queue","op":"add","at":"end"|"next","item":{<item>},"thumb":"<base64>"}
|
||||
{"t":"queue","op":"move","id":"q3","to":0}
|
||||
{"t":"queue","op":"remove","id":"q3","why":"Bob left"}
|
||||
{"t":"queue","op":"play","id":"q3"}
|
||||
{"t":"queue","op":"skip"}
|
||||
{"t":"swap","media":{...},"queueId":"q3"}
|
||||
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>"}
|
||||
```
|
||||
|
||||
- `add`: `item` without `id`, `by`, `byName`, `thumb` (the server sets
|
||||
them: `id` `q<n>`, unique for the server's life; who sent it and their
|
||||
name then). `at` `next` puts it first, else last. `thumb` (optional):
|
||||
base64 picture, checked and capped like a poster (256 KiB decoded);
|
||||
`item.thumb` is its hash, served at `/i/` (see "Media art") while the
|
||||
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)`.
|
||||
- `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
|
||||
with a password (as for a fetch swap). The server keeps the URL to
|
||||
itself: the broadcast `media` has none.
|
||||
- `through` (a link the adder passes on) / `file` (the adder's file):
|
||||
`name` and `size` only, and `key` (8 to 64 of `[A-Za-z0-9_-]`): the
|
||||
adder's own handle for the link or path, which never leave its app.
|
||||
A `url` sent along is dropped.
|
||||
- `jellyfin`: a `jellyfin` ref, checked like a swap's (any `media` with
|
||||
a ref is `jellyfin`).
|
||||
Anything else: `err` `bad message: ...`.
|
||||
- `move`: `id` to index `to` (clamped). `remove`: drops `id`; with `why`
|
||||
(cut to 120 characters) it is a skip (feed `queue-skip` with the
|
||||
reason; clients show it), else feed `queue-remove`. `add` gives feed
|
||||
`queue-add`. Unknown ids are ignored.
|
||||
- `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.
|
||||
- `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
|
||||
leave out `url`: the server uses the one it kept. A swap without
|
||||
`queueId` leaves the queue as it is.
|
||||
- `state.queue` (omitted when empty) and `state.queuePlay` (omitted when
|
||||
none) ride in every state, `welcome.state` included.
|
||||
+16
-11
@@ -12,7 +12,8 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
ever be skipped.
|
||||
|
||||
2. **Files**: next episode from Files (by parsed episode, with the
|
||||
countdown). Later: tracking for file series.
|
||||
countdown, and under Up next's "Continues with"). Later: tracking for
|
||||
file series.
|
||||
|
||||
3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
|
||||
bundled Windows libmpv has the ytdl hook (Lua) and plays `edl://` URLs
|
||||
@@ -36,7 +37,10 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
filter on the Schedule from the user's AniList lists. This is the
|
||||
Schedule's personal tracking; AnimeFlow has no per-user follow.
|
||||
|
||||
5. **Ops**: check shaders on Windows; the relay e2e "source drops
|
||||
5. **Ops**: `leaf_widgets` (the Up next popover's `LeafPopover`) is a
|
||||
local path dependency: make it a git dependency like `fox_utils`
|
||||
before the next release (CI cannot see the path), or go back to the
|
||||
app's popover route. Check shaders on Windows; the relay e2e "source drops
|
||||
uncleanly and resumes" fails about half the time under the full suite.
|
||||
|
||||
6. **Windows: first real use.** CI builds and publishes
|
||||
@@ -52,8 +56,6 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
over instead of the buffer running dry.
|
||||
- "Bob can't open this link — relay it?": offer the relay to whoever put
|
||||
a direct link up when a viewer's player fails to open it.
|
||||
- Show who is relaying and how fast in the member list; warn the source
|
||||
when its upload is below the media's bitrate.
|
||||
|
||||
8. **Jellyfin** (epic, below).
|
||||
|
||||
@@ -77,19 +79,22 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
- HTTP: move the app's clients (Jellyfin, AniList, AnimeFlow, updater)
|
||||
to dio from `fox_utils` when touching them, for retries/interceptors.
|
||||
- TLS: document a reverse proxy in front of the server.
|
||||
- Playlist: `queue-add`, `queue-skip`, auto-advance like a swap.
|
||||
- Up next follow-ups (see ARCHITECTURE "Up next"): getting the first
|
||||
item ready only resolves its link; fetching its first seconds early
|
||||
through the relay would make relayed starts instant too. A member
|
||||
whose reconnect lands as a new member loses its "through me" links
|
||||
and files in the queue (skipped as gone); the key could follow the
|
||||
member. A whole season or a pick list at once ("Add the rest of the
|
||||
season").
|
||||
- Sidecar subtitles.
|
||||
|
||||
## Epic: Jellyfin
|
||||
|
||||
What is left (the rest is in [ARCHITECTURE.md](ARCHITECTURE.md), "Jellyfin
|
||||
in a session"):
|
||||
- Queue: put several library items (a season, a pick list) up as a
|
||||
playlist that advances like a swap (shares `queue-add` / `queue-skip`
|
||||
with the backlog's Playlist item).
|
||||
- Auto-advance cancel for everyone: today Cancel is per screen (the
|
||||
designated swapper's Cancel stops the swap; a seek stops all). A shared
|
||||
cancel needs a wire message.
|
||||
- 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). A shared cancel needs a wire message.
|
||||
- Sidecar (external) subtitle streams of Jellyfin items: only embedded
|
||||
tracks are mapped to mpv today.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export 'src/logging.dart';
|
||||
export 'src/clock_sync.dart';
|
||||
export 'src/config.dart';
|
||||
export 'src/protocol.dart';
|
||||
export 'src/queue.dart';
|
||||
export 'src/relay.dart';
|
||||
export 'src/relay_pieces.dart';
|
||||
export 'src/link_probe.dart';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
abstract class RelayConstants {
|
||||
static const int protoVersion = 11;
|
||||
static const int protoVersion = 12;
|
||||
|
||||
// Relay (`/r/...`): a member's bytes through the server's buffer.
|
||||
static const int relaySliceBytes = 1 << 20;
|
||||
@@ -56,4 +56,12 @@ abstract class RelayConstants {
|
||||
/// minute; the server drops the rest silently.
|
||||
static const int nudgesPerSec = 3;
|
||||
static const int nudgesPerMin = 20;
|
||||
|
||||
/// 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].
|
||||
static const int maxQueueItems = 100;
|
||||
static const int maxQueueTextLength = 200;
|
||||
static const int queuePlayTimeoutSecs = 30;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'constants.dart';
|
||||
import 'errors.dart';
|
||||
import 'queue.dart';
|
||||
|
||||
/// Reads an optional JSON number as a finite double. JSON like 1e400 decodes to
|
||||
/// Infinity, which would later break jsonEncode on every broadcast.
|
||||
@@ -179,12 +180,22 @@ class RelayTrackSpec {
|
||||
/// [tracks]: the tracks of a media relayed as several played together, or
|
||||
/// as one segmented playlist; empty for one file. A member swap names
|
||||
/// them, the server fills them in for [fetch] once it has read the link.
|
||||
/// [rate] and [need] are the server's measures, 0 while unknown: bytes per
|
||||
/// second the source (or the server's own fetch) delivers while the
|
||||
/// buffer asks for bytes, and the media's average bitrate.
|
||||
class MediaRelay {
|
||||
final String source;
|
||||
final bool fetch;
|
||||
final bool live;
|
||||
final List<RelayTrackSpec> tracks;
|
||||
const MediaRelay({this.source = '', this.fetch = false, this.live = false, this.tracks = const []});
|
||||
final int rate;
|
||||
final int need;
|
||||
const MediaRelay(
|
||||
{this.source = '', this.fetch = false, this.live = false, this.tracks = const [], this.rate = 0, this.need = 0});
|
||||
|
||||
/// The source is measured slower than the media plays (both known):
|
||||
/// below 85 % of [need], so a rate close to it does not flicker.
|
||||
bool get slow => rate > 0 && need > 0 && rate < need * 0.85;
|
||||
|
||||
static const trackKinds = {'video', 'audio'};
|
||||
static const maxTracks = 4;
|
||||
@@ -193,14 +204,21 @@ class MediaRelay {
|
||||
static bool validTracks(List<RelayTrackSpec> t) =>
|
||||
t.length <= maxTracks && (t.length > 1 || (t.length == 1 && t.single.pieces));
|
||||
|
||||
MediaRelay copyWith({bool? live, List<RelayTrackSpec>? tracks}) =>
|
||||
MediaRelay(source: source, fetch: fetch, live: live ?? this.live, tracks: tracks ?? this.tracks);
|
||||
MediaRelay copyWith({bool? live, List<RelayTrackSpec>? tracks, int? rate, int? need}) => MediaRelay(
|
||||
source: source,
|
||||
fetch: fetch,
|
||||
live: live ?? this.live,
|
||||
tracks: tracks ?? this.tracks,
|
||||
rate: rate ?? this.rate,
|
||||
need: need ?? this.need);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
if (source.isNotEmpty) 'source': source,
|
||||
if (fetch) 'fetch': true,
|
||||
if (live) 'live': true,
|
||||
if (validTracks(tracks)) 'tracks': [for (final t in tracks) t.toJson()],
|
||||
if (rate > 0) 'rate': rate,
|
||||
if (need > 0) 'need': need,
|
||||
};
|
||||
|
||||
/// Unknown kinds, a single byte track or too many make it one file.
|
||||
@@ -212,8 +230,13 @@ class MediaRelay {
|
||||
source: json['source'] is String ? json['source'] as String : '',
|
||||
fetch: json['fetch'] == true,
|
||||
live: json['live'] == true,
|
||||
tracks: ok ? List.unmodifiable(specs.cast<RelayTrackSpec>()) : const []);
|
||||
tracks: ok ? List.unmodifiable(specs.cast<RelayTrackSpec>()) : const [],
|
||||
rate: _byteRate(json['rate']),
|
||||
need: _byteRate(json['need']));
|
||||
}
|
||||
|
||||
/// A bytes-per-second field: a finite non-negative number, else 0.
|
||||
static int _byteRate(Object? v) => v is num && v.isFinite && v > 0 ? v.round() : 0;
|
||||
}
|
||||
|
||||
class HelloMessage {
|
||||
@@ -412,15 +435,23 @@ class SeekIntent {
|
||||
|
||||
class SwapIntent {
|
||||
final HelloMedia media;
|
||||
const SwapIntent({required this.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});
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'swap', 'media': media.toJson()};
|
||||
Map<String, Object?> toJson() =>
|
||||
{'t': 'swap', 'media': media.toJson(), if (queueId != null) 'queueId': queueId};
|
||||
|
||||
factory SwapIntent.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'swap') throw const ProtocolException('not a swap intent');
|
||||
final media = json['media'];
|
||||
if (media is! Map) throw const ProtocolException('swap missing media');
|
||||
return SwapIntent(media: HelloMedia.fromJson(Map<String, Object?>.from(media)));
|
||||
final queueId = json['queueId'];
|
||||
return SwapIntent(
|
||||
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
|
||||
queueId: queueId is String && queueId.isNotEmpty ? queueId : null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,6 +631,9 @@ class MemberView {
|
||||
/// Why its player cannot open the current media (`report.error`); empty
|
||||
/// while it can. Errored: outside the ready gate.
|
||||
final String error;
|
||||
/// Bytes per second the server sent this member lately (its file, the
|
||||
/// Jellyfin relay or the relay); 0 when none.
|
||||
final int rate;
|
||||
const MemberView({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -612,6 +646,7 @@ class MemberView {
|
||||
this.host = false,
|
||||
this.out = false,
|
||||
this.error = '',
|
||||
this.rate = 0,
|
||||
});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -626,6 +661,7 @@ class MemberView {
|
||||
if (host) 'host': true,
|
||||
if (out) 'out': true,
|
||||
if (error.isNotEmpty) 'error': error,
|
||||
if (rate > 0) 'rate': rate,
|
||||
};
|
||||
|
||||
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
|
||||
@@ -640,6 +676,7 @@ class MemberView {
|
||||
host: json['host'] == true,
|
||||
out: json['out'] == true,
|
||||
error: memberErrorFromJson(json['error']),
|
||||
rate: MediaRelay._byteRate(json['rate']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -674,6 +711,11 @@ class StateBroadcast {
|
||||
/// The server can relay the current Jellyfin media (`/j/...`) for viewers
|
||||
/// without their own access to it.
|
||||
final bool relay;
|
||||
/// Up next, in order (see `QueueItem`); empty when nothing is queued.
|
||||
final List<QueueItem> queue;
|
||||
/// The queued item someone asked to play now, waiting for the member
|
||||
/// who can put it up; null when none.
|
||||
final String? queuePlay;
|
||||
const StateBroadcast({
|
||||
required this.eventId,
|
||||
required this.transport,
|
||||
@@ -685,6 +727,8 @@ class StateBroadcast {
|
||||
this.seekBy,
|
||||
this.seekSeq,
|
||||
this.relay = false,
|
||||
this.queue = const [],
|
||||
this.queuePlay,
|
||||
});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -699,6 +743,8 @@ class StateBroadcast {
|
||||
if (seekBy != null) 'seekBy': seekBy,
|
||||
if (seekSeq != null) 'seekSeq': seekSeq,
|
||||
if (relay) 'relay': true,
|
||||
if (queue.isNotEmpty) 'queue': [for (final q in queue) q.toJson()],
|
||||
if (queuePlay != null) 'queuePlay': queuePlay,
|
||||
};
|
||||
|
||||
factory StateBroadcast.fromJson(Map<String, Object?> json) {
|
||||
@@ -721,6 +767,10 @@ class StateBroadcast {
|
||||
seekBy: json['seekBy'] as String?,
|
||||
seekSeq: json['seekSeq'] == null ? null : finiteInt(json['seekSeq'], 'seekSeq'),
|
||||
relay: (json['relay'] as bool?) ?? false,
|
||||
queue: json['queue'] is List
|
||||
? (json['queue'] as List).map(QueueItem.fromJson).whereType<QueueItem>().toList()
|
||||
: const [],
|
||||
queuePlay: json['queuePlay'] is String ? json['queuePlay'] as String : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'constants.dart';
|
||||
import 'errors.dart';
|
||||
import 'protocol.dart';
|
||||
import 'sync_engine.dart';
|
||||
|
||||
// Up next: what plays after the current media, held by the server for
|
||||
// everyone (see PROTOCOL.md "Queue"). Wire types, and [EngineQueue], the
|
||||
// server's list (pure, so the engine and tests share it).
|
||||
|
||||
/// How a queued item plays, and so who can put it up when it comes up.
|
||||
enum QueueHow {
|
||||
/// A link everyone opens themselves: anyone puts it up.
|
||||
direct,
|
||||
/// A link the server fetches: anyone puts it up (the server keeps the URL).
|
||||
server,
|
||||
/// A link the adder's app reads and passes on: only the adder.
|
||||
through,
|
||||
/// A file on the adder's computer: only the adder.
|
||||
file,
|
||||
/// A Jellyfin item: someone with an account on that server.
|
||||
jellyfin;
|
||||
|
||||
static QueueHow? parse(Object? v) {
|
||||
for (final h in values) {
|
||||
if (h.name == v) return h;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Only the adder's app has what it needs (its file, its cookies).
|
||||
bool get adderOnly => this == through || this == file;
|
||||
}
|
||||
|
||||
/// One entry of Up next as everyone sees it. [media] is what the swap
|
||||
/// puts up, without a URL for [QueueHow.server], [QueueHow.through] and
|
||||
/// [QueueHow.file] (the server, or the adder's app, keeps it). [key]: the
|
||||
/// adder's own handle for what it keeps (adder-only items).
|
||||
class QueueItem {
|
||||
final String id;
|
||||
final HelloMedia media;
|
||||
final QueueHow how;
|
||||
final String title;
|
||||
/// A second line: the site, uploader or show; may be empty.
|
||||
final String sub;
|
||||
/// Seconds, 0 when unknown.
|
||||
final double duration;
|
||||
/// Picture hash, served at `/i/...`; empty when none.
|
||||
final String thumb;
|
||||
/// Member id and name of who added it (the name as it was then).
|
||||
final String by;
|
||||
final String byName;
|
||||
final String key;
|
||||
const QueueItem({
|
||||
this.id = '',
|
||||
required this.media,
|
||||
required this.how,
|
||||
required this.title,
|
||||
this.sub = '',
|
||||
this.duration = 0,
|
||||
this.thumb = '',
|
||||
this.by = '',
|
||||
this.byName = '',
|
||||
this.key = '',
|
||||
});
|
||||
|
||||
QueueItem copyWith({String? id, HelloMedia? media, String? title, String? sub, double? duration,
|
||||
String? thumb, String? by, String? byName, String? key}) =>
|
||||
QueueItem(
|
||||
id: id ?? this.id,
|
||||
media: media ?? this.media,
|
||||
how: how,
|
||||
title: title ?? this.title,
|
||||
sub: sub ?? this.sub,
|
||||
duration: duration ?? this.duration,
|
||||
thumb: thumb ?? this.thumb,
|
||||
by: by ?? this.by,
|
||||
byName: byName ?? this.byName,
|
||||
key: key ?? this.key,
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
if (id.isNotEmpty) 'id': id,
|
||||
'media': media.toJson(),
|
||||
'how': how.name,
|
||||
'title': title,
|
||||
if (sub.isNotEmpty) 'sub': sub,
|
||||
if (duration > 0) 'duration': duration,
|
||||
if (thumb.isNotEmpty) 'thumb': thumb,
|
||||
if (by.isNotEmpty) 'by': by,
|
||||
if (byName.isNotEmpty) 'byName': byName,
|
||||
if (key.isNotEmpty) 'key': key,
|
||||
};
|
||||
|
||||
/// Null when [j] is not an item this side can read.
|
||||
static QueueItem? fromJson(Object? j) {
|
||||
if (j is! Map) return null;
|
||||
final media = j['media'];
|
||||
final how = QueueHow.parse(j['how']);
|
||||
if (media is! Map || how == null) return null;
|
||||
final d = j['duration'];
|
||||
return QueueItem(
|
||||
id: j['id'] is String ? j['id'] as String : '',
|
||||
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
|
||||
how: how,
|
||||
title: j['title'] is String ? j['title'] as String : '',
|
||||
sub: j['sub'] is String ? j['sub'] as String : '',
|
||||
duration: d is num && d.isFinite && d > 0 ? d.toDouble() : 0,
|
||||
thumb: j['thumb'] is String ? j['thumb'] as String : '',
|
||||
by: j['by'] is String ? j['by'] as String : '',
|
||||
byName: j['byName'] is String ? j['byName'] as String : '',
|
||||
key: j['key'] is String ? j['key'] as String : '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Client to server: one change to Up next. [op] `add` (with [item],
|
||||
/// [next] = right after what plays instead of at the end, [thumb] base64
|
||||
/// picture), `move` ([id] to index [to]), `remove` ([id]; [why] set when it
|
||||
/// is skipped because it cannot play), `play` ([id] now) or `skip` (the
|
||||
/// first item now).
|
||||
class QueueIntent {
|
||||
final String op;
|
||||
final QueueItem? item;
|
||||
final bool next;
|
||||
final String thumb;
|
||||
final String id;
|
||||
final int to;
|
||||
final String why;
|
||||
const QueueIntent._(this.op,
|
||||
{this.item, this.next = false, this.thumb = '', this.id = '', this.to = 0, this.why = ''});
|
||||
|
||||
const QueueIntent.add(QueueItem item, {bool next = false, String thumb = ''})
|
||||
: this._('add', item: item, next: next, thumb: thumb);
|
||||
const QueueIntent.move(String id, int to) : this._('move', id: id, to: to);
|
||||
const QueueIntent.remove(String id, {String why = ''}) : this._('remove', id: id, why: why);
|
||||
const QueueIntent.play(String id) : this._('play', id: id);
|
||||
const QueueIntent.skip() : this._('skip');
|
||||
|
||||
static const ops = {'add', 'move', 'remove', 'play', 'skip'};
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'queue',
|
||||
'op': op,
|
||||
if (item != null) 'item': item!.toJson(),
|
||||
if (op == 'add') 'at': next ? 'next' : 'end',
|
||||
if (thumb.isNotEmpty) 'thumb': thumb,
|
||||
if (id.isNotEmpty) 'id': id,
|
||||
if (op == 'move') 'to': to,
|
||||
if (why.isNotEmpty) 'why': why,
|
||||
};
|
||||
|
||||
factory QueueIntent.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'queue') throw const ProtocolException('not a queue intent');
|
||||
final op = json['op'];
|
||||
if (op is! String || !ops.contains(op)) throw const ProtocolException('unknown queue op');
|
||||
final item = QueueItem.fromJson(json['item']);
|
||||
if (op == 'add' && item == null) throw const ProtocolException('queue add needs an item');
|
||||
return QueueIntent._(op,
|
||||
item: item,
|
||||
next: json['at'] == 'next',
|
||||
thumb: json['thumb'] is String ? json['thumb'] as String : '',
|
||||
id: json['id'] is String ? json['id'] as String : '',
|
||||
to: finiteInt(json['to'], 'to'),
|
||||
why: json['why'] is String ? _cut(json['why'] as String, 120) : '');
|
||||
}
|
||||
}
|
||||
|
||||
String _cut(String s, int max) {
|
||||
final line = s.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
return line.length > max ? line.substring(0, max) : line;
|
||||
}
|
||||
|
||||
/// Display text of a queued item: one line, at most
|
||||
/// [RelayConstants.maxQueueTextLength] characters.
|
||||
String cleanQueueText(String s) => _cut(s, RelayConstants.maxQueueTextLength);
|
||||
|
||||
class _Entry {
|
||||
QueueItem item;
|
||||
/// A server-fetched link's URL: never broadcast.
|
||||
final String? privateUrl;
|
||||
final ({String hash, Uint8List bytes})? thumb;
|
||||
_Entry(this.item, this.privateUrl, this.thumb);
|
||||
}
|
||||
|
||||
/// The server's Up next: items in order with server-assigned ids, and at
|
||||
/// most one pending [play] request (an item someone asked to play now,
|
||||
/// waiting for the member who can put it up). In memory only.
|
||||
class EngineQueue {
|
||||
final List<_Entry> _items = [];
|
||||
int _nextId = 1;
|
||||
String? play;
|
||||
DateTime? _playAt;
|
||||
|
||||
List<QueueItem> get items => [for (final e in _items) e.item];
|
||||
bool get isEmpty => _items.isEmpty;
|
||||
int get length => _items.length;
|
||||
|
||||
int _index(String id) => _items.indexWhere((e) => e.item.id == id);
|
||||
bool has(String id) => _index(id) >= 0;
|
||||
QueueItem? item(String id) {
|
||||
final i = _index(id);
|
||||
return i < 0 ? null : _items[i].item;
|
||||
}
|
||||
|
||||
/// The URL a server-fetched item keeps to itself.
|
||||
String? privateUrlOf(String id) {
|
||||
final i = _index(id);
|
||||
return i < 0 ? null : _items[i].privateUrl;
|
||||
}
|
||||
|
||||
/// Adds [item] (already cleaned) with a fresh id, at the end or, with
|
||||
/// [next], first. Null when full ([RelayConstants.maxQueueItems]).
|
||||
QueueItem? add(QueueItem item, {bool next = false, String? privateUrl, Uint8List? thumb}) {
|
||||
if (_items.length >= RelayConstants.maxQueueItems) return null;
|
||||
final t = thumb == null ? null : ArtImage(thumb);
|
||||
final added = item.copyWith(id: 'q${_nextId++}', thumb: t?.hash ?? '');
|
||||
final e = _Entry(added, privateUrl, t == null ? null : (hash: t.hash, bytes: t.bytes));
|
||||
if (next) {
|
||||
_items.insert(0, e);
|
||||
} else {
|
||||
_items.add(e);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/// Moves [id] to [to] (clamped). False when unknown or unchanged.
|
||||
bool move(String id, int to) {
|
||||
final i = _index(id);
|
||||
if (i < 0) return false;
|
||||
final target = to.clamp(0, _items.length - 1);
|
||||
if (target == i) return false;
|
||||
final e = _items.removeAt(i);
|
||||
_items.insert(target, e);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Removes [id]; returns it, or null when unknown.
|
||||
QueueItem? remove(String id) {
|
||||
final i = _index(id);
|
||||
if (i < 0) return null;
|
||||
final e = _items.removeAt(i);
|
||||
if (play == id) _clearPlay();
|
||||
return e.item;
|
||||
}
|
||||
|
||||
/// Someone asked to play [id] now: it moves first and waits for the
|
||||
/// member who can put it up ([play]). False when unknown.
|
||||
bool requestPlay(String id, DateTime now) {
|
||||
final i = _index(id);
|
||||
if (i < 0) return false;
|
||||
if (i > 0) _items.insert(0, _items.removeAt(i));
|
||||
play = id;
|
||||
_playAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// A swap happened: [id] (when it came from Up next) leaves the list,
|
||||
/// and any pending play request is over.
|
||||
void swapped(String? id) {
|
||||
if (id != null) {
|
||||
final i = _index(id);
|
||||
if (i >= 0) _items.removeAt(i);
|
||||
}
|
||||
_clearPlay();
|
||||
}
|
||||
|
||||
/// A play request nobody acted on ends after
|
||||
/// [RelayConstants.queuePlayTimeoutSecs]. Returns whether it changed.
|
||||
bool tick(DateTime now) {
|
||||
final at = _playAt;
|
||||
if (play == null || at == null) return false;
|
||||
if (now.difference(at).inSeconds < RelayConstants.queuePlayTimeoutSecs) return false;
|
||||
_clearPlay();
|
||||
return true;
|
||||
}
|
||||
|
||||
void _clearPlay() {
|
||||
play = null;
|
||||
_playAt = null;
|
||||
}
|
||||
|
||||
/// Bytes of an item's picture by hash.
|
||||
Uint8List? thumbFor(String hash) {
|
||||
for (final e in _items) {
|
||||
if (e.thumb?.hash == hash) return e.thumb!.bytes;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The `queue` messages applied to the engine's [SyncEngine.queue], with
|
||||
/// their feed lines (`queue-add`, `queue-remove`, `queue-skip`). Each
|
||||
/// returns whether the queue changed; [applyQueueAdd] an error text for the
|
||||
/// sender instead (null when added).
|
||||
extension EngineQueueOps on SyncEngine {
|
||||
/// [item] is already cleaned by the server (see `cleanQueueAdd`).
|
||||
String? applyQueueAdd(String token, QueueItem item,
|
||||
{bool next = false, String? privateUrl, Uint8List? thumb}) {
|
||||
final member = members[token];
|
||||
if (member == null) return null;
|
||||
final added = queue.add(item.copyWith(by: member.id, byName: member.name),
|
||||
next: next, privateUrl: privateUrl, thumb: thumb);
|
||||
if (added == null) return 'Up next is full (${RelayConstants.maxQueueItems} items)';
|
||||
feed.add(FeedMessage(actor: member.name, action: 'queue-add', target: added.title, at: now));
|
||||
return null;
|
||||
}
|
||||
|
||||
bool applyQueueMove(String token, String id, int to) =>
|
||||
members.containsKey(token) && queue.move(id, to);
|
||||
|
||||
/// [why] set: skipped because it cannot play (`queue-skip`, with the
|
||||
/// reason), else removed by hand (`queue-remove`).
|
||||
bool applyQueueRemove(String token, String id, {String why = ''}) {
|
||||
final member = members[token];
|
||||
if (member == null) return false;
|
||||
final gone = queue.remove(id);
|
||||
if (gone == null) return false;
|
||||
feed.add(FeedMessage(
|
||||
actor: member.name,
|
||||
action: why.isEmpty ? 'queue-remove' : 'queue-skip',
|
||||
target: gone.title,
|
||||
reason: why,
|
||||
at: now));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// `play` ([id]) or `skip` (the first item, [id] empty).
|
||||
bool applyQueuePlay(String token, String id) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:crypto/crypto.dart';
|
||||
|
||||
import 'constants.dart';
|
||||
import 'protocol.dart';
|
||||
import 'queue.dart';
|
||||
|
||||
typedef SyncClock = DateTime Function();
|
||||
|
||||
@@ -59,6 +60,8 @@ class EngineMember {
|
||||
/// Its player cannot open the current media (`report.error`): outside
|
||||
/// the ready gate until a report clears it or the media changes.
|
||||
String error = '';
|
||||
/// Bytes per second the server sent it lately (set by the server).
|
||||
int rate = 0;
|
||||
DateTime lastSeen;
|
||||
EngineMember({
|
||||
required this.id,
|
||||
@@ -101,6 +104,7 @@ class EngineMember {
|
||||
host: host,
|
||||
out: out,
|
||||
error: error,
|
||||
rate: rate,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,6 +158,9 @@ class SyncEngine {
|
||||
/// ride in `state.media.art`. Every swap clears it.
|
||||
ArtImage? poster;
|
||||
ArtImage? banner;
|
||||
/// Up next ([EngineQueue]): in `state.queue`, edited by `queue`
|
||||
/// messages, taken from by swaps that name an item.
|
||||
final EngineQueue queue = EngineQueue();
|
||||
int _nextId = 1;
|
||||
// Who sent the seek that produced [_seekEventId], for the echo check.
|
||||
String? _seekBy;
|
||||
@@ -211,6 +218,7 @@ class SyncEngine {
|
||||
/// Heartbeat: stale sweep, end of media.
|
||||
void tick() {
|
||||
sweepStale();
|
||||
queue.tick(now);
|
||||
evaluate();
|
||||
}
|
||||
|
||||
@@ -272,7 +280,7 @@ class SyncEngine {
|
||||
if (hash.isEmpty) return null;
|
||||
if (poster?.hash == hash) return poster!.bytes;
|
||||
if (banner?.hash == hash) return banner!.bytes;
|
||||
return null;
|
||||
return queue.thumbFor(hash);
|
||||
}
|
||||
|
||||
/// `state.media.art`; null when there is none.
|
||||
@@ -397,9 +405,11 @@ class SyncEngine {
|
||||
}
|
||||
|
||||
/// Returns false (no state change) for media with non-finite numbers.
|
||||
bool applySwap(String actorToken, HelloMedia next) {
|
||||
/// [queueId]: the Up next item this swap puts up; it leaves the queue.
|
||||
bool applySwap(String actorToken, HelloMedia next, {String? queueId}) {
|
||||
if (!next.duration.isFinite || next.size < 0) return false;
|
||||
final actor = members[actorToken];
|
||||
queue.swapped(queueId);
|
||||
mediaId++;
|
||||
media = next;
|
||||
swapBy = actorToken;
|
||||
@@ -544,6 +554,8 @@ class SyncEngine {
|
||||
seekBy: bySeek ? _seekBy : null,
|
||||
seekSeq: bySeek ? _seekSeq : null,
|
||||
relay: relay,
|
||||
queue: queue.items,
|
||||
queuePlay: queue.play,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late DateTime t;
|
||||
late SyncEngine engine;
|
||||
|
||||
setUp(() {
|
||||
t = DateTime.utc(2026, 1, 1);
|
||||
engine = SyncEngine(clock: () => t);
|
||||
engine.applyJoin('a', 'alice', '');
|
||||
engine.applyJoin('b', 'bob', '');
|
||||
});
|
||||
|
||||
QueueItem link(String title) => QueueItem(
|
||||
media: HelloMedia(name: title, size: 0, duration: 0, url: 'https://example.com/$title'),
|
||||
how: QueueHow.direct,
|
||||
title: title);
|
||||
|
||||
List<String> titles() => [for (final q in engine.queue.items) q.title];
|
||||
String idOf(String title) => engine.queue.items.firstWhere((q) => q.title == title).id;
|
||||
|
||||
test('add at the end or next, with who added it, a feed line and stable ids', () {
|
||||
engine.applyQueueAdd('a', link('one'));
|
||||
engine.applyQueueAdd('b', link('two'));
|
||||
engine.applyQueueAdd('b', link('first'), next: true);
|
||||
expect(titles(), ['first', 'one', 'two']);
|
||||
final one = engine.queue.items[1];
|
||||
expect((one.by, one.byName), ('m1', 'alice'));
|
||||
final ids = {for (final q in engine.queue.items) q.id};
|
||||
expect(ids.length, 3);
|
||||
final feed = engine.feed.drain().where((f) => f.action == 'queue-add').toList();
|
||||
expect([for (final f in feed) '${f.actor}:${f.target}'], ['alice:one', 'bob:two', 'bob:first']);
|
||||
});
|
||||
|
||||
test('move and remove by id; unknown ids change nothing', () {
|
||||
for (final s in ['a1', 'a2', 'a3']) {
|
||||
engine.applyQueueAdd('a', link(s));
|
||||
}
|
||||
expect(engine.applyQueueMove('b', idOf('a3'), 0), isTrue);
|
||||
expect(titles(), ['a3', 'a1', 'a2']);
|
||||
expect(engine.applyQueueMove('b', idOf('a3'), 99), isTrue);
|
||||
expect(titles(), ['a1', 'a2', 'a3']);
|
||||
expect(engine.applyQueueMove('b', 'q99', 0), isFalse);
|
||||
expect(engine.applyQueueRemove('b', idOf('a2')), isTrue);
|
||||
expect(engine.applyQueueRemove('b', 'q99'), isFalse);
|
||||
expect(titles(), ['a1', 'a3']);
|
||||
final skip = engine.applyQueueRemove('a', idOf('a1'), why: 'bob left');
|
||||
expect(skip, isTrue);
|
||||
final lines = engine.feed.drain().where((f) => f.action.startsWith('queue-r') || f.action == 'queue-skip');
|
||||
expect([for (final f in lines) '${f.action}:${f.target}:${f.reason}'],
|
||||
['queue-remove:a2:', 'queue-skip:a1:bob left']);
|
||||
});
|
||||
|
||||
test('a swap naming an item takes it out; any swap ends a play request', () {
|
||||
engine.applyQueueAdd('a', link('one'));
|
||||
engine.applyQueueAdd('a', link('two'));
|
||||
expect(engine.applyQueuePlay('b', idOf('two')), isTrue);
|
||||
expect(titles(), ['two', 'one']);
|
||||
expect(engine.broadcast().queuePlay, idOf('two'));
|
||||
engine.applySwap('a', const HelloMedia(name: 'manual', size: 0, duration: 0, url: 'https://x/y'));
|
||||
expect(engine.queue.play, isNull);
|
||||
expect(titles(), ['two', 'one'], reason: 'a manual swap leaves the queue alone');
|
||||
engine.applySwap('a', const HelloMedia(name: 'two', size: 0, duration: 0, url: 'https://x/two'),
|
||||
queueId: idOf('two'));
|
||||
expect(titles(), ['one']);
|
||||
});
|
||||
|
||||
test('skip asks for the first item; a play request times out', () {
|
||||
expect(engine.applyQueuePlay('a', ''), isFalse, reason: 'nothing queued');
|
||||
engine.applyQueueAdd('a', link('one'));
|
||||
expect(engine.applyQueuePlay('a', ''), isTrue);
|
||||
expect(engine.queue.play, idOf('one'));
|
||||
t = t.add(const Duration(seconds: RelayConstants.queuePlayTimeoutSecs + 1));
|
||||
engine.tick();
|
||||
expect(engine.queue.play, isNull);
|
||||
});
|
||||
|
||||
test('full at the cap', () {
|
||||
for (var i = 0; i < RelayConstants.maxQueueItems; i++) {
|
||||
expect(engine.applyQueueAdd('a', link('$i')), isNull);
|
||||
}
|
||||
expect(engine.applyQueueAdd('a', link('over')), contains('full'));
|
||||
expect(engine.queue.length, RelayConstants.maxQueueItems);
|
||||
});
|
||||
|
||||
test('thumbnails are served by hash while queued', () {
|
||||
final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, 1, 2, 3]);
|
||||
engine.applyQueueAdd('a', link('one'), thumb: png);
|
||||
final hash = engine.queue.items.single.thumb;
|
||||
expect(hash, isNotEmpty);
|
||||
expect(engine.artFor(hash), png);
|
||||
engine.applyQueueRemove('a', engine.queue.items.single.id);
|
||||
expect(engine.artFor(hash), isNull);
|
||||
});
|
||||
|
||||
test('state round trip keeps the queue and the play request', () {
|
||||
engine.applyQueueAdd('a', link('one'));
|
||||
engine.applyQueueAdd('b', QueueItem(
|
||||
media: const HelloMedia(name: 'f', size: 3, duration: 0),
|
||||
how: QueueHow.file,
|
||||
title: 'f',
|
||||
sub: 'S1E2',
|
||||
duration: 1440,
|
||||
key: 'abcdefgh12'));
|
||||
engine.applyQueuePlay('a', idOf('f'));
|
||||
final back = StateBroadcast.fromJson(
|
||||
Map<String, Object?>.from(jsonDecode(jsonEncode(engine.broadcast().toJson())) as Map));
|
||||
expect([for (final q in back.queue) q.title], ['f', 'one']);
|
||||
final f = back.queue.first;
|
||||
expect((f.how, f.sub, f.duration, f.key, f.byName), (QueueHow.file, 'S1E2', 1440.0, 'abcdefgh12', 'bob'));
|
||||
expect(back.queuePlay, f.id);
|
||||
final intent = QueueIntent.fromJson(Map<String, Object?>.from(
|
||||
jsonDecode(jsonEncode(QueueIntent.add(link('x'), next: true).toJson())) as Map));
|
||||
expect((intent.op, intent.next, intent.item!.title), ('add', true, 'x'));
|
||||
expect(() => QueueIntent.fromJson(const {'t': 'queue', 'op': 'nope'}), throwsA(isA<ProtocolException>()));
|
||||
});
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export 'src/sync_server.dart';
|
||||
export 'src/jellyfin_relay.dart';
|
||||
export 'src/relay_buffer.dart';
|
||||
export 'src/relay_routes.dart';
|
||||
export 'src/relay_stats.dart';
|
||||
@@ -5,6 +5,7 @@ import 'dart:typed_data';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'relay_buffer.dart';
|
||||
import 'relay_stats.dart';
|
||||
|
||||
final _log = createLogger('relay');
|
||||
|
||||
@@ -67,7 +68,18 @@ class PieceBuffer implements TrackBuffer {
|
||||
final _waiters = <int, List<Completer<void>>>{};
|
||||
final _aux = <int, int>{}; // init/key piece a request waits on -> since (us)
|
||||
final _viewers = <String, _Viewer>{};
|
||||
_PiecePull? _pull;
|
||||
@override
|
||||
FeedClock clock = FeedClock();
|
||||
_PiecePull? _pullNow;
|
||||
_PiecePull? get _pull => _pullNow;
|
||||
set _pull(_PiecePull? p) {
|
||||
if ((_pullNow == null) != (p == null)) p == null ? clock.stop() : clock.start();
|
||||
_pullNow = p;
|
||||
}
|
||||
|
||||
/// The track's average bytes per second from the segments in so far;
|
||||
/// null before a minute of it has arrived.
|
||||
double? get bytesPerSec => _knownSecs >= 60 ? _knownBytes / _knownSecs : null;
|
||||
DateTime? _retryAt;
|
||||
var _disposed = false;
|
||||
Completer<void> _kick = Completer();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:relic/relic.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'server_hub.dart';
|
||||
|
||||
// `queue` messages (Up next, see PROTOCOL.md "Queue"): what an added item
|
||||
// may carry, and the ops applied to the engine's queue.
|
||||
|
||||
final _key = RegExp(r'^[A-Za-z0-9_-]{8,64}$');
|
||||
|
||||
bool _httpUrl(String? url) {
|
||||
final u = Uri.tryParse(url ?? '');
|
||||
return u != null && (u.isScheme('http') || u.isScheme('https')) && u.host.isNotEmpty;
|
||||
}
|
||||
|
||||
/// An added item as the server keeps it: names cleaned like a swap's,
|
||||
/// how it plays checked against what it carries, a server-fetched link's
|
||||
/// URL taken out ([privateUrl]: never broadcast). Throws
|
||||
/// [ProtocolException] with the reason for the sender.
|
||||
({QueueItem item, String? privateUrl}) cleanQueueAdd(QueueItem raw, {required bool serverHasPassword}) {
|
||||
final media = raw.media;
|
||||
var name = sanitizeMediaName(media.name);
|
||||
final ref = media.jellyfin;
|
||||
final QueueItem item;
|
||||
String? privateUrl;
|
||||
if (ref != null || raw.how == QueueHow.jellyfin) {
|
||||
final clean = ref == null ? null : sanitizeJellyfinRef(ref);
|
||||
if (clean == null) throw const ProtocolException('bad queue item');
|
||||
if (name.isEmpty) name = clean.title;
|
||||
item = QueueItem(
|
||||
media: HelloMedia(name: name, size: 0, duration: 0, jellyfin: clean), how: QueueHow.jellyfin, title: '');
|
||||
} else {
|
||||
switch (raw.how) {
|
||||
case QueueHow.direct:
|
||||
if (!_httpUrl(media.url)) throw const ProtocolException('a queued link needs an http(s) url');
|
||||
item = QueueItem(media: HelloMedia(name: name, size: 0, duration: 0, url: media.url), how: raw.how, title: '');
|
||||
case QueueHow.server:
|
||||
if (!_httpUrl(media.url)) throw const ProtocolException('relay fetch needs an http(s) url');
|
||||
if (!serverHasPassword) throw const ProtocolException('server fetch needs a server password');
|
||||
privateUrl = media.url;
|
||||
item = QueueItem(
|
||||
media: HelloMedia(name: name, size: 0, duration: 0, relay: const MediaRelay(fetch: true)),
|
||||
how: raw.how,
|
||||
title: '');
|
||||
case QueueHow.through:
|
||||
case QueueHow.file:
|
||||
if (!_key.hasMatch(raw.key)) throw const ProtocolException('a queued item of yours needs a key');
|
||||
item = QueueItem(
|
||||
media: HelloMedia(name: name, size: media.size < 0 ? 0 : media.size, duration: 0),
|
||||
how: raw.how,
|
||||
title: '',
|
||||
key: raw.key);
|
||||
case QueueHow.jellyfin:
|
||||
throw const ProtocolException('bad queue item');
|
||||
}
|
||||
}
|
||||
var title = cleanQueueText(sanitizeMediaName(raw.title));
|
||||
if (title.isEmpty) title = cleanQueueText(item.media.name.isEmpty ? 'media' : item.media.name);
|
||||
return (
|
||||
item: item.copyWith(
|
||||
title: title, sub: cleanQueueText(sanitizeMediaName(raw.sub)), duration: raw.duration),
|
||||
privateUrl: privateUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/// Applies one `queue` message from [token]; a refused add answers the
|
||||
/// sender with `err` (the socket stays open).
|
||||
void handleQueueIntent(ServerHub hub, String token, RelicWebSocket ws, Map<String, Object?> decoded) {
|
||||
final intent = QueueIntent.fromJson(decoded);
|
||||
final engine = hub.engine;
|
||||
var changed = false;
|
||||
switch (intent.op) {
|
||||
case 'add':
|
||||
final cleaned = cleanQueueAdd(intent.item!, serverHasPassword: hub.session.secret.isNotEmpty);
|
||||
final thumb = intent.thumb.isEmpty ? null : decodePicture(intent.thumb, RelayConstants.maxPosterBytes);
|
||||
final error = engine.applyQueueAdd(token, cleaned.item,
|
||||
next: intent.next, privateUrl: cleaned.privateUrl, thumb: thumb);
|
||||
if (error != null) {
|
||||
wsErr(ws, error);
|
||||
return;
|
||||
}
|
||||
changed = true;
|
||||
case 'move':
|
||||
changed = engine.applyQueueMove(token, intent.id, intent.to);
|
||||
case 'remove':
|
||||
changed = engine.applyQueueRemove(token, intent.id, why: intent.why);
|
||||
case 'play':
|
||||
changed = engine.applyQueuePlay(token, intent.id);
|
||||
case 'skip':
|
||||
changed = engine.applyQueuePlay(token, '');
|
||||
}
|
||||
if (changed) hub.changed();
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'relay_stats.dart';
|
||||
|
||||
final _log = createLogger('relay');
|
||||
|
||||
/// One track's buffer on the server: a byte stream ([RelayBuffer]) or
|
||||
@@ -16,6 +18,9 @@ abstract interface class TrackBuffer {
|
||||
int get cachedBytes;
|
||||
int get bytesFromFeed;
|
||||
int get bytesToViewers;
|
||||
/// Runs while this buffer has a pull in flight; the relay gives its
|
||||
/// tracks one, for the source's rate.
|
||||
abstract FeedClock clock;
|
||||
/// Waits for the feed's first connect, up to the wait limit.
|
||||
Future<bool> ready();
|
||||
void detach(RelayFeed feed);
|
||||
@@ -162,7 +167,15 @@ class RelayBuffer implements TrackBuffer {
|
||||
final _waiters = <int, List<Completer<void>>>{};
|
||||
final _blocked = <int, int>{}; // slice -> wait start (us)
|
||||
final _viewers = <String, _Viewer>{};
|
||||
_Pull? _pull;
|
||||
@override
|
||||
FeedClock clock = FeedClock();
|
||||
_Pull? _pullNow;
|
||||
_Pull? get _pull => _pullNow;
|
||||
set _pull(_Pull? p) {
|
||||
if ((_pullNow == null) != (p == null)) p == null ? clock.stop() : clock.start();
|
||||
_pullNow = p;
|
||||
}
|
||||
|
||||
var _reqSeq = 0;
|
||||
var _disposed = false;
|
||||
Completer<void> _kick = Completer();
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:web_socket/web_socket.dart';
|
||||
import 'media_endpoint.dart';
|
||||
import 'piece_buffer.dart';
|
||||
import 'relay_buffer.dart';
|
||||
import 'relay_stats.dart';
|
||||
import 'session.dart';
|
||||
|
||||
final _log = createLogger('relay');
|
||||
@@ -56,9 +57,39 @@ class ActiveRelay {
|
||||
var _local = const <LocalRelayFeed>[];
|
||||
RelicWebSocket? _channel;
|
||||
var _channelFeeds = const <ChannelRelayFeed>[];
|
||||
/// Shared by the tracks: time any of them waits on the source.
|
||||
final clock = FeedClock();
|
||||
final _rate = SourceRate();
|
||||
|
||||
ActiveRelay._(this.mediaId, this.name, this.sourceToken, this.onLiveChanged);
|
||||
|
||||
/// Takes the bytes the source sent so far, for [rate]; the server calls
|
||||
/// it once a second.
|
||||
void sampleRate() => _rate.sample(buffers.fold(0, (a, b) => a + b.bytesFromFeed), clock.busy);
|
||||
|
||||
/// Bytes per second the source delivers while the buffer asks for bytes
|
||||
/// (`media.relay.rate`); 0 until measured.
|
||||
int get rate => _rate.rate;
|
||||
|
||||
/// The media's average bytes per second for a [duration] in seconds
|
||||
/// (`media.relay.need`): byte tracks by size, pieces tracks from the
|
||||
/// pieces in so far; 0 while any track is unknown.
|
||||
int need(double duration) {
|
||||
if (buffers.isEmpty) return 0;
|
||||
var total = 0.0;
|
||||
for (final b in buffers) {
|
||||
if (b is RelayBuffer) {
|
||||
if (b.size <= 0 || duration <= 0) return 0;
|
||||
total += b.size / duration;
|
||||
} else if (b is PieceBuffer) {
|
||||
final r = b.bytesPerSec;
|
||||
if (r == null) return 0;
|
||||
total += r;
|
||||
}
|
||||
}
|
||||
return roundRate(total);
|
||||
}
|
||||
|
||||
int _nextId() => ++_ids;
|
||||
|
||||
void _setTracks(List<RelayTrackSpec> tracks) {
|
||||
@@ -69,6 +100,9 @@ class ActiveRelay {
|
||||
for (final t in this.tracks)
|
||||
t.pieces ? PieceBuffer(nextId: _nextId, rate: _piecesRate(t.kind)) : RelayBuffer(nextId: _nextId),
|
||||
];
|
||||
for (final b in buffers) {
|
||||
b.clock = clock;
|
||||
}
|
||||
if (!_known.isCompleted) _known.complete();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/// How fast bytes move through the server, coarse enough for `state`
|
||||
/// (sent every second): the relay source's delivery rate and each
|
||||
/// member's read rate.
|
||||
library;
|
||||
|
||||
/// [bytesPerSec] kept to two significant digits (under 10 kB/s: 0), so
|
||||
/// the state does not change with every byte.
|
||||
int roundRate(double bytesPerSec) {
|
||||
if (!bytesPerSec.isFinite || bytesPerSec < 10000) return 0;
|
||||
var unit = 1;
|
||||
while (bytesPerSec / unit >= 100) {
|
||||
unit *= 10;
|
||||
}
|
||||
return (bytesPerSec / unit).round() * unit;
|
||||
}
|
||||
|
||||
/// Time a relay's feed had at least one pull in flight. The tracks of a
|
||||
/// media share one (they share the source's uplink), so its time counts
|
||||
/// once however many tracks pull at the same moment.
|
||||
class FeedClock {
|
||||
final Stopwatch _watch;
|
||||
var _open = 0;
|
||||
FeedClock([Stopwatch? watch]) : _watch = watch ?? Stopwatch();
|
||||
|
||||
void start() {
|
||||
if (_open++ == 0) _watch.start();
|
||||
}
|
||||
|
||||
void stop() {
|
||||
if (_open == 0) return;
|
||||
if (--_open == 0) _watch.stop();
|
||||
}
|
||||
|
||||
Duration get busy => _watch.elapsed;
|
||||
}
|
||||
|
||||
/// The source's delivery rate: bytes over the time a pull was in flight,
|
||||
/// so a buffer that is full (the source idles) does not read as a slow
|
||||
/// source. Smoothed over about [window] of busy time; null until
|
||||
/// [settle] of busy time has been measured.
|
||||
class SourceRate {
|
||||
final Duration window;
|
||||
final Duration settle;
|
||||
SourceRate({this.window = const Duration(seconds: 4), this.settle = const Duration(seconds: 2)});
|
||||
|
||||
int _bytes = 0;
|
||||
Duration _busy = Duration.zero;
|
||||
Duration _measured = Duration.zero;
|
||||
double? _rate;
|
||||
|
||||
/// Takes the feed's totals now: all bytes so far, all busy time so far.
|
||||
/// Gaps under a quarter second of busy time wait for the next sample
|
||||
/// (a pull's first-byte wait would dominate them).
|
||||
void sample(int bytes, Duration busy) {
|
||||
final db = bytes - _bytes;
|
||||
final dt = busy - _busy;
|
||||
if (db < 0 || dt < Duration.zero) {
|
||||
// Totals went back (a new feed): start over.
|
||||
_bytes = bytes;
|
||||
_busy = busy;
|
||||
return;
|
||||
}
|
||||
if (dt < const Duration(milliseconds: 250)) return;
|
||||
_bytes = bytes;
|
||||
_busy = busy;
|
||||
final secs = dt.inMicroseconds / 1e6;
|
||||
final now = db / secs;
|
||||
final r = _rate;
|
||||
final w = (secs / (window.inMicroseconds / 1e6)).clamp(0.0, 1.0);
|
||||
_rate = r == null ? now : r + (now - r) * w;
|
||||
_measured += dt;
|
||||
}
|
||||
|
||||
/// Bytes per second, rounded ([roundRate]); 0 while not settled.
|
||||
int get rate => _measured < settle || _rate == null ? 0 : roundRate(_rate!);
|
||||
}
|
||||
|
||||
/// Each member's read rate from the server: the change of its
|
||||
/// `bytesServed` over the last [window].
|
||||
class ReadRates {
|
||||
final Duration window;
|
||||
ReadRates({this.window = const Duration(seconds: 5)});
|
||||
|
||||
final _seen = <String, List<(DateTime, int)>>{};
|
||||
|
||||
/// Records [served] totals by token at [now]; returns each token's rate
|
||||
/// (rounded). Tokens not in [served] are forgotten.
|
||||
Map<String, int> sample(DateTime now, Map<String, int> served) {
|
||||
_seen.removeWhere((t, _) => !served.containsKey(t));
|
||||
final out = <String, int>{};
|
||||
for (final e in served.entries) {
|
||||
final list = _seen.putIfAbsent(e.key, () => []);
|
||||
list.add((now, e.value));
|
||||
while (list.length > 1 && now.difference(list[1].$1) >= window) {
|
||||
list.removeAt(0);
|
||||
}
|
||||
final first = list.first;
|
||||
final secs = now.difference(first.$1).inMicroseconds / 1e6;
|
||||
out[e.key] = secs <= 0 ? 0 : roundRate((e.value - first.$2) / secs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import 'package:relic/relic.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:web_socket/web_socket.dart';
|
||||
|
||||
import 'queue_ops.dart';
|
||||
import 'relay_routes.dart';
|
||||
import 'relay_stats.dart';
|
||||
import 'session.dart';
|
||||
|
||||
final _hubLog = createLogger('server_hub');
|
||||
@@ -80,6 +82,29 @@ class ServerHub {
|
||||
changed();
|
||||
}
|
||||
|
||||
final _readRates = ReadRates();
|
||||
|
||||
/// Once a second: each member's read rate (`members[].rate`) and the
|
||||
/// relay's source rate and bitrate (`media.relay.rate` / `need`). Goes
|
||||
/// out with the next state.
|
||||
void sampleRates() {
|
||||
final tokens = session.tokens;
|
||||
final rates = _readRates.sample(engine.now, {
|
||||
for (final t in engine.members.keys) t: tokens[t]?.bytesServed ?? 0,
|
||||
});
|
||||
for (final m in engine.members.values) {
|
||||
m.rate = rates[m.token] ?? 0;
|
||||
}
|
||||
final r = relay, m = engine.media;
|
||||
final was = m?.relay;
|
||||
if (r == null || m == null || was == null || r.mediaId != engine.mediaId) return;
|
||||
r.sampleRate();
|
||||
final rate = r.rate, need = r.need(m.duration);
|
||||
if (rate != was.rate || need != was.need) {
|
||||
engine.media = m.copyWith(relay: was.copyWith(rate: rate, need: need));
|
||||
}
|
||||
}
|
||||
|
||||
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
|
||||
|
||||
/// The member a reconnecting client may take over with its old token:
|
||||
@@ -151,6 +176,13 @@ class ServerHub {
|
||||
broadcastFeed();
|
||||
}
|
||||
|
||||
/// The server's own fetch of [name] failed: everyone hears it (the
|
||||
/// swapper also gets the reason as an error), not just "getting it…".
|
||||
void fetchFailed(String name) {
|
||||
engine.feed.add(FeedMessage(actor: 'server', action: 'fetch-fail', target: name, at: engine.now));
|
||||
broadcastFeed();
|
||||
}
|
||||
|
||||
void changed() {
|
||||
broadcastState();
|
||||
broadcastFeed();
|
||||
@@ -314,7 +346,19 @@ void mountSyncRoute(
|
||||
hub.changed();
|
||||
case 'swap':
|
||||
if (token == null) return;
|
||||
final parsed = SwapIntent.fromJson(decoded).media;
|
||||
final swap = SwapIntent.fromJson(decoded);
|
||||
var parsed = swap.media;
|
||||
final queueId = swap.queueId;
|
||||
if (queueId != null) {
|
||||
// Already put up by someone else: nothing to do.
|
||||
if (!hub.engine.queue.has(queueId)) return;
|
||||
// A server-fetched item's link never left the server.
|
||||
final hidden = hub.engine.queue.privateUrlOf(queueId);
|
||||
if (hidden != null && (parsed.url ?? '').isEmpty) {
|
||||
parsed = HelloMedia(
|
||||
name: parsed.name, size: parsed.size, duration: parsed.duration, url: hidden, relay: parsed.relay);
|
||||
}
|
||||
}
|
||||
if (parsed.jellyfin != null) {
|
||||
// By reference only: no URL (it could carry a token) is
|
||||
// stored or broadcast for Jellyfin media.
|
||||
@@ -330,7 +374,7 @@ void mountSyncRoute(
|
||||
duration: parsed.duration,
|
||||
jellyfin: ref,
|
||||
relay: relayReq == null ? null : hub.relayFor(token, const MediaRelay()));
|
||||
if (media == null || !hub.engine.applySwap(token, media)) {
|
||||
if (media == null || !hub.engine.applySwap(token, media, queueId: queueId)) {
|
||||
wsErr(ws, 'bad swap media');
|
||||
return;
|
||||
}
|
||||
@@ -368,7 +412,7 @@ void mountSyncRoute(
|
||||
size: parsed.size,
|
||||
duration: parsed.duration,
|
||||
relay: hub.relayFor(token, relayReq));
|
||||
if (!hub.engine.applySwap(token, media)) {
|
||||
if (!hub.engine.applySwap(token, media, queueId: queueId)) {
|
||||
wsErr(ws, 'bad swap media');
|
||||
return;
|
||||
}
|
||||
@@ -379,7 +423,10 @@ void mountSyncRoute(
|
||||
? ActiveRelay.fetch(hub.engine.mediaId, name, link!,
|
||||
ytDlp: hub.ytDlp,
|
||||
onLiveChanged: () => hub.relayLiveChanged(active),
|
||||
onFail: (why) => wsErr(ws, hub.fetchFailText(why)))
|
||||
onFail: (why) {
|
||||
wsErr(ws, hub.fetchFailText(why));
|
||||
hub.fetchFailed(name);
|
||||
})
|
||||
: _memberRelay(hub, token, name, media.relay!.tracks);
|
||||
hub.setRelay(active);
|
||||
hub.changed();
|
||||
@@ -399,7 +446,7 @@ void mountSyncRoute(
|
||||
wsErr(ws, 'swap needs a url or the hosted file');
|
||||
return;
|
||||
}
|
||||
if (!hub.engine.applySwap(token, intent.media)) {
|
||||
if (!hub.engine.applySwap(token, intent.media, queueId: queueId)) {
|
||||
wsErr(ws, 'bad swap media');
|
||||
return;
|
||||
}
|
||||
@@ -464,6 +511,9 @@ void mountSyncRoute(
|
||||
case 'nudge':
|
||||
if (token == null) return;
|
||||
hub.nudge(token, NudgeIntent.fromJson(decoded).target);
|
||||
case 'queue':
|
||||
if (token == null) return;
|
||||
handleQueueIntent(hub, token, ws, decoded);
|
||||
}
|
||||
} on ProtocolException catch (e) {
|
||||
wsErr(ws, 'bad message: ${e.message}');
|
||||
|
||||
@@ -148,6 +148,7 @@ class SyncServer {
|
||||
hub.engine.tick();
|
||||
hub.sweepClosedSockets();
|
||||
hub.dropSilentSockets();
|
||||
hub.sampleRates();
|
||||
hub.broadcastState();
|
||||
hub.broadcastFeed();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
class _Peer {
|
||||
final WebSocket ws;
|
||||
final List<Map<String, Object?>> msgs = [];
|
||||
_Peer(this.ws) {
|
||||
ws.listen((m) => msgs.add(Map<String, Object?>.from(jsonDecode('$m') as Map)));
|
||||
}
|
||||
|
||||
static Future<_Peer> join(int port, String name) async {
|
||||
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||
final p = _Peer(ws);
|
||||
p.send(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 's',
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||
.toJson());
|
||||
await p.until((m) => m['t'] == 'welcome');
|
||||
return p;
|
||||
}
|
||||
|
||||
void send(Map<String, Object?> json) => ws.add(jsonEncode(json));
|
||||
|
||||
Future<Map<String, Object?>> until(bool Function(Map<String, Object?> m) test) async {
|
||||
final end = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
for (final m in msgs) {
|
||||
if (test(m)) {
|
||||
msgs.remove(m);
|
||||
return m;
|
||||
}
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
fail('nothing matched in $msgs');
|
||||
}
|
||||
|
||||
/// The next state whose queue passes [test].
|
||||
Future<StateBroadcast> state(bool Function(StateBroadcast s) test) async {
|
||||
final m = await until((m) => m['t'] == 'state' && test(StateBroadcast.fromJson(m)));
|
||||
return StateBroadcast.fromJson(m);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late SyncServer server;
|
||||
|
||||
setUp(() async {
|
||||
server = SyncServer(const AppConfig(port: 0, secret: 's', name: 't'));
|
||||
await server.start();
|
||||
});
|
||||
|
||||
tearDown(() => server.stop());
|
||||
|
||||
test('queued items reach everyone; a server-fetched link stays on the server; a swap takes its item', () async {
|
||||
final a = await _Peer.join(server.boundPort, 'alice');
|
||||
final b = await _Peer.join(server.boundPort, 'bob');
|
||||
a.send(const QueueIntent.add(QueueItem(
|
||||
media: HelloMedia(name: 'https://example.com/v?token=secret', size: 0, duration: 0,
|
||||
url: 'https://example.com/v?token=secret'),
|
||||
how: QueueHow.server,
|
||||
title: 'A video',
|
||||
sub: 'Example'))
|
||||
.toJson());
|
||||
a.send(const QueueIntent.add(
|
||||
QueueItem(
|
||||
media: HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://example.com/x.mp4'),
|
||||
how: QueueHow.direct,
|
||||
title: 'Direct'),
|
||||
next: true)
|
||||
.toJson());
|
||||
final s = await b.state((s) => s.queue.length == 2);
|
||||
expect([for (final q in s.queue) q.title], ['Direct', 'A video']);
|
||||
final fetched = s.queue[1];
|
||||
expect(fetched.byName, 'alice');
|
||||
expect(jsonEncode(s.toJson()), isNot(contains('secret')), reason: 'the fetched URL is never broadcast');
|
||||
expect(fetched.media.relay?.fetch, isTrue);
|
||||
|
||||
// Bob puts the fetched item up without knowing its URL; the server fills it in.
|
||||
b.send(SwapIntent(
|
||||
media: const HelloMedia(name: 'A video', size: 0, duration: 0, relay: MediaRelay(fetch: true)),
|
||||
queueId: fetched.id)
|
||||
.toJson());
|
||||
final after = await a.state((s) => s.media != null);
|
||||
expect(after.media?['name'], 'A video');
|
||||
expect(after.queue.single.title, 'Direct');
|
||||
final mediaId = after.mediaId;
|
||||
|
||||
// A second swap of the same item (a race) is ignored.
|
||||
a.send(SwapIntent(
|
||||
media: const HelloMedia(name: 'A video', size: 0, duration: 0, relay: MediaRelay(fetch: true)),
|
||||
queueId: fetched.id)
|
||||
.toJson());
|
||||
a.msgs.clear();
|
||||
final later = await a.state((s) => true);
|
||||
expect(later.mediaId, mediaId);
|
||||
});
|
||||
|
||||
test('a bad add answers the sender; adder-only items need a key', () async {
|
||||
final a = await _Peer.join(server.boundPort, 'alice');
|
||||
a.send(const QueueIntent.add(QueueItem(
|
||||
media: HelloMedia(name: 'f.mkv', size: 10, duration: 0), how: QueueHow.file, title: 'f'))
|
||||
.toJson());
|
||||
final err = await a.until((m) => m['t'] == 'err');
|
||||
expect('${err['message']}', contains('key'));
|
||||
a.send(const QueueIntent.add(QueueItem(
|
||||
media: HelloMedia(name: 'f.mkv', size: 10, duration: 0, url: 'file:///home/me/f.mkv'),
|
||||
how: QueueHow.file,
|
||||
title: 'f',
|
||||
key: 'k1234567890'))
|
||||
.toJson());
|
||||
final s = await a.state((s) => s.queue.isNotEmpty);
|
||||
expect(s.queue.single.media.url, isNull, reason: 'an adder-only item carries no path or link');
|
||||
expect(s.queue.single.key, 'k1234567890');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// A feed that only records pulls; the test answers them.
|
||||
class _Feed extends RelayFeed {
|
||||
final pulls = <int>[];
|
||||
@override
|
||||
void pull(int id, int start, int end) => pulls.add(id);
|
||||
@override
|
||||
void pullPiece(int id, int index) => pulls.add(id);
|
||||
@override
|
||||
void cancel(int id) {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('rates keep two significant digits; under 10 kB/s is 0', () {
|
||||
expect(roundRate(3249999), 3200000);
|
||||
expect(roundRate(87654), 88000);
|
||||
expect(roundRate(9999), 0);
|
||||
expect(roundRate(double.nan), 0);
|
||||
});
|
||||
|
||||
test('the source rate counts busy time only, and settles first', () {
|
||||
final r = SourceRate();
|
||||
r.sample(2000000, const Duration(seconds: 1));
|
||||
expect(r.rate, 0, reason: 'under 2 s of busy time');
|
||||
r.sample(4000000, const Duration(seconds: 2));
|
||||
expect(r.rate, 2000000);
|
||||
// The buffer is full: no pull, no bytes, no busy time: the rate holds.
|
||||
r.sample(4000000, const Duration(seconds: 2));
|
||||
expect(r.rate, 2000000);
|
||||
// Slower now (0.5 MB/s): moves toward it over the window.
|
||||
r.sample(5000000, const Duration(seconds: 4));
|
||||
expect(r.rate, inExclusiveRange(500000, 2000000));
|
||||
// A new feed (totals back to 0) starts over without a bogus sample.
|
||||
final before = r.rate;
|
||||
r.sample(0, Duration.zero);
|
||||
expect(r.rate, before);
|
||||
});
|
||||
|
||||
test('read rates: bytesServed over the last 5 s, forgotten tokens drop', () {
|
||||
final rates = ReadRates();
|
||||
final t0 = DateTime.utc(2026);
|
||||
expect(rates.sample(t0, {'a': 0, 'b': 0}), {'a': 0, 'b': 0});
|
||||
for (var i = 1; i <= 10; i++) {
|
||||
final out = rates.sample(t0.add(Duration(seconds: i)), {'a': i * 2000000, 'b': 0});
|
||||
expect(out['a'], 2000000);
|
||||
expect(out['b'], 0);
|
||||
}
|
||||
// a stops reading: its rate falls to 0 once the window has passed.
|
||||
var out = <String, int>{};
|
||||
for (var i = 11; i <= 16; i++) {
|
||||
out = rates.sample(t0.add(Duration(seconds: i)), {'a': 20000000});
|
||||
}
|
||||
expect(out, {'a': 0});
|
||||
});
|
||||
|
||||
test('a buffer runs its clock while a pull is in flight', () async {
|
||||
final clock = FeedClock();
|
||||
final buf = RelayBuffer()..clock = clock;
|
||||
final feed = _Feed();
|
||||
buf.attach(feed, 3 * RelayBuffer.slice);
|
||||
// A viewer asks: the buffer pulls.
|
||||
final read = buf.read('v', 0, 9).toList();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(feed.pulls, isNotEmpty);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
final busy = clock.busy;
|
||||
expect(busy, greaterThan(Duration.zero));
|
||||
final id = feed.pulls.first;
|
||||
buf.feedBytes(id, Uint8List(3 * RelayBuffer.slice));
|
||||
buf.feedEnd(id);
|
||||
await read;
|
||||
final after = clock.busy;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
expect(clock.busy, after, reason: 'idle: the clock stopped');
|
||||
buf.dispose();
|
||||
});
|
||||
|
||||
test('the hub puts each member\'s read rate in the state', () {
|
||||
var now = DateTime.utc(2026);
|
||||
final hub = ServerHub(SessionMap(), 'pw', engine: SyncEngine(clock: () => now));
|
||||
final token = hub.session.issueToken('bob', '');
|
||||
hub.engine.applyJoin(token, 'bob', '');
|
||||
hub.sampleRates();
|
||||
hub.session.addBytes(token, 3000000);
|
||||
now = now.add(const Duration(seconds: 1));
|
||||
hub.sampleRates();
|
||||
final json = hub.stateBroadcast().toJson();
|
||||
final members = (json['members'] as List).cast<Map<String, Object?>>();
|
||||
expect(members.single['rate'], 3000000);
|
||||
expect(StateBroadcast.fromJson(json).members.single.rate, 3000000);
|
||||
});
|
||||
|
||||
test('media.relay rate and need round-trip; slow only with both known', () {
|
||||
const r = MediaRelay(source: 'm1', live: true, rate: 1200000, need: 2300000);
|
||||
final back = MediaRelay.fromJson(r.toJson());
|
||||
expect(back.rate, 1200000);
|
||||
expect(back.need, 2300000);
|
||||
expect(back.slow, isTrue);
|
||||
expect(const MediaRelay(rate: 1200000).slow, isFalse);
|
||||
expect(const MediaRelay().toJson().containsKey('rate'), isFalse);
|
||||
expect(MediaRelay.fromJson({'rate': double.infinity, 'need': -1}).rate, 0);
|
||||
});
|
||||
|
||||
test('a relay\'s need: byte tracks by size over the duration', () {
|
||||
final r = ActiveRelay.fromMember(1, 'x', 't', onLiveChanged: () {});
|
||||
expect(r.need(100), 0, reason: 'size unknown until the source connects');
|
||||
(r.buffers.single as RelayBuffer).attach(_Feed(), 230000000);
|
||||
expect(r.need(100), 2300000);
|
||||
expect(r.need(0), 0, reason: 'duration unknown');
|
||||
r.dispose();
|
||||
});
|
||||
|
||||
}
|
||||
@@ -64,8 +64,8 @@ void main() {
|
||||
});
|
||||
|
||||
group('core welcome + proto', () {
|
||||
test('proto 11: avatars by hash, profile intent', () {
|
||||
expect(RelayConstants.protoVersion, 11);
|
||||
test('proto 12: avatars by hash, profile intent', () {
|
||||
expect(RelayConstants.protoVersion, 12);
|
||||
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
|
||||
expect(v.toJson().containsKey('avatarHash'), isFalse);
|
||||
expect(v.toJson().containsKey('avatar'), isFalse);
|
||||
|
||||
Reference in new issue
Block a user