Restructure step 1: lasting identity, presence and protocol 16
Server and core: version ranges on both sides with a coded refusal; a client id maps to the same member and token for the server's life; a drop is `gone` for a 10-minute grace keeping ready, presence, queue items, relay and avatar; `bye` leaves at once; ordered takeover (seq, replaced/stale); presence joining/in/away/gone with one PresencePolicy; joiners never pause playback and hold a held gate at most ~15 s; state.hold; feed ids, seq and replay; the relay lives through its source's grace and continues on re-dial; queue `by` lasting, auto items and cancel; MediaRef carried; client ids never logged. App: persisted client id and seq; Connection with unstable/lost/ replaced states, backoff with jitter, no lost/back pair per flap; a drop keeps the session (state, player, media, relay source); presence in the strip and members card; sit out = away intent; feed matched by id; auto sit-out (idle when not playing, minimised for a while; never when alone) replaces auto-unready; bye on close and Leave, not on update restart; relay source re-dials through its grace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
51c1c5ab78
commit
9f2586b7ff
85 files changed
+5198
-1902
No files matched your search
@@ -81,11 +81,13 @@ settings), the Library / Schedule / Settings tabs, and the party. Offline
|
||||
the party side has Join a friend and Host; watching solo it says Solo,
|
||||
with Invite friends and Stop; in a server it shows everyone's avatar
|
||||
with a ring for their state (green ready, amber buffering, grey not ready,
|
||||
dimmed reconnecting, dimmed with a moon sitting out; a crown on whoever
|
||||
a little faded joining, dimmed reconnecting, dimmed with a moon sitting
|
||||
out; a crown on whoever
|
||||
hosts, a tower on whoever passes the video on). Click the avatars for the
|
||||
member list (force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's
|
||||
network addresses with the port, and friends outside your network need
|
||||
that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m", the chat button with unread
|
||||
that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m" (who is sitting out or
|
||||
reconnecting does not count), the chat button with unread
|
||||
count, and Leave. Empty strip space drags the window.
|
||||
|
||||
The player fills the window under the strip. Pick a tab and it shrinks to
|
||||
@@ -120,8 +122,25 @@ row in the member list, or × on the mini player) unloads the video on your
|
||||
end, and nobody waits for you any more; you stay in chat. Anyone can sit
|
||||
anyone out, the host too ("Alice sat you out" tells you who). The player
|
||||
then shows the cover, title and where the others are; Join back (or Space)
|
||||
loads the video there and readies you. Settings → Party & network → "When idle,
|
||||
sit out instead of just unready" does it after 10 idle minutes.
|
||||
loads the video there and readies you, in the background while the others
|
||||
play on (nobody is paused for you).
|
||||
|
||||
**Auto sit-out.** Idle for 5 minutes while nothing plays, or the window
|
||||
minimised for 10 minutes (playing or not), sits you out by itself, so
|
||||
nobody waits for you; you are back as soon as you return (a key, the
|
||||
mouse, the window's focus). It never happens when you are alone, and an
|
||||
away you chose (or someone else's) stays until you join back. Settings →
|
||||
Party & network → "Auto sit-out" turns it off.
|
||||
|
||||
**Reconnects.** A bad connection does not throw you out: the video keeps
|
||||
playing, the pill says "Connection unstable…" and then "Reconnecting…",
|
||||
and the app comes back on its own as the same member (ready, sitting out,
|
||||
your Up next items and the video you pass on all kept) for up to 10
|
||||
minutes; the others see you dimmed, "reconnecting…". Only a drop of a
|
||||
few seconds or more makes the lost / back sounds. Leave and closing the
|
||||
app tell the others at once; Update and restart keeps your place for the
|
||||
restarted app. Opening Starsync a second time (another window with the
|
||||
same profile) takes your place over, and the first one says so and stops.
|
||||
|
||||
**Nudge.** Nudge in the member list pokes someone, sitting out or not:
|
||||
only they hear it (a sound, a pop-up, and the taskbar entry asks for
|
||||
|
||||
@@ -52,6 +52,8 @@ Future<void> main(List<String> args) async {
|
||||
final ports = SessionState.instance.ports;
|
||||
await ports.shell.start(
|
||||
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: shutDownApp);
|
||||
// Focus, minimise and restore feed auto sit-out.
|
||||
ports.shell.watchActivity(SessionState.instance.coordinator.windowActivity);
|
||||
try {
|
||||
await SessionState.instance.loadForm();
|
||||
} catch (e, st) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../session/party.dart' show isPresent;
|
||||
import 'accounts.dart';
|
||||
import 'format.dart';
|
||||
|
||||
@@ -333,13 +334,13 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
|
||||
|
||||
/// Who performs the auto-advance swap, so several counting clients do not
|
||||
/// swap twice: the lowest member id among members that play the item with
|
||||
/// their own account (`jf[serverId] == true`, not stale, not out). Only what every
|
||||
/// their own account (`jf[serverId] == true`, present: `in` or joining). Only what every
|
||||
/// client sees in `state` decides (who put the item up is not in it: a
|
||||
/// late joiner never saw that swap), so all clients agree.
|
||||
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
|
||||
final eligible = [
|
||||
for (final m in members)
|
||||
if (!m.stale && !m.out && m.jf[serverId] == true) m
|
||||
if (isPresent(m) && m.jf[serverId] == true) m
|
||||
];
|
||||
if (eligible.isEmpty) return null;
|
||||
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
|
||||
|
||||
@@ -19,9 +19,12 @@ bool _shuttingDown = false;
|
||||
bool get shuttingDown => _shuttingDown;
|
||||
|
||||
/// Hides the window, releases every player and the sound device, leaves
|
||||
/// (others see "left", a hosted server stops), runs [beforeQuit], then
|
||||
/// closes the window. Every step is bounded; a second call does nothing.
|
||||
Future<void> shutDownApp({Future<void> Function()? beforeQuit}) async {
|
||||
/// (the `bye` goes out first, so others see "left" at once; a hosted
|
||||
/// server stops), runs [beforeQuit], then closes the window. [restart]
|
||||
/// (Update and restart) leaves without the `bye`: the member stays
|
||||
/// `gone`, and the restarted app, same client id, takes it back within
|
||||
/// the grace. Every step is bounded; a second call does nothing.
|
||||
Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = false}) async {
|
||||
if (_shuttingDown) return;
|
||||
_shuttingDown = true;
|
||||
final session = SessionState.instance;
|
||||
@@ -38,9 +41,9 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit}) async {
|
||||
// its own after dispose returns; it must be done before the engine
|
||||
// stops, or the plugin's teardown waits on it for good.
|
||||
if (Platform.isWindows) await Future.delayed(const Duration(milliseconds: 300));
|
||||
// Jellyfin stop report, last diagnostics, the goodbye, then the hosted
|
||||
// server: 3 s in all.
|
||||
await _step('leave', session.leave(), const Duration(milliseconds: 2500));
|
||||
// Jellyfin stop report, last diagnostics, the bye (flushed), then the
|
||||
// hosted server: 3 s in all.
|
||||
await _step('leave', session.leave(bye: !restart), const Duration(milliseconds: 2500));
|
||||
await _step('server stop', session.stopServer(), const Duration(milliseconds: 500));
|
||||
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
|
||||
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
|
||||
|
||||
@@ -55,6 +55,19 @@ class DesktopShell implements ShellPort {
|
||||
return () => windowManager.removeListener(l);
|
||||
}
|
||||
|
||||
final _activity = <void Function(WindowActivity)>{};
|
||||
|
||||
@override
|
||||
void Function() watchActivity(void Function(WindowActivity activity) onChange) {
|
||||
final l = _ActivityWatch(onChange);
|
||||
_activity.add(onChange);
|
||||
windowManager.addListener(l);
|
||||
return () {
|
||||
_activity.remove(onChange);
|
||||
windowManager.removeListener(l);
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startDragging() => windowManager.startDragging();
|
||||
|
||||
@@ -65,7 +78,13 @@ class DesktopShell implements ShellPort {
|
||||
Future<void> saveWindow() async => _memory?.save();
|
||||
|
||||
@override
|
||||
Future<void> hide() => windowManager.hide();
|
||||
Future<void> hide() async {
|
||||
// window_manager has no hide event: tell the watchers here.
|
||||
for (final f in [..._activity]) {
|
||||
f(WindowActivity.hidden);
|
||||
}
|
||||
await windowManager.hide();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> quit() async {
|
||||
@@ -131,3 +150,18 @@ class _MaximizeWatch with WindowListener {
|
||||
@override
|
||||
void onWindowUnmaximize() => onChange(false);
|
||||
}
|
||||
|
||||
/// Focus, blur, minimise and restore as [WindowActivity] (a restored
|
||||
/// window is shown; its focus event follows when it gets the keyboard).
|
||||
class _ActivityWatch with WindowListener {
|
||||
final void Function(WindowActivity) onChange;
|
||||
_ActivityWatch(this.onChange);
|
||||
@override
|
||||
void onWindowFocus() => onChange(WindowActivity.focused);
|
||||
@override
|
||||
void onWindowBlur() => onChange(WindowActivity.unfocused);
|
||||
@override
|
||||
void onWindowMinimize() => onChange(WindowActivity.minimised);
|
||||
@override
|
||||
void onWindowRestore() => onChange(WindowActivity.unfocused);
|
||||
}
|
||||
@@ -1,5 +1,21 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Where the app window stands, for auto sit-out
|
||||
/// ([ShellPort.watchActivity]).
|
||||
enum WindowActivity {
|
||||
/// The window has the keyboard focus.
|
||||
focused,
|
||||
|
||||
/// Shown, but another window has the focus.
|
||||
unfocused,
|
||||
|
||||
/// Minimised to the taskbar.
|
||||
minimised,
|
||||
|
||||
/// Hidden (closing, or a platform that hides instead of minimising).
|
||||
hidden,
|
||||
}
|
||||
|
||||
/// What the clipboard holds for a paste: its files, else its text.
|
||||
typedef Paste = ({List<String> files, String? text});
|
||||
|
||||
@@ -22,6 +38,10 @@ abstract interface class ShellPort {
|
||||
/// returns the call that stops it.
|
||||
void Function() watchMaximized(void Function(bool maximized) onChange);
|
||||
|
||||
/// Calls [onChange] whenever the window gains or loses the focus, is
|
||||
/// minimised, restored or hidden; returns the call that stops it.
|
||||
void Function() watchActivity(void Function(WindowActivity activity) onChange);
|
||||
|
||||
/// Moves the window with the pointer (a press on the title area).
|
||||
Future<void> startDragging();
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ class QueueController {
|
||||
effect(() {
|
||||
shownContinuation.value;
|
||||
coord.settledMediaId.value;
|
||||
coord.selfOut.value;
|
||||
coord.selfAway.value;
|
||||
final r = coord.serverState.value;
|
||||
untracked(() => _warm(r));
|
||||
});
|
||||
@@ -107,6 +107,7 @@ class QueueController {
|
||||
Future<String?> add(PickedMedia pick, {bool next = false}) async {
|
||||
coord.markInput();
|
||||
if (coord.serverState.peek() == null) return 'not in a server';
|
||||
if (coord.stateStale.peek()) return 'reconnecting — add it again once back';
|
||||
if (items.peek().length >= RelayConstants.maxQueueItems) {
|
||||
return 'Up next is full (${RelayConstants.maxQueueItems} items)';
|
||||
}
|
||||
@@ -490,7 +491,7 @@ class QueueController {
|
||||
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();
|
||||
if (coord.selfAway.peek()) coord.joinBack();
|
||||
return coord.swapPicked(local.pick, queueId: item.id);
|
||||
}
|
||||
}
|
||||
@@ -528,7 +529,7 @@ class QueueController {
|
||||
/// puts up (its swap reads it). Null: nothing.
|
||||
_WarmTarget? _warmTarget(StateBroadcast? r) {
|
||||
final t = _warmTargetHere(r);
|
||||
if (t == null || !coord.selfOut.peek()) return t;
|
||||
if (t == null || !coord.selfAway.peek()) return t;
|
||||
return t.probe == null ? null : _WarmTarget(t.id, probe: t.probe);
|
||||
}
|
||||
|
||||
@@ -718,7 +719,7 @@ class QueueController {
|
||||
final seen = _lastState;
|
||||
if (r == null || (seen != null && seen.mediaId > r.mediaId)) r = seen;
|
||||
if (r == null) return;
|
||||
final out = coord.selfOut.peek();
|
||||
final out = coord.selfAway.peek();
|
||||
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
|
||||
final want = out ? null : _preloadWant;
|
||||
final playback = coord.playback;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/session_media.dart' show compareMemberIds;
|
||||
import '../session/party.dart' show isPresent;
|
||||
|
||||
// Who puts an Up next item up. Pure: only what every client sees in
|
||||
// `state` decides, so all clients agree without a wire message (like the
|
||||
@@ -12,20 +13,22 @@ import '../jellyfin/session_media.dart' show compareMemberIds;
|
||||
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.
|
||||
/// (their app has the file, the cookies). Gone (disconnected, in its
|
||||
/// grace): it waits for them; left for good: skipped.
|
||||
/// - Jellyfin: the adder when it plays that server with its own account,
|
||||
/// else the lowest member id that does (`jf[serverId] == true`, not
|
||||
/// stale, not sitting out). Nobody: skipped.
|
||||
/// else the lowest member id that does (`jf[serverId] == true`,
|
||||
/// present: `in` or joining). Nobody: skipped.
|
||||
/// - A link everyone opens, or one the server fetches: the adder when
|
||||
/// present (not stale or sitting out), else the lowest such member id.
|
||||
/// present, else the lowest present member id.
|
||||
QueuePerformer queuePerformer(QueueItem item, List<MemberView> members) {
|
||||
MemberView? adder;
|
||||
for (final m in members) {
|
||||
if (m.id == item.by) adder = m;
|
||||
}
|
||||
bool active(MemberView m) => !m.stale && !m.out;
|
||||
bool active(MemberView m) => isPresent(m);
|
||||
if (item.how.adderOnly) {
|
||||
if (adder == null) return (id: null, why: '${_name(item)} left');
|
||||
if (adder.presence == Presence.gone) return (id: null, why: null);
|
||||
return (id: adder.id, why: null);
|
||||
}
|
||||
final ref = item.media.jellyfin;
|
||||
@@ -48,8 +51,9 @@ String? autoAdvanceLowest(Iterable<MemberView> members) {
|
||||
}
|
||||
|
||||
/// 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));
|
||||
/// the lowest member id that is connected (not gone).
|
||||
String? queueJanitor(List<MemberView> members) =>
|
||||
autoAdvanceLowest(members.where((m) => m.presence != Presence.gone));
|
||||
|
||||
/// How [item] plays, in a few words for its row: "through Foxyz" (you:
|
||||
/// "through you"), "server fetches", "everyone fetches", "Jellyfin",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../session/connection.dart' show connectErrorText;
|
||||
import '../../session/identity.dart';
|
||||
import '../../session/session_coordinator.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import 'profile.dart';
|
||||
import '../../sync/sync_client.dart' show HelloRefused;
|
||||
|
||||
/// What a card shows under its fields after a failed attempt.
|
||||
class CardError {
|
||||
@@ -107,7 +109,9 @@ class ConnectFlow extends ChangeNotifier {
|
||||
} catch (e) {
|
||||
if (myAttempt != _attempt) return;
|
||||
busy = null;
|
||||
errors[mode] = CardError('Could not connect', '$e');
|
||||
// A refusal says why in plain words (an older host, a wrong password,
|
||||
// another window).
|
||||
errors[mode] = CardError('Could not connect', e is HelloRefused ? connectErrorText(e) : '$e');
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,56 +19,74 @@ sealed class FeedEvent extends AppEvent {
|
||||
|
||||
String get actor => feed.actor;
|
||||
|
||||
/// [feed] as its typed event. [selfName]: this member's name (the feed
|
||||
/// names members by name); [readyCount]: members ready in the latest
|
||||
/// state (the pitch of a `ready` sound).
|
||||
factory FeedEvent.of(FeedMessage f, {String? selfName, int readyCount = 0}) => switch (f.action) {
|
||||
'join' => MemberJoined(f),
|
||||
'leave' => MemberLeft(f),
|
||||
'stale' => MemberStale(f),
|
||||
'out' => MemberSatOut(f, atYou: f.target != null && f.target == selfName && f.actor != selfName),
|
||||
'back' => MemberBack(f),
|
||||
'ready' => ReadyChanged(f, ready: true, readyIndex: readyCount),
|
||||
'unready' => ReadyChanged(f, ready: false),
|
||||
'force-ready' => ForcedReady(f, ready: true),
|
||||
'force-unready' => ForcedReady(f, ready: false),
|
||||
'force-play' => ForcedTransport(f, play: true),
|
||||
'force-hold' => ForcedTransport(f, play: false),
|
||||
'swap' => MediaSwapped(f),
|
||||
'seek' => Seeked(f),
|
||||
'pause' => Paused(f),
|
||||
'resume' => Resumed(f),
|
||||
'finished' => Finished(f),
|
||||
'chat' => ChatPosted(f, own: f.actor == selfName),
|
||||
'rename' => MemberRenamed(f),
|
||||
'avatar' => AvatarChanged(f),
|
||||
'queue-add' => QueueAdded(f),
|
||||
'queue-remove' => QueueRemoved(f),
|
||||
'queue-skip' => QueueSkipped(f),
|
||||
'fetch-fail' => FetchFailed(f),
|
||||
_ => OtherFeed(f),
|
||||
};
|
||||
/// [feed] as its typed event. [selfId]: this member's id (people are
|
||||
/// matched by `actorId` / `targetId`, never by name); [readyCount]:
|
||||
/// members ready in the latest state (the pitch of a `ready` sound).
|
||||
factory FeedEvent.of(FeedMessage f, {String selfId = '', int readyCount = 0}) {
|
||||
bool isSelf(String id) => selfId.isNotEmpty && id == selfId;
|
||||
return switch (f.action) {
|
||||
'join' => MemberJoined(f),
|
||||
'leave' => MemberLeft(f),
|
||||
'gone' => MemberGone(f),
|
||||
'return' => MemberReturned(f),
|
||||
'away' => MemberSatOut(f,
|
||||
atYou: isSelf(f.targetId) && !isSelf(f.actorId),
|
||||
autoSelf: isSelf(f.actorId) && f.targetId.isEmpty && f.reason.isNotEmpty),
|
||||
'in' => MemberBack(f),
|
||||
'ready' => ReadyChanged(f, ready: true, readyIndex: readyCount),
|
||||
'unready' => ReadyChanged(f, ready: false),
|
||||
'force-ready' => ForcedReady(f, ready: true),
|
||||
'force-unready' => ForcedReady(f, ready: false),
|
||||
'force-play' => ForcedTransport(f, play: true),
|
||||
'force-hold' => ForcedTransport(f, play: false),
|
||||
'swap' => MediaSwapped(f),
|
||||
'seek' => Seeked(f),
|
||||
'pause' => Paused(f),
|
||||
'resume' => Resumed(f),
|
||||
'finished' => Finished(f),
|
||||
'chat' => ChatPosted(f, own: isSelf(f.actorId)),
|
||||
'rename' => MemberRenamed(f),
|
||||
'avatar' => AvatarChanged(f),
|
||||
'queue-add' => QueueAdded(f),
|
||||
'queue-remove' => QueueRemoved(f),
|
||||
'queue-skip' => QueueSkipped(f),
|
||||
'fetch-fail' => FetchFailed(f),
|
||||
_ => OtherFeed(f),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class MemberJoined extends FeedEvent {
|
||||
const MemberJoined(super.feed);
|
||||
}
|
||||
|
||||
/// Left for good (`reason` `bye`: the app closed or Leave; `grace`: gone
|
||||
/// too long).
|
||||
class MemberLeft extends FeedEvent {
|
||||
const MemberLeft(super.feed);
|
||||
}
|
||||
|
||||
class MemberStale extends FeedEvent {
|
||||
const MemberStale(super.feed);
|
||||
/// Lost signal: gone for a while, kept in its grace period.
|
||||
class MemberGone extends FeedEvent {
|
||||
const MemberGone(super.feed);
|
||||
}
|
||||
|
||||
/// Someone sat out (`target` sat out by `actor`); [atYou]: someone else
|
||||
/// sat this member out.
|
||||
/// Back within its grace after a [MemberGone].
|
||||
class MemberReturned extends FeedEvent {
|
||||
const MemberReturned(super.feed);
|
||||
}
|
||||
|
||||
/// Someone sat out (`away`: the actor itself, or `target` sat out by the
|
||||
/// actor; `reason` `idle` / `minimised` for an automatic one); [atYou]:
|
||||
/// someone else sat this member out; [autoSelf]: this app's auto sit-out
|
||||
/// set this member away.
|
||||
class MemberSatOut extends FeedEvent {
|
||||
final bool atYou;
|
||||
const MemberSatOut(super.feed, {this.atYou = false});
|
||||
final bool autoSelf;
|
||||
const MemberSatOut(super.feed, {this.atYou = false, this.autoSelf = false});
|
||||
}
|
||||
|
||||
/// Back from sitting out (`in`).
|
||||
class MemberBack extends FeedEvent {
|
||||
const MemberBack(super.feed);
|
||||
}
|
||||
@@ -180,13 +198,8 @@ class RelayFailed extends AppEvent {
|
||||
const RelayFailed(this.error);
|
||||
}
|
||||
|
||||
/// Idle: auto-unready (or sit-out, [sitsOut]) in 2 minutes.
|
||||
class IdleWarning extends AppEvent {
|
||||
final bool sitsOut;
|
||||
const IdleWarning({required this.sitsOut});
|
||||
}
|
||||
|
||||
/// The connection to the server dropped (once per drop).
|
||||
/// The connection to the server dropped and stayed down a few seconds
|
||||
/// (once per drop).
|
||||
class ConnectionLost extends AppEvent {
|
||||
const ConnectionLost();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:sync_relay_core/core.dart' show Presence, PresenceReason;
|
||||
|
||||
import '../platform/ports/shell_port.dart' show WindowActivity;
|
||||
|
||||
/// The auto sit-out timings, in one place.
|
||||
abstract final class AutoSitOutTimes {
|
||||
/// Not playing and no input for this long: away (`idle`). Watching
|
||||
/// counts as activity, so a pause after a long watch starts fresh.
|
||||
static const idle = Duration(minutes: 5);
|
||||
|
||||
/// Minimised (or hidden) for this long, playing or not: away
|
||||
/// (`minimised`).
|
||||
static const minimised = Duration(minutes: 10);
|
||||
}
|
||||
|
||||
/// What [AutoSitOut.tick] asks the session to do.
|
||||
sealed class AutoSitOutStep {
|
||||
const AutoSitOutStep();
|
||||
}
|
||||
|
||||
/// Nothing to do.
|
||||
final class AutoStay extends AutoSitOutStep {
|
||||
const AutoStay();
|
||||
}
|
||||
|
||||
/// Sit this member out (`presence` `away` with [reason]).
|
||||
final class AutoAway extends AutoSitOutStep {
|
||||
final PresenceReason reason;
|
||||
const AutoAway(this.reason);
|
||||
}
|
||||
|
||||
/// Bring this member back `in`: it was set away automatically and the
|
||||
/// user is back.
|
||||
final class AutoBack extends AutoSitOutStep {
|
||||
const AutoBack();
|
||||
}
|
||||
|
||||
/// Auto sit-out: idle for
|
||||
/// [AutoSitOutTimes.idle] while nothing plays, or minimised for
|
||||
/// [AutoSitOutTimes.minimised] (playing or not), sets this member away;
|
||||
/// input or the window's focus brings it back, but only from an away
|
||||
/// this set ([autoAway]); a manual away (own or someone else's) stays.
|
||||
/// Never when alone (nobody to hold up or to tell). Pure: the session
|
||||
/// feeds it input, window changes and a 1 s [tick].
|
||||
class AutoSitOut {
|
||||
final Duration idleAfter;
|
||||
final Duration minimisedAfter;
|
||||
AutoSitOut(DateTime now,
|
||||
{this.idleAfter = AutoSitOutTimes.idle, this.minimisedAfter = AutoSitOutTimes.minimised})
|
||||
: _lastActive = now;
|
||||
|
||||
DateTime _lastActive;
|
||||
DateTime? _minimisedSince;
|
||||
bool _activitySinceAway = false;
|
||||
bool _autoAway = false;
|
||||
// An away sent, not yet seen in a state.
|
||||
DateTime? _sentAt;
|
||||
static const _echoWait = Duration(seconds: 10);
|
||||
|
||||
/// The member is away because this set it (not by hand, not someone
|
||||
/// else).
|
||||
bool get autoAway => _autoAway;
|
||||
|
||||
/// Keyboard, mouse or any action in the app.
|
||||
void input(DateTime now) {
|
||||
_lastActive = now;
|
||||
_activitySinceAway = true;
|
||||
}
|
||||
|
||||
/// The window's focus or visibility changed.
|
||||
void window(WindowActivity a, DateTime now) {
|
||||
switch (a) {
|
||||
case WindowActivity.minimised:
|
||||
case WindowActivity.hidden:
|
||||
_minimisedSince ??= now;
|
||||
case WindowActivity.focused:
|
||||
_minimisedSince = null;
|
||||
input(now);
|
||||
case WindowActivity.unfocused:
|
||||
_minimisedSince = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The user sat out (or came back) by hand: an away is no longer ours.
|
||||
void manual() {
|
||||
_autoAway = false;
|
||||
_sentAt = null;
|
||||
}
|
||||
|
||||
/// Once a second. [self]: this member's presence in the latest state
|
||||
/// (null: not in one); [alone]: nobody else connected ([isAlone]);
|
||||
/// [enabled]: the setting (and not a solo session).
|
||||
AutoSitOutStep tick(DateTime now,
|
||||
{required Presence? self, required bool playing, required bool alone, required bool enabled}) {
|
||||
if (playing) _lastActive = now;
|
||||
if (self == null || self == Presence.gone) return const AutoStay();
|
||||
if (self != Presence.away) {
|
||||
final sent = _sentAt;
|
||||
if (sent != null && now.difference(sent) < _echoWait) return const AutoStay();
|
||||
_sentAt = null;
|
||||
_autoAway = false;
|
||||
} else {
|
||||
_sentAt = null;
|
||||
if (_autoAway && _activitySinceAway && _minimisedSince == null) {
|
||||
_autoAway = false;
|
||||
return const AutoBack();
|
||||
}
|
||||
return const AutoStay();
|
||||
}
|
||||
if (!enabled || alone) return const AutoStay();
|
||||
final since = _minimisedSince;
|
||||
final PresenceReason? reason;
|
||||
if (since != null && now.difference(since) >= minimisedAfter) {
|
||||
reason = PresenceReason.minimised;
|
||||
} else if (!playing && now.difference(_lastActive) >= idleAfter) {
|
||||
reason = PresenceReason.idle;
|
||||
} else {
|
||||
reason = null;
|
||||
}
|
||||
if (reason == null) return const AutoStay();
|
||||
_autoAway = true;
|
||||
_activitySinceAway = false;
|
||||
_sentAt = now;
|
||||
return AutoAway(reason);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:signals/signals_core.dart';
|
||||
@@ -9,6 +10,7 @@ import '../screens/home/ui.dart' show appName, displayVersion;
|
||||
import '../state/settings.dart';
|
||||
import '../sync/sync_client.dart';
|
||||
import 'app_events.dart';
|
||||
import 'identity.dart' show ClientIdentity;
|
||||
|
||||
/// Thrown when a connect is superseded by cancel/leave before it finished.
|
||||
class ConnectCancelled implements Exception {
|
||||
@@ -74,6 +76,10 @@ sealed class ConnState {
|
||||
|
||||
/// The reconnect loop runs ([ConnLost] or [ConnReconnecting]).
|
||||
bool get retrying => false;
|
||||
|
||||
/// A welcomed socket is up ([ConnOnline] or [ConnUnstable]): what the
|
||||
/// server says is current.
|
||||
bool get online => false;
|
||||
}
|
||||
|
||||
/// Not connected and not trying: before the first connect, after leave,
|
||||
@@ -91,15 +97,30 @@ final class ConnConnecting extends ConnState {
|
||||
String toString() => 'connecting';
|
||||
}
|
||||
|
||||
/// Welcome received.
|
||||
/// Welcome received; frames arrive.
|
||||
final class ConnOnline extends ConnState {
|
||||
const ConnOnline();
|
||||
@override
|
||||
bool get online => true;
|
||||
@override
|
||||
String toString() => 'online';
|
||||
}
|
||||
|
||||
/// Dropped (the socket closed, or no state for [Connection.silenceLimit]);
|
||||
/// the next attempt is scheduled.
|
||||
/// Still the same socket, but nothing came for [Connection.unstableAfter]
|
||||
/// (the server sends a state every second): the link may be dying. Back
|
||||
/// to [ConnOnline] with the next frame, [ConnLost] at
|
||||
/// [Connection.silenceLimit].
|
||||
final class ConnUnstable extends ConnState {
|
||||
const ConnUnstable();
|
||||
@override
|
||||
bool get online => true;
|
||||
@override
|
||||
String toString() => 'unstable';
|
||||
}
|
||||
|
||||
/// Dropped (the socket closed, or nothing came for
|
||||
/// [Connection.silenceLimit]); the next attempt is scheduled. The session
|
||||
/// keeps what it had (the last state, the player, a relay it serves).
|
||||
final class ConnLost extends ConnState {
|
||||
const ConnLost();
|
||||
@override
|
||||
@@ -120,9 +141,10 @@ final class ConnReconnecting extends ConnState {
|
||||
String toString() => 'reconnecting($attempt)';
|
||||
}
|
||||
|
||||
/// The first dial failed; nothing retries. [code]: `version` or `secret`
|
||||
/// when the server said so, else empty (offline, timeout, anything else);
|
||||
/// [message]: in plain words ([connectErrorText]).
|
||||
/// The server refused this app; nothing retries. [code]: `version` or
|
||||
/// `secret` when the server said so, else empty (offline, timeout,
|
||||
/// anything else on the first dial); [message]: in plain words
|
||||
/// ([connectErrorText]).
|
||||
final class ConnRefused extends ConnState {
|
||||
final String code;
|
||||
final String message;
|
||||
@@ -131,88 +153,173 @@ final class ConnRefused extends ConnState {
|
||||
String toString() => 'refused($code: $message)';
|
||||
}
|
||||
|
||||
/// Another socket took this member over. Not produced yet (protocol 16
|
||||
/// adds the `replaced` refusal).
|
||||
/// Another connection of this install holds the member (`err` `replaced`,
|
||||
/// close 4013, or this hello was `stale`): Starsync was opened in another
|
||||
/// window. Nothing retries ("Retry" takes the member back).
|
||||
final class ConnReplaced extends ConnState {
|
||||
const ConnReplaced();
|
||||
String get message => replacedText;
|
||||
@override
|
||||
String toString() => 'replaced';
|
||||
}
|
||||
|
||||
/// [ConnReplaced] in plain words.
|
||||
const replacedText = 'Starsync was opened in another window';
|
||||
|
||||
/// A version refusal in plain words: [err] is the server's `err`
|
||||
/// `version` (its range in `min`/`max`), or a proto 15 server's codeless
|
||||
/// `proto mismatch`, which is always an older host.
|
||||
String versionText(ErrorFrame? err) {
|
||||
final max = err?.max, min = err?.min;
|
||||
if (err?.code == null || (max != null && max < RelayConstants.protoMin)) {
|
||||
return 'the host runs an older Starsync — they need to update';
|
||||
}
|
||||
if (min != null && min > RelayConstants.protoMax) return 'the host runs a newer Starsync — update the app';
|
||||
return 'the host runs a different Starsync version — update both apps';
|
||||
}
|
||||
|
||||
/// A failed dial in plain words.
|
||||
String connectErrorText(Object e) {
|
||||
if (e is HelloRefused) {
|
||||
final err = e.error;
|
||||
if (err != null && err.code == null && err.message.contains('proto mismatch')) return versionText(err);
|
||||
return switch (e.code) {
|
||||
ErrCode.version => versionText(err),
|
||||
ErrCode.secret => 'wrong server password (401)',
|
||||
ErrCode.replaced || ErrCode.stale => replacedText,
|
||||
_ => 'refused: ${err?.message ?? 'closed (${e.closeCode})'}',
|
||||
};
|
||||
}
|
||||
final msg = '$e';
|
||||
if (msg.contains('proto mismatch')) return 'host runs a different version — update the app';
|
||||
if (msg.contains('wrong secret')) return 'wrong server password (401)';
|
||||
if (msg.contains('SocketException') || msg.contains('Connection refused')) {
|
||||
return 'host offline — check address and port';
|
||||
}
|
||||
return 'connect failed: $e';
|
||||
}
|
||||
|
||||
ConnRefused _refused(Object e) {
|
||||
final msg = '$e';
|
||||
final code = msg.contains('proto mismatch')
|
||||
? 'version'
|
||||
: msg.contains('wrong secret')
|
||||
? 'secret'
|
||||
: '';
|
||||
return ConnRefused(code, connectErrorText(e));
|
||||
/// The terminal state a failed dial leaves, or null when another attempt
|
||||
/// may work.
|
||||
ConnState? _terminalFor(Object e, {required bool first}) {
|
||||
if (e is HelloRefused) {
|
||||
final err = e.error;
|
||||
if (err != null && err.code == null && err.message.contains('proto mismatch')) {
|
||||
return ConnRefused('version', versionText(err));
|
||||
}
|
||||
switch (e.code) {
|
||||
case ErrCode.replaced:
|
||||
case ErrCode.stale:
|
||||
return const ConnReplaced();
|
||||
case ErrCode.version:
|
||||
return ConnRefused('version', connectErrorText(e));
|
||||
case ErrCode.secret:
|
||||
return ConnRefused('secret', connectErrorText(e));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return first ? ConnRefused('', connectErrorText(e)) : null;
|
||||
}
|
||||
|
||||
/// What a hello says about this install besides the connect form.
|
||||
typedef HelloIdentity = ({String client, int seq, ClientCaps caps, int? feedAfter});
|
||||
|
||||
/// Builds the `hello` for [params]. Injectable so later protocol versions
|
||||
/// add fields without changing [Connection].
|
||||
typedef HelloBuilder = HelloMessage Function(ConnectParams params);
|
||||
typedef HelloBuilder = HelloMessage Function(ConnectParams params, HelloIdentity id);
|
||||
|
||||
/// Today's hello.
|
||||
HelloMessage defaultHello(ConnectParams params) => HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
/// Protocol 16's hello: this build's version range, the install's client
|
||||
/// id and connection count, the app version, platform and what it can do;
|
||||
/// `feedAfter` on a reconnect to the same server.
|
||||
HelloMessage defaultHello(ConnectParams params, HelloIdentity id) => HelloMessage(
|
||||
proto: RelayConstants.protoMax,
|
||||
protoMin: RelayConstants.protoMin,
|
||||
secret: params.secret,
|
||||
user: HelloUser(name: params.name, avatar: params.avatar),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0),
|
||||
hostKey: params.hostKey,
|
||||
client: id.client,
|
||||
seq: id.seq,
|
||||
app: appVersion,
|
||||
platform: Platform.operatingSystem,
|
||||
caps: id.caps,
|
||||
feedAfter: id.feedAfter,
|
||||
);
|
||||
|
||||
/// The session's part of a reconnect: called right before an attempt
|
||||
/// dials (read what to restore then; the welcome's state replaces it). The
|
||||
/// function it returns runs once that attempt is online; [stale] turns
|
||||
/// true when a cancel, retry, leave or new connect superseded it. An error
|
||||
/// there counts as a failed attempt.
|
||||
typedef ReconnectHandler = Future<void> Function(WelcomeMessage welcome, bool Function() stale) Function();
|
||||
/// How a welcome relates to the member this app held before.
|
||||
typedef Rejoin = ({
|
||||
/// A reconnect of the running session (not its first dial).
|
||||
bool reconnect,
|
||||
/// `welcome.resumed`: the same member, everything kept.
|
||||
bool resumed,
|
||||
/// The same server boot as the last welcome (`welcome.instance`).
|
||||
bool sameServer,
|
||||
/// The token the session held before this welcome ('' on the first).
|
||||
String oldToken,
|
||||
});
|
||||
|
||||
/// The session's part of a welcome, called before its state goes out
|
||||
/// (see [Connection.onWelcome]).
|
||||
typedef WelcomeHandler = void Function(WelcomeMessage welcome, Rejoin rejoin);
|
||||
|
||||
/// The link to the server: the [SyncClient] socket, the first connect,
|
||||
/// reconnects ([reconnectDelay] between attempts, the previous token
|
||||
/// offered as `resume`, epochs that make a cancelled attempt stale), the
|
||||
/// "no state for [silenceLimit] = lost" policy, the 1 s heartbeat (clock
|
||||
/// ping, diagnostics), the connection lost/back events and [state]. What
|
||||
/// a reconnect restores in the session is the [onReconnect] handler's.
|
||||
/// reconnects (backoff with jitter between attempts, epochs that make a
|
||||
/// cancelled attempt stale), the silence policy ("unstable" after
|
||||
/// [unstableAfter] without a frame, lost at [silenceLimit]), the 1 s
|
||||
/// heartbeat (clock ping, which also keeps this member under the
|
||||
/// server's silence limit, and diagnostics), the connection lost/back
|
||||
/// events and [state]. The install's identity rides in every hello
|
||||
/// ([identity]); what a welcome means to the session is its [onWelcome].
|
||||
class Connection {
|
||||
final SyncClient client;
|
||||
/// Where [ConnectionLost] / [ConnectionBack] go (the Notifier).
|
||||
final void Function(AppEvent event) _notify;
|
||||
/// Builds each dial's hello.
|
||||
final HelloBuilder hello;
|
||||
/// Wait before each reconnect attempt.
|
||||
/// `hello.client` / `hello.seq` (the stored one in the app; a fresh
|
||||
/// in-memory one by default, so every test connection is its own
|
||||
/// install).
|
||||
final ClientIdentity identity;
|
||||
/// `hello.caps` at each dial (the session sets it).
|
||||
ClientCaps Function() caps;
|
||||
/// Wait before the first reconnect attempt; it doubles per failed
|
||||
/// attempt up to [maxReconnectDelay], each with ±20 % jitter.
|
||||
final Duration reconnectDelay;
|
||||
/// No state broadcast for this long means the connection is lost.
|
||||
final Duration maxReconnectDelay;
|
||||
/// No frame for this long: [ConnUnstable].
|
||||
final Duration unstableAfter;
|
||||
/// No frame for this long means the connection is lost.
|
||||
final Duration silenceLimit;
|
||||
/// The clock [silenceLimit] is measured on (UTC; a test passes a fake
|
||||
/// one). The heartbeat that checks it stays a real 1 s timer.
|
||||
/// The lost sound plays only once a drop lasted this long (a quick
|
||||
/// reconnect says nothing, and no lost/back pair per flap).
|
||||
final Duration lostSoundAfter;
|
||||
/// The clock the silence and the lost sound are measured on (UTC; a
|
||||
/// test passes a fake one). The heartbeat that checks it stays a real
|
||||
/// 1 s timer.
|
||||
final DateTime Function() now;
|
||||
final Random _random;
|
||||
|
||||
Connection(
|
||||
{void Function(AppEvent event)? notify,
|
||||
SyncClient? client,
|
||||
this.hello = defaultHello,
|
||||
this.reconnectDelay = const Duration(milliseconds: 1500),
|
||||
this.silenceLimit = const Duration(seconds: 5),
|
||||
DateTime Function()? now})
|
||||
ClientIdentity? identity,
|
||||
ClientCaps Function()? caps,
|
||||
this.reconnectDelay = const Duration(seconds: 1),
|
||||
this.maxReconnectDelay = const Duration(seconds: 15),
|
||||
this.unstableAfter = const Duration(seconds: 3),
|
||||
this.silenceLimit = const Duration(seconds: 9),
|
||||
this.lostSoundAfter = const Duration(seconds: 4),
|
||||
DateTime Function()? now,
|
||||
Random? random})
|
||||
: _notify = notify ?? ((_) {}),
|
||||
now = now ?? _utcNow,
|
||||
identity = identity ?? ClientIdentity.ephemeral(),
|
||||
caps = caps ?? (() => const ClientCaps(source: true)),
|
||||
_random = random ?? Random(),
|
||||
client = client ?? SyncClient() {
|
||||
this.client.onFrame = () => _lastFrameAt = this.now();
|
||||
_subs = [
|
||||
this.client.status.listen(_onStatus),
|
||||
this.client.states.listen((_) => _lastStateAt = this.now()),
|
||||
this.client.errors.listen(_onError),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -224,8 +331,8 @@ class Connection {
|
||||
/// Every change is notified, a repeat too.
|
||||
ReadonlySignal<ConnState> get state => _state;
|
||||
|
||||
/// Set by the session (see [ReconnectHandler]).
|
||||
ReconnectHandler? onReconnect;
|
||||
/// Set by the session (see [WelcomeHandler]).
|
||||
WelcomeHandler? onWelcome;
|
||||
|
||||
/// This app's log lines to the server's session log (Settings: "Share
|
||||
/// diagnostics with the server"), and "Mark a problem". Runs from
|
||||
@@ -239,12 +346,6 @@ class Connection {
|
||||
return (base: DiagUploader.baseOf(uri), token: client.token, offsetMs: client.clockSync.offsetMs);
|
||||
}
|
||||
|
||||
/// No state broadcast for [silenceLimit] (checked every second while not
|
||||
/// closed): what the server said is stale. Synchronous, before [state]
|
||||
/// goes lost.
|
||||
Stream<void> get silence => _silence.stream;
|
||||
final _silence = StreamController<void>.broadcast(sync: true);
|
||||
|
||||
ConnectParams? _params;
|
||||
/// The last connect's parameters (with [updateProfile]'s changes); a
|
||||
/// reconnect dials with them.
|
||||
@@ -254,23 +355,26 @@ class Connection {
|
||||
int _epoch = 0;
|
||||
Timer? _reconnectTimer;
|
||||
Timer? _heartbeat;
|
||||
// Set by close/cancel: no reconnect, no lost detection.
|
||||
// Set by close/cancel/a terminal refusal: no reconnect, no lost detection.
|
||||
bool _closed = false;
|
||||
bool _everOnline = false;
|
||||
/// The connection-lost sound played; the reconnected one answers it once.
|
||||
bool _lostSounded = false;
|
||||
// Token of the current connection, offered as `resume` on reconnect.
|
||||
String _resumeToken = '';
|
||||
DateTime? _lastStateAt;
|
||||
DateTime? _lastFrameAt;
|
||||
/// When the current drop began (the last frame for a silent link).
|
||||
DateTime? _droppedAt;
|
||||
int _attempt = 0;
|
||||
/// Non-terminal `err` frames, in plain words.
|
||||
final _errorTexts = StreamController<String>.broadcast();
|
||||
|
||||
// The socket and what the server said, read through.
|
||||
Stream<StateBroadcast> get states => client.states;
|
||||
/// Feed entries, chat included.
|
||||
/// Feed entries, chat included; replayed ones after a reconnect.
|
||||
Stream<FeedMessage> get feeds => client.feeds;
|
||||
Stream<NudgeNotice> get nudges => client.nudges;
|
||||
/// `err` frames after the welcome.
|
||||
Stream<String> get errors => client.errors;
|
||||
/// `err` frames after the welcome (a `replaced` one ends the connection
|
||||
/// instead).
|
||||
Stream<String> get errors => _errorTexts.stream;
|
||||
String get token => client.token;
|
||||
String get memberId => client.memberId;
|
||||
/// This member's `/j/...` URL from `welcome`.
|
||||
@@ -304,7 +408,7 @@ class Connection {
|
||||
void sendChat(String text) => client.sendChat(text);
|
||||
void sendArt(int mediaId, {String? poster, String? banner}) =>
|
||||
client.sendArt(mediaId, poster: poster, banner: banner);
|
||||
void sendOut(String target, bool out) => client.sendOut(target, out);
|
||||
void sendPresence(PresenceIntent intent) => client.sendPresence(intent);
|
||||
void sendNudge(String target) => client.sendNudge(target);
|
||||
|
||||
/// Name and/or picture change for everyone now; reconnects send them too.
|
||||
@@ -325,14 +429,14 @@ class Connection {
|
||||
/// False once a cancel, retry, leave or new connect followed [epoch].
|
||||
bool isCurrent(int epoch) => epoch == _epoch;
|
||||
|
||||
/// A new session with [params]: forgets the last one's resume token and
|
||||
/// A new session with [params]: forgets the last one's server and
|
||||
/// starts the diagnostics. [dial] connects.
|
||||
void begin(ConnectParams params) {
|
||||
_params = params;
|
||||
_closed = false;
|
||||
_everOnline = false;
|
||||
_lostSounded = false;
|
||||
_resumeToken = '';
|
||||
_droppedAt = null;
|
||||
_attempt = 0;
|
||||
diag
|
||||
..share = AppSettings.instance.shareDiagnostics.peek()
|
||||
@@ -341,125 +445,184 @@ class Connection {
|
||||
|
||||
/// The first dial of the session [begin] started. Throws
|
||||
/// [ConnectCancelled] when [epoch] went stale meanwhile (whoever bumped
|
||||
/// it also closed the socket); any other failure leaves [state] refused.
|
||||
/// it also closed the socket); any other failure leaves [state] refused
|
||||
/// (or replaced).
|
||||
Future<WelcomeMessage> dial(int epoch) async {
|
||||
final params = _params!;
|
||||
_set(const ConnConnecting());
|
||||
try {
|
||||
final welcome = await client.connectWith(wsUri: _wsUri(params), hello: hello(params));
|
||||
final welcome = await _connect(params, reconnect: false);
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
_online();
|
||||
return welcome;
|
||||
} catch (e) {
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
debugPrint('connect failed: $e');
|
||||
_set(_refused(e));
|
||||
_set(_terminalFor(e, first: true)!);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Uri _wsUri(ConnectParams p) => Uri.parse('ws://${p.host}:${p.port}/sync');
|
||||
|
||||
/// [hello] offering the previous connection's token.
|
||||
HelloMessage _resumeHello(ConnectParams params) {
|
||||
final h = hello(params);
|
||||
return HelloMessage(
|
||||
proto: h.proto, secret: h.secret, user: h.user, media: h.media, hostKey: h.hostKey, resume: _resumeToken);
|
||||
Future<WelcomeMessage> _connect(ConnectParams params, {required bool reconnect}) {
|
||||
final oldToken = client.token;
|
||||
final before = client.instance;
|
||||
final id = (
|
||||
client: identity.clientId,
|
||||
seq: identity.nextSeq(),
|
||||
caps: caps(),
|
||||
feedAfter: reconnect && before.isNotEmpty && client.lastFeedSeq > 0 ? client.lastFeedSeq : null,
|
||||
);
|
||||
return client.connectWith(
|
||||
wsUri: _wsUri(params),
|
||||
hello: hello(params, id),
|
||||
onWelcome: (w) {
|
||||
_lastFrameAt = now();
|
||||
onWelcome?.call(w, (
|
||||
reconnect: reconnect,
|
||||
resumed: w.resumed,
|
||||
sameServer: before.isNotEmpty && before == w.instance,
|
||||
oldToken: oldToken,
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Uri _wsUri(ConnectParams p) => Uri.parse('ws://${p.host}:${p.port}/sync');
|
||||
|
||||
void _online() {
|
||||
_attempt = 0;
|
||||
_everOnline = true;
|
||||
_droppedAt = null;
|
||||
if (_lostSounded) {
|
||||
_lostSounded = false;
|
||||
_notify(const ConnectionBack());
|
||||
}
|
||||
_resumeToken = client.token;
|
||||
_lastStateAt = now();
|
||||
_lastFrameAt = now();
|
||||
_set(const ConnOnline());
|
||||
_startHeartbeat();
|
||||
_heartbeat ??= Timer.periodic(const Duration(seconds: 1), (_) => _beat());
|
||||
}
|
||||
|
||||
void _set(ConnState s) => _state.set(s, force: true);
|
||||
|
||||
void _onStatus(SyncStatus s) {
|
||||
// Before the first welcome a close is just the dial failing.
|
||||
if (s != SyncStatus.closed || _closed || !_everOnline) return;
|
||||
_dropped();
|
||||
}
|
||||
|
||||
/// Lost (said again while already retrying), the lost sound once per
|
||||
/// drop, and a reconnect unless one is already scheduled or dialling.
|
||||
void _dropped() {
|
||||
if (_state.peek().retrying) {
|
||||
_set(const ConnLost());
|
||||
} else {
|
||||
scheduleReconnect();
|
||||
// Before the first welcome a close is just the dial failing; during a
|
||||
// reconnect attempt the attempt reports it. A socket error (a reset
|
||||
// link) may come without a close.
|
||||
if (s != SyncStatus.closed && s != SyncStatus.error) return;
|
||||
if (_closed || !_everOnline || !_state.peek().online) return;
|
||||
switch (s == SyncStatus.closed ? client.closeCode : null) {
|
||||
case CloseCodes.replaced:
|
||||
case CloseCodes.stale:
|
||||
_end(const ConnReplaced());
|
||||
case CloseCodes.version:
|
||||
_end(ConnRefused('version', versionText(null)));
|
||||
case CloseCodes.secret:
|
||||
_end(const ConnRefused('secret', 'wrong server password (401)'));
|
||||
default:
|
||||
_dropped(now());
|
||||
}
|
||||
_soundLost();
|
||||
}
|
||||
|
||||
void _startHeartbeat() {
|
||||
_heartbeat?.cancel();
|
||||
_heartbeat = Timer.periodic(const Duration(seconds: 1), (_) => _beat());
|
||||
void _onError(ErrorFrame e) {
|
||||
if (e.code == ErrCode.replaced) {
|
||||
_end(const ConnReplaced());
|
||||
return;
|
||||
}
|
||||
if (!_errorTexts.isClosed) _errorTexts.add(e.message);
|
||||
}
|
||||
|
||||
/// Every second while connected: a clock sample, the diagnostics, and
|
||||
/// the silence check.
|
||||
/// A terminal state: no more attempts, the socket goes.
|
||||
void _end(ConnState s) {
|
||||
debugPrint('connection ended: $s');
|
||||
_halt(s);
|
||||
unawaited(client.drop());
|
||||
}
|
||||
|
||||
/// Lost since [since]: the socket goes, a reconnect is scheduled (unless
|
||||
/// one already is or dials).
|
||||
void _dropped(DateTime since) {
|
||||
if (_closed || _state.peek().retrying) return;
|
||||
_droppedAt = since;
|
||||
unawaited(client.drop());
|
||||
scheduleReconnect();
|
||||
}
|
||||
|
||||
/// Every second once online: a ping (a clock sample; it also keeps this
|
||||
/// member under the server's silence limit while nothing else is sent),
|
||||
/// the diagnostics, the silence check and the delayed lost sound.
|
||||
void _beat() {
|
||||
final t = now();
|
||||
if (client.token.isNotEmpty) client.sendPing();
|
||||
final s = _state.peek();
|
||||
if (s.online) client.sendPing();
|
||||
diag
|
||||
..share = AppSettings.instance.shareDiagnostics.peek()
|
||||
..tick();
|
||||
final last = _lastStateAt;
|
||||
if (!_closed && last != null && t.difference(last) >= silenceLimit) {
|
||||
_silence.add(null);
|
||||
if (!_state.peek().retrying) _dropped();
|
||||
if (_closed) return;
|
||||
final last = _lastFrameAt;
|
||||
if (s.online && last != null) {
|
||||
final quiet = t.difference(last);
|
||||
if (quiet >= silenceLimit) {
|
||||
debugPrint('connection: nothing for ${quiet.inSeconds} s; reconnecting');
|
||||
_dropped(last);
|
||||
} else if (quiet >= unstableAfter) {
|
||||
if (s is ConnOnline) _set(const ConnUnstable());
|
||||
} else if (s is ConnUnstable) {
|
||||
_set(const ConnOnline());
|
||||
}
|
||||
}
|
||||
final since = _droppedAt;
|
||||
if (_state.peek().retrying && since != null && !_lostSounded && t.difference(since) >= lostSoundAfter) {
|
||||
_lostSounded = true;
|
||||
_notify(const ConnectionLost());
|
||||
}
|
||||
}
|
||||
|
||||
void _soundLost() {
|
||||
if (!_everOnline || _lostSounded) return;
|
||||
_lostSounded = true;
|
||||
_notify(const ConnectionLost());
|
||||
}
|
||||
|
||||
/// Reconnect after [reconnectDelay] ([state] lost), unless closed,
|
||||
/// never online (unless [manual]) or already retrying.
|
||||
/// Reconnect after the backoff ([state] lost), unless closed, never
|
||||
/// online (unless [manual]) or already retrying.
|
||||
void scheduleReconnect({bool manual = false}) {
|
||||
if (_closed || _params == null) return;
|
||||
if (!manual && !_everOnline) return;
|
||||
if (_state.peek().retrying) return;
|
||||
_droppedAt ??= now();
|
||||
_set(const ConnLost());
|
||||
_armReconnect();
|
||||
}
|
||||
|
||||
/// The wait before attempt [attempt] (1 = the first after a drop).
|
||||
Duration delayFor(int attempt) {
|
||||
final base = reconnectDelay.inMilliseconds * pow(2, max(0, attempt - 1));
|
||||
final capped = min(base.toDouble(), maxReconnectDelay.inMilliseconds.toDouble());
|
||||
return Duration(milliseconds: (capped * (0.8 + 0.4 * _random.nextDouble())).round());
|
||||
}
|
||||
|
||||
void _armReconnect() {
|
||||
final epoch = _epoch;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(reconnectDelay, () => _reconnect(epoch));
|
||||
_reconnectTimer = Timer(delayFor(_attempt + 1), () => _reconnect(epoch));
|
||||
}
|
||||
|
||||
/// One reconnect attempt. An attempt from an older epoch must not touch
|
||||
/// state (it would kill the newer connection and leave a ghost member).
|
||||
/// state (it would kill the newer connection).
|
||||
Future<void> _reconnect(int epoch) async {
|
||||
_reconnectTimer = null;
|
||||
bool stale() => epoch != _epoch;
|
||||
final params = _params;
|
||||
if (stale() || params == null || _closed) return;
|
||||
final after = onReconnect?.call();
|
||||
try {
|
||||
_set(ConnReconnecting(++_attempt));
|
||||
final welcome = await client.connectWith(wsUri: _wsUri(params), hello: _resumeHello(params));
|
||||
await _connect(params, reconnect: true);
|
||||
// Whoever bumped the epoch also closed (or replaced) the socket.
|
||||
if (stale()) return;
|
||||
_online();
|
||||
if (after != null) await after(welcome, stale);
|
||||
} catch (e) {
|
||||
if (stale()) return;
|
||||
debugPrint('reconnect failed: $e');
|
||||
_retryAfterFailure(e);
|
||||
final end = _terminalFor(e, first: false);
|
||||
if (end != null) {
|
||||
_end(end);
|
||||
} else {
|
||||
_retryAfterFailure(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,7 +631,7 @@ class Connection {
|
||||
_set(const ConnClosed());
|
||||
} else if (!_everOnline) {
|
||||
// Only a manual retry before any welcome gets here.
|
||||
_set(_refused(e));
|
||||
_set(_terminalFor(e, first: true)!);
|
||||
} else {
|
||||
if (!_state.peek().retrying) _set(const ConnLost());
|
||||
_armReconnect();
|
||||
@@ -477,32 +640,34 @@ class Connection {
|
||||
|
||||
/// "Stop reconnecting": no more attempts; one dialling is aborted.
|
||||
void cancelReconnect() {
|
||||
_halt();
|
||||
unawaited(client.disconnect().catchError((_) {}));
|
||||
_halt(const ConnClosed());
|
||||
unawaited(client.drop());
|
||||
}
|
||||
|
||||
/// Cancel during setup: aborts the dial.
|
||||
Future<void> cancelConnect() async {
|
||||
_halt();
|
||||
_halt(const ConnClosed());
|
||||
try {
|
||||
await client.disconnect();
|
||||
await client.drop();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _halt() {
|
||||
void _halt(ConnState s) {
|
||||
_closed = true;
|
||||
invalidate();
|
||||
_heartbeat?.cancel();
|
||||
_heartbeat = null;
|
||||
_set(const ConnClosed());
|
||||
_set(s);
|
||||
}
|
||||
|
||||
/// "Retry": a reconnect after [reconnectDelay] whatever the state.
|
||||
/// False without a session to retry.
|
||||
/// "Retry": a reconnect after the first backoff whatever the state
|
||||
/// (after [ConnReplaced] it takes the member back from the other
|
||||
/// window). False without a session to retry.
|
||||
bool retry() {
|
||||
if (_params == null) return false;
|
||||
invalidate();
|
||||
_closed = false;
|
||||
_attempt = 0;
|
||||
_set(const ConnClosed());
|
||||
scheduleReconnect(manual: true);
|
||||
return true;
|
||||
@@ -519,10 +684,14 @@ class Connection {
|
||||
_set(const ConnClosed());
|
||||
}
|
||||
|
||||
/// [stop], the last diagnostics (before the token goes), the socket.
|
||||
Future<void> close() async {
|
||||
/// [stop], the last diagnostics (before the token goes), with [bye] the
|
||||
/// member's `bye` (flushed: the server's close is awaited briefly), then
|
||||
/// the socket.
|
||||
Future<void> close({bool bye = false}) async {
|
||||
stop();
|
||||
await diag.stop();
|
||||
// A welcomed socket still open (a dead one costs the short wait).
|
||||
if (bye && client.isOpen && client.token.isNotEmpty) await client.sendBye();
|
||||
try {
|
||||
await client.disconnect();
|
||||
} catch (_) {}
|
||||
@@ -533,7 +702,7 @@ class Connection {
|
||||
for (final s in _subs) {
|
||||
await s.cancel();
|
||||
}
|
||||
await _silence.close();
|
||||
await _errorTexts.close();
|
||||
await client.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/session_media.dart';
|
||||
import 'party.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.
|
||||
@@ -217,7 +218,7 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam
|
||||
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;
|
||||
if (!isPresent(m) || m.id == sourceId) continue;
|
||||
switch (routeOf(m, kind, sourceId, ref, r.relay)) {
|
||||
case DeliveryRoute.server:
|
||||
via++;
|
||||
@@ -277,7 +278,7 @@ DeliveryRoute? routeOf(MemberView m, DeliveryKind kind, String sourceId, Jellyfi
|
||||
|
||||
/// [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 counts = countsForReady(m);
|
||||
final need = r.bufferNeed;
|
||||
final ref = refOfStateMedia(r.media);
|
||||
final health = !counts
|
||||
@@ -288,7 +289,7 @@ MemberDelivery memberDeliveryOf(MemberView m, StateBroadcast r, Delivery d) {
|
||||
? BufferHealth.starving
|
||||
: BufferHealth.low;
|
||||
return MemberDelivery(
|
||||
route: m.out ? null : routeOf(m, d.kind, d.sourceId, ref, r.relay),
|
||||
route: m.presence == Presence.away ? null : routeOf(m, d.kind, d.sourceId, ref, r.relay),
|
||||
kind: d.kind,
|
||||
jellyfin: ref != null,
|
||||
rate: m.rate,
|
||||
|
||||
+73
-3
@@ -1,13 +1,83 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show validClientId;
|
||||
|
||||
import '../../state/connect_prefs.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import '../../widgets/avatar_pipeline.dart';
|
||||
import '../state/connect_prefs.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart' show settingsBoxName;
|
||||
import '../widgets/avatar_pipeline.dart';
|
||||
|
||||
// Who this app is: to the server (the per-install client id and its
|
||||
// rising connection count, [ClientIdentity]) and to people (name, picture
|
||||
// and colour, [Profile]).
|
||||
|
||||
/// `hello.client` and `hello.seq` (PROTOCOL.md "Presence"): a random id
|
||||
/// per install, kept in the settings box (so per data folder, and per
|
||||
/// `STARSYNC_PROFILE`), and a number that rises with every connection this
|
||||
/// install makes. A server maps the id to one lasting member; the higher
|
||||
/// `seq` wins when two windows of the same install connect.
|
||||
class ClientIdentity {
|
||||
static const _keyClient = 'clientId';
|
||||
static const _keySeq = 'clientSeq';
|
||||
|
||||
final Box? _box;
|
||||
ClientIdentity._(this._box, this.clientId, this._seq);
|
||||
|
||||
/// The id: 22 characters of base64url (16 random bytes). Private: never
|
||||
/// shown, never logged.
|
||||
final String clientId;
|
||||
int _seq;
|
||||
|
||||
/// The last `seq` handed out.
|
||||
int get seq => _seq;
|
||||
|
||||
/// The stored identity (the settings box, once open); one is made and
|
||||
/// stored on first use. Without the box (tests, no store): [ephemeral].
|
||||
factory ClientIdentity.stored() {
|
||||
final box = Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null;
|
||||
if (box == null) return ClientIdentity.ephemeral();
|
||||
var id = box.get(_keyClient);
|
||||
if (id is! String || !validClientId(id)) {
|
||||
id = newClientId();
|
||||
unawaited(_put(box, _keyClient, id));
|
||||
}
|
||||
final seq = box.get(_keySeq);
|
||||
return ClientIdentity._(box, id, seq is int && seq > 0 ? seq : 0);
|
||||
}
|
||||
|
||||
/// A fresh id kept in memory only (each test coordinator is its own
|
||||
/// install).
|
||||
factory ClientIdentity.ephemeral() => ClientIdentity._(null, newClientId(), 0);
|
||||
|
||||
/// The `seq` for the next hello: one higher than any before, stored at
|
||||
/// once.
|
||||
int nextSeq() {
|
||||
_seq++;
|
||||
final box = _box;
|
||||
if (box != null) unawaited(_put(box, _keySeq, _seq));
|
||||
return _seq;
|
||||
}
|
||||
|
||||
static Future<void> _put(Box box, String key, Object value) async {
|
||||
try {
|
||||
await box.put(key, value);
|
||||
} catch (e) {
|
||||
debugPrint('save $key failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 16 random bytes as 22 characters of base64url.
|
||||
static String newClientId() {
|
||||
final r = Random.secure();
|
||||
return base64Url.encode([for (var i = 0; i < 16; i++) r.nextInt(256)]).replaceAll('=', '');
|
||||
}
|
||||
}
|
||||
|
||||
/// Colours offered in the profile card; the first is the default.
|
||||
const profileColors = <Color>[
|
||||
@@ -1,43 +0,0 @@
|
||||
enum IdleEvent { none, warn, unready }
|
||||
|
||||
/// Idle auto-unready clock. Idle time counts from the later of the user's
|
||||
/// last input and the last moment playback was running: watching is
|
||||
/// activity, so a long watch followed by a pause starts a fresh 10 min.
|
||||
/// The unready also always trails its warning by the full grace period.
|
||||
class IdleTracker {
|
||||
static const warnAfter = Duration(minutes: 8);
|
||||
static const unreadyAfter = Duration(minutes: 10);
|
||||
|
||||
DateTime _lastActive;
|
||||
DateTime? _warnedAt;
|
||||
|
||||
IdleTracker(DateTime now) : _lastActive = now;
|
||||
|
||||
void activity(DateTime now) {
|
||||
_lastActive = now;
|
||||
_warnedAt = null;
|
||||
}
|
||||
|
||||
/// Called once per tick. Warn and unready only matter while self is
|
||||
/// ready; while playing the ready flag gates nothing.
|
||||
IdleEvent tick(DateTime now, {required bool playing, required bool selfReady}) {
|
||||
if (playing) {
|
||||
activity(now);
|
||||
return IdleEvent.none;
|
||||
}
|
||||
if (!selfReady) return IdleEvent.none;
|
||||
final warnedAt = _warnedAt;
|
||||
if (warnedAt != null) {
|
||||
if (now.difference(warnedAt) >= unreadyAfter - warnAfter) {
|
||||
activity(now);
|
||||
return IdleEvent.unready;
|
||||
}
|
||||
return IdleEvent.none;
|
||||
}
|
||||
if (now.difference(_lastActive) >= warnAfter) {
|
||||
_warnedAt = now;
|
||||
return IdleEvent.warn;
|
||||
}
|
||||
return IdleEvent.none;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,9 @@ class ToastItem {
|
||||
/// The member a feed or chat toast is about (their name, as the feed
|
||||
/// carries it; [text] starts with it); null for the app's own toasts.
|
||||
final String? actor;
|
||||
const ToastItem({required this.id, required this.text, required this.at, this.actor});
|
||||
/// [actor]'s member id (feed and chat toasts); '' when unknown.
|
||||
final String actorId;
|
||||
const ToastItem({required this.id, required this.text, required this.at, this.actor, this.actorId = ''});
|
||||
}
|
||||
|
||||
/// What one [AppEvent] does: a toast ([toast], about [actor]), a sound,
|
||||
@@ -34,6 +36,8 @@ class Notice {
|
||||
|
||||
/// The policy: what each event does.
|
||||
Notice noticeFor(AppEvent e) => switch (e) {
|
||||
// Lines a reconnect replayed (`welcome.feed`): the log only.
|
||||
FeedEvent(:final feed) when feed.replayed => Notice(log: feed),
|
||||
// The feed: everything is logged; joins, leaves, swaps and chat
|
||||
// pop up; the social actions have a sound.
|
||||
MemberJoined(:final feed) => Notice(
|
||||
@@ -50,6 +54,10 @@ Notice noticeFor(AppEvent e) => switch (e) {
|
||||
sound: own ? null : const SfxCue(SfxKind.chat)),
|
||||
MemberSatOut(:final feed, atYou: true) => Notice(
|
||||
log: feed, toast: '${feed.actor} sat you out', actor: feed.actor, sound: const SfxCue(SfxKind.kick)),
|
||||
MemberSatOut(:final feed, autoSelf: true) => Notice(
|
||||
log: feed,
|
||||
toast: 'You sit out (${feed.reason == 'minimised' ? 'minimised' : 'idle'}); you are back as soon as you return',
|
||||
sound: const SfxCue(SfxKind.warn)),
|
||||
ReadyChanged(:final feed, ready: true, :final readyIndex) =>
|
||||
Notice(log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)),
|
||||
ReadyChanged(:final feed, ready: false) => Notice(log: feed, sound: const SfxCue(SfxKind.unready)),
|
||||
@@ -68,11 +76,6 @@ Notice noticeFor(AppEvent e) => switch (e) {
|
||||
RelayStarted() =>
|
||||
const Notice(toast: 'Passing the video on through the server. Keep the app open while it plays.'),
|
||||
RelayFailed(:final error) => Notice(toast: 'Cannot pass the video on: $error'),
|
||||
IdleWarning(:final sitsOut) => Notice(
|
||||
toast: sitsOut
|
||||
? 'idle — sitting out in 2 min unless you press Space'
|
||||
: 'idle — auto-unready in 2 min unless you press Space',
|
||||
sound: const SfxCue(SfxKind.warn)),
|
||||
ConnectionLost() => const Notice(sound: SfxCue(SfxKind.lost)),
|
||||
ConnectionBack() => const Notice(sound: SfxCue(SfxKind.back)),
|
||||
JellyfinProgressFailed(:final signInExpired) =>
|
||||
@@ -119,7 +122,12 @@ class Notifier {
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: n.actor),
|
||||
ToastItem(
|
||||
id: _toastId,
|
||||
text: text,
|
||||
at: DateTime.now().toUtc(),
|
||||
actor: n.actor,
|
||||
actorId: n.actor == null ? '' : n.log?.actorId ?? ''),
|
||||
];
|
||||
}
|
||||
final sound = n.sound;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
// The app's reading of presence (PROTOCOL.md "Presence"): every count,
|
||||
// list and wait in the UI goes through these, on top of core's
|
||||
// [PresencePolicy] and [isAlone].
|
||||
|
||||
/// What each presence counts for (the one table, shared with the server).
|
||||
const presencePolicy = PresencePolicy();
|
||||
|
||||
/// Counted in "2/3 ready" and waited for by name: its presence counts for
|
||||
/// the ready counter and its player can open the media.
|
||||
bool countsForReady(MemberView m) => m.error.isEmpty && presencePolicy.countsFor(m.presence, Purpose.readyCounter);
|
||||
|
||||
/// Connected and watching (`in` or `joining`): can do things for the
|
||||
/// party now (put up an Up next item, the auto-advance swap).
|
||||
bool isPresent(MemberView m) => m.presence == Presence.watching || m.presence == Presence.joining;
|
||||
|
||||
/// Nobody but [selfId] is connected in [members] (D9, core's [isAlone]).
|
||||
bool aloneIn(List<MemberView> members, String selfId) =>
|
||||
isAlone([for (final m in members) if (m.id != selfId) m.presence]);
|
||||
|
||||
/// "3 min" / "40 s": how long since [since] (a `goneSince`), at [now].
|
||||
String sinceText(DateTime since, DateTime now) {
|
||||
final d = now.difference(since);
|
||||
if (d.inMinutes >= 1) return '${d.inMinutes} min';
|
||||
return '${d.inSeconds.clamp(0, 59)} s';
|
||||
}
|
||||
@@ -24,14 +24,16 @@ import '../sync/avatar_cache.dart';
|
||||
import '../sync/state_diff.dart';
|
||||
import '../sync/user_seek.dart';
|
||||
import '../queue/queue_controller.dart';
|
||||
import '../platform/ports/shell_port.dart' show WindowActivity;
|
||||
import 'auto_sit_out.dart';
|
||||
import 'connection.dart';
|
||||
import 'delivery.dart';
|
||||
import 'health.dart';
|
||||
import 'idle.dart';
|
||||
import 'media_art.dart';
|
||||
import 'notifier.dart';
|
||||
import 'now_playing.dart';
|
||||
import 'now_playing_info.dart';
|
||||
import 'party.dart';
|
||||
import 'switch_trace.dart';
|
||||
|
||||
export 'connection.dart' show ConnectCancelled, ConnectParams;
|
||||
@@ -60,8 +62,8 @@ const autoAdvanceSecs = 10;
|
||||
|
||||
/// "Unready when the video changes": a state with a newer [mediaId] than
|
||||
/// the last one this session saw ([lastMediaId], -1 before the first) while
|
||||
/// self is ready (or a reconnect is restoring it). A swap keeps the
|
||||
/// server's ready flags, so the client unreadies itself.
|
||||
/// self is ready (or a fresh member after a reconnect is restoring it). A
|
||||
/// swap keeps the server's ready flags, so the client unreadies itself.
|
||||
bool unreadyForNewMedia(
|
||||
{required bool enabled,
|
||||
required int lastMediaId,
|
||||
@@ -106,10 +108,12 @@ final class _JellyfinOrigin extends _RelayOrigin {
|
||||
HealthStatus? connectionHealth(ConnState s) => switch (s) {
|
||||
ConnConnecting() => const HealthStatus(HealthSeverity.info, 'connecting…'),
|
||||
ConnOnline() => const HealthStatus(HealthSeverity.ok, 'connected'),
|
||||
ConnUnstable() => const HealthStatus(HealthSeverity.warn, 'connection unstable…'),
|
||||
ConnLost() => const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'),
|
||||
ConnReconnecting() => const HealthStatus(HealthSeverity.info, 'reconnecting…'),
|
||||
ConnRefused(:final message) => HealthStatus(HealthSeverity.error, message),
|
||||
ConnReplaced() || ConnClosed() => null,
|
||||
ConnReplaced(:final message) => HealthStatus(HealthSeverity.error, message),
|
||||
ConnClosed() => null,
|
||||
};
|
||||
|
||||
String? stateMediaUrl(StateBroadcast state) {
|
||||
@@ -169,13 +173,24 @@ class SessionCoordinator {
|
||||
|
||||
final health =
|
||||
signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||
/// The server's latest state. A drop keeps it (with the player, the
|
||||
/// media and a relay this member serves): [stateStale] says it is not
|
||||
/// current; only leaving clears it.
|
||||
final serverState = signal<StateBroadcast?>(null);
|
||||
/// [serverState] is from before a drop: the connection is reconnecting
|
||||
/// or ended (replaced, refused).
|
||||
late final Computed<bool> stateStale = computed(() {
|
||||
final s = connection.state.value;
|
||||
return serverState.value != null && (s.retrying || s is ConnReplaced || s is ConnRefused);
|
||||
});
|
||||
/// The activity log ([Notifier.feedLog]).
|
||||
Signal<List<FeedMessage>> get feedLog => notifier.feedLog;
|
||||
/// Toasts on screen ([Notifier.toasts]).
|
||||
Signal<List<ToastItem>> get toasts => notifier.toasts;
|
||||
/// The reconnect loop runs ([ConnState.retrying]).
|
||||
late final Computed<bool> reconnecting = computed(() => connection.state.value.retrying);
|
||||
/// The link is up but went quiet ([ConnUnstable]).
|
||||
late final Computed<bool> unstable = computed(() => connection.state.value is ConnUnstable);
|
||||
final starving = signal<String?>(null);
|
||||
/// Why this player cannot open the current media, in plain words
|
||||
/// ([playErrorText]); empty while it can. It rides in `report.error`, so
|
||||
@@ -232,42 +247,40 @@ class SessionCoordinator {
|
||||
return null;
|
||||
});
|
||||
|
||||
/// Self sits out: the player has nothing open until [joinBack].
|
||||
late final Computed<bool> selfOut = computed(() => selfMember.value?.out ?? false);
|
||||
/// Self sits out (`away`): the player has nothing open until [joinBack].
|
||||
late final Computed<bool> selfAway = computed(() => selfMember.value?.presence == Presence.away);
|
||||
|
||||
Timer? _ticker;
|
||||
StreamSubscription? _stateSub;
|
||||
StreamSubscription? _feedSub;
|
||||
StreamSubscription? _nudgeSub;
|
||||
StreamSubscription? _errSub;
|
||||
StreamSubscription? _silenceSub;
|
||||
void Function()? _connectionHealth;
|
||||
|
||||
/// A solo session (set by SessionState): no idle unready / sit out,
|
||||
/// there is nobody to hold up.
|
||||
/// A solo session (set by SessionState): no auto sit-out, there is
|
||||
/// nobody to hold up.
|
||||
bool solo = false;
|
||||
bool _playerReady = false;
|
||||
bool _wasPlayAllowed = false;
|
||||
// Last ready flag the server reported for self; restored after a
|
||||
// reconnect (the server forgets dropped members).
|
||||
// Last ready flag the server reported for self; sent again when a
|
||||
// reconnect lands as a new member (grace over, server restarted).
|
||||
bool _lastReady = false;
|
||||
// Set after a reconnect until the server echoes the restored ready, so
|
||||
// the fresh member's ready=false doesn't overwrite [_lastReady].
|
||||
// Set after such a reconnect until the server echoes the restored
|
||||
// ready, so the fresh member's ready=false doesn't overwrite [_lastReady].
|
||||
bool _restoringReady = false;
|
||||
// Last out flag the server reported for self; a reconnect that lands
|
||||
// as a new member sits out again.
|
||||
bool _lastOut = false;
|
||||
// Self is `away` in the last state; a reconnect that lands as a new
|
||||
// member sits out again.
|
||||
bool _lastAway = false;
|
||||
// [joinBack] ran while playing: ready once the player has buffered
|
||||
// (or after [_joinBackWait]), so the others do not pause for the load.
|
||||
DateTime? _joinBackAt;
|
||||
static const _joinBackWait = Duration(seconds: 15);
|
||||
// Last mediaId seen in a state; unlike [_mediaId] it survives a
|
||||
// reconnect, so only a real swap counts as a new video.
|
||||
// Last mediaId seen in a state; unlike [_mediaId] it survives sitting
|
||||
// out, so only a real swap counts as a new video.
|
||||
int _seenMediaId = -1;
|
||||
int _mediaId = -1;
|
||||
// The mediaId whose media the player holds (or is opening); -1 for none.
|
||||
// Unlike [_mediaId] it survives a lost connection, so a reconnect to the
|
||||
// same media keeps the player, and a swap stops the previous media.
|
||||
// A swap stops the previous media.
|
||||
int _playerMediaId = -1;
|
||||
// [_playerMediaId] at the last open: the same media again (a reconnect,
|
||||
// joining back) keeps the player, any other gets a fresh one (see
|
||||
@@ -282,8 +295,7 @@ class SessionCoordinator {
|
||||
// [link]: the web link it came from (a relayed one has no `url`).
|
||||
({HelloMedia media, int after, String? link, Uint8List? art})? _pendingArt;
|
||||
AniListClient? _aniList;
|
||||
// What this member serves for media [$1]; survives a reconnect (the
|
||||
// state then looks new again).
|
||||
// What this member serves for media [$1].
|
||||
(int, _RelayOrigin)? _activeOrigin;
|
||||
RelaySource? _relaySource;
|
||||
int _relaySourceMediaId = -1;
|
||||
@@ -308,7 +320,9 @@ class SessionCoordinator {
|
||||
// True while a media open is in flight: the player's duration may still
|
||||
// belong to the previous media then.
|
||||
bool _opening = false;
|
||||
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
|
||||
/// Auto sit-out (Settings: "Auto sit-out"): idle or minimised for a
|
||||
/// while sets this member away, coming back sets it in again.
|
||||
final AutoSitOut autoSitOut = AutoSitOut(DateTime.now().toUtc());
|
||||
Map<String, String>? _openHeaders;
|
||||
|
||||
// Jellyfin: the resolved ref, the mediaId it belongs to, a generation
|
||||
@@ -355,7 +369,26 @@ class SessionCoordinator {
|
||||
required this.ytDlp,
|
||||
required this.jellyfin,
|
||||
required this.trackPrefs}) {
|
||||
connection.onReconnect = _onReconnect;
|
||||
connection
|
||||
..onWelcome = _onWelcome
|
||||
..caps = () => caps;
|
||||
}
|
||||
|
||||
// yt-dlp is there to resolve links (checked at each connect).
|
||||
bool _canResolve = false;
|
||||
|
||||
/// `hello.caps`: this app serves relays; resolves links when its yt-dlp
|
||||
/// is there; plays Jellyfin with its own account when one is linked.
|
||||
ClientCaps get caps => ClientCaps(
|
||||
source: true,
|
||||
resolve: _canResolve,
|
||||
jellyfin: jellyfin.accounts.any((a) => jellyfin.statusOf(a.serverId) != JellyfinAccountStatus.expired));
|
||||
|
||||
Future<void> _checkResolve() async {
|
||||
try {
|
||||
final y = await ytDlp.ytDlp.timeout(const Duration(milliseconds: 500));
|
||||
_canResolve = y.enabled && (y.path != null || await File(y.managedPath).exists());
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// The session's first player: created here on connect, disposed by
|
||||
@@ -449,7 +482,12 @@ class SessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
void markInput() => _idle.activity(DateTime.now().toUtc());
|
||||
/// Keyboard, mouse or an action: activity for auto sit-out.
|
||||
void markInput() => autoSitOut.input(DateTime.now().toUtc());
|
||||
|
||||
/// The window gained or lost the focus, was minimised or restored (the
|
||||
/// shell port's [WindowActivity], wired in `main`).
|
||||
void windowActivity(WindowActivity a) => autoSitOut.window(a, DateTime.now().toUtc());
|
||||
|
||||
Future<void> connect(ConnectParams params) async {
|
||||
// Own epoch first: a cancel during any await below makes this stale.
|
||||
@@ -469,7 +507,7 @@ class SessionCoordinator {
|
||||
_memberErrors = const {};
|
||||
_lastReady = false;
|
||||
_restoringReady = false;
|
||||
_lastOut = false;
|
||||
_lastAway = false;
|
||||
_joinBackAt = null;
|
||||
_seenMediaId = -1;
|
||||
_wasPlayAllowed = false;
|
||||
@@ -486,11 +524,15 @@ class SessionCoordinator {
|
||||
markInput();
|
||||
_subscribe();
|
||||
notifier.clear();
|
||||
await _checkResolve();
|
||||
await initPlayer();
|
||||
if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
|
||||
// Health follows the connection's state from here (connecting,
|
||||
// connected; a failed dial leaves its reason).
|
||||
final welcome = await connection.dial(epoch);
|
||||
// Reports, the seek check and auto sit-out run from here until leave,
|
||||
// through drops.
|
||||
_startTicker();
|
||||
try {
|
||||
final url = welcome.mediaUrl;
|
||||
if (url != null && url.isNotEmpty) {
|
||||
@@ -503,7 +545,6 @@ class SessionCoordinator {
|
||||
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
|
||||
if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
|
||||
if (media != null) _swap(media);
|
||||
_startTicker();
|
||||
} catch (e) {
|
||||
if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
|
||||
debugPrint('connect failed: $e');
|
||||
@@ -522,30 +563,18 @@ class SessionCoordinator {
|
||||
_errSub ??= connection.errors.listen((msg) {
|
||||
health.value = HealthStatus(HealthSeverity.warn, msg);
|
||||
});
|
||||
_silenceSub ??= connection.silence.listen((_) => _onConnectionSilent());
|
||||
_connectionHealth ??= connection.state.subscribe((s) {
|
||||
final h = connectionHealth(s);
|
||||
if (h != null) health.value = h;
|
||||
});
|
||||
}
|
||||
|
||||
/// The drop handling: no state for [Connection.silenceLimit], so what
|
||||
/// the server said is stale. (A closed socket keeps it until then.)
|
||||
void _onConnectionSilent() {
|
||||
if (serverState.peek() == null) return;
|
||||
serverState.value = null;
|
||||
starving.value = null;
|
||||
_wasPlayAllowed = false;
|
||||
_mediaId = -1;
|
||||
_clearPendingSeek();
|
||||
diff.reset();
|
||||
}
|
||||
|
||||
/// Opens [url] (with [headers] on every request); false on failure. A
|
||||
/// new media gets a fresh player (or the preload holding it); the media
|
||||
/// the player held before (a reconnect) keeps it.
|
||||
/// the player held before (back from sitting out, a reload) keeps it.
|
||||
Future<bool> _openMedia(String url, {Map<String, String>? headers}) async {
|
||||
if (url == _openUrl) return true;
|
||||
_opens++;
|
||||
_log.info('media $_mediaId: open ${_urlKind(url, headers)}: ${logUrl(url)}'
|
||||
'${headers == null || headers.isEmpty ? '' : ' (headers: ${headers.keys.join(', ')})'}');
|
||||
final keepPlayer = _playerMediaId == _openedFor;
|
||||
@@ -581,6 +610,12 @@ class SessionCoordinator {
|
||||
return ok;
|
||||
}
|
||||
|
||||
int _opens = 0;
|
||||
|
||||
/// How many times this session opened a media in the player (a drop
|
||||
/// and return must not add one).
|
||||
int get opens => _opens;
|
||||
|
||||
// The media whose buffer reached the gate's need was reported at once.
|
||||
int _bufferReportedFor = -1;
|
||||
|
||||
@@ -589,7 +624,7 @@ class SessionCoordinator {
|
||||
void _onBuffered() {
|
||||
final r = serverState.peek();
|
||||
if (r == null || r.transport != 'held' || _bufferReportedFor == r.mediaId) return;
|
||||
if (_lastOut || _opening || _openUrl == null || _playerMediaId != r.mediaId || !_playerReady) return;
|
||||
if (_lastAway || _opening || _openUrl == null || _playerMediaId != r.mediaId || !_playerReady) return;
|
||||
if (connection.token.isEmpty || playback.bufferedSecsAhead < r.bufferNeed) return;
|
||||
_bufferReportedFor = r.mediaId;
|
||||
_sendReport(r);
|
||||
@@ -660,9 +695,10 @@ class SessionCoordinator {
|
||||
|
||||
/// A relay nobody feeds yet: the server's fetch before it is read, or a
|
||||
/// member's before its channel is up while that member is still here
|
||||
/// (once it left, the server's buffer is all there is: open that).
|
||||
/// (once it is gone or left, the server's buffer is all there is: open
|
||||
/// that).
|
||||
bool _waitForSource(StateBroadcast r, MediaRelay relay) =>
|
||||
!relay.live && (relay.fetch || r.members.any((m) => m.id == relay.source));
|
||||
!relay.live && !relay.sourceGone && (relay.fetch || r.members.any((m) => m.id == relay.source));
|
||||
|
||||
/// Relayed through a member whose channel is down: say who everyone is
|
||||
/// waiting for (the buffer still plays what it has).
|
||||
@@ -677,8 +713,13 @@ class SessionCoordinator {
|
||||
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 ? '$gone left: playing what the server has' : 'waiting for $who to pass the video on');
|
||||
health.value = HealthStatus(
|
||||
HealthSeverity.warn,
|
||||
who == null
|
||||
? '$gone left: playing what the server has'
|
||||
: relay.sourceGone
|
||||
? '$who lost signal: playing what the server has'
|
||||
: 'waiting for $who to pass the video on');
|
||||
_relayWaitShown = true;
|
||||
} else if (_relayWaitShown) {
|
||||
_relayWaitShown = false;
|
||||
@@ -743,9 +784,10 @@ class SessionCoordinator {
|
||||
/// The server only pulls while someone watches through it. When the
|
||||
/// media came from Up next item [queueId] whose warm relay this member
|
||||
/// feeds, that source carries on instead.
|
||||
void _startRelaySource(int mediaId, RelayMedia media, {bool announce = false, String queueId = ''}) {
|
||||
// Back after a reconnect on the same media: it is still serving (and
|
||||
// redials by itself).
|
||||
void _startRelaySource(int mediaId, RelayMedia media,
|
||||
{bool announce = false, String queueId = '', String name = '', double duration = 0}) {
|
||||
// Already serving this media (it redials by itself through a drop and
|
||||
// goes on once this member is back).
|
||||
if (_relaySource != null && _relaySourceMediaId == mediaId) {
|
||||
unawaited(media.close());
|
||||
return;
|
||||
@@ -767,8 +809,11 @@ class SessionCoordinator {
|
||||
}));
|
||||
return;
|
||||
}
|
||||
// The same `info` on every dial (name and length included), so the
|
||||
// server knows a re-dial after a drop as the same media and continues.
|
||||
final src = RelaySource(
|
||||
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${connection.token}/source'), media);
|
||||
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${connection.token}/source'), media,
|
||||
name: name, duration: duration);
|
||||
_relaySource = src;
|
||||
_relaySourceMediaId = mediaId;
|
||||
relaying.value = true;
|
||||
@@ -841,7 +886,7 @@ class SessionCoordinator {
|
||||
|
||||
Future<void> _applyState(StateBroadcast r) async {
|
||||
_trackSelfReady(r);
|
||||
if (_lastOut) {
|
||||
if (_lastAway) {
|
||||
// A newer media than the one this member serves: that relay ended.
|
||||
if (_relaySourceMediaId != -1 && _relaySourceMediaId != r.mediaId) _stopRelaySource();
|
||||
await _unloadForOut();
|
||||
@@ -886,12 +931,12 @@ class SessionCoordinator {
|
||||
} else if (origin is _FileOrigin) {
|
||||
// The source first: viewers wait on it, not on this player.
|
||||
_startRelaySource(r.mediaId, RelayMedia.single(FileRelayBytes(File(origin.path))),
|
||||
announce: true, queueId: relay?.queueId ?? '');
|
||||
announce: true, queueId: relay?.queueId ?? '', name: name, duration: _durationOf(r));
|
||||
await _openMedia(origin.path);
|
||||
} else if (origin is _LinkOrigin) {
|
||||
_startRelaySource(
|
||||
r.mediaId, LinkRelayMedia(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe),
|
||||
announce: true, queueId: relay?.queueId ?? '');
|
||||
announce: true, queueId: relay?.queueId ?? '', name: name, duration: _durationOf(r));
|
||||
await _openMedia(origin.url);
|
||||
} else if (relay != null && !mine) {
|
||||
if (_waitForSource(r, relay)) {
|
||||
@@ -973,20 +1018,18 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
/// Mirrors the server's ready flag for self into [_lastReady] (force
|
||||
/// by others included). After a stale spell the server puts a ready
|
||||
/// member back in the set by itself.
|
||||
/// by others included), and whether self is away.
|
||||
void _trackSelfReady(StateBroadcast r) {
|
||||
final self = selfMember.peek();
|
||||
if (self == null) return;
|
||||
// [_seenMediaId], not [_mediaId]: that one is -1 after a lost
|
||||
// connection, which would end the restore before its echo.
|
||||
// [_seenMediaId], not [_mediaId]: that one is -1 while sitting out.
|
||||
if (_seenMediaId >= 0 && r.mediaId != _seenMediaId) _restoringReady = false;
|
||||
if (_restoringReady) {
|
||||
if (self.ready) _restoringReady = false;
|
||||
} else {
|
||||
_lastReady = self.ready;
|
||||
}
|
||||
_lastOut = self.out;
|
||||
_lastAway = self.presence == Presence.away;
|
||||
final last = _seenMediaId;
|
||||
_seenMediaId = r.mediaId;
|
||||
// A ready restored by a reconnect may not have echoed yet: unready
|
||||
@@ -1060,9 +1103,10 @@ class SessionCoordinator {
|
||||
}
|
||||
final self = selfMember.peek();
|
||||
if (self != null && self.ready) {
|
||||
final present = r.members.where((m) => !m.out).toList();
|
||||
// Who the ready counter counts (not away, not gone, can play it).
|
||||
final present = r.members.where(countsForReady).toList();
|
||||
final waiters = present
|
||||
.where((m) => m.id != self.id && !m.stale && !(m.inSet && m.ready))
|
||||
.where((m) => m.id != self.id && !(m.inSet && m.ready))
|
||||
.map((m) => m.name)
|
||||
.toList();
|
||||
if (waiters.isNotEmpty) {
|
||||
@@ -1085,8 +1129,10 @@ class SessionCoordinator {
|
||||
|
||||
void _onFeed(FeedMessage f) {
|
||||
final event = FeedEvent.of(f,
|
||||
selfName: selfMember.peek()?.name,
|
||||
readyCount: f.action == 'ready' ? serverState.peek()?.members.where((m) => m.ready).length ?? 0 : 0);
|
||||
selfId: connection.memberId,
|
||||
readyCount: f.action == 'ready'
|
||||
? serverState.peek()?.members.where((m) => m.ready && countsForReady(m)).length ?? 0
|
||||
: 0);
|
||||
notify(event);
|
||||
// The server gave up fetching what everyone waits for.
|
||||
if (event is FetchFailed && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) {
|
||||
@@ -1107,18 +1153,18 @@ class SessionCoordinator {
|
||||
/// server's gate does not wait for the next tick).
|
||||
void _sendReport(StateBroadcast r) {
|
||||
// Duration only once the open media is the server's current one.
|
||||
// Sitting out: still a report (it keeps this member fresh), empty.
|
||||
final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId;
|
||||
// Sitting out: still a report, empty.
|
||||
final known = !_lastAway && !_opening && _openUrl != null && _mediaId == r.mediaId;
|
||||
// The buffer counts only while the player holds the server's current
|
||||
// media: before a swap's media is open it is the previous one's (or
|
||||
// none), and the start must wait for this member.
|
||||
final holds = !_lastOut && !_opening && _openUrl != null && _playerMediaId == r.mediaId;
|
||||
final holds = !_lastAway && !_opening && _openUrl != null && _playerMediaId == r.mediaId;
|
||||
_checkPlayerError(r.mediaId);
|
||||
final error = playerError.peek();
|
||||
final buffered = holds ? playback.bufferedSecsAhead : 0.0;
|
||||
if (holds && buffered >= r.bufferNeed) switchTrace.mark(r.mediaId, SwitchTrace.buffered);
|
||||
connection.sendReport(
|
||||
position: _lastOut ? 0 : playback.positionSecs,
|
||||
position: _lastAway ? 0 : playback.positionSecs,
|
||||
buffered: buffered,
|
||||
eventId: r.eventId,
|
||||
duration: known ? playback.durationSecs : 0,
|
||||
@@ -1132,34 +1178,33 @@ class SessionCoordinator {
|
||||
final now = DateTime.now().toUtc();
|
||||
notifier.expire(now);
|
||||
final r = serverState.peek();
|
||||
if (connection.token.isNotEmpty && r != null && _playerReady) {
|
||||
final online = connection.state.peek().online;
|
||||
if (online && connection.token.isNotEmpty && r != null && _playerReady) {
|
||||
_sendReport(r);
|
||||
if (!_lastOut) {
|
||||
_checkSeekLanded(r);
|
||||
unawaited(_tickJellyfin(r));
|
||||
}
|
||||
if (!_lastAway) _checkSeekLanded(r);
|
||||
}
|
||||
// No warning when there is nothing to lose (unready), and none while
|
||||
// PLAYING: the ready flag gates nothing then, and watching counts as
|
||||
// activity so a pause after a long watch starts a fresh idle clock.
|
||||
if (r != null) _readyAfterJoinBack(r, now);
|
||||
final sitOutWhenIdle = AppSettings.instance.idleSitsOut.peek();
|
||||
final event = solo
|
||||
? IdleEvent.none
|
||||
: _idle.tick(now,
|
||||
playing: serverState.peek()?.transport == 'playing',
|
||||
selfReady: selfMember.peek()?.ready ?? false);
|
||||
switch (event) {
|
||||
case IdleEvent.warn:
|
||||
notify(IdleWarning(sitsOut: sitOutWhenIdle));
|
||||
case IdleEvent.unready:
|
||||
_lastReady = false;
|
||||
if (sitOutWhenIdle) {
|
||||
sitOut();
|
||||
} else {
|
||||
connection.sendReady(false, source: 'auto');
|
||||
}
|
||||
case IdleEvent.none:
|
||||
// The Jellyfin progress goes on through a drop (the player does).
|
||||
if (connection.token.isNotEmpty && r != null && _playerReady && !_lastAway) unawaited(_tickJellyfin(r));
|
||||
if (r != null && online) _readyAfterJoinBack(r, now);
|
||||
_tickAutoSitOut(r, now, online: online);
|
||||
}
|
||||
|
||||
/// Auto sit-out's second (only while online: the state is current).
|
||||
void _tickAutoSitOut(StateBroadcast? r, DateTime now, {required bool online}) {
|
||||
final step = autoSitOut.tick(now,
|
||||
self: online ? selfMember.peek()?.presence : null,
|
||||
playing: r?.transport == 'playing',
|
||||
alone: r == null || aloneIn(r.members, connection.memberId),
|
||||
enabled: !solo && AppSettings.instance.autoSitOut.peek());
|
||||
switch (step) {
|
||||
case AutoAway(:final reason):
|
||||
_log.info('auto sit-out: away (${reason.name})');
|
||||
_joinBackAt = null;
|
||||
connection.sendPresence(PresenceIntent(state: Presence.away, reason: reason));
|
||||
case AutoBack():
|
||||
_log.info('auto sit-out: back');
|
||||
_joinBack(ready: false);
|
||||
case AutoStay():
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1170,7 +1215,7 @@ class SessionCoordinator {
|
||||
final at = _joinBackAt;
|
||||
if (at == null) return;
|
||||
final self = selfMember.peek();
|
||||
if (self == null || self.out) {
|
||||
if (self == null || self.presence == Presence.away) {
|
||||
if (now.difference(at) > _joinBackWait) _joinBackAt = null;
|
||||
return;
|
||||
}
|
||||
@@ -1252,12 +1297,13 @@ class SessionCoordinator {
|
||||
|
||||
void toggleReady() {
|
||||
final self = selfMember.peek();
|
||||
if (self?.out ?? false) {
|
||||
if (self?.presence == Presence.away) {
|
||||
joinBack();
|
||||
return;
|
||||
}
|
||||
// Ready but out of the set (dropped out while stale) gates like
|
||||
// unready, so Space puts self back in instead of unreadying.
|
||||
// Ready but out of the set (joined while playing, a player error)
|
||||
// gates like unready, so Space puts self back in instead of
|
||||
// unreadying.
|
||||
final next = !((self?.ready ?? false) && (self?.inSet ?? false));
|
||||
markInput();
|
||||
_lastReady = next;
|
||||
@@ -1333,20 +1379,34 @@ class SessionCoordinator {
|
||||
void sitOut([String? memberId]) {
|
||||
final target = memberId ?? connection.memberId;
|
||||
if (target.isEmpty) return;
|
||||
final self = target == connection.memberId;
|
||||
if (self) {
|
||||
_joinBackAt = null;
|
||||
autoSitOut.manual();
|
||||
}
|
||||
markInput();
|
||||
if (target == connection.memberId) _joinBackAt = null;
|
||||
connection.sendOut(target, true);
|
||||
connection.sendPresence(PresenceIntent(target: self ? '' : target, state: Presence.away));
|
||||
}
|
||||
|
||||
/// Back from sitting out: the media opens at the server position and
|
||||
/// this member readies. Held: at once (the gate waits for the buffer);
|
||||
/// playing: once buffered, so the others do not pause for the load.
|
||||
/// Back from sitting out (`in`, through joining): the media opens at
|
||||
/// the server position and this member readies. Held: at once (the
|
||||
/// gate waits for the buffer); playing: once buffered, so the others do
|
||||
/// not pause for the load.
|
||||
void joinBack() {
|
||||
final self = selfMember.peek();
|
||||
if (self == null || !self.out) return;
|
||||
if (self == null || self.presence != Presence.away) return;
|
||||
autoSitOut.manual();
|
||||
markInput();
|
||||
connection.sendOut(self.id, false);
|
||||
if (serverState.peek()?.transport == 'playing') {
|
||||
_joinBack(ready: true);
|
||||
}
|
||||
|
||||
/// `in` again; with [ready] this member readies too (auto sit-out's
|
||||
/// return keeps the ready flag the server kept).
|
||||
void _joinBack({required bool ready}) {
|
||||
connection.sendPresence(const PresenceIntent(state: Presence.watching));
|
||||
if (!ready) {
|
||||
_joinBackAt = null;
|
||||
} else if (serverState.peek()?.transport == 'playing') {
|
||||
_joinBackAt = DateTime.now().toUtc();
|
||||
} else {
|
||||
_joinBackAt = null;
|
||||
@@ -1365,10 +1425,18 @@ class SessionCoordinator {
|
||||
/// [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}) {
|
||||
// A swap sent into a dropped link would be lost without a word.
|
||||
if (!connection.state.peek().online) {
|
||||
notify(const ActionFailed('Reconnecting: put it up again once back'));
|
||||
return;
|
||||
}
|
||||
// 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 && queueId == null) connection.sendOut(self.id, false);
|
||||
if (self != null && self.presence == Presence.away && queueId == null) {
|
||||
autoSitOut.manual();
|
||||
connection.sendPresence(const PresenceIntent(state: Presence.watching));
|
||||
}
|
||||
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url, art: art);
|
||||
connection.sendSwap(media, queueId: queueId);
|
||||
}
|
||||
@@ -1722,12 +1790,14 @@ class SessionCoordinator {
|
||||
// This member put it up: serve it to viewers without an account, with
|
||||
// its own sign-in, else the pasted link (played directly here too).
|
||||
if (origin != null) {
|
||||
final name = (r.media?['name'] as String?) ?? '';
|
||||
if (plan is OwnPlay) {
|
||||
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers)),
|
||||
queueId: stateMediaRelay(r)?.queueId ?? '');
|
||||
queueId: stateMediaRelay(r)?.queueId ?? '', name: name, duration: _durationOf(r));
|
||||
} else if (origin.link != null) {
|
||||
plan = RelayPlay(origin.link!);
|
||||
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!))));
|
||||
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!))),
|
||||
name: name, duration: _durationOf(r));
|
||||
}
|
||||
}
|
||||
jellyfinPlan.value = plan;
|
||||
@@ -1957,42 +2027,83 @@ class SessionCoordinator {
|
||||
unawaited(_playJellyfin(ref, r));
|
||||
}
|
||||
|
||||
/// A reconnect's part here (see [ReconnectHandler]): what to restore is
|
||||
/// read before the attempt dials.
|
||||
Future<void> Function(WelcomeMessage, bool Function()) _onReconnect() {
|
||||
final wasOut = _lastOut;
|
||||
var wantReady = _lastReady && !wasOut;
|
||||
final seenBefore = _seenMediaId;
|
||||
return (welcome, stale) async {
|
||||
final url = welcome.mediaUrl;
|
||||
if (url != null && url.isNotEmpty && url != _openUrl) {
|
||||
await _openMedia(url);
|
||||
}
|
||||
if (stale()) return;
|
||||
// The server forgot the dropped member; restore the ready flag it
|
||||
// last reported (not a local guess: others may have forced it).
|
||||
// Not when the video changed meanwhile and the user unreadies then.
|
||||
if (unreadyForNewMedia(
|
||||
enabled: AppSettings.instance.unreadyOnSwap.peek(),
|
||||
lastMediaId: seenBefore,
|
||||
mediaId: connection.lastState?.mediaId ?? seenBefore,
|
||||
ready: wantReady)) {
|
||||
wantReady = false;
|
||||
_lastReady = false;
|
||||
}
|
||||
// A new member (the server dropped the old one) sits out again; a
|
||||
// resumed one still does.
|
||||
final selfNow = connection.lastState?.members.where((m) => m.id == connection.memberId).firstOrNull;
|
||||
if (wasOut && !(selfNow?.out ?? false)) connection.sendOut(connection.memberId, true);
|
||||
if (wantReady) {
|
||||
_lastReady = true;
|
||||
_restoringReady = true;
|
||||
connection.sendReady(true, source: 'manual');
|
||||
}
|
||||
_startTicker();
|
||||
};
|
||||
/// A welcome's part here (see [WelcomeHandler]), before its state is
|
||||
/// applied. The first one and a resumed one need nothing: the member
|
||||
/// kept everything (ready flag, presence, queue items, relay) and the
|
||||
/// player kept the media. A new member after a drop (grace over, or the
|
||||
/// server restarted) gets this app's intent again (ready, away), and
|
||||
/// the media opens again only if it changed or its URL named the old
|
||||
/// token.
|
||||
void _onWelcome(WelcomeMessage welcome, Rejoin rejoin) {
|
||||
if (!rejoin.reconnect) return;
|
||||
_log.info('reconnected: ${rejoin.resumed ? 'resumed' : 'a new member'}'
|
||||
'${rejoin.sameServer ? '' : ' (the server restarted)'}');
|
||||
if (rejoin.resumed) return;
|
||||
final before = serverState.peek();
|
||||
final raw = welcome.state;
|
||||
StateBroadcast? now;
|
||||
try {
|
||||
now = raw == null ? null : StateBroadcast.fromJson(raw);
|
||||
} catch (_) {}
|
||||
final changed = before == null ||
|
||||
now == null ||
|
||||
(rejoin.sameServer ? now.mediaId != before.mediaId : !_sameMedia(before.media, now.media));
|
||||
final tokenUrl = rejoin.oldToken.isNotEmpty &&
|
||||
rejoin.oldToken != welcome.token &&
|
||||
(_openUrl?.contains('/${rejoin.oldToken}/') ?? false);
|
||||
if (changed || tokenUrl) {
|
||||
_forgetMedia();
|
||||
} else if (!rejoin.sameServer && _mediaId != -1) {
|
||||
// The same media under a restarted server's numbering: the player
|
||||
// keeps it.
|
||||
_mediaId = _playerMediaId = _openedFor = now.mediaId;
|
||||
_seenMediaId = now.mediaId;
|
||||
}
|
||||
// The intent the server lost: ready (unless the video changed and the
|
||||
// user unreadies then), and sitting out.
|
||||
var wantReady = _lastReady && !_lastAway;
|
||||
if (changed && wantReady && _seenMediaId >= 0 && AppSettings.instance.unreadyOnSwap.peek()) {
|
||||
wantReady = false;
|
||||
_lastReady = false;
|
||||
}
|
||||
if (_lastAway) {
|
||||
connection.sendPresence(PresenceIntent(
|
||||
state: Presence.away, reason: autoSitOut.autoAway ? PresenceReason.idle : PresenceReason.manual));
|
||||
}
|
||||
if (wantReady) {
|
||||
_restoringReady = true;
|
||||
connection.sendReady(true, source: 'manual');
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two states' `media` are the same media (a restarted server
|
||||
/// counts its `mediaId` from 1 again).
|
||||
static bool _sameMedia(Map<String, Object?>? a, Map<String, Object?>? b) {
|
||||
if (a == null || b == null) return a == b;
|
||||
final ra = refOfStateMedia(a), rb = refOfStateMedia(b);
|
||||
if (ra != null || rb != null) return ra != null && rb != null && sameJellyfinItem(ra, rb);
|
||||
return a['name'] == b['name'] && a['size'] == b['size'] && a['url'] == b['url'] && (a['relay'] == null) == (b['relay'] == null);
|
||||
}
|
||||
|
||||
/// The next state's media is new to this player: it opens again from
|
||||
/// scratch (a relay this member served for the old one stops).
|
||||
void _forgetMedia() {
|
||||
_mediaId = -1;
|
||||
_playerMediaId = -1;
|
||||
_openUrl = null;
|
||||
_openHeaders = null;
|
||||
_awaitLiveFor = -1;
|
||||
_wasPlayAllowed = false;
|
||||
diff.reset();
|
||||
_clearPendingSeek();
|
||||
_stopRelaySource();
|
||||
stopWarmSource();
|
||||
_activeOrigin = null;
|
||||
}
|
||||
|
||||
/// The media's length from a state (0 unknown).
|
||||
static double _durationOf(StateBroadcast r) => (r.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||
|
||||
/// "Stop reconnecting".
|
||||
void cancelReconnect() => connection.cancelReconnect();
|
||||
|
||||
@@ -2018,12 +2129,16 @@ class SessionCoordinator {
|
||||
await playback.disposeAll();
|
||||
}
|
||||
|
||||
Future<void> leave({bool silent = false}) async {
|
||||
/// Out of the session: with [bye] (Leave, closing the app) the member
|
||||
/// leaves the server at once (others see "left"); without it (Update and
|
||||
/// restart) it goes `gone` and the restarted app takes it back within
|
||||
/// the grace.
|
||||
Future<void> leave({bool silent = false, bool bye = true}) async {
|
||||
connection.invalidate();
|
||||
await _teardown(silent: silent);
|
||||
await _teardown(silent: silent, bye: bye);
|
||||
}
|
||||
|
||||
Future<void> _teardown({bool silent = false}) async {
|
||||
Future<void> _teardown({bool silent = false, bool bye = true}) async {
|
||||
connection.stop();
|
||||
_ticker?.cancel();
|
||||
_ticker = null;
|
||||
@@ -2041,8 +2156,8 @@ class SessionCoordinator {
|
||||
try {
|
||||
await playback.dispose();
|
||||
} catch (_) {}
|
||||
// The last lines go before the token does.
|
||||
await connection.close();
|
||||
// The last lines go before the token does, then the bye.
|
||||
await connection.close(bye: bye);
|
||||
if (!silent) {
|
||||
serverState.value = null;
|
||||
starving.value = null;
|
||||
@@ -2060,7 +2175,6 @@ class SessionCoordinator {
|
||||
await _feedSub?.cancel();
|
||||
await _nudgeSub?.cancel();
|
||||
await _errSub?.cancel();
|
||||
await _silenceSub?.cancel();
|
||||
_connectionHealth?.call();
|
||||
_bufferSub?.call();
|
||||
_aniList?.close();
|
||||
|
||||
@@ -13,7 +13,7 @@ enum SfxKind {
|
||||
/// A new video or episode is put up.
|
||||
swap,
|
||||
chat,
|
||||
/// Idle warning: auto-unready soon.
|
||||
/// Auto sit-out set you away (idle, minimised).
|
||||
warn,
|
||||
/// The connection to the server dropped / came back.
|
||||
lost,
|
||||
|
||||
@@ -261,7 +261,7 @@ const soundInfo = {
|
||||
SfxKind.kick: ('Sat out', 'Someone sits you out'),
|
||||
SfxKind.swap: ('New video', 'A different video or episode is put up'),
|
||||
SfxKind.chat: ('Message', 'A chat message from someone else'),
|
||||
SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'),
|
||||
SfxKind.warn: ('Auto sit-out', 'You were idle or minimised and now sit out'),
|
||||
SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'),
|
||||
SfxKind.back: ('Reconnected', 'Back in the server after a drop'),
|
||||
SfxKind.nudge: ('Nudge', 'Someone nudges you; always plays (volume still applies)'),
|
||||
|
||||
@@ -16,7 +16,7 @@ import '../session/app_events.dart' show ActionFailed, ScheduleResult;
|
||||
import '../session/session_coordinator.dart' show ConnectParams;
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../screens/home/profile.dart';
|
||||
import '../session/identity.dart';
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
@@ -202,7 +202,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
// plays again).
|
||||
var wasOut = false;
|
||||
_stops.add(effect(() {
|
||||
final out = coord.selfOut.value;
|
||||
final out = coord.selfAway.value;
|
||||
if (wasOut && !out && session.route.peek() == AppRoute.session) {
|
||||
_later(() {
|
||||
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
|
||||
@@ -323,7 +323,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
/// player shows the join-back card). Once out, × closes the player.
|
||||
void _closePlayer() {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
if (coord.selfOut.peek()) {
|
||||
if (coord.selfAway.peek()) {
|
||||
_show(PlayerMode.none);
|
||||
} else {
|
||||
coord.sitOut();
|
||||
@@ -447,6 +447,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
|
||||
/// Ctrl+V outside a text field (a focused field pastes on its own).
|
||||
bool _onKey(KeyEvent e) {
|
||||
// Any key is activity (auto sit-out).
|
||||
SessionState.instance.coordinator.markInput();
|
||||
if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false;
|
||||
final keys = HardwareKeyboard.instance;
|
||||
if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false;
|
||||
@@ -675,9 +677,18 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Any pointer use anywhere is activity (auto sit-out).
|
||||
final coord = SessionState.instance.coordinator;
|
||||
return Listener(
|
||||
onPointerHover: (e) => _pointer = e.localPosition,
|
||||
onPointerDown: (e) => _pointer = e.localPosition,
|
||||
onPointerHover: (e) {
|
||||
_pointer = e.localPosition;
|
||||
coord.markInput();
|
||||
},
|
||||
onPointerDown: (e) {
|
||||
_pointer = e.localPosition;
|
||||
coord.markInput();
|
||||
},
|
||||
onPointerSignal: (_) => coord.markInput(),
|
||||
child: _dropTarget(shell),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/profile.dart';
|
||||
import '../session/identity.dart';
|
||||
import '../session/delivery.dart';
|
||||
import '../session/party.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'motion.dart';
|
||||
@@ -17,9 +18,12 @@ class AvatarInfo {
|
||||
final String name;
|
||||
final Color color;
|
||||
final ImageProvider? image;
|
||||
final bool ready, buffering, relaying, stale, host, self;
|
||||
/// Sitting out: dimmed, with an "away" mark.
|
||||
final bool out;
|
||||
final bool ready, buffering, relaying, host, self;
|
||||
/// Where they stand: joining (a little faded), away (dimmed with a moon),
|
||||
/// gone (dimmed, grey ring: reconnecting).
|
||||
final Presence presence;
|
||||
/// Since when they are gone; null otherwise.
|
||||
final DateTime? goneSince;
|
||||
/// Why their player cannot open the media (`members[].error`): an error
|
||||
/// mark; empty when it can.
|
||||
final String error;
|
||||
@@ -34,13 +38,19 @@ class AvatarInfo {
|
||||
this.ready = false,
|
||||
this.buffering = false,
|
||||
this.relaying = false,
|
||||
this.stale = false,
|
||||
this.presence = Presence.watching,
|
||||
this.goneSince,
|
||||
this.host = false,
|
||||
this.self = false,
|
||||
this.out = false,
|
||||
this.error = '',
|
||||
this.delivery = '',
|
||||
});
|
||||
|
||||
/// Sitting out.
|
||||
bool get away => presence == Presence.away;
|
||||
|
||||
/// Disconnected, in their grace period.
|
||||
bool get gone => presence == Presence.gone;
|
||||
}
|
||||
|
||||
/// Stable colour for someone else (colours are not on the wire).
|
||||
@@ -87,7 +97,8 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
|
||||
}
|
||||
|
||||
/// Your own avatar from the profile (also offline).
|
||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false, String error = ''}) {
|
||||
AvatarInfo selfAvatarInfo(
|
||||
{bool ready = false, bool host = false, Presence presence = Presence.watching, String error = ''}) {
|
||||
final p = Profile.instance;
|
||||
final png = p.picture.value;
|
||||
final name = p.name.value;
|
||||
@@ -98,7 +109,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = fal
|
||||
ready: ready,
|
||||
host: host,
|
||||
self: true,
|
||||
out: out,
|
||||
presence: presence,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
@@ -115,14 +126,14 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
||||
color: self ? Profile.instance.color.value : colorForName(m.name),
|
||||
image: memberAvatarImage(m, coord),
|
||||
ready: m.ready,
|
||||
buffering: !m.stale && m.ready && m.error.isEmpty && playing && m.buffered < r!.bufferNeed,
|
||||
buffering: countsForReady(m) && m.ready && playing && m.buffered < r!.bufferNeed,
|
||||
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
|
||||
stale: m.stale,
|
||||
presence: m.presence,
|
||||
goneSince: m.goneSince,
|
||||
// The state marks the host (`members[].host`); your own app also
|
||||
// knows before the first state.
|
||||
host: m.host || (self && coord.hostFile != null),
|
||||
self: self,
|
||||
out: m.out,
|
||||
error: m.error,
|
||||
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
|
||||
);
|
||||
@@ -150,8 +161,9 @@ class PartyWatch extends StatelessWidget {
|
||||
|
||||
/// Circle avatar with a status ring outside it and a gap in the background
|
||||
/// colour, so overlapping neighbours never hide the status. Crown = host,
|
||||
/// tower = passing the video on, spinner = buffering, dimmed = stale,
|
||||
/// dimmed with a moon = sitting out, red "!" = cannot play the media.
|
||||
/// tower = passing the video on, spinner = buffering, a little faded =
|
||||
/// joining, dimmed with a grey ring = gone (reconnecting), dimmed with a
|
||||
/// moon = sitting out, red "!" = cannot play the media.
|
||||
class StatusAvatar extends StatelessWidget {
|
||||
final AvatarInfo info;
|
||||
final double size;
|
||||
@@ -168,7 +180,7 @@ class StatusAvatar extends StatelessWidget {
|
||||
final bg = Theme.of(context).colorScheme.surfaceContainerLowest;
|
||||
final ringColor = !ring
|
||||
? bg
|
||||
: info.stale
|
||||
: info.gone
|
||||
? Colors.grey.shade700
|
||||
: info.buffering
|
||||
? Colors.amber
|
||||
@@ -195,7 +207,11 @@ class StatusAvatar extends StatelessWidget {
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
padding: EdgeInsets.all(stroke),
|
||||
child: Opacity(
|
||||
opacity: info.stale || info.out ? 0.4 : 1,
|
||||
opacity: info.gone || info.away
|
||||
? 0.4
|
||||
: info.presence == Presence.joining
|
||||
? 0.7
|
||||
: 1,
|
||||
child: CircleAvatar(
|
||||
radius: size / 2,
|
||||
backgroundColor: info.color,
|
||||
@@ -224,7 +240,7 @@ class StatusAvatar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (info.out)
|
||||
if (info.away)
|
||||
Positioned(
|
||||
key: const ValueKey('away-mark'),
|
||||
left: -3,
|
||||
@@ -281,7 +297,7 @@ class _AvatarStackState extends State<AvatarStack> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = widget.size;
|
||||
int rank(AvatarInfo m) => m.host ? 0 : (m.out ? 3 : m.ready ? 1 : 2);
|
||||
int rank(AvatarInfo m) => m.host ? 0 : (m.gone ? 4 : m.away ? 3 : m.ready ? 1 : 2);
|
||||
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
|
||||
final visible = sorted.take(widget.shown).toList();
|
||||
final extra = sorted.length - visible.length;
|
||||
@@ -356,13 +372,16 @@ class Pill extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Someone by display name (feed lines carry names, not ids): from the
|
||||
/// latest state when still there, else their initial on a stable colour.
|
||||
AvatarInfo avatarForName(String name, [SessionCoordinator? coord]) {
|
||||
/// Someone in a feed line or toast: by member [id] when it has one, else
|
||||
/// by display [name]; from the latest state when still there, else their
|
||||
/// initial on a stable colour.
|
||||
AvatarInfo avatarForName(String name, [SessionCoordinator? coord, String id = '']) {
|
||||
final c = coord ?? SessionState.instance.coordinator;
|
||||
final r = c.serverState.value;
|
||||
for (final m in r?.members ?? const <MemberView>[]) {
|
||||
if (m.name == name) return memberAvatarInfo(m, r, c);
|
||||
final members = r?.members ?? const <MemberView>[];
|
||||
// By id when the line has one (names change and repeat), else by name.
|
||||
for (final m in members) {
|
||||
if (id.isNotEmpty ? m.id == id : m.name == name) return memberAvatarInfo(m, r, c);
|
||||
}
|
||||
return AvatarInfo(name: name, color: colorForName(name));
|
||||
}
|
||||
@@ -188,7 +188,7 @@ class ActivityRow extends StatelessWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StatusAvatar(info: avatarForName(item.actor), size: 34, badges: false),
|
||||
StatusAvatar(info: avatarForName(item.actor, null, item.actorId), size: 34, badges: false),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -261,7 +261,7 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
|
||||
case 'join':
|
||||
return (Icons.login, Colors.green, 'joined');
|
||||
case 'leave':
|
||||
final reason = f.reason == 'silent too long' ? ' · timed out' : (f.reason.isEmpty ? '' : ' · ${f.reason}');
|
||||
final reason = f.reason == 'grace' ? ' · lost signal too long' : '';
|
||||
return (Icons.logout, Colors.grey, 'left$reason');
|
||||
case 'ready':
|
||||
return (Icons.check_circle, Colors.green, 'is ready');
|
||||
@@ -279,12 +279,15 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
|
||||
return (Icons.fast_forward, scheme.primary, 'seeked');
|
||||
case 'swap':
|
||||
return (Icons.swap_horiz, scheme.primary, 'changed the media');
|
||||
case 'stale':
|
||||
case 'gone':
|
||||
return (Icons.cloud_off, Colors.orange, 'lost signal');
|
||||
case 'out':
|
||||
case 'return':
|
||||
return (Icons.cloud_done, Colors.green, 'is back');
|
||||
case 'away':
|
||||
final who = f.target;
|
||||
return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out');
|
||||
case 'back':
|
||||
final why = f.reason.isEmpty ? '' : ' · ${f.reason}';
|
||||
return (Icons.bedtime, Colors.blueGrey, '${who == null || who.isEmpty ? 'is sitting out' : 'sat $who out'}$why');
|
||||
case 'in':
|
||||
return (Icons.login, Colors.green, 'joined back');
|
||||
case 'queue-add':
|
||||
return (Icons.playlist_add_rounded, scheme.primary, 'added ${f.target ?? ''} to Up next');
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
|
||||
import '../session/delivery.dart';
|
||||
import '../session/party.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
@@ -282,8 +283,11 @@ class _ServerLabel extends StatelessWidget {
|
||||
builder: (context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final r = coord.serverState.value;
|
||||
final members = r?.members ?? const <MemberView>[];
|
||||
final ready = members.where((m) => m.ready).length;
|
||||
// The ready counter counts who the presence policy counts (not away,
|
||||
// not gone, able to play it).
|
||||
final counted = (r?.members ?? const <MemberView>[]).where(countsForReady).toList();
|
||||
final ready = counted.where((m) => m.ready).length;
|
||||
final stale = coord.stateStale.value;
|
||||
return Column(
|
||||
key: const ValueKey('server-label'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -294,7 +298,11 @@ class _ServerLabel extends StatelessWidget {
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
|
||||
),
|
||||
Text(
|
||||
r == null ? 'connecting…' : 'ready $ready/${members.length}',
|
||||
r == null
|
||||
? 'connecting…'
|
||||
: stale
|
||||
? 'reconnecting…'
|
||||
: 'ready $ready/${counted.length}',
|
||||
style: TextStyle(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
@@ -586,10 +594,13 @@ class _MemberTile extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final m = member;
|
||||
final status = m.stale
|
||||
? 'reconnecting…'
|
||||
: m.out
|
||||
final since = m.goneSince;
|
||||
final status = m.presence == Presence.gone
|
||||
? 'reconnecting…${since == null ? '' : ' (${sinceText(since, DateTime.now().toUtc())})'}'
|
||||
: m.presence == Presence.away
|
||||
? 'sitting out'
|
||||
: m.presence == Presence.joining
|
||||
? 'joining…'
|
||||
: m.error.isNotEmpty
|
||||
? "can't play this: ${m.error}"
|
||||
: info.buffering
|
||||
@@ -628,11 +639,11 @@ class _MemberTile extends StatelessWidget {
|
||||
}
|
||||
},
|
||||
items: () => [
|
||||
if (!m.out) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'),
|
||||
if (m.presence != Presence.away) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'),
|
||||
if (!info.self) const AppMenuItem(value: 'nudge', label: 'Nudge'),
|
||||
if (info.self && m.out)
|
||||
if (info.self && m.presence == Presence.away)
|
||||
const AppMenuItem(value: 'back', label: 'Join back')
|
||||
else if (!m.out)
|
||||
else if (m.presence != Presence.away)
|
||||
const AppMenuItem(value: 'out', label: 'Sit out'),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../session/health.dart';
|
||||
import '../session/party.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/app_menu.dart' show AppGlass;
|
||||
@@ -61,6 +62,7 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
|
||||
StatusView? playerStatusFor({
|
||||
required HealthStatus health,
|
||||
bool reconnecting = false,
|
||||
bool unstable = false,
|
||||
StateBroadcast? state,
|
||||
MemberView? self,
|
||||
bool slowConnect = false,
|
||||
@@ -70,7 +72,11 @@ StatusView? playerStatusFor({
|
||||
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
||||
tone: HealthSeverity.warn, action: StatusAction.cancel);
|
||||
}
|
||||
if (playerError.isNotEmpty && state?.media != null && !(self?.out ?? false)) {
|
||||
if (unstable) {
|
||||
return const StatusView(StatusKind.reconnecting, 'Connection unstable…', tone: HealthSeverity.warn);
|
||||
}
|
||||
final away = self?.presence == Presence.away;
|
||||
if (playerError.isNotEmpty && state?.media != null && !away) {
|
||||
return StatusView(StatusKind.problem, "Can't play this: $playerError",
|
||||
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
|
||||
}
|
||||
@@ -84,7 +90,7 @@ StatusView? playerStatusFor({
|
||||
final r = state;
|
||||
if (r == null || r.media == null || r.transport == 'playing') return null;
|
||||
// Sitting out: the join-back card says it; nobody waits for you.
|
||||
if (self != null && self.out) return null;
|
||||
if (away) return null;
|
||||
if (r.transport == 'finished') {
|
||||
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
|
||||
}
|
||||
@@ -92,7 +98,7 @@ StatusView? playerStatusFor({
|
||||
// them; say why instead of "waiting for".
|
||||
final failed = [
|
||||
for (final m in r.members)
|
||||
if (m.error.isNotEmpty && !m.out && (self == null || m.id != self.id)) m,
|
||||
if (m.error.isNotEmpty && isPresent(m) && (self == null || m.id != self.id)) m,
|
||||
];
|
||||
// As the server holds playback: someone in the ready set short of the
|
||||
// minimum buffer.
|
||||
@@ -119,8 +125,8 @@ StatusView? playerStatusFor({
|
||||
return StatusView(StatusKind.you, "Press Space when you're ready",
|
||||
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
|
||||
}
|
||||
// Members sitting out are never waited for nor counted.
|
||||
final active = [for (final m in r.members) if (!m.stale && !m.out && m.error.isEmpty) m];
|
||||
// Members sitting out or gone are never waited for nor counted.
|
||||
final active = [for (final m in r.members) if (countsForReady(m)) m];
|
||||
final others = [
|
||||
for (final m in active)
|
||||
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
||||
@@ -137,6 +143,7 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
|
||||
return playerStatusFor(
|
||||
health: c.health.value,
|
||||
reconnecting: c.reconnecting.value,
|
||||
unstable: c.unstable.value,
|
||||
state: c.serverState.value,
|
||||
self: c.selfMember.value,
|
||||
slowConnect: slowConnect,
|
||||
|
||||
@@ -6,9 +6,10 @@ import 'dart:ui' as ui;
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show Presence;
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../screens/home/profile.dart';
|
||||
import '../session/identity.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import 'avatar.dart';
|
||||
@@ -80,8 +81,8 @@ class _ProfileCardState extends State<ProfileCard> {
|
||||
final hosting = online && !session.solo.value && coord.hostFile != null;
|
||||
final color = _p.color.value;
|
||||
final png = _p.picture.value;
|
||||
final out = self?.out ?? false;
|
||||
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, out: out);
|
||||
final out = self?.presence == Presence.away;
|
||||
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, presence: self?.presence ?? Presence.watching);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
||||
@@ -302,7 +302,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_miniOverlay(),
|
||||
// Sitting out: nothing is loaded; the card covers the
|
||||
// empty player (which stays mounted for joining back).
|
||||
if (_coord.selfOut.value) _sitOut(),
|
||||
if (_coord.selfAway.value) _sitOut(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../jellyfin/accounts_panel.dart';
|
||||
import '../schedule/animeflow.dart';
|
||||
import '../screens/home/about_page.dart';
|
||||
import '../screens/home/settings_page.dart';
|
||||
import '../session/auto_sit_out.dart' show AutoSitOutTimes;
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
import '../state/session_state.dart';
|
||||
@@ -100,15 +101,17 @@ class SettingsTab extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'When idle',
|
||||
title: 'Away',
|
||||
child: SignalBuilder(
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('idle-sits-out'),
|
||||
key: const ValueKey('auto-sit-out'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('When idle, sit out instead of just unready'),
|
||||
subtitle: const Text('After 10 min away while ready and held, your player unloads the video until you join back (Space)'),
|
||||
value: AppSettings.instance.idleSitsOut.value,
|
||||
onChanged: AppSettings.instance.setIdleSitsOut,
|
||||
title: const Text('Auto sit-out'),
|
||||
subtitle: Text('Idle for ${AutoSitOutTimes.idle.inMinutes} min while nothing plays, or minimised '
|
||||
'for ${AutoSitOutTimes.minimised.inMinutes} min, sits you out so nobody waits for you; '
|
||||
"you're back as soon as you return. Never when you're alone"),
|
||||
value: AppSettings.instance.autoSitOut.value,
|
||||
onChanged: AppSettings.instance.setAutoSitOut,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -778,7 +781,7 @@ class NotificationsInfo extends StatelessWidget {
|
||||
),
|
||||
line('Sat out or nudged by someone', 'Only on your end, with their picture'),
|
||||
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
|
||||
line('App notices', 'Shader changes, the idle warning'),
|
||||
line('App notices', 'Shader changes, auto sit-out setting you away'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -80,7 +80,7 @@ class _PartyToastsState extends State<PartyToasts> {
|
||||
final playing = coord.serverState.value?.transport == 'playing';
|
||||
final popups = AppSettings.instance.chatPopups.value;
|
||||
for (final t in fresh) {
|
||||
final toast = shellToastFor(t, (n) => avatarForName(n, coord));
|
||||
final toast = shellToastFor(t, (n) => avatarForName(n, coord, t.actorId));
|
||||
// Chat per Settings → Notifications (it is in the chat either way).
|
||||
if (toast.chat && !popups.shows(playing: playing)) continue;
|
||||
_add(toast);
|
||||
|
||||
@@ -19,6 +19,7 @@ import '../sfx/synth_sfx.dart';
|
||||
import '../session/health.dart';
|
||||
import '../session/notifier.dart';
|
||||
import '../session/connection.dart' show Connection;
|
||||
import '../session/identity.dart' show ClientIdentity;
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../widgets/avatar_pipeline.dart';
|
||||
import 'connect_prefs.dart';
|
||||
@@ -175,7 +176,9 @@ class SessionState {
|
||||
/// Every toast, sound and activity-log line goes through it.
|
||||
late final Notifier notifier = Notifier(sfx: sfx, attention: ports.shell.requestAttention);
|
||||
/// The link to the server; lost/back go to the notifier.
|
||||
late final Connection connection = Connection(notify: notifier.notify);
|
||||
/// The link to the server, with this install's stored identity
|
||||
/// (`hello.client` / `seq`); lost/back go to the notifier.
|
||||
late final Connection connection = Connection(notify: notifier.notify, identity: ClientIdentity.stored());
|
||||
late final SessionCoordinator coordinator =
|
||||
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: ports.links);
|
||||
final lastForm = ConnectForm();
|
||||
@@ -513,8 +516,10 @@ class SessionState {
|
||||
await server?.stop();
|
||||
}
|
||||
|
||||
Future<void> leave() async {
|
||||
await coordinator.leave();
|
||||
/// Out of the session (Leave, the app closing). [bye] false: Update
|
||||
/// and restart, the restarted app takes the member back within its grace.
|
||||
Future<void> leave({bool bye = true}) async {
|
||||
await coordinator.leave(bye: bye);
|
||||
await stopServer();
|
||||
solo.value = false;
|
||||
coordinator.solo = false;
|
||||
|
||||
@@ -37,7 +37,7 @@ const _keySoundPreset = 'soundPreset';
|
||||
const _keyWelcomeDismissed = 'welcomeDismissed';
|
||||
const _keySoundPresetFor = 'soundPresetFor';
|
||||
const _keyUnreadyOnSwap = 'unreadyOnSwap';
|
||||
const _keyIdleSitsOut = 'idleSitsOut';
|
||||
const keyAutoSitOut = 'autoSitOut';
|
||||
const _keyChatPopups = 'chatPopups';
|
||||
const _keyShareDiagnostics = 'shareDiagnostics';
|
||||
|
||||
@@ -77,8 +77,9 @@ class AppSettings {
|
||||
/// Session: send ready=false when the video changes (a swap keeps the
|
||||
/// server's ready flags).
|
||||
final unreadyOnSwap = signal(false);
|
||||
/// Session: the idle auto-unready sits this member out instead.
|
||||
final idleSitsOut = signal(false);
|
||||
/// Session: auto sit-out (idle while nothing plays, or minimised for a
|
||||
/// while, sets you away; coming back sets you in).
|
||||
final autoSitOut = signal(true);
|
||||
/// The player controls' skin (`controls/`). Applies live.
|
||||
final controlsSkin = signal(ControlsSkin.standard);
|
||||
/// The bar's clock: elapsed / total or the time left.
|
||||
@@ -151,8 +152,8 @@ class AppSettings {
|
||||
if (advance is bool) autoAdvance.value = advance;
|
||||
final unready = box.get(_keyUnreadyOnSwap);
|
||||
if (unready is bool) unreadyOnSwap.value = unready;
|
||||
final idleOut = box.get(_keyIdleSitsOut);
|
||||
if (idleOut is bool) idleSitsOut.value = idleOut;
|
||||
final autoOut = box.get(keyAutoSitOut);
|
||||
if (autoOut is bool) autoSitOut.value = autoOut;
|
||||
controlsSkin.value =
|
||||
ControlsSkin.fromStored(box.get(_keyControlsSkin), legacyMpvControls: box.get(_keyMpvControls));
|
||||
final time = box.get(_keyTimeDisplay);
|
||||
@@ -230,9 +231,9 @@ class AppSettings {
|
||||
_put(_keyUnreadyOnSwap, on);
|
||||
}
|
||||
|
||||
void setIdleSitsOut(bool on) {
|
||||
idleSitsOut.value = on;
|
||||
_put(_keyIdleSitsOut, on);
|
||||
void setAutoSitOut(bool on) {
|
||||
autoSitOut.value = on;
|
||||
_put(keyAutoSitOut, on);
|
||||
}
|
||||
|
||||
void setControlsSkin(ControlsSkin skin) {
|
||||
|
||||
@@ -5,8 +5,9 @@ import 'package:hive_ce/hive.dart';
|
||||
// changed formats); the restructure steps add theirs here.
|
||||
|
||||
/// The layout this build reads. 1: the layout of 1.2.x, where versioning
|
||||
/// starts (a box without a version is at 1).
|
||||
const settingsSchemaVersion = 1;
|
||||
/// starts (a box without a version is at 1). 2: auto sit-out replaces the
|
||||
/// idle switch.
|
||||
const settingsSchemaVersion = 2;
|
||||
|
||||
/// The box key holding the stored version.
|
||||
const settingsSchemaKey = 'schemaVersion';
|
||||
@@ -20,9 +21,19 @@ class SettingsMigration {
|
||||
const SettingsMigration(this.to, this.what, this.apply);
|
||||
}
|
||||
|
||||
/// Every step in order of [SettingsMigration.to] (2, 3, ...); empty while
|
||||
/// version 1 is current.
|
||||
const settingsMigrations = <SettingsMigration>[];
|
||||
/// Every step in order of [SettingsMigration.to] (2, 3, ...).
|
||||
const settingsMigrations = <SettingsMigration>[
|
||||
SettingsMigration(2, 'auto sit-out replaces "when idle, sit out instead of just unready"', _autoSitOut),
|
||||
];
|
||||
|
||||
/// 2: `idleSitsOut` only chose between two automatic steps after 10 idle
|
||||
/// minutes (unready, or sit out); both are auto sit-out now, on unless
|
||||
/// switched off (`autoSitOut`).
|
||||
Future<void> _autoSitOut(Box box) async {
|
||||
if (!box.containsKey('idleSitsOut')) return;
|
||||
await box.delete('idleSitsOut');
|
||||
if (!box.containsKey('autoSitOut')) await box.put('autoSitOut', true);
|
||||
}
|
||||
|
||||
/// Runs the [steps] past the box's stored version up to [current], in
|
||||
/// order, storing the version after each, then stores [current]. A box
|
||||
|
||||
@@ -6,6 +6,31 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
enum SyncStatus { idle, connecting, open, closed, error }
|
||||
|
||||
/// The server refused the hello: its `err` ([error]; a proto 15 server's
|
||||
/// has no code), or the socket closed before a welcome with [closeCode].
|
||||
class HelloRefused implements Exception {
|
||||
final ErrorFrame? error;
|
||||
final int? closeCode;
|
||||
const HelloRefused({this.error, this.closeCode});
|
||||
|
||||
/// The refusal's code: the frame's, else read from the close code.
|
||||
ErrCode? get code =>
|
||||
error?.code ??
|
||||
switch (closeCode) {
|
||||
CloseCodes.version => ErrCode.version,
|
||||
CloseCodes.secret => ErrCode.secret,
|
||||
CloseCodes.replaced => ErrCode.replaced,
|
||||
CloseCodes.stale => ErrCode.stale,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
String toString() => error?.message ?? 'closed before welcome ($closeCode)';
|
||||
}
|
||||
|
||||
/// One `/sync` socket at a time: the hello and welcome, the frames as
|
||||
/// streams, the sends. [token], [memberId] and [lastState] outlive a
|
||||
/// dropped socket ([drop]); only [disconnect] forgets them.
|
||||
class SyncClient {
|
||||
/// Local clock; injectable so tests can run a client whose clock is off.
|
||||
final DateTime Function() _clock;
|
||||
@@ -21,19 +46,27 @@ class SyncClient {
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _sub;
|
||||
Completer<void>? _socketDone;
|
||||
final StreamController<StateBroadcast> _states = StreamController.broadcast();
|
||||
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
|
||||
final StreamController<NudgeNotice> _nudges = StreamController.broadcast();
|
||||
final StreamController<String> _errors = StreamController.broadcast();
|
||||
final StreamController<ErrorFrame> _errors = StreamController.broadcast();
|
||||
final StreamController<SyncStatus> _status = StreamController.broadcast();
|
||||
|
||||
Stream<StateBroadcast> get states => _states.stream;
|
||||
/// Feed lines (chat included, with its text), and after a welcome the
|
||||
/// lines it replayed (`replayed`).
|
||||
Stream<FeedMessage> get feeds => _feeds.stream;
|
||||
/// Someone poked this member (`nudge`).
|
||||
Stream<NudgeNotice> get nudges => _nudges.stream;
|
||||
Stream<String> get errors => _errors.stream;
|
||||
/// `err` frames after the welcome.
|
||||
Stream<ErrorFrame> get errors => _errors.stream;
|
||||
Stream<SyncStatus> get status => _status.stream;
|
||||
|
||||
/// Called on every frame from the server (the welcome included), before
|
||||
/// it is handled: the connection's silence clock.
|
||||
void Function()? onFrame;
|
||||
|
||||
/// The `/sync` URL last dialled; its host serves `/a/...` and the media.
|
||||
Uri? wsUri;
|
||||
String token = '';
|
||||
@@ -43,38 +76,51 @@ class SyncClient {
|
||||
/// relays); private like [token].
|
||||
String? relayUrl;
|
||||
StateBroadcast? lastState;
|
||||
/// `welcome.instance` of the server last joined: a welcome of another
|
||||
/// one is a restarted (or another) server.
|
||||
String instance = '';
|
||||
/// The highest `feed.seq` seen from [instance] (`hello.feedAfter`).
|
||||
int lastFeedSeq = 0;
|
||||
/// The close code of the last socket that closed (null: none given).
|
||||
int? closeCode;
|
||||
Completer<WelcomeMessage>? _pendingWelcome;
|
||||
|
||||
/// A plain hello (tools and tests): [client] / [seq] as given.
|
||||
Future<WelcomeMessage> connect({
|
||||
required Uri wsUri,
|
||||
required String secret,
|
||||
required String name,
|
||||
String avatar = '',
|
||||
HelloMedia? media,
|
||||
String hostKey = '',
|
||||
String resume = '',
|
||||
String client = '',
|
||||
int seq = 0,
|
||||
}) =>
|
||||
connectWith(
|
||||
wsUri: wsUri,
|
||||
hello: HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: secret,
|
||||
user: HelloUser(name: name, avatar: avatar),
|
||||
media: media ?? const HelloMedia(name: '', size: 0, duration: 0),
|
||||
hostKey: hostKey,
|
||||
resume: resume,
|
||||
client: client,
|
||||
seq: seq,
|
||||
),
|
||||
);
|
||||
|
||||
/// Dials [wsUri], sends [hello] and waits for the welcome (10 s).
|
||||
Future<WelcomeMessage> connectWith({required Uri wsUri, required HelloMessage hello}) async {
|
||||
await disconnect();
|
||||
/// Dials [wsUri], sends [hello] and waits for the welcome (10 s). A
|
||||
/// socket still open is dropped first. [onWelcome] runs once the welcome
|
||||
/// is read, before its state and replayed feed go out on the streams.
|
||||
/// Throws [HelloRefused] when the server refuses.
|
||||
Future<WelcomeMessage> connectWith(
|
||||
{required Uri wsUri, required HelloMessage hello, void Function(WelcomeMessage welcome)? onWelcome}) async {
|
||||
await drop();
|
||||
clockSync.reset();
|
||||
closeCode = null;
|
||||
this.wsUri = wsUri;
|
||||
_status.add(SyncStatus.connecting);
|
||||
final ch = WebSocketChannel.connect(wsUri);
|
||||
unawaited(ch.ready.then((_) {}, onError: (_) {}));
|
||||
_channel = ch;
|
||||
final done = _socketDone = Completer<void>();
|
||||
final welcomeCompleter = Completer<WelcomeMessage>();
|
||||
_sub = ch.stream.listen(
|
||||
(raw) => _onText('$raw', welcomeCompleter),
|
||||
@@ -85,8 +131,12 @@ class SyncClient {
|
||||
_status.add(SyncStatus.error);
|
||||
},
|
||||
onDone: () {
|
||||
closeCode = ch.closeCode;
|
||||
if (!done.isCompleted) done.complete();
|
||||
if (!welcomeCompleter.isCompleted) {
|
||||
welcomeCompleter.completeError(StateError('closed before welcome'));
|
||||
welcomeCompleter.completeError(ch.closeCode == null
|
||||
? StateError('closed before welcome')
|
||||
: HelloRefused(closeCode: ch.closeCode));
|
||||
}
|
||||
_status.add(SyncStatus.closed);
|
||||
},
|
||||
@@ -99,28 +149,47 @@ class SyncClient {
|
||||
const Duration(seconds: 10),
|
||||
);
|
||||
_pendingWelcome = null;
|
||||
final sameServer = instance.isNotEmpty && welcome.instance == instance;
|
||||
token = welcome.token;
|
||||
memberId = welcome.memberId;
|
||||
mediaUrl = welcome.mediaUrl;
|
||||
relayUrl = welcome.relayUrl;
|
||||
instance = welcome.instance;
|
||||
// Another server's lines count from 1 again: its replay is not ours.
|
||||
if (!sameServer) lastFeedSeq = 0;
|
||||
onWelcome?.call(welcome);
|
||||
if (welcome.state != null) {
|
||||
try {
|
||||
lastState = StateBroadcast.fromJson(welcome.state!);
|
||||
_states.add(lastState!);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (sameServer) {
|
||||
for (final f in welcome.feed) {
|
||||
_addFeed(f.copyWith(replayed: true));
|
||||
}
|
||||
}
|
||||
_status.add(SyncStatus.open);
|
||||
sendPing();
|
||||
return welcome;
|
||||
} catch (e) {
|
||||
_pendingWelcome = null;
|
||||
await disconnect();
|
||||
await drop();
|
||||
_status.add(SyncStatus.error);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _addFeed(FeedMessage f) {
|
||||
if (f.seq > lastFeedSeq) lastFeedSeq = f.seq;
|
||||
// The server delivers every chat twice: a `chat` frame carrying the
|
||||
// text (turned into a feed line below) and a textless feed line. Keep
|
||||
// only the former: one send, one row, one toast, one sound.
|
||||
if (f.action != 'chat') _feeds.add(f);
|
||||
}
|
||||
|
||||
void _onText(String raw, Completer<WelcomeMessage> welcomeCompleter) {
|
||||
onFrame?.call();
|
||||
Map<String, Object?> decoded;
|
||||
try {
|
||||
final parsed = jsonDecode(raw);
|
||||
@@ -138,9 +207,13 @@ class SyncClient {
|
||||
welcomeCompleter.completeError(e);
|
||||
}
|
||||
} else if (t == 'err') {
|
||||
welcomeCompleter.completeError(
|
||||
ProtocolException('${decoded['message'] ?? 'rejected'}'),
|
||||
);
|
||||
ErrorFrame frame;
|
||||
try {
|
||||
frame = ErrorFrame.fromJson(decoded);
|
||||
} catch (_) {
|
||||
frame = const ErrorFrame(code: null, message: 'rejected');
|
||||
}
|
||||
welcomeCompleter.completeError(HelloRefused(error: frame));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -153,12 +226,7 @@ class SyncClient {
|
||||
} catch (_) {}
|
||||
case 'feed':
|
||||
try {
|
||||
final f = FeedMessage.fromJson(decoded);
|
||||
// Server delivers every chat twice: a ChatMessage carrying the
|
||||
// text (converted to a feed entry in `case 'chat'` below) and a
|
||||
// textless feed entry. Keep only the former: one send, one row,
|
||||
// one toast, one sfx.
|
||||
if (f.action != 'chat') _feeds.add(f);
|
||||
_addFeed(FeedMessage.fromJson(decoded));
|
||||
} catch (_) {}
|
||||
case 'nudge':
|
||||
try {
|
||||
@@ -173,21 +241,26 @@ class SyncClient {
|
||||
receivedMs: _clock().millisecondsSinceEpoch);
|
||||
} catch (_) {}
|
||||
case 'err':
|
||||
_errors.add('${decoded['message'] ?? 'error'}');
|
||||
case 'chat':
|
||||
try {
|
||||
_feeds.add(
|
||||
FeedMessage(
|
||||
actor: '${decoded['from'] ?? '?'}',
|
||||
action: 'chat',
|
||||
reason: (decoded['text'] as String?) ?? '',
|
||||
at: DateTime.now().toUtc(),
|
||||
),
|
||||
);
|
||||
_errors.add(ErrorFrame.fromJson(decoded));
|
||||
} catch (_) {}
|
||||
case 'chat':
|
||||
_feeds.add(
|
||||
FeedMessage(
|
||||
actor: '${decoded['from'] ?? '?'}',
|
||||
actorId: decoded['fromId'] is String ? decoded['fromId'] as String : '',
|
||||
action: 'chat',
|
||||
reason: (decoded['text'] as String?) ?? '',
|
||||
at: DateTime.now().toUtc(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A socket is open (it may be half-open: the connection's silence
|
||||
/// check tells).
|
||||
bool get isOpen => _channel != null;
|
||||
|
||||
void _send(Map<String, Object?> json) {
|
||||
final ch = _channel;
|
||||
if (ch == null) return;
|
||||
@@ -209,7 +282,8 @@ class SyncClient {
|
||||
_send(SeekIntent(position: position, seq: seq).toJson());
|
||||
}
|
||||
|
||||
/// One clock sample; the coordinator sends one per second.
|
||||
/// One clock sample; the connection sends one per second (it also keeps
|
||||
/// this member under the server's silence limit).
|
||||
void sendPing() {
|
||||
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
|
||||
}
|
||||
@@ -265,34 +339,58 @@ class SyncClient {
|
||||
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
|
||||
}
|
||||
|
||||
/// Sits the member [target] out, or with [out] false brings this
|
||||
/// member back (only oneself can come back).
|
||||
void sendOut(String target, bool out) {
|
||||
_send(OutIntent(target: target, out: out).toJson());
|
||||
/// Sits someone out, or brings this member back in (see [PresenceIntent]).
|
||||
void sendPresence(PresenceIntent intent) {
|
||||
_send(intent.toJson());
|
||||
}
|
||||
|
||||
void sendNudge(String target) {
|
||||
_send(NudgeIntent(target: target).toJson());
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
/// `bye`: this member leaves now. Waits (at most [wait]) for the server
|
||||
/// to close the socket, so the frame went out before ours closes.
|
||||
Future<void> sendBye({Duration wait = const Duration(milliseconds: 600)}) async {
|
||||
final done = _socketDone;
|
||||
if (_channel == null || done == null) return;
|
||||
_send(const ByeMessage().toJson());
|
||||
try {
|
||||
await done.future.timeout(wait);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Closes the socket (no [SyncStatus] for it) and keeps who this member
|
||||
/// is ([token], [memberId], [lastState], [instance]): a reconnect takes
|
||||
/// it over. The close is not waited for (a half-open link never
|
||||
/// answers it).
|
||||
Future<void> drop() async {
|
||||
final pending = _pendingWelcome;
|
||||
_pendingWelcome = null;
|
||||
if (pending != null && !pending.isCompleted) {
|
||||
pending.completeError(StateError('cancelled'));
|
||||
} try {
|
||||
await _sub?.cancel();
|
||||
} catch (_) {}
|
||||
}
|
||||
final sub = _sub;
|
||||
_sub = null;
|
||||
try {
|
||||
await _channel?.sink.close();
|
||||
} catch (_) {}
|
||||
final ch = _channel;
|
||||
_channel = null;
|
||||
_socketDone = null;
|
||||
try {
|
||||
await sub?.cancel().timeout(const Duration(milliseconds: 500));
|
||||
} catch (_) {}
|
||||
if (ch != null) unawaited(ch.sink.close().timeout(const Duration(seconds: 2), onTimeout: () {}).catchError((_) {}));
|
||||
}
|
||||
|
||||
/// [drop], then forgets the member and the server: the next connect is
|
||||
/// a new session.
|
||||
Future<void> disconnect() async {
|
||||
await drop();
|
||||
token = '';
|
||||
memberId = '';
|
||||
mediaUrl = null;
|
||||
relayUrl = null;
|
||||
lastState = null;
|
||||
instance = '';
|
||||
lastFeedSeq = 0;
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
|
||||
@@ -37,7 +37,7 @@ typedef RestartFn = Future<void> Function(String executable, List<String> args);
|
||||
// relaunched detached with the same args, then the window closes.
|
||||
Future<void> _closeAndRestart(String executable, List<String> args) async {
|
||||
final keep = args.where((a) => a != '--upgrade').toList();
|
||||
await shutDownApp(beforeQuit: () async {
|
||||
await shutDownApp(restart: true, beforeQuit: () async {
|
||||
try {
|
||||
await Hive.close();
|
||||
} catch (e) {
|
||||
|
||||
@@ -43,7 +43,7 @@ void main() {
|
||||
position: 0,
|
||||
serverTime: DateTime.now().toUtc(),
|
||||
mediaId: 0,
|
||||
members: [MemberView(id: 'a', name: 'Alex', ready: false, inSet: false, buffered: 0, stale: false)],
|
||||
members: [MemberView(id: 'a', name: 'Alex', ready: false, inSet: false, buffered: 0)],
|
||||
);
|
||||
session.route.value = AppRoute.session;
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// The Connection against a scripted fake server: what a hello says, a
|
||||
// reconnect's hello, refusals in plain words, the backoff.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/session/connection.dart';
|
||||
import 'package:starsync/src/session/identity.dart';
|
||||
import 'package:starsync/src/sync/sync_client.dart';
|
||||
|
||||
/// A `/sync` endpoint that hands each socket and its hello to [onHello].
|
||||
class _Fake {
|
||||
final HttpServer _server;
|
||||
final hellos = <Map<String, Object?>>[];
|
||||
void Function(WebSocket ws, Map<String, Object?> hello) onHello;
|
||||
_Fake(this._server, this.onHello) {
|
||||
_server.listen((req) async {
|
||||
final ws = await WebSocketTransformer.upgrade(req);
|
||||
var first = true;
|
||||
ws.listen((m) {
|
||||
if (!first) return;
|
||||
first = false;
|
||||
final hello = Map<String, Object?>.from(jsonDecode('$m') as Map);
|
||||
hellos.add(hello);
|
||||
onHello(ws, hello);
|
||||
}, onError: (_) {});
|
||||
});
|
||||
}
|
||||
|
||||
static Future<_Fake> start(void Function(WebSocket ws, Map<String, Object?> hello) onHello) async =>
|
||||
_Fake(await HttpServer.bind(InternetAddress.loopbackIPv4, 0), onHello);
|
||||
|
||||
int get port => _server.port;
|
||||
Future<void> close() => _server.close(force: true);
|
||||
}
|
||||
|
||||
void _welcome(WebSocket ws, {String instance = 'boot-1', bool resumed = false}) => ws.add(jsonEncode(WelcomeMessage(
|
||||
token: 'tok', memberId: 'm1', instance: instance, resumed: resumed,
|
||||
state: StateBroadcast(
|
||||
eventId: 1, transport: 'held', position: 0, serverTime: DateTime.now().toUtc(), mediaId: 0, members: const [])
|
||||
.toJson())
|
||||
.toJson()));
|
||||
|
||||
Future<void> _poll(FutureOr<bool> Function() cond, String what, {Duration timeout = const Duration(seconds: 10)}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (await cond()) return;
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
fail('timed out waiting for $what');
|
||||
}
|
||||
|
||||
ConnectParams _params(int port) => ConnectParams(host: '127.0.0.1', port: port, secret: 's', name: 'me');
|
||||
|
||||
void main() {
|
||||
test('the hello: version range, client id, a rising seq, app, platform, caps; feedAfter only on a reconnect',
|
||||
() async {
|
||||
final fake = await _Fake.start((ws, hello) {
|
||||
_welcome(ws);
|
||||
// One feed line, then the link dies.
|
||||
ws.add(jsonEncode(FeedMessage(actor: 'Ann', action: 'join', at: DateTime.now().toUtc(), seq: 5).toJson()));
|
||||
});
|
||||
final id = ClientIdentity.ephemeral();
|
||||
final c = Connection(identity: id, caps: () => const ClientCaps(source: true, resolve: true),
|
||||
reconnectDelay: const Duration(milliseconds: 50));
|
||||
try {
|
||||
c.begin(_params(fake.port));
|
||||
await c.dial(c.invalidate());
|
||||
await _poll(() => c.client.lastFeedSeq == 5, 'the feed line');
|
||||
final h = fake.hellos.single;
|
||||
expect(h['proto'], RelayConstants.protoMax);
|
||||
expect(h['protoMin'], RelayConstants.protoMin);
|
||||
expect(h['client'], id.clientId);
|
||||
expect((h['client'] as String).length, 22);
|
||||
expect(validClientId(h['client'] as String), isTrue);
|
||||
expect(h['seq'], 1);
|
||||
expect(h['app'], appVersion);
|
||||
expect(h['platform'], Platform.operatingSystem);
|
||||
expect(h['caps'], {'source': true, 'resolve': true});
|
||||
expect(h.containsKey('feedAfter'), isFalse, reason: 'a first join replays nothing');
|
||||
expect(h['media'], <String, Object?>{}, reason: 'a proto 15 server answers its own proto mismatch');
|
||||
|
||||
await c.client.drop();
|
||||
// A dropped socket: no status from it; drive the drop the way the
|
||||
// silence check does.
|
||||
c.scheduleReconnect();
|
||||
await _poll(() => fake.hellos.length == 2 && c.state.peek() is ConnOnline, 'the reconnect');
|
||||
expect(fake.hellos[1]['seq'], 2);
|
||||
expect(fake.hellos[1]['client'], id.clientId);
|
||||
expect(fake.hellos[1]['feedAfter'], 5);
|
||||
} finally {
|
||||
await c.dispose();
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('a proto 15 host refuses in its own words: "the host runs an older Starsync"', () async {
|
||||
final fake = await _Fake.start((ws, hello) {
|
||||
ws.add(jsonEncode({'t': 'err', 'message': 'proto mismatch: got 16, want 15'}));
|
||||
ws.close(1008, 'refused');
|
||||
});
|
||||
final c = Connection();
|
||||
try {
|
||||
c.begin(_params(fake.port));
|
||||
await expectLater(c.dial(c.invalidate()), throwsA(isA<HelloRefused>()));
|
||||
final s = c.state.peek();
|
||||
expect(s, isA<ConnRefused>());
|
||||
expect((s as ConnRefused).code, 'version');
|
||||
expect(s.message, 'the host runs an older Starsync — they need to update');
|
||||
} finally {
|
||||
await c.dispose();
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('refusals in plain words', () {
|
||||
HelloRefused v({int? min, int? max}) =>
|
||||
HelloRefused(error: ErrorFrame(code: ErrCode.version, message: 'proto mismatch', min: min, max: max));
|
||||
expect(connectErrorText(v(min: 15, max: 15)), contains('older Starsync'));
|
||||
expect(connectErrorText(v(min: RelayConstants.protoMax + 1, max: RelayConstants.protoMax + 2)),
|
||||
contains('newer Starsync'));
|
||||
expect(connectErrorText(const HelloRefused(error: ErrorFrame(code: ErrCode.secret, message: 'wrong secret'))),
|
||||
'wrong server password (401)');
|
||||
expect(connectErrorText(const HelloRefused(closeCode: CloseCodes.replaced)), replacedText);
|
||||
expect(connectErrorText(const HelloRefused(error: ErrorFrame(code: ErrCode.stale, message: 'older'))), replacedText);
|
||||
});
|
||||
|
||||
test('backoff: about 1 s first, doubling, capped near 15 s, with jitter', () {
|
||||
final c = Connection();
|
||||
for (var i = 0; i < 20; i++) {
|
||||
final first = c.delayFor(1).inMilliseconds;
|
||||
expect(first, inInclusiveRange(800, 1200));
|
||||
expect(c.delayFor(2).inMilliseconds, inInclusiveRange(1600, 2400));
|
||||
expect(c.delayFor(30).inMilliseconds, inInclusiveRange(12000, 18000));
|
||||
}
|
||||
unawaited(c.dispose());
|
||||
});
|
||||
|
||||
test('replaced is terminal: no more attempts', () async {
|
||||
late WebSocket held;
|
||||
final fake = await _Fake.start((ws, hello) {
|
||||
held = ws;
|
||||
_welcome(ws);
|
||||
});
|
||||
final c = Connection(reconnectDelay: const Duration(milliseconds: 50));
|
||||
try {
|
||||
c.begin(_params(fake.port));
|
||||
await c.dial(c.invalidate());
|
||||
held.add(jsonEncode(const ErrorFrame(code: ErrCode.replaced, message: 'another connection').toJson()));
|
||||
await held.close(CloseCodes.replaced, 'replaced');
|
||||
await _poll(() => c.state.peek() is ConnReplaced, 'replaced');
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
expect(c.state.peek(), isA<ConnReplaced>());
|
||||
expect(fake.hellos, hasLength(1), reason: 'it never dials again by itself');
|
||||
} finally {
|
||||
await c.dispose();
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,18 +1,31 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): reconnect,
|
||||
// resume and ready restore; "feed and chat toasts" in step 2 (Notifier).
|
||||
// restructure: rewrite in step 2 (Status + Notifier): "feed and chat toasts
|
||||
// name their actor exactly".
|
||||
//
|
||||
// The session through drops, takeovers and goodbyes: real coordinators
|
||||
// (real mpv), a real server, and FlakyProxy between a member and it.
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_core/testing.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:starsync/src/jellyfin/accounts.dart';
|
||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||
import 'package:starsync/src/playback/yt_dlp_app.dart';
|
||||
import 'package:starsync/src/screens/home/picked_media.dart';
|
||||
import 'package:starsync/src/session/connection.dart';
|
||||
import 'package:starsync/src/session/identity.dart';
|
||||
import 'package:starsync/src/session/notifier.dart';
|
||||
import 'package:starsync/src/session/session_coordinator.dart';
|
||||
import 'package:starsync/src/sfx/sfx.dart';
|
||||
import 'package:starsync/src/state/session_state.dart';
|
||||
import 'package:starsync/src/sync/sync_client.dart';
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
|
||||
Future<void> _poll(
|
||||
FutureOr<bool> Function() cond,
|
||||
String what, {
|
||||
@@ -26,224 +39,38 @@ Future<void> _poll(
|
||||
fail('timed out waiting for $what');
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'dead server clears server state and reconnects instead of freezing',
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final server = SyncServer(
|
||||
const AppConfig(port: 0, secret: 'presence', name: 'presence'));
|
||||
await server.start();
|
||||
final coord = SessionCoordinator();
|
||||
try {
|
||||
await coord.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.boundPort,
|
||||
secret: 'presence',
|
||||
name: 'watcher',
|
||||
));
|
||||
await coord.playback.player.setVolume(0);
|
||||
await _poll(() => coord.serverState.peek() != null, 'server state to arrive');
|
||||
expect(coord.reconnecting.peek(), isFalse);
|
||||
/// Two minutes of small test video with sound (headless mpv needs audio).
|
||||
Future<String> _media() async {
|
||||
final path = '$_tmp/coord_presence_120s.mkv';
|
||||
if (await File(path).exists()) return path;
|
||||
final r = await Process.run('ffmpeg', [
|
||||
'-y', '-v', 'error',
|
||||
'-f', 'lavfi', '-i', 'testsrc2=size=160x90:rate=12',
|
||||
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=22050',
|
||||
'-t', '120', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '12', '-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac', '-b:a', '32k', path,
|
||||
]);
|
||||
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
|
||||
return path;
|
||||
}
|
||||
|
||||
await server.stop();
|
||||
await _poll(
|
||||
() => coord.reconnecting.peek() == true,
|
||||
'reconnect loop to engage',
|
||||
);
|
||||
await _poll(
|
||||
() => coord.serverState.peek() == null,
|
||||
'stale state (ghost members) to clear',
|
||||
);
|
||||
coord.cancelReconnect();
|
||||
expect(coord.reconnecting.peek(), isFalse);
|
||||
} finally {
|
||||
await coord.dispose();
|
||||
/// Serves [file] with ranges: a link everyone opens directly, so only the
|
||||
/// sync socket goes through the proxy.
|
||||
Future<HttpServer> _origin(String file) async {
|
||||
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
s.listen((req) async {
|
||||
final size = await File(file).length();
|
||||
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mkv');
|
||||
req.response.statusCode = r.status;
|
||||
r.headers.forEach(req.response.headers.set);
|
||||
try {
|
||||
if (r.status == 200 || r.status == 206) {
|
||||
await req.response.addStream(File(file).openRead(r.start, (r.end ?? size - 1) + 1));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'cancel then retry within the reconnect delay dials only once',
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final first = SyncServer(
|
||||
const AppConfig(port: 0, secret: 'race', name: 'race'));
|
||||
await first.start();
|
||||
final port = first.boundPort;
|
||||
final coord = SessionCoordinator();
|
||||
SyncServer? second;
|
||||
final observer = SyncClient();
|
||||
try {
|
||||
await coord.connect(ConnectParams(
|
||||
host: '127.0.0.1', port: port, secret: 'race', name: 'watcher'));
|
||||
await coord.playback.player.setVolume(0);
|
||||
await first.stop();
|
||||
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
||||
second = SyncServer(
|
||||
AppConfig(port: port, secret: 'race', name: 'race'));
|
||||
await second.start();
|
||||
var joins = 0;
|
||||
final sub = observer.feeds.listen((f) {
|
||||
if (f.action == 'join' && f.actor == 'watcher') joins++;
|
||||
});
|
||||
await observer.connect(
|
||||
wsUri: Uri.parse('ws://127.0.0.1:$port/sync'),
|
||||
secret: 'race',
|
||||
name: 'observer');
|
||||
// Within the 1.5 s delay: the first attempt must die with Cancel.
|
||||
coord.cancelReconnect();
|
||||
coord.retry();
|
||||
await _poll(() => !coord.reconnecting.peek() && coord.serverState.peek() != null,
|
||||
'reconnect to land');
|
||||
await Future.delayed(const Duration(seconds: 4));
|
||||
await sub.cancel();
|
||||
expect(joins, 1, reason: 'a stale attempt dialled a second time');
|
||||
expect(
|
||||
second.hub.engine.members.values
|
||||
.where((m) => m.name == 'watcher')
|
||||
.length,
|
||||
1);
|
||||
} finally {
|
||||
await observer.dispose();
|
||||
await coord.dispose();
|
||||
await second?.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'reconnect restores the server-side ready flag (force-all included)',
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final first = SyncServer(
|
||||
const AppConfig(port: 0, secret: 'force', name: 'force'));
|
||||
await first.start();
|
||||
final port = first.boundPort;
|
||||
final coord = SessionCoordinator();
|
||||
final friend = SyncClient();
|
||||
SyncServer? second;
|
||||
try {
|
||||
await coord.connect(ConnectParams(
|
||||
host: '127.0.0.1', port: port, secret: 'force', name: 'watcher'));
|
||||
await coord.playback.player.setVolume(0);
|
||||
await friend.connect(
|
||||
wsUri: Uri.parse('ws://127.0.0.1:$port/sync'),
|
||||
secret: 'force',
|
||||
name: 'friend');
|
||||
// Never pressed Space locally; a friend marks everyone ready.
|
||||
friend.sendForce('all', true);
|
||||
await _poll(() => coord.selfMember.peek()?.ready == true,
|
||||
'forced ready to reach watcher');
|
||||
await friend.disconnect();
|
||||
await first.stop();
|
||||
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
||||
second = SyncServer(
|
||||
AppConfig(port: port, secret: 'force', name: 'force'));
|
||||
await second.start();
|
||||
await _poll(
|
||||
() => second!.hub.engine.members.values
|
||||
.any((m) => m.name == 'watcher' && m.ready),
|
||||
'watcher to come back ready');
|
||||
} finally {
|
||||
await friend.dispose();
|
||||
await coord.dispose();
|
||||
await second?.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'cancel during connect setup aborts without joining',
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final server = SyncServer(
|
||||
const AppConfig(port: 0, secret: '', name: 'cancel'));
|
||||
await server.start();
|
||||
final coord = SessionCoordinator();
|
||||
try {
|
||||
final pending = coord.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.boundPort,
|
||||
secret: '',
|
||||
name: 'watcher'));
|
||||
await coord.cancelConnect();
|
||||
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
expect(server.hub.engine.members, isEmpty);
|
||||
expect(coord.reconnecting.peek(), isFalse);
|
||||
} finally {
|
||||
await coord.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'cancelling a host setup releases the port',
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
Hive.init(
|
||||
'${Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path}/hive_coord_test');
|
||||
final probe = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||
final port = probe.port;
|
||||
await probe.close();
|
||||
final session = SessionState.instance;
|
||||
try {
|
||||
final pending = session.host(
|
||||
ConnectParams(host: '', port: port, secret: '', name: 'host'),
|
||||
ConnectForm());
|
||||
await session.cancelPending();
|
||||
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
|
||||
expect(session.route.peek(), isNot(AppRoute.session));
|
||||
final rebind = await ServerSocket.bind(InternetAddress.anyIPv4, port);
|
||||
await rebind.close();
|
||||
} finally {
|
||||
await session.coordinator.leave(silent: true);
|
||||
await session.stopServer();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'feed and chat toasts name their actor exactly',
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast'));
|
||||
await server.start();
|
||||
final sounds = _RecordingSfx();
|
||||
final coord = SessionCoordinator(sfx: sounds);
|
||||
final friend = SyncClient();
|
||||
try {
|
||||
await coord.connect(ConnectParams(
|
||||
host: '127.0.0.1', port: server.boundPort, secret: 'toast', name: 'Bob'));
|
||||
await coord.playback.player.setVolume(0);
|
||||
await friend.connect(
|
||||
wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), secret: 'toast', name: 'Bob Jr');
|
||||
friend.sendChat('hi there');
|
||||
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob Jr: hi there'), 'chat toast');
|
||||
final toasts = coord.toasts.peek();
|
||||
expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr');
|
||||
expect(toasts.firstWhere((t) => t.text == 'Bob Jr: hi there').actor, 'Bob Jr');
|
||||
// Others' chat makes a sound, your own does not.
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
|
||||
coord.sendChat('mine');
|
||||
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob: mine'), 'own chat');
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
|
||||
expect(sounds.kinds, contains(SfxKind.join));
|
||||
} finally {
|
||||
await friend.dispose();
|
||||
await coord.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
await req.response.close();
|
||||
} catch (_) {}
|
||||
});
|
||||
return s;
|
||||
}
|
||||
|
||||
class _RecordingSfx implements SfxService {
|
||||
@@ -251,3 +78,351 @@ class _RecordingSfx implements SfxService {
|
||||
@override
|
||||
Future<void> play(SfxCue cue) async => kinds.add(cue.kind);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late List<SessionCoordinator> all;
|
||||
|
||||
setUp(() {
|
||||
MediaKit.ensureInitialized();
|
||||
all = [];
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
for (final c in all) {
|
||||
await c.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
/// A member: its own install unless [identity] is given.
|
||||
Future<SessionCoordinator> member(int port, String name,
|
||||
{String secret = 'pw', _RecordingSfx? sfx, ClientIdentity? identity}) async {
|
||||
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await acc.ensureLoaded(validate: false);
|
||||
final n = Notifier(sfx: sfx ?? const NoopSfxService());
|
||||
final c = SessionCoordinator(
|
||||
notifier: n,
|
||||
connection: Connection(notify: n.notify, identity: identity),
|
||||
jellyfin: acc,
|
||||
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
|
||||
ytDlp: AppYtDlp(YtDlp('$_tmp/coord_presence_no_ytdlp', enabled: false)));
|
||||
all.add(c);
|
||||
await c.connect(ConnectParams(host: '127.0.0.1', port: port, secret: secret, name: name));
|
||||
await c.playback.player.setVolume(0);
|
||||
return c;
|
||||
}
|
||||
|
||||
/// [up] puts the video up, everyone readies, it plays.
|
||||
Future<void> playTogether(SessionCoordinator up, List<SessionCoordinator> xs, String link) async {
|
||||
expect(await up.swapPicked(LinkMedia(link, anyway: true)), isNull);
|
||||
await _poll(() => xs.every((x) => x.playback.durationSecs > 100), 'all opened it', timeout: const Duration(seconds: 30));
|
||||
for (final x in xs) {
|
||||
x.toggleReady();
|
||||
}
|
||||
await _poll(() => xs.every((x) => x.playback.player.state.playing), 'all playing');
|
||||
}
|
||||
|
||||
test('a drop and return while playing: same member, nothing reopened, the state kept throughout',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'drop'));
|
||||
await server.start();
|
||||
final origin = await _origin(await _media());
|
||||
final proxy = await FlakyProxy.start(server.boundPort);
|
||||
try {
|
||||
final alice = await member(server.boundPort, 'alice');
|
||||
final sounds = _RecordingSfx();
|
||||
final bob = await member(proxy.port, 'bob', sfx: sounds);
|
||||
await playTogether(alice, [alice, bob], 'http://127.0.0.1:${origin.port}/ep.mkv');
|
||||
final id = bob.connection.memberId;
|
||||
final token = bob.connection.token;
|
||||
final opens = bob.opens;
|
||||
final mediaId = bob.serverState.peek()!.mediaId;
|
||||
|
||||
// Bob's link goes half-open (no FIN): unstable from ~3 s, lost at
|
||||
// ~9 s, then a new dial through a link that works.
|
||||
final link = proxy.links.single;
|
||||
link.blackhole();
|
||||
var sawStale = false, sawUnstable = false, lostState = false;
|
||||
await _poll(() {
|
||||
final s = bob.serverState.peek();
|
||||
if (s == null || s.media == null) lostState = true;
|
||||
if (bob.stateStale.peek()) sawStale = true;
|
||||
if (bob.unstable.peek()) sawUnstable = true;
|
||||
return proxy.links.length > 1 && bob.connection.state.peek() is ConnOnline;
|
||||
}, 'bob back online', timeout: const Duration(seconds: 30));
|
||||
expect(lostState, isFalse, reason: 'the state (and the update band off the video) survives the drop');
|
||||
expect(sawUnstable, isTrue, reason: '"connection unstable" before lost');
|
||||
expect(sawStale, isTrue);
|
||||
expect(bob.connection.memberId, id, reason: 'the same member');
|
||||
expect(bob.connection.token, token);
|
||||
expect(bob.opens, opens, reason: 'the player kept the media');
|
||||
expect(bob.serverState.peek()!.mediaId, mediaId);
|
||||
// Playing on (a drift correction on the return may seek once).
|
||||
await _poll(() => bob.playback.player.state.playing, 'bob playing', timeout: const Duration(seconds: 3));
|
||||
expect(server.hub.engine.members.values.where((m) => m.name == 'bob'), hasLength(1));
|
||||
// A drop that long is heard once each way.
|
||||
await _poll(() => sounds.kinds.contains(SfxKind.back), 'back sound');
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.lost), hasLength(1));
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.back), hasLength(1));
|
||||
// In sync again.
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
final gap = (alice.playback.positionSecs - bob.playback.positionSecs).abs();
|
||||
expect(gap, lessThan(1.5), reason: 'in sync after the return');
|
||||
} finally {
|
||||
await proxy.close();
|
||||
await origin.close(force: true);
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('10 flaps in 60 s: at most one lost/back sound, nothing reopened, one member',
|
||||
timeout: const Timeout(Duration(minutes: 3)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'flaps'));
|
||||
await server.start();
|
||||
final origin = await _origin(await _media());
|
||||
final proxy = await FlakyProxy.start(server.boundPort);
|
||||
try {
|
||||
final alice = await member(server.boundPort, 'alice');
|
||||
final sounds = _RecordingSfx();
|
||||
final bob = await member(proxy.port, 'bob', sfx: sounds);
|
||||
await playTogether(alice, [alice, bob], 'http://127.0.0.1:${origin.port}/ep.mkv');
|
||||
final id = bob.connection.memberId;
|
||||
final opens = bob.opens;
|
||||
final watcher = SyncClient();
|
||||
final feed = <FeedMessage>[];
|
||||
final sub = watcher.feeds.listen(feed.add);
|
||||
await watcher.connect(wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), secret: 'pw', name: 'watcher');
|
||||
// The sync socket: the first link, then each one dialled while
|
||||
// reconnecting (the session log upload and pictures go through the
|
||||
// proxy too).
|
||||
var sync = proxy.links.single;
|
||||
final dials = proxy.connections.listen((l) {
|
||||
if (bob.connection.state.peek().retrying) sync = l;
|
||||
});
|
||||
var drops = 0;
|
||||
final stopCount = bob.connection.state.subscribe((s) {
|
||||
if (s is ConnLost) drops++;
|
||||
});
|
||||
try {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await _poll(() => bob.connection.state.peek() is ConnOnline, 'bob online before flap $i');
|
||||
sync.reset();
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
}
|
||||
await _poll(() => bob.connection.state.peek() is ConnOnline, 'bob online after the flaps');
|
||||
expect(drops, greaterThanOrEqualTo(10), reason: 'every flap dropped the link');
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.lost).length, lessThanOrEqualTo(1));
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.back).length, lessThanOrEqualTo(1));
|
||||
expect(bob.opens, opens, reason: 'no reopen per flap');
|
||||
expect(bob.connection.memberId, id);
|
||||
expect(server.hub.engine.members.values.where((m) => m.name == 'bob'), hasLength(1));
|
||||
expect(feed.where((f) => f.actorId == id && (f.action == 'join' || f.action == 'leave')), isEmpty,
|
||||
reason: 'quick returns say nothing to the others');
|
||||
expect(bob.playback.player.state.playing, isTrue);
|
||||
} finally {
|
||||
stopCount();
|
||||
await dials.cancel();
|
||||
await sub.cancel();
|
||||
await watcher.dispose();
|
||||
}
|
||||
} finally {
|
||||
await proxy.close();
|
||||
await origin.close(force: true);
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('replaced: opened in another window, the older one stops for good',
|
||||
timeout: const Timeout(Duration(minutes: 1)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'replaced'));
|
||||
await server.start();
|
||||
try {
|
||||
final install = ClientIdentity.ephemeral();
|
||||
final first = await member(server.boundPort, 'me', identity: install);
|
||||
final id = first.connection.memberId;
|
||||
final second = await member(server.boundPort, 'me', identity: install);
|
||||
expect(second.connection.memberId, id, reason: 'the newer connection took the member over');
|
||||
await _poll(() => first.connection.state.peek() is ConnReplaced, 'the first one replaced');
|
||||
expect(first.health.peek().message, replacedText);
|
||||
// It does not fight back.
|
||||
await Future.delayed(const Duration(seconds: 4));
|
||||
expect(first.connection.state.peek(), isA<ConnReplaced>());
|
||||
expect(first.reconnecting.peek(), isFalse);
|
||||
expect(second.connection.state.peek(), isA<ConnOnline>());
|
||||
expect(server.hub.engine.members, hasLength(1));
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('leave sends bye (others see "left" at once); update and restart does not',
|
||||
timeout: const Timeout(Duration(minutes: 1)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'bye'));
|
||||
await server.start();
|
||||
final watcher = SyncClient();
|
||||
final feed = <FeedMessage>[];
|
||||
final sub = watcher.feeds.listen(feed.add);
|
||||
try {
|
||||
await watcher.connect(wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), secret: 'pw', name: 'watcher');
|
||||
final leaver = await member(server.boundPort, 'leaver');
|
||||
final id = leaver.connection.memberId;
|
||||
final sw = Stopwatch()..start();
|
||||
await leaver.leave();
|
||||
await _poll(() => feed.any((f) => f.action == 'leave' && f.actorId == id), 'leave line',
|
||||
timeout: const Duration(seconds: 3));
|
||||
expect(sw.elapsed, lessThan(const Duration(seconds: 3)));
|
||||
expect(feed.firstWhere((f) => f.action == 'leave' && f.actorId == id).reason, 'bye');
|
||||
expect(server.hub.engine.byId(id), isNull);
|
||||
|
||||
final restarter = await member(server.boundPort, 'restarter');
|
||||
final rid = restarter.connection.memberId;
|
||||
await restarter.leave(bye: false);
|
||||
await _poll(() => server.hub.engine.byId(rid)?.presence == Presence.gone, 'gone, kept for its grace');
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
expect(feed.where((f) => f.action == 'leave' && f.actorId == rid), isEmpty);
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
await watcher.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('a dead server: the state stays (stale), the app reconnects, and "stop reconnecting" stops',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'presence', name: 'presence'));
|
||||
await server.start();
|
||||
final coord = await member(server.boundPort, 'watcher', secret: 'presence');
|
||||
await _poll(() => coord.serverState.peek() != null, 'server state to arrive');
|
||||
expect(coord.reconnecting.peek(), isFalse);
|
||||
expect(coord.stateStale.peek(), isFalse);
|
||||
|
||||
await server.stop();
|
||||
await _poll(() => coord.reconnecting.peek() == true, 'reconnect loop to engage');
|
||||
expect(coord.serverState.peek(), isNotNull, reason: 'a drop keeps the last state');
|
||||
expect(coord.stateStale.peek(), isTrue);
|
||||
coord.cancelReconnect();
|
||||
expect(coord.reconnecting.peek(), isFalse);
|
||||
});
|
||||
|
||||
test('cancel then retry within the reconnect delay dials only once',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final first = SyncServer(const AppConfig(port: 0, secret: 'race', name: 'race'));
|
||||
await first.start();
|
||||
final port = first.boundPort;
|
||||
SyncServer? second;
|
||||
final observer = SyncClient();
|
||||
try {
|
||||
final coord = await member(port, 'watcher', secret: 'race');
|
||||
await first.stop();
|
||||
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
||||
second = SyncServer(AppConfig(port: port, secret: 'race', name: 'race'));
|
||||
await second.start();
|
||||
var joins = 0;
|
||||
final sub = observer.feeds.listen((f) {
|
||||
if (f.action == 'join' && f.actor == 'watcher') joins++;
|
||||
});
|
||||
await observer.connect(wsUri: Uri.parse('ws://127.0.0.1:$port/sync'), secret: 'race', name: 'observer');
|
||||
// Within the first delay: the first attempt must die with Cancel.
|
||||
coord.cancelReconnect();
|
||||
coord.retry();
|
||||
await _poll(() => !coord.reconnecting.peek() && coord.connection.state.peek().online, 'reconnect to land');
|
||||
await Future.delayed(const Duration(seconds: 4));
|
||||
await sub.cancel();
|
||||
expect(joins, 1, reason: 'a stale attempt dialled a second time');
|
||||
expect(second.hub.engine.members.values.where((m) => m.name == 'watcher').length, 1);
|
||||
} finally {
|
||||
await observer.dispose();
|
||||
await second?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('a restarted server (not resumed): the app sends its ready again (force-all included)',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final first = SyncServer(const AppConfig(port: 0, secret: 'force', name: 'force'));
|
||||
await first.start();
|
||||
final port = first.boundPort;
|
||||
final friend = SyncClient();
|
||||
SyncServer? second;
|
||||
try {
|
||||
final coord = await member(port, 'watcher', secret: 'force');
|
||||
await friend.connect(wsUri: Uri.parse('ws://127.0.0.1:$port/sync'), secret: 'force', name: 'friend');
|
||||
// Never pressed Space locally; a friend marks everyone ready.
|
||||
friend.sendForce('all', true);
|
||||
await _poll(() => coord.selfMember.peek()?.ready == true, 'forced ready to reach watcher');
|
||||
await friend.disconnect();
|
||||
await first.stop();
|
||||
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
||||
second = SyncServer(AppConfig(port: port, secret: 'force', name: 'force'));
|
||||
await second.start();
|
||||
await _poll(() => second!.hub.engine.members.values.any((m) => m.name == 'watcher' && m.ready),
|
||||
'watcher to come back ready');
|
||||
} finally {
|
||||
await friend.dispose();
|
||||
await second?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel'));
|
||||
await server.start();
|
||||
final coord = SessionCoordinator();
|
||||
try {
|
||||
final pending =
|
||||
coord.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: '', name: 'watcher'));
|
||||
await coord.cancelConnect();
|
||||
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
expect(server.hub.engine.members, isEmpty);
|
||||
expect(coord.reconnecting.peek(), isFalse);
|
||||
} finally {
|
||||
await coord.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelling a host setup releases the port', timeout: const Timeout(Duration(minutes: 1)), () async {
|
||||
Hive.init('$_tmp/hive_coord_test');
|
||||
final probe = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||
final port = probe.port;
|
||||
await probe.close();
|
||||
final session = SessionState.instance;
|
||||
try {
|
||||
final pending = session.host(ConnectParams(host: '', port: port, secret: '', name: 'host'), ConnectForm());
|
||||
await session.cancelPending();
|
||||
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
|
||||
expect(session.route.peek(), isNot(AppRoute.session));
|
||||
final rebind = await ServerSocket.bind(InternetAddress.anyIPv4, port);
|
||||
await rebind.close();
|
||||
} finally {
|
||||
await session.coordinator.leave(silent: true);
|
||||
await session.stopServer();
|
||||
}
|
||||
});
|
||||
|
||||
test('feed and chat toasts name their actor exactly', timeout: const Timeout(Duration(minutes: 1)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast'));
|
||||
await server.start();
|
||||
final sounds = _RecordingSfx();
|
||||
final friend = SyncClient();
|
||||
try {
|
||||
final coord = await member(server.boundPort, 'Bob', secret: 'toast', sfx: sounds);
|
||||
await friend.connect(
|
||||
wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), secret: 'toast', name: 'Bob Jr');
|
||||
friend.sendChat('hi there');
|
||||
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob Jr: hi there'), 'chat toast');
|
||||
final toasts = coord.toasts.peek();
|
||||
expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr');
|
||||
final chat = toasts.firstWhere((t) => t.text == 'Bob Jr: hi there');
|
||||
expect(chat.actor, 'Bob Jr');
|
||||
expect(chat.actorId, friend.memberId, reason: 'matched by member id');
|
||||
// Others' chat makes a sound, your own does not.
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
|
||||
coord.sendChat('mine');
|
||||
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob: mine'), 'own chat');
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
|
||||
expect(sounds.kinds, contains(SfxKind.join));
|
||||
} finally {
|
||||
await friend.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import 'package:starsync/src/session/delivery.dart';
|
||||
import 'package:starsync/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);
|
||||
MemberView(id: id, name: name, ready: true, inSet: true, buffered: buffered, host: host, rate: rate);
|
||||
|
||||
StateBroadcast _state(Map<String, Object?>? media, List<MemberView> members, {String transport = 'playing'}) =>
|
||||
StateBroadcast(
|
||||
@@ -87,7 +87,7 @@ void main() {
|
||||
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});
|
||||
MemberView(id: id, name: name, ready: true, inSet: true, buffered: 30, 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')!;
|
||||
|
||||
@@ -60,9 +60,15 @@ JellyfinMediaSource fixtureSource() => JellyfinPlaybackInfo.fromJson(
|
||||
jsonDecode(jellyfinFixture('playback_info')) as Map<String, dynamic>)
|
||||
.primarySource!;
|
||||
|
||||
MemberView member(String id, String name, {Map<String, bool> jf = const {}, bool stale = false}) =>
|
||||
MemberView member(String id, String name, {Map<String, bool> jf = const {}, bool gone = false}) =>
|
||||
MemberView(
|
||||
id: id, name: name, ready: true, inSet: true, buffered: 10, stale: stale, jf: jf);
|
||||
id: id,
|
||||
name: name,
|
||||
ready: true,
|
||||
inSet: true,
|
||||
buffered: 10,
|
||||
presence: gone ? Presence.gone : Presence.watching,
|
||||
jf: jf);
|
||||
|
||||
void main() {
|
||||
setUp(reports.clear);
|
||||
@@ -371,7 +377,7 @@ void main() {
|
||||
member('m10', 'Ann', jf: {fixtureServerId: true}),
|
||||
member('m2', 'Bob', jf: {fixtureServerId: true}),
|
||||
member('m1', 'Guest', jf: {fixtureServerId: false}),
|
||||
member('m0', 'Old', jf: {fixtureServerId: true}, stale: true),
|
||||
member('m0', 'Old', jf: {fixtureServerId: true}, gone: true),
|
||||
member('m3', 'Fresh'),
|
||||
];
|
||||
expect(autoAdvanceSwapper(members, fixtureServerId), 'm2');
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): the
|
||||
// feed rows follow the new feed actions one-to-one (away/in/gone/return);
|
||||
// step 2 (Status + Notifier): every row, when noticeFor takes a context.
|
||||
// restructure: rewrite in step 2 (Status + Notifier): every row, when
|
||||
// noticeFor takes a context.
|
||||
//
|
||||
// The Notifier policy as a table: one row per AppEvent subtype, what
|
||||
// noticeFor makes of it (toast text and actor, sound, attention, activity-log
|
||||
@@ -14,8 +13,21 @@ import 'package:starsync/src/sfx/sfx.dart';
|
||||
|
||||
final _at = DateTime.utc(2026, 1, 1);
|
||||
|
||||
FeedMessage _feed(String action, {String actor = 'Ann', String? target, String reason = ''}) =>
|
||||
FeedMessage(actor: actor, action: action, target: target, reason: reason, at: _at);
|
||||
/// A feed line; people are named and identified (Ann is m1, Bob m2, this
|
||||
/// member, Me, m9).
|
||||
FeedMessage _feed(String action,
|
||||
{String actor = 'Ann', String? target, String reason = '', bool replayed = false}) =>
|
||||
FeedMessage(
|
||||
actor: actor,
|
||||
actorId: _ids[actor] ?? '',
|
||||
action: action,
|
||||
target: target,
|
||||
targetId: _ids[target] ?? '',
|
||||
reason: reason,
|
||||
at: _at,
|
||||
replayed: replayed);
|
||||
|
||||
const _ids = {'Ann': 'm1', 'Bob': 'm2', 'Me': 'm9', 'server': 'server'};
|
||||
|
||||
/// One row: the event, then what it must do.
|
||||
class _Row {
|
||||
@@ -33,22 +45,36 @@ class _Row {
|
||||
{this.toast, this.actor, this.sound, this.readyIndex = 0, this.attention = false, this.logged = false});
|
||||
}
|
||||
|
||||
/// The feed as the coordinator turns it into events: this member is 'Me',
|
||||
/// two members ready in the latest state.
|
||||
FeedEvent _of(FeedMessage f) => FeedEvent.of(f, selfName: 'Me', readyCount: 2);
|
||||
/// The feed as the coordinator turns it into events: this member is m9
|
||||
/// ('Me'), two members ready in the latest state.
|
||||
FeedEvent _of(FeedMessage f) => FeedEvent.of(f, selfId: 'm9', readyCount: 2);
|
||||
|
||||
final _rows = <_Row>[
|
||||
// ---- the feed (FeedEvent.of, then noticeFor); every line is logged
|
||||
_Row('join', _of(_feed('join')), MemberJoined,
|
||||
toast: 'Ann joined', actor: 'Ann', sound: SfxKind.join, logged: true),
|
||||
_Row('leave', _of(_feed('leave')), MemberLeft,
|
||||
_Row('leave (bye)', _of(_feed('leave', reason: 'bye')), MemberLeft,
|
||||
toast: 'Ann left', actor: 'Ann', sound: SfxKind.leave, logged: true),
|
||||
_Row('stale', _of(_feed('stale')), MemberStale, logged: true),
|
||||
_Row('out, someone else sat out', _of(_feed('out', target: 'Bob')), MemberSatOut, logged: true),
|
||||
_Row('out, sat yourself out', _of(_feed('out', actor: 'Me', target: 'Me')), MemberSatOut, logged: true),
|
||||
_Row('out, someone sat you out', _of(_feed('out', target: 'Me')), MemberSatOut,
|
||||
_Row('leave (grace over)', _of(_feed('leave', reason: 'grace')), MemberLeft,
|
||||
toast: 'Ann left', actor: 'Ann', sound: SfxKind.leave, logged: true),
|
||||
_Row('gone (lost signal)', _of(_feed('gone')), MemberGone, logged: true),
|
||||
_Row('return', _of(_feed('return')), MemberReturned, logged: true),
|
||||
_Row('away, someone sat themselves out', _of(_feed('away')), MemberSatOut, logged: true),
|
||||
_Row('away, someone else auto sat out', _of(_feed('away', reason: 'idle')), MemberSatOut, logged: true),
|
||||
_Row('away, someone sat someone else out', _of(_feed('away', target: 'Bob')), MemberSatOut, logged: true),
|
||||
_Row('away, you sat yourself out', _of(_feed('away', actor: 'Me')), MemberSatOut, logged: true),
|
||||
_Row('away, someone sat you out', _of(_feed('away', target: 'Me')), MemberSatOut,
|
||||
toast: 'Ann sat you out', actor: 'Ann', sound: SfxKind.kick, logged: true),
|
||||
_Row('back', _of(_feed('back')), MemberBack, logged: true),
|
||||
_Row('away, auto sit-out (idle)', _of(_feed('away', actor: 'Me', reason: 'idle')), MemberSatOut,
|
||||
toast: 'You sit out (idle); you are back as soon as you return', sound: SfxKind.warn, logged: true),
|
||||
_Row('away, auto sit-out (minimised)', _of(_feed('away', actor: 'Me', reason: 'minimised')), MemberSatOut,
|
||||
toast: 'You sit out (minimised); you are back as soon as you return', sound: SfxKind.warn, logged: true),
|
||||
_Row('in (back from away)', _of(_feed('in')), MemberBack, logged: true),
|
||||
_Row('a replayed join: logged only', _of(_feed('join', replayed: true)), MemberJoined, logged: true),
|
||||
_Row('a replayed chat: logged only', _of(_feed('chat', reason: 'hi', replayed: true)), ChatPosted, logged: true),
|
||||
_Row('someone with your name is not you', _of(FeedMessage(
|
||||
actor: 'Me', actorId: 'm4', action: 'away', target: 'Me', targetId: 'm9', at: _at)), MemberSatOut,
|
||||
toast: 'Me sat you out', actor: 'Me', sound: SfxKind.kick, logged: true),
|
||||
_Row('ready', _of(_feed('ready')), ReadyChanged, sound: SfxKind.ready, readyIndex: 2, logged: true),
|
||||
_Row('unready', _of(_feed('unready')), ReadyChanged, sound: SfxKind.unready, logged: true),
|
||||
_Row('force-ready', _of(_feed('force-ready')), ForcedReady, sound: SfxKind.forceReady, logged: true),
|
||||
@@ -87,10 +113,6 @@ final _rows = <_Row>[
|
||||
_Row('relay started', const RelayStarted(), RelayStarted,
|
||||
toast: 'Passing the video on through the server. Keep the app open while it plays.'),
|
||||
_Row('relay failed', const RelayFailed('closed'), RelayFailed, toast: 'Cannot pass the video on: closed'),
|
||||
_Row('idle, unready', const IdleWarning(sitsOut: false), IdleWarning,
|
||||
toast: 'idle — auto-unready in 2 min unless you press Space', sound: SfxKind.warn),
|
||||
_Row('idle, sits out', const IdleWarning(sitsOut: true), IdleWarning,
|
||||
toast: 'idle — sitting out in 2 min unless you press Space', sound: SfxKind.warn),
|
||||
_Row('connection lost', const ConnectionLost(), ConnectionLost, sound: SfxKind.lost),
|
||||
_Row('connection back', const ConnectionBack(), ConnectionBack, sound: SfxKind.back),
|
||||
_Row('jellyfin, sign-in expired', const JellyfinProgressFailed(signInExpired: true), JellyfinProgressFailed,
|
||||
|
||||
@@ -272,8 +272,8 @@ void main() {
|
||||
|
||||
testWidgets('the state\'s host flag crowns that member for everyone', (tester) async {
|
||||
online(const [
|
||||
MemberView(id: 'm1', name: 'Hana', ready: true, inSet: true, buffered: 30, stale: false, host: true),
|
||||
MemberView(id: 'm2', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false),
|
||||
MemberView(id: 'm1', name: 'Hana', ready: true, inSet: true, buffered: 30, host: true),
|
||||
MemberView(id: 'm2', name: 'Bob', ready: false, inSet: false, buffered: 0),
|
||||
]);
|
||||
final avatars = {for (final a in partyAvatars(coord)) a.name: a};
|
||||
expect(avatars['Hana']!.host, isTrue);
|
||||
@@ -292,7 +292,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('hosting: Copy invite address lists this machine\'s addresses', (tester) async {
|
||||
online(const [MemberView(id: 'm1', name: 'Me', ready: false, inSet: false, buffered: 0, stale: false)]);
|
||||
online(const [MemberView(id: 'm1', name: 'Me', ready: false, inSet: false, buffered: 0)]);
|
||||
coord.hostFile = (_) async => null;
|
||||
SessionState.instance.lastForm.hostPort = '8080';
|
||||
bigWindow(tester);
|
||||
|
||||
@@ -29,8 +29,14 @@ final png = Uint8List.fromList(const [
|
||||
]);
|
||||
|
||||
MemberView mv(String id, String name,
|
||||
{bool ready = false, bool? inSet, double buffered = 30, bool stale = false, bool out = false}) =>
|
||||
MemberView(id: id, name: name, ready: ready, inSet: inSet ?? ready, buffered: buffered, stale: stale, out: out);
|
||||
{bool ready = false, bool? inSet, double buffered = 30, bool gone = false, bool away = false}) =>
|
||||
MemberView(
|
||||
id: id,
|
||||
name: name,
|
||||
ready: ready,
|
||||
inSet: inSet ?? ready,
|
||||
buffered: buffered,
|
||||
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching);
|
||||
|
||||
StateBroadcast held(List<MemberView> members, {String transport = 'held', double position = 0, int mediaId = 1}) =>
|
||||
StateBroadcast(
|
||||
@@ -103,12 +109,12 @@ void main() {
|
||||
expect(v.self, isTrue);
|
||||
final w = playerStatusFor(
|
||||
health: ok,
|
||||
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', stale: true), mv('o', 'Ola', out: true)]),
|
||||
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', gone: true), mv('o', 'Ola', away: true)]),
|
||||
self: me)!;
|
||||
expect(w.kind, StatusKind.waiting);
|
||||
expect(w.text, 'Waiting for Bob and Mika', reason: 'nobody waits for who sits out');
|
||||
expect(w.detail, '1/3 ready');
|
||||
final outMe = mv('me', 'Me', out: true);
|
||||
final outMe = mv('me', 'Me', away: true);
|
||||
expect(playerStatusFor(health: ok, state: held([outMe, mv('b', 'Bob')]), self: outMe), isNull,
|
||||
reason: 'sitting out: the join-back card speaks');
|
||||
expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
|
||||
@@ -117,7 +123,7 @@ void main() {
|
||||
test("can't play: yours with Retry (reload); others' instead of waiting", () {
|
||||
var v = playerStatusFor(health: ok, state: held([me]), self: me, playerError: 'Not allowed (403)')!;
|
||||
expect((v.kind, v.text, v.action), (StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload));
|
||||
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false,
|
||||
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0,
|
||||
error: 'Not allowed (403)');
|
||||
v = playerStatusFor(health: ok, state: held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!;
|
||||
expect((v.kind, v.text, v.action), (StatusKind.cannotPlay, "Bob can't play this: Not allowed (403)", null));
|
||||
|
||||
@@ -5,9 +5,15 @@ import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/queue/queue_logic.dart';
|
||||
|
||||
MemberView member(String id,
|
||||
{bool stale = false, bool out = false, Map<String, bool> jf = const {}, String name = ''}) =>
|
||||
{bool gone = false, bool away = false, Map<String, bool> jf = const {}, String name = ''}) =>
|
||||
MemberView(
|
||||
id: id, name: name.isEmpty ? id : name, ready: false, inSet: true, buffered: 0, stale: stale, out: out, jf: jf);
|
||||
id: id,
|
||||
name: name.isEmpty ? id : name,
|
||||
ready: false,
|
||||
inSet: true,
|
||||
buffered: 0,
|
||||
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching,
|
||||
jf: jf);
|
||||
|
||||
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
|
||||
id: 'q1',
|
||||
@@ -22,12 +28,14 @@ QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => Queue
|
||||
|
||||
void main() {
|
||||
group('queuePerformer', () {
|
||||
test('a link passed on or a file: only its adder; gone means skipped', () {
|
||||
test('a link passed on or a file: only its adder; gone waits for it, left means skipped', () {
|
||||
for (final how in [QueueHow.through, QueueHow.file]) {
|
||||
expect(queuePerformer(item(how), [member('m1'), member('m2', out: true)]).id, 'm2');
|
||||
final gone = queuePerformer(item(how), [member('m1'), member('m3')]);
|
||||
expect(gone.id, isNull);
|
||||
expect(gone.why, 'Bob left');
|
||||
expect(queuePerformer(item(how), [member('m1'), member('m2', away: true)]).id, 'm2');
|
||||
final waits = queuePerformer(item(how), [member('m1'), member('m2', gone: true)]);
|
||||
expect((waits.id, waits.why), (null, null), reason: 'a gone adder may come back within its grace');
|
||||
final left = queuePerformer(item(how), [member('m1'), member('m3')]);
|
||||
expect(left.id, isNull);
|
||||
expect(left.why, 'Bob left');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -38,22 +46,22 @@ void main() {
|
||||
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,
|
||||
expect(queuePerformer(item(QueueHow.jellyfin), [member('m3', jf: access, away: true), member('m2')]).why,
|
||||
isNotNull);
|
||||
});
|
||||
|
||||
test('a link everyone opens or the server fetches: the adder, else the lowest active member', () {
|
||||
for (final how in [QueueHow.direct, QueueHow.server]) {
|
||||
expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2');
|
||||
expect(queuePerformer(item(how), [member('m4'), member('m2', stale: true), member('m3')]).id, 'm3');
|
||||
final nobody = queuePerformer(item(how), [member('m2', out: true)]);
|
||||
expect(queuePerformer(item(how), [member('m4'), member('m2', gone: true), member('m3')]).id, 'm3');
|
||||
final nobody = queuePerformer(item(how), [member('m2', away: true)]);
|
||||
expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('the janitor is the lowest member that is not stale', () {
|
||||
expect(queueJanitor([member('m9'), member('m1', stale: true), member('m2')]), 'm2');
|
||||
test('the janitor is the lowest member that is not gone', () {
|
||||
expect(queueJanitor([member('m9'), member('m1', gone: true), member('m2')]), 'm2');
|
||||
});
|
||||
|
||||
test('labels', () {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): "the
|
||||
// source drops uncleanly and resumes" (resume token, D6 source resume); the
|
||||
// rest stays.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -8,11 +5,13 @@ import 'dart:io';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_core/testing.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:starsync/src/jellyfin/accounts.dart';
|
||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||
import 'package:starsync/src/playback/yt_dlp_app.dart';
|
||||
import 'package:starsync/src/screens/home/picked_media.dart';
|
||||
import 'package:starsync/src/session/connection.dart' show ConnOnline;
|
||||
import 'package:starsync/src/session/session_coordinator.dart';
|
||||
|
||||
// The relay with real coordinators, real mpv and a dedicated server that
|
||||
@@ -197,12 +196,7 @@ void main() {
|
||||
final j = jsonDecode('$f') as Map;
|
||||
if (j['t'] == 'welcome' && !welcome.isCompleted) welcome.complete(j);
|
||||
}, onError: (_) {});
|
||||
ws.add(jsonEncode(const HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'pw',
|
||||
user: HelloUser(name: 'bob'),
|
||||
media: HelloMedia(name: '', size: 0, duration: 0))
|
||||
.toJson()));
|
||||
ws.add(jsonEncode(const HelloMessage(secret: 'pw', user: HelloUser(name: 'bob')).toJson()));
|
||||
final token = (await welcome.future.timeout(const Duration(seconds: 5)))['token'] as String;
|
||||
final size = await File(file).length();
|
||||
ws.add(jsonEncode({
|
||||
@@ -225,10 +219,10 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('the source drops uncleanly and resumes: it keeps its copy and keeps serving',
|
||||
test('the source drops uncleanly and comes back: the others play on, the relay continues (D6)',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final file = await _media();
|
||||
final proxy = await _Blackhole.start(server.boundPort);
|
||||
final proxy = await FlakyProxy.start(server.boundPort);
|
||||
try {
|
||||
final alice = await member('alice');
|
||||
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
@@ -241,17 +235,30 @@ void main() {
|
||||
await _poll(() => server.hub.relay?.live == true, 'source live');
|
||||
await _poll(() => alice.playback.durationSecs > 19 && bob.playback.durationSecs > 19, 'both opened');
|
||||
final token = bob.connection.token;
|
||||
final opens = bob.opens;
|
||||
|
||||
// Bob's network goes silent (no FIN): states stop, he reconnects
|
||||
// with `resume`, the server still has him.
|
||||
proxy.cut();
|
||||
// Bob's network goes silent (no FIN) on every link he has, the sync
|
||||
// socket and his relay channel, and new ones too, long enough for
|
||||
// the server to call him gone: the relay stays (`sourceGone`).
|
||||
proxy.onConnect = (l) => l.blackhole();
|
||||
for (final l in proxy.links) {
|
||||
l.blackhole();
|
||||
}
|
||||
await _poll(() => bob.reconnecting.peek(), 'reconnect engaged', timeout: const Duration(seconds: 15));
|
||||
await _poll(() => !bob.reconnecting.peek() && bob.serverState.peek() != null, 'back',
|
||||
timeout: const Duration(seconds: 20));
|
||||
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.sourceGone ?? false, 'source gone for alice',
|
||||
timeout: const Duration(seconds: 25));
|
||||
expect(alice.serverState.peek()!.media, isNotNull);
|
||||
expect(server.hub.relay, isNotNull, reason: 'the relay is kept through the grace');
|
||||
// His link comes back: the same member, his source dials its channel
|
||||
// again with the same `info` and the relay continues.
|
||||
proxy.onConnect = null;
|
||||
await _poll(() => bob.connection.state.peek() is ConnOnline, 'back', timeout: const Duration(seconds: 30));
|
||||
expect(bob.connection.token, token, reason: 'resumed as the same member');
|
||||
await _poll(() => server.hub.relay?.live == true, 'source redialled', timeout: const Duration(seconds: 15));
|
||||
expect(bob.serverState.peek(), isNotNull, reason: 'the state stayed through the drop');
|
||||
await _poll(() => server.hub.relay?.live == true && !(stateMediaRelay(alice.serverState.peek()!)?.sourceGone ?? true),
|
||||
'source redialled', timeout: const Duration(seconds: 25));
|
||||
expect(bob.relaying.peek(), isTrue);
|
||||
expect(bob.playback.durationSecs, greaterThan(19), reason: 'still his own copy');
|
||||
expect(bob.opens, opens, reason: 'still his own copy, not reopened');
|
||||
|
||||
// Alice can still get bytes the buffer never had.
|
||||
alice.seekTo(17);
|
||||
@@ -261,42 +268,3 @@ void main() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// TCP proxy whose [cut] silently stops forwarding on every connection made
|
||||
/// so far (no FIN, like a dead Wi-Fi); later connections work.
|
||||
class _Blackhole {
|
||||
final ServerSocket _server;
|
||||
final _conns = <(Socket, Socket, List<bool>)>[];
|
||||
_Blackhole(this._server);
|
||||
int get port => _server.port;
|
||||
|
||||
static Future<_Blackhole> start(int target) async {
|
||||
final b = _Blackhole(await ServerSocket.bind(InternetAddress.loopbackIPv4, 0));
|
||||
b._server.listen((c) async {
|
||||
final up = await Socket.connect('127.0.0.1', target);
|
||||
final dead = [false];
|
||||
b._conns.add((c, up, dead));
|
||||
c.listen((d) {
|
||||
if (!dead[0]) up.add(d);
|
||||
}, onDone: () => dead[0] ? null : up.destroy(), onError: (_) {});
|
||||
up.listen((d) {
|
||||
if (!dead[0]) c.add(d);
|
||||
}, onDone: () => dead[0] ? null : c.destroy(), onError: (_) {});
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
void cut() {
|
||||
for (final (_, _, dead) in _conns) {
|
||||
dead[0] = true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await _server.close();
|
||||
for (final (a, b, _) in _conns) {
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,12 +38,7 @@ class _Raw {
|
||||
|
||||
static Future<_Raw> join(int port, String name, {String secret = ''}) async {
|
||||
final r = _Raw(await WebSocket.connect('ws://127.0.0.1:$port/sync'));
|
||||
r.ws.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: secret,
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||
.toJson()));
|
||||
r.ws.add(jsonEncode(HelloMessage(secret: secret, user: HelloUser(name: name)).toJson()));
|
||||
await r.next('welcome');
|
||||
return r;
|
||||
}
|
||||
@@ -135,8 +130,8 @@ void main() {
|
||||
.cast<Map>()
|
||||
.firstWhere((m) => m['name'] == 'host'))['id'];
|
||||
// Anyone may sit the host out; its player unloads, the server serves on.
|
||||
friend.ws.add(jsonEncode(OutIntent(target: '$hostId', out: true).toJson()));
|
||||
await _poll(() => session.coordinator.selfOut.peek(), 'host sat out');
|
||||
friend.ws.add(jsonEncode(PresenceIntent(target: '$hostId', state: Presence.away).toJson()));
|
||||
await _poll(() => session.coordinator.selfAway.peek(), 'host sat out');
|
||||
|
||||
// The host nudges the friend: only the friend hears it.
|
||||
final friendId = ((state['members'] as List)
|
||||
|
||||
@@ -1,78 +1,108 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence):
|
||||
// IdleTracker and unreadyForNewMedia give way to auto sit-out;
|
||||
// linkDisplayName stays.
|
||||
// Pure session logic: auto sit-out on a fake clock, link names, the
|
||||
// unready-on-swap rule.
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:starsync/src/session/idle.dart';
|
||||
import 'package:sync_relay_core/core.dart' show Presence, PresenceReason;
|
||||
import 'package:starsync/src/platform/ports/shell_port.dart' show WindowActivity;
|
||||
import 'package:starsync/src/session/auto_sit_out.dart';
|
||||
import 'package:starsync/src/session/session_coordinator.dart';
|
||||
|
||||
void main() {
|
||||
group('IdleTracker', () {
|
||||
group('AutoSitOut', () {
|
||||
final t0 = DateTime.utc(2026, 1, 1, 12);
|
||||
const idle = AutoSitOutTimes.idle;
|
||||
const minimised = AutoSitOutTimes.minimised;
|
||||
|
||||
List<IdleEvent> run(IdleTracker idle, DateTime from, Duration span,
|
||||
{required bool playing, bool selfReady = true}) {
|
||||
final out = <IdleEvent>[];
|
||||
/// Ticks once a second from [from] for [span]; the steps that were not
|
||||
/// [AutoStay], at their second.
|
||||
List<(int, String)> run(AutoSitOut a, DateTime from, Duration span,
|
||||
{Presence self = Presence.watching, bool playing = false, bool alone = false, bool enabled = true}) {
|
||||
final out = <(int, String)>[];
|
||||
for (var s = 0; s <= span.inSeconds; s++) {
|
||||
final e = idle.tick(from.add(Duration(seconds: s)),
|
||||
playing: playing, selfReady: selfReady);
|
||||
if (e != IdleEvent.none) out.add(e);
|
||||
final step = a.tick(from.add(Duration(seconds: s)),
|
||||
self: self, playing: playing, alone: alone, enabled: enabled);
|
||||
switch (step) {
|
||||
case AutoAway(:final reason):
|
||||
out.add((s, 'away ${reason.name}'));
|
||||
case AutoBack():
|
||||
out.add((s, 'back'));
|
||||
case AutoStay():
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('long watch then pause: no instant warn+unready', () {
|
||||
final idle = IdleTracker(t0);
|
||||
// 15 min of playback without any input.
|
||||
expect(run(idle, t0, const Duration(minutes: 15), playing: true), isEmpty);
|
||||
final paused = t0.add(const Duration(minutes: 15));
|
||||
// First minute after the pause: nothing fires.
|
||||
expect(run(idle, paused, const Duration(minutes: 1), playing: false),
|
||||
isEmpty);
|
||||
test('not playing: idle for the idle time sets you away (idle)', () {
|
||||
final a = AutoSitOut(t0);
|
||||
expect(run(a, t0, idle), [(idle.inSeconds, 'away idle')]);
|
||||
});
|
||||
|
||||
test('pause measures from when playback stopped', () {
|
||||
final idle = IdleTracker(t0);
|
||||
run(idle, t0, const Duration(minutes: 15), playing: true);
|
||||
// Last playing tick was at +15 min; count seconds from there.
|
||||
final paused = t0.add(const Duration(minutes: 15));
|
||||
final events = <(int, IdleEvent)>[];
|
||||
for (var s = 1; s <= 11 * 60; s++) {
|
||||
final e = idle.tick(paused.add(Duration(seconds: s)),
|
||||
playing: false, selfReady: true);
|
||||
if (e != IdleEvent.none) events.add((s, e));
|
||||
}
|
||||
expect(events, [(8 * 60, IdleEvent.warn), (10 * 60, IdleEvent.unready)]);
|
||||
test('never when alone, never when switched off', () {
|
||||
expect(run(AutoSitOut(t0), t0, const Duration(minutes: 30), alone: true), isEmpty);
|
||||
expect(run(AutoSitOut(t0), t0, const Duration(minutes: 30), enabled: false), isEmpty);
|
||||
final m = AutoSitOut(t0)..window(WindowActivity.minimised, t0);
|
||||
expect(run(m, t0, const Duration(minutes: 30), alone: true, playing: true), isEmpty);
|
||||
});
|
||||
|
||||
test('unready always trails the warning by 2 min', () {
|
||||
final idle = IdleTracker(t0);
|
||||
// Idle 20 min while unready (nothing to lose, nothing fires)...
|
||||
expect(
|
||||
run(idle, t0, const Duration(minutes: 20),
|
||||
playing: false, selfReady: false),
|
||||
isEmpty);
|
||||
// ...then someone force-readies us: warn now, unready 2 min later.
|
||||
final forced = t0.add(const Duration(minutes: 20, seconds: 1));
|
||||
expect(idle.tick(forced, playing: false, selfReady: true), IdleEvent.warn);
|
||||
expect(
|
||||
idle.tick(forced.add(const Duration(seconds: 1)),
|
||||
playing: false, selfReady: true),
|
||||
IdleEvent.none);
|
||||
expect(
|
||||
idle.tick(forced.add(const Duration(minutes: 2)),
|
||||
playing: false, selfReady: true),
|
||||
IdleEvent.unready);
|
||||
test('while playing idle input does not count, minimised for a while does', () {
|
||||
final a = AutoSitOut(t0);
|
||||
expect(run(a, t0, const Duration(minutes: 30), playing: true), isEmpty, reason: 'watching is activity');
|
||||
final at = t0.add(const Duration(minutes: 30));
|
||||
a.window(WindowActivity.minimised, at);
|
||||
expect(run(a, at, minimised, playing: true), [(minimised.inSeconds, 'away minimised')]);
|
||||
});
|
||||
|
||||
test('input resets the clock and the warning', () {
|
||||
final idle = IdleTracker(t0);
|
||||
expect(idle.tick(t0.add(const Duration(minutes: 8)),
|
||||
playing: false, selfReady: true), IdleEvent.warn);
|
||||
idle.activity(t0.add(const Duration(minutes: 9)));
|
||||
expect(idle.tick(t0.add(const Duration(minutes: 11)),
|
||||
playing: false, selfReady: true), IdleEvent.none);
|
||||
expect(idle.tick(t0.add(const Duration(minutes: 17)),
|
||||
playing: false, selfReady: true), IdleEvent.warn);
|
||||
test('a pause after a long watch starts a fresh idle clock', () {
|
||||
final a = AutoSitOut(t0);
|
||||
run(a, t0, const Duration(minutes: 30), playing: true);
|
||||
final paused = t0.add(const Duration(minutes: 30, seconds: 1));
|
||||
expect(run(a, paused, idle - const Duration(seconds: 2)), isEmpty);
|
||||
});
|
||||
|
||||
test('back on input or focus, only from an automatic away', () {
|
||||
final a = AutoSitOut(t0);
|
||||
expect(run(a, t0, idle), [(idle.inSeconds, 'away idle')]);
|
||||
final at = t0.add(idle);
|
||||
// The away shows in the next states; nothing until the user is back.
|
||||
expect(run(a, at, const Duration(minutes: 5), self: Presence.away), isEmpty);
|
||||
final later = at.add(const Duration(minutes: 5, seconds: 1));
|
||||
a.input(later);
|
||||
expect(run(a, later, const Duration(seconds: 1), self: Presence.away), [(0, 'back')]);
|
||||
|
||||
// Minimised away: restoring is not enough, the focus brings it back.
|
||||
final b = AutoSitOut(t0)..window(WindowActivity.minimised, t0);
|
||||
expect(run(b, t0, minimised, playing: true), [(minimised.inSeconds, 'away minimised')]);
|
||||
final m = t0.add(minimised);
|
||||
b.window(WindowActivity.unfocused, m);
|
||||
expect(run(b, m, const Duration(seconds: 3), self: Presence.away), isEmpty);
|
||||
b.window(WindowActivity.focused, m);
|
||||
expect(run(b, m, const Duration(seconds: 1), self: Presence.away), [(0, 'back')]);
|
||||
});
|
||||
|
||||
test('a manual away (own or someone else\'s) is never returned automatically', () {
|
||||
// Someone else sat you out: activity does not bring you back.
|
||||
final a = AutoSitOut(t0);
|
||||
a.input(t0);
|
||||
expect(run(a, t0, const Duration(minutes: 20), self: Presence.away), isEmpty);
|
||||
// Auto away, then the user sits out by hand: it stays.
|
||||
final b = AutoSitOut(t0);
|
||||
expect(run(b, t0, idle), [(idle.inSeconds, 'away idle')]);
|
||||
b.manual();
|
||||
final at = t0.add(idle);
|
||||
b.input(at);
|
||||
expect(run(b, at, const Duration(minutes: 1), self: Presence.away), isEmpty);
|
||||
});
|
||||
|
||||
test('an away not yet echoed is not sent again', () {
|
||||
final a = AutoSitOut(t0);
|
||||
// The state still says `in` for a few seconds after the away went out.
|
||||
expect(run(a, t0, idle + const Duration(seconds: 8)), [(idle.inSeconds, 'away idle')]);
|
||||
expect(a.autoAway, isTrue);
|
||||
});
|
||||
|
||||
test('the reasons on the wire', () {
|
||||
expect(PresenceReason.idle.name, 'idle');
|
||||
expect(PresenceReason.minimised.name, 'minimised');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,10 +16,26 @@ void main() {
|
||||
await dir.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('a box without a version is at 1 and gets stamped', () async {
|
||||
test('a box without a version is at 1, runs the steps and gets stamped', () async {
|
||||
final box = await Hive.openBox('settings');
|
||||
expect(await migrateSettings(box), isEmpty);
|
||||
expect(await migrateSettings(box), hasLength(settingsSchemaVersion - 1));
|
||||
expect(box.get(settingsSchemaKey), settingsSchemaVersion);
|
||||
expect(await migrateSettings(box), isEmpty);
|
||||
});
|
||||
|
||||
test('2: the idle switch gives way to auto sit-out, on', () async {
|
||||
for (final old in [true, false]) {
|
||||
final box = await Hive.openBox('settings');
|
||||
await box.put('idleSitsOut', old);
|
||||
await migrateSettings(box);
|
||||
expect(box.containsKey('idleSitsOut'), isFalse);
|
||||
expect(box.get('autoSitOut'), isTrue);
|
||||
await box.deleteFromDisk();
|
||||
}
|
||||
final kept = await Hive.openBox('settings');
|
||||
await kept.putAll({'idleSitsOut': true, 'autoSitOut': false});
|
||||
await migrateSettings(kept);
|
||||
expect(kept.get('autoSitOut'), isFalse, reason: 'a choice already made stays');
|
||||
});
|
||||
|
||||
test('steps run in order, only past the stored version', () async {
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/screens/home/profile.dart';
|
||||
import 'package:starsync/src/session/identity.dart';
|
||||
import 'package:starsync/src/shell/app_shell.dart';
|
||||
import 'package:starsync/src/shell/avatar.dart';
|
||||
import 'package:starsync/src/shell/motion.dart';
|
||||
@@ -38,8 +38,14 @@ class _FakeVideoState extends State<FakeVideo> {
|
||||
Widget build(BuildContext context) => const ColoredBox(color: Colors.black);
|
||||
}
|
||||
|
||||
MemberView member(String id, String name, {bool ready = false, bool stale = false}) =>
|
||||
MemberView(id: id, name: name, ready: ready, inSet: ready, buffered: 30, stale: stale);
|
||||
MemberView member(String id, String name, {bool ready = false, bool gone = false}) =>
|
||||
MemberView(
|
||||
id: id,
|
||||
name: name,
|
||||
ready: ready,
|
||||
inSet: ready,
|
||||
buffered: 30,
|
||||
presence: gone ? Presence.gone : Presence.watching);
|
||||
|
||||
void main() {
|
||||
final session = SessionState.instance;
|
||||
@@ -93,7 +99,7 @@ void main() {
|
||||
member('b', 'Bob'),
|
||||
member('c', 'Cleo', ready: true),
|
||||
member('d', 'Dan'),
|
||||
member('e', 'Eve', stale: true),
|
||||
member('e', 'Eve', gone: true),
|
||||
member('f', 'Finn'),
|
||||
];
|
||||
|
||||
@@ -145,7 +151,7 @@ void main() {
|
||||
expect(find.byKey(const ValueKey('strip-join')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('strip-leave')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('strip-chat')), findsOneWidget);
|
||||
expect(find.text('ready 2/6'), findsOneWidget);
|
||||
expect(find.text('ready 2/5'), findsOneWidget, reason: 'Eve is gone: not counted');
|
||||
final stack = find.byKey(const ValueKey('party-avatars'));
|
||||
// Five shown, the sixth as "+1"; ready ones first.
|
||||
expect(find.descendant(of: stack, matching: find.byType(StatusAvatar)), findsNWidgets(5));
|
||||
|
||||
@@ -130,22 +130,19 @@ void main() {
|
||||
name: 'a',
|
||||
ready: true,
|
||||
inSet: true,
|
||||
buffered: 9,
|
||||
stale: false);
|
||||
buffered: 9);
|
||||
const catchingUp = MemberView(
|
||||
id: 'm2',
|
||||
name: 'b',
|
||||
ready: false,
|
||||
inSet: false,
|
||||
buffered: 0,
|
||||
stale: false);
|
||||
buffered: 0);
|
||||
const unreadyInSet = MemberView(
|
||||
id: 'm3',
|
||||
name: 'c',
|
||||
ready: false,
|
||||
inSet: true,
|
||||
buffered: 9,
|
||||
stale: false);
|
||||
buffered: 9);
|
||||
|
||||
test('only in-set ready members may follow transport to playing', () {
|
||||
final state = _stateWithMembers([ready, catchingUp, unreadyInSet]);
|
||||
|
||||
+236
-134
@@ -66,8 +66,10 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
|
||||
SIGINT and SIGTERM stop it. A Flutter binary cannot run without a
|
||||
display, which is why the server is its own build.
|
||||
|
||||
`SyncServer` runs a 1 s heartbeat: stale sweep, closed-socket sweep, silent
|
||||
socket drop, state + feed broadcast. There is one session, id `default`.
|
||||
`SyncServer` runs a 1 s heartbeat (`ServerHub.heartbeat`): closed-socket
|
||||
sweep, the engine's sweep (silence, joining, gone notices, grace),
|
||||
relay source state, warm relay, rates, state + feed broadcast. There is
|
||||
one session, id `default`.
|
||||
|
||||
Passwords: the default secret is empty, which means an open server (a
|
||||
warning is logged). Otherwise one shared secret, compared at hello.
|
||||
@@ -77,8 +79,8 @@ it in the server config and sends it in its own `hello`. The member whose
|
||||
key matches is the host (`members[].host`: the crown, art rights). The
|
||||
address a connection comes from plays no part (so a reverse proxy or tunnel
|
||||
on the same machine does not make everyone the host). The dedicated server
|
||||
has no key, so it has no host. Nobody removes anyone: members sit each
|
||||
other out ("Sitting out" below), the host included, since the hosted
|
||||
has no key, so it has no host. Nobody removes anyone: members set each
|
||||
other away ("Presence" below), the host included, since the hosted
|
||||
server and its `/m/` file serving do not need the host's player.
|
||||
|
||||
## Sync model
|
||||
@@ -108,47 +110,80 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
Besides the 1 s tick, a report goes out right after a new media is
|
||||
open and, while HELD, the moment the buffer reaches `need`, so the gate
|
||||
does not wait for the next tick.
|
||||
- **Ready gate** (HELD to PLAYING): the in-set is non-empty and every
|
||||
in-set member is ready, fresh and has `buffered >= need`. `need` is 4 s
|
||||
- **Ready gate** (HELD to PLAYING): the members that count for the gate
|
||||
(`SyncEngine.countsForGate`: in the set, no player error, and their
|
||||
presence counts, see "Presence") are not empty and every one is ready
|
||||
and has `buffered >= need`. `need` is 4 s
|
||||
(`minBufferSecs`), or near the end what is left minus 2 s
|
||||
(`endSlackSecs`, floor 0), since mpv cannot buffer past the end. So a
|
||||
lone viewer plays alone; with others present everyone must ready.
|
||||
- **In-set** (who the gate waits for): a member joins it on joining, or
|
||||
on coming back from stale, while not PLAYING (any media, the one the
|
||||
server started with included); joining while PLAYING it stays out
|
||||
(`ready=false`) until it readies: a late joiner never pauses anyone. A
|
||||
swap puts every fresh member in the set and keeps everyone's ready flag,
|
||||
so the next video starts once everyone ready has buffered (the client
|
||||
setting "Unready when the video changes", `AppSettings.unreadyOnSwap`,
|
||||
default off, sends `ready=false` itself on a new `mediaId`; not for the
|
||||
first state of a session, and a reconnect does not restore a ready that
|
||||
a swap meanwhile would have dropped). Stale, leave, sitting out and a
|
||||
player error remove from the set.
|
||||
While HELD, `state.hold` says why (`HoldReason`: a joiner within its
|
||||
bound, someone unready, a short buffer, the same with the relay's
|
||||
source gone, or nobody to start).
|
||||
- **In-set** (`EngineMember.inSet`, who the gate may wait for): a member
|
||||
joins it on joining, or on coming back from `gone` or `away`, while not
|
||||
PLAYING (any media, the one the server started with included); joining
|
||||
while PLAYING it stays out (`ready=false`) until it readies. A swap puts
|
||||
every member in the set and keeps everyone's ready flag, so the next
|
||||
video starts once everyone ready has buffered (the client setting
|
||||
"Unready when the video changes", `AppSettings.unreadyOnSwap`, default
|
||||
off, sends `ready=false` itself on a new `mediaId`; not for the first
|
||||
state of a session, and a reconnect does not restore a ready that a
|
||||
swap meanwhile would have dropped). A player error removes from the
|
||||
set; presence decides whether a member in the set counts.
|
||||
- **Identity**: an install sends a random client id (`hello.client`,
|
||||
private: never broadcast, never logged) and a `seq` that rises with
|
||||
every connection. The server maps the client id to one member for its
|
||||
life: the same id and token on every reconnect (`welcome.resumed`),
|
||||
so the member keeps its ready flag, presence, queue items (`by`), the
|
||||
relay it serves and its picture. A hello takes the member over when its
|
||||
`seq` is not older than the member's; the older socket gets `replaced`
|
||||
and closes, an older hello gets `stale` (so two windows of one install
|
||||
cannot fight: the newest wins, the client stops on `replaced`).
|
||||
`welcome.instance` changes per server boot, so a client tells "the
|
||||
server restarted" from "my grace ran out" (both: `resumed` false).
|
||||
- **Presence** (`Presence`, `members[].presence`; D1, D2):
|
||||
`joining` -> `in`, `away`, `gone`. What each counts for is one table,
|
||||
`PresencePolicy.countsFor(presence, Purpose)` in core
|
||||
(`presence.dart`), shared by the server and the app: `in` counts for
|
||||
the gate, the ready counter, watched tracking, preloads and social
|
||||
notifications; `joining` for all but tracking, and for the gate only
|
||||
while HELD and for at most `joinerHold` (15 s) after it started
|
||||
joining, never while PLAYING; `away` and `gone` for none.
|
||||
- `joining`: a new member, one back from `gone`, or one back `in` from
|
||||
`away`, while media is up (with nothing up it is `in` at once). It
|
||||
loads in the background; a report that holds the current media at
|
||||
the server position (within the 5 s seek threshold) with `need`
|
||||
buffered makes it `in`, already in sync, so a joiner during playback
|
||||
never pauses anyone (D2). A report error or `joiningTimeout` (60 s)
|
||||
ends joining too. Coming `in` while PLAYING it stays out of the set
|
||||
until it is ready (its own auto-ready follows its buffer); while
|
||||
HELD it keeps its place in the set. While HELD a joiner holds the
|
||||
start at most 15 s (bounded hold), then the others go on without it.
|
||||
- `away` (sat out): anyone sets anyone away (`presence` `away`, the
|
||||
host included); only the member itself comes back (`in`, through
|
||||
`joining`). Its ready flag and reports are kept but never count;
|
||||
`force` leaves it alone; a swap leaves it away. Away survives a drop
|
||||
(back from `gone` it is `away` again).
|
||||
- `gone`: its socket closed or fell silent. Kept for the grace
|
||||
(`Liveness.grace`, 10 min) with everything it had, its token working
|
||||
(`/m` `/r` `/j` URLs, the relay channel); out of the gate. The feed
|
||||
says `gone` only after `goneNotice` (10 s), so a quick reconnect is
|
||||
silent; back after that line it says `return`. After the grace it
|
||||
leaves (feed `leave`, reason `grace`); `bye` leaves at once (reason
|
||||
`bye`). Only a hello brings it back; frames of a socket that no longer
|
||||
holds the member are ignored.
|
||||
- **Player error** (`EngineMember.error`, `report.error`,
|
||||
`state.members[].error`): a member whose player cannot open the current
|
||||
media says why in its reports; the server takes it out of the set at
|
||||
once (like sitting out, but automatic: `ready`/`force` do not put it
|
||||
back, its ready flag is kept), so the others start without it. A report
|
||||
without it (the media opened, after a reload too) or a swap clears it:
|
||||
back in the set like a join while not PLAYING; while PLAYING from its
|
||||
next frame if it is ready (the others then hold for its buffer, as for a
|
||||
stale member coming back), else from its `ready`.
|
||||
- **Sitting out** (`EngineMember.out`, `state.members[].out`): a member
|
||||
stays connected (chat, feed, party, nudges) but is never in the set and
|
||||
never ready: no `ready` or `force` reaches it, a swap leaves it out, its
|
||||
reports keep it fresh but their `buffered` counts as 0. Anyone can sit
|
||||
anyone out (`out` with `out:true`, the host included); only the member
|
||||
itself comes back (`out:false`): it is then in the set like a fresh join
|
||||
(unless PLAYING) and still unready. The app: an out member's player has
|
||||
nothing open (no stream or relay pulls; a relay it serves goes on) and
|
||||
shows the join-back card (cover, title, episode, the server position and
|
||||
playing/paused). "Join back" or Space sends `out:false` and then
|
||||
`ready`: at once while HELD (the gate waits for its buffer), while
|
||||
PLAYING once the player has buffered `need` (at most 15 s), so the others
|
||||
do not pause for the load. Putting something up while out comes back
|
||||
first. A reconnect that lands as a new member sits out again.
|
||||
- **Pause**: while PLAYING, any in-set member who becomes not ready, under
|
||||
`need` buffered or stale flips the server to HELD (feed action `pause` by
|
||||
once (like away, but automatic: `ready`/`force` do not put it
|
||||
back, its ready flag is kept), so the others start without it; it also
|
||||
ends a `joining`. A report without it (the media opened, after a reload
|
||||
too) or a swap clears it: back in the set like a join while not
|
||||
PLAYING; while PLAYING at once if it is ready (the others then hold for
|
||||
its buffer), else from its `ready`.
|
||||
- **Pause**: while PLAYING, any member counting for the gate who becomes
|
||||
not ready or under `need` buffered flips the server to HELD (feed action `pause` by
|
||||
`server`). Recovery resumes by itself when the gate passes.
|
||||
- **End**: while PLAYING with a known duration, reaching it sets FINISHED
|
||||
(feed `finished` by `server`, position = duration). Clients pause where they
|
||||
@@ -178,36 +213,48 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
landed after 1.5 s is re-applied once (`_checkSeekLanded`), to the server
|
||||
position recomputed now, and only while its event is still current and the
|
||||
user is not seeking.
|
||||
- **Liveness**: WS ping every 5 s; silent over 15 s means stale (out of the
|
||||
gate, `stale` feed); over 30 s the socket is closed and the member removed.
|
||||
The durations are a `Liveness` (core, `sync_engine.dart`; defaults
|
||||
`RelayConstants.staleAfterSecs` / `dropAfterSecs` / `pingIntervalSecs`)
|
||||
given to `SyncEngine(liveness:)` and `SyncServer(liveness:)`; stale and
|
||||
drop are measured on the engine's clock (`SyncEngine.clock`,
|
||||
`SyncServer(clock:)`) by the 1 s heartbeat sweep, so a test with a fake
|
||||
clock steps through them; the ping is the socket's own real timer.
|
||||
A stale member goes back into the set with its next frame if it is ready
|
||||
(also one stale at a swap) or the server is not PLAYING.
|
||||
Closed sockets are polled with `isClosed` every second: Relic's server-side
|
||||
`onDone` does not fire for dead peers, so presence never relies on it.
|
||||
- **Resume**: a reconnecting client sends its previous token as
|
||||
`hello.resume`. If the server still has that member (an unclean drop it has
|
||||
not noticed), the new socket takes it over: same token, member id, ready
|
||||
flag and media URL (the player keeps streaming), no join/leave feed, and the
|
||||
dead socket is closed without removing the member. An unknown token joins
|
||||
as a new member.
|
||||
- **Client policies**: no state broadcast for 5 s means connection lost, clear
|
||||
the server state and reconnect every 1.5 s until cancelled (an epoch counter makes
|
||||
a cancelled attempt stale). A closed socket means lost at once (the
|
||||
server state stays until the 5 s pass). After a reconnect the ready flag is
|
||||
restored from the server's state. Cancel during setup aborts the join.
|
||||
Closing the window leaves gracefully (see "App close" under Desktop UI).
|
||||
Where it lives: see "Connection" below.
|
||||
- **Idle** (`IdleTracker`): measured from the later of the last input and the
|
||||
moment playback stopped. While HELD and self ready: warning at 8 min,
|
||||
auto-unready at 10 min, and never less than 2 min after the warning.
|
||||
With "When idle, sit out instead of just unready"
|
||||
(`AppSettings.idleSitsOut`, default off) the 10 min step sits out.
|
||||
- **Liveness**: WS ping every 5 s; a socket without any frame for 15 s
|
||||
(`silence`) is closed (4015) and its member goes `gone`; the grace, the
|
||||
`gone` notice, the joiner bound and the joining timeout follow. The
|
||||
durations are a `Liveness` (core, `sync_engine.dart`; defaults
|
||||
`RelayConstants.silenceSecs` / `graceSecs` / `goneNoticeSecs` /
|
||||
`joinerHoldSecs` / `joiningTimeoutSecs` / `pingIntervalSecs`) given to
|
||||
`SyncEngine(liveness:)` and `SyncServer(liveness:)`; all but the ping
|
||||
are measured on the engine's clock (`SyncEngine.clock`,
|
||||
`SyncServer(clock:)`) by the 1 s heartbeat sweep, so a test with a
|
||||
fake clock steps through them; the ping is the socket's own real
|
||||
timer. Any frame counts (`SyncEngine.touch`). Closed sockets are polled
|
||||
with `isClosed` every second: Relic's server-side `onDone` does not
|
||||
fire for dead peers, so presence never relies on it. A half-open link
|
||||
is caught by the silence.
|
||||
- **Client policies**: a drop is not the end of the session. The app
|
||||
keeps the last state (`SessionCoordinator.stateStale` while it
|
||||
reconnects or after the connection ended; the strip says
|
||||
"reconnecting…"), the player and its media, and a relay it
|
||||
serves; it reconnects with backoff and jitter and comes back as the same
|
||||
member (see "Connection" below). No frame for 3 s is "connection
|
||||
unstable", 9 s is lost (well under the server's 15 s). A welcome that
|
||||
resumed needs nothing re-sent; a new member (grace over, server
|
||||
restarted) gets the app's ready and sitting out again, and the media
|
||||
opens again only when it changed (or its URL named the old token). The
|
||||
app's own `ping` every second keeps it under the server's silence limit
|
||||
even with nothing playing. Cancel during setup aborts the join. Leave
|
||||
and closing the window send `bye` (see "App close" under Desktop UI);
|
||||
Update and restart does not.
|
||||
- **Auto sit-out** (`AutoSitOut`, `session/auto_sit_out.dart`; Settings
|
||||
"Auto sit-out", `AppSettings.autoSitOut`, default on): not playing and
|
||||
no input for 5 min sets this member `away` (reason `idle`); minimised
|
||||
or hidden for 10 min does, playing or not (`minimised`): while playing
|
||||
idle input does not count, and watching is activity, so a pause after
|
||||
a long watch starts a fresh clock. Input (any key or pointer use in the
|
||||
window, `markInput`, from `AppShell` and the controls) or the window's
|
||||
focus brings
|
||||
it back `in` (without touching the ready flag), but only from an away
|
||||
it set itself: a manual one (own or someone else's) stays. Never when
|
||||
alone (`isAlone` in core: no other member connected, `in`, `joining` or
|
||||
`away`) and never in a solo session. The window changes come from
|
||||
`ShellPort.watchActivity` (focused, unfocused, minimised, hidden); the
|
||||
timings are `AutoSitOutTimes`.
|
||||
- **Nudge**: `nudge` names a member; the server passes it to that member
|
||||
only (no feed), at most 3 per second and 20 per minute per sender
|
||||
(`nudgesPerSec`, `nudgesPerMin`; the rest is dropped silently). The
|
||||
@@ -227,41 +274,66 @@ Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
|
||||
`Connection` (`session/connection.dart`) is the link to the server, made
|
||||
in the composition root (`SessionState.connection`) and passed to the
|
||||
`SessionCoordinator`. It owns the `SyncClient` socket, the first connect
|
||||
(`begin` + `dial`), reconnects (`reconnectDelay`, 1.5 s; the previous
|
||||
token as `hello.resume`), the epoch (`invalidate`, `isCurrent`) that makes
|
||||
a cancelled connect or attempt stale, the 1 s heartbeat (clock `ping`,
|
||||
the diagnostics tick, the silence check: `silenceLimit`, 5 s), the
|
||||
(`begin` + `dial`), reconnects (`delayFor`: 1 s, doubling per failed
|
||||
attempt up to 15 s, each ±20 % jitter), the epoch (`invalidate`,
|
||||
`isCurrent`) that makes a cancelled connect or attempt stale, the 1 s
|
||||
heartbeat (clock `ping`, the diagnostics tick, the silence check), the
|
||||
diagnostics uploader (`diag`, started with the session, stopped by
|
||||
`close` before the socket), "Stop reconnecting" (`cancelReconnect`),
|
||||
"Retry" (`retry`) and setup cancel (`cancelConnect`). The `hello` is
|
||||
built by an injectable `HelloBuilder` (`defaultHello`). The silence is
|
||||
measured on `Connection.now` (injectable, UTC wall clock by default); the
|
||||
heartbeat and the reconnect delay are real timers. The incoming
|
||||
streams (`states`, `feeds`, `nudges`, `errors`), the send methods, the
|
||||
`token`, `memberId` and `serverNow()` are read through it.
|
||||
"Retry" (`retry`) and setup cancel (`cancelConnect`).
|
||||
|
||||
Each hello is built by an injectable `HelloBuilder` (`defaultHello`):
|
||||
this build's version range (`RelayConstants.protoMin` .. `protoMax`), the
|
||||
install's `ClientIdentity` (`session/identity.dart`: a random 22
|
||||
character base64url client id and a `seq` that rises with every
|
||||
connection, both in the settings box, so per data folder and per
|
||||
`STARSYNC_PROFILE`; tests get a fresh in-memory one per `Connection`),
|
||||
`app` (the app version), `platform`, `caps` (source always; resolve when
|
||||
the app's yt-dlp is there; jellyfin when an account is linked, checked by
|
||||
the coordinator at each connect) and, on a reconnect to a server seen
|
||||
before, `feedAfter` (the last `feed.seq`). The replay (`welcome.feed`)
|
||||
reaches the activity log only, marked `replayed`; a welcome of another
|
||||
`instance` (a restarted server) drops it and counts from 1 again.
|
||||
|
||||
`Connection.state` (a signal of the sealed `ConnState`): `ConnClosed`
|
||||
(before a connect, after leave or a cancel), `ConnConnecting` (the first
|
||||
dial), `ConnOnline` (welcome received), `ConnLost` (dropped: the socket
|
||||
closed or the silence limit passed; the next attempt is scheduled),
|
||||
`ConnReconnecting(attempt)` (an attempt dialling, and after it failed
|
||||
until the next one), `ConnRefused(code, message)` (the first dial failed:
|
||||
`version`, `secret` or empty, and the plain-words reason; nothing
|
||||
retries) and `ConnReplaced` (not produced yet). Every change notifies,
|
||||
a repeat too. The coordinator maps it to `health` (`connectionHealth`:
|
||||
"connecting…", "connected", "connection lost — retrying…",
|
||||
"reconnecting…", the reason; closed writes nothing, a cancel writes
|
||||
"cancelled", "Retry" "retrying…") and to `reconnecting` (lost or
|
||||
reconnecting). The first drop of a connection raises `ConnectionLost`,
|
||||
the reconnect that follows `ConnectionBack` (each once per drop, through
|
||||
the Notifier).
|
||||
dial), `ConnOnline` (welcome received, frames arrive), `ConnUnstable`
|
||||
(the same socket, nothing for `unstableAfter`, 3 s), `ConnLost` (the
|
||||
socket closed or errored, or nothing for `silenceLimit`, 9 s: the socket
|
||||
is dropped, the next attempt scheduled), `ConnReconnecting(attempt)` (an
|
||||
attempt dialling, and after it failed until the next one),
|
||||
`ConnRefused(code, message)` (`version`, `secret`, or empty for a first
|
||||
dial that failed otherwise; nothing retries) and `ConnReplaced` (`err`
|
||||
`replaced`, close 4013 or 4014: another window of this install holds the
|
||||
member; nothing retries, "Retry" takes it back). The silence and the
|
||||
lost sound are measured on `Connection.now` (injectable, UTC wall clock
|
||||
by default); the heartbeat and the reconnect delay are real timers.
|
||||
|
||||
The session's part of a reconnect is the coordinator's
|
||||
`onReconnect` handler: before an attempt dials it reads what to restore
|
||||
(ready, sat out, the media seen), once online it opens the media again
|
||||
if its URL changed, sends `out`/`ready` back and restarts the session's
|
||||
ticker. The silence (`Connection.silence`) clears the coordinator's
|
||||
server state (`_onConnectionSilent`).
|
||||
Refusals in plain words (`connectErrorText`, `versionText`): `version`
|
||||
says whether the host runs an older or a newer Starsync (from the
|
||||
server's `min`/`max`; a proto 15 server's codeless `proto mismatch` is
|
||||
always an older host), `secret` a wrong password, `replaced` "Starsync
|
||||
was opened in another window". Close code 4015 (the server found it
|
||||
silent) and any other drop reconnect.
|
||||
|
||||
The coordinator maps the state to `health` (`connectionHealth`:
|
||||
"connecting…", "connected", "connection unstable…", "connection lost —
|
||||
retrying…", "reconnecting…", the reason; closed writes nothing, a cancel
|
||||
writes "cancelled", "Retry" "retrying…"), to `reconnecting` (lost or
|
||||
reconnecting), `unstable` and `stateStale`. A drop that lasted
|
||||
`lostSoundAfter` (4 s; a silent link counts from its last frame) raises
|
||||
`ConnectionLost`, the reconnect that follows `ConnectionBack` (each once
|
||||
per drop, through the Notifier): a quick flap makes no sound.
|
||||
|
||||
The session's part of a welcome is the coordinator's `onWelcome`
|
||||
handler (`_onWelcome`), called before the welcome's state goes out: the
|
||||
first welcome and a resumed one need nothing; a new member after a drop
|
||||
gets the app's intent again (`ready`, `presence` `away`) and the media is
|
||||
forgotten (opened again from the state) only when it changed. The
|
||||
incoming streams (`states`, `feeds`, `nudges`, `errors`), the send
|
||||
methods, the `token`, `memberId` and `serverNow()` are read through it.
|
||||
`close(bye:)` sends `bye` and waits briefly for the server's close, so
|
||||
the frame goes out before the socket does.
|
||||
|
||||
## Media
|
||||
|
||||
@@ -425,8 +497,9 @@ output (`generation`), volume (and so mute) and shaders carry over, and
|
||||
the old player is paused and disposed once the view let go of its
|
||||
texture (as on leave). A fresh player waits for the previous old one to
|
||||
be gone, so at most one old player lingers. The first open after join or
|
||||
Reload video uses the player just created; a reconnect or joining back to
|
||||
the media the player held keeps it (`_openedFor`). Cost (`SwitchTrace`
|
||||
Reload video uses the player just created; joining back to the media the
|
||||
player held keeps it (`_openedFor`); a dropped connection never touches
|
||||
the player. Cost (`SwitchTrace`
|
||||
`player`, headless, so without the `VideoController`): 45 to 65 ms per
|
||||
swap for one app in the process (mpv created and initialised, then
|
||||
pinned), about 150 ms with three in one process
|
||||
@@ -436,8 +509,7 @@ switch of about 1.5 s. `openUrl` opens with `play: false`; play/pause/seek/rate
|
||||
ready) or the user's own seeks (see Sync model). A server seek within 0.5 s of the current position is skipped. `leave()`
|
||||
stops the player (otherwise audio keeps playing). On media change
|
||||
(`mediaId` bump) the coordinator stops the previous media at once (no old
|
||||
picture or sound while the new one resolves, is fetched or opens; a
|
||||
reconnect to the media the player holds keeps it), then opens the new URL
|
||||
picture or sound while the new one resolves, is fetched or opens), then opens the new URL
|
||||
(or promotes the preload holding it) and `StateDiff` seeks to 0, holds
|
||||
and resets rate.
|
||||
|
||||
@@ -700,12 +772,24 @@ How bytes move:
|
||||
the server has); Up next preloads wait the same way. The putter starts
|
||||
its source before opening its own player, and the source reads its
|
||||
tracks' sizes at once.
|
||||
- Source away: viewers keep playing from the buffer; a request waits up
|
||||
to 60 s for missing bytes. The source reconnects with backoff (same
|
||||
token after a resume); a different size (or piece count and roles)
|
||||
drops that track's buffer.
|
||||
- Source away (D6): viewers keep playing from the buffer; a request
|
||||
waits up to 60 s for missing bytes. The source member going `gone`
|
||||
does not end the relay: it lives through the source's grace
|
||||
(`media.relay.sourceGone`, `ServerHub.refreshRelaySource`). The source
|
||||
(`RelaySource`, kept by the coordinator through the app's own drop)
|
||||
reconnects its channel with backoff (its token lasts through the
|
||||
grace; a half-open channel is caught by its 5 s WebSocket ping) and
|
||||
sends its `info` again, the same on every dial (the coordinator passes
|
||||
the media's `name` and `duration`): the same as its first (sizes, piece
|
||||
shapes, kinds, and name and duration when given) carries on, the
|
||||
buffer filler pulling what is missing; another one is closed with
|
||||
4002 and ends the relay (`ServerHub.relayOtherMedia`); a second channel
|
||||
replaces the first (4003). The source's leave (grace over, `bye`) or a
|
||||
swap ends the relay: its buffer goes, `live` drops, `/r` answers 404.
|
||||
`state.media.relay.live` follows the channel, and viewers show "waiting
|
||||
for <name> to pass the video on" while it is down, "<name> left:
|
||||
for <name> to pass the video on" while it is down, "<name> lost
|
||||
signal: playing what the server has" while the source is `gone` (a
|
||||
viewer opening then opens the buffer instead of waiting), "<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
|
||||
@@ -942,7 +1026,8 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
||||
next episode, every client with the setting on shows a 10 s countdown
|
||||
card (Cancel / Play now; without the setting, a "Next" card with Play
|
||||
next). Exactly one client swaps: `autoAdvanceSwapper`, the lowest member
|
||||
id among non-stale members whose `jf` for that server is true. Only what
|
||||
id among present members (`in` or `joining`) whose `jf` for that
|
||||
server is true. Only what
|
||||
every client sees in `state` decides it (who put the item up is not in
|
||||
`state`; a late joiner never saw that swap), so clients agree without a
|
||||
wire change. Cancel is local: it stops the countdown on that screen, and
|
||||
@@ -985,15 +1070,16 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI 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.
|
||||
clients agree): a link passed on or a file, only its adder (`gone`:
|
||||
the item waits for it through its grace; left for good: skipped, "Bob
|
||||
left"); a Jellyfin item, its adder when it plays that server with its
|
||||
own account, else the lowest present member id that does (`jf`, which
|
||||
lists every working account; nobody: skipped); a link everyone opens
|
||||
or the server fetches, its adder when present, else the lowest present
|
||||
member id (present: `in` or `joining`, `isPresent` in
|
||||
`session/party.dart`). A skip is a `remove` with the reason from the
|
||||
lowest member id that is not `gone` (`queueJanitor`); the feed's
|
||||
`queue-skip` toasts on every screen.
|
||||
- **Advancing**: at `finished` with Up next waiting, every screen shows
|
||||
`UpNextCard` (picture, title, who added it and how; with auto-advance on
|
||||
a 10 s countdown with Cancel, else Play now only); at zero the app that
|
||||
@@ -1136,12 +1222,14 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
|
||||
**Events.** Everything that may become a toast, a sound, a window
|
||||
attention request or an activity-log line is an `AppEvent`
|
||||
(`session/app_events.dart`, a sealed type, pure Dart): the server feed as
|
||||
typed `FeedEvent`s (`FeedEvent.of`: joins, leaves, stale, sat out, back,
|
||||
typed `FeedEvent`s (`FeedEvent.of`, one per feed action: joins, leaves
|
||||
(`bye` or `grace`), gone (lost signal), return, sat out (`away`: by
|
||||
oneself, by someone else, or by this app's auto sit-out), back (`in`),
|
||||
ready/unready, force ready/play/hold, swap, seek, pause, resume,
|
||||
finished, chat, rename, avatar, queue add/remove/skip, fetch failed),
|
||||
the session's own (nudged, a member can't play, slow source, relay
|
||||
started/failed, idle warning, connection lost/back, Jellyfin progress
|
||||
not saved) and the app's (shader message, added to Up next, a put-up or
|
||||
finished, chat, rename, avatar, queue add/remove/skip, fetch failed;
|
||||
people are matched by `actorId` / `targetId`, chat by `fromId`, never by
|
||||
name), the session's own (nudged, a member can't play, slow source, relay
|
||||
started/failed, connection lost/back, Jellyfin progress not saved) and the app's (shader message, added to Up next, a put-up or
|
||||
queue add that failed, Schedule results, problem marked, copied / copy
|
||||
failed, update available). Code with news calls
|
||||
`SessionCoordinator.notify` (= `Notifier.notify`, `session/notifier.dart`);
|
||||
@@ -1149,8 +1237,10 @@ nothing else writes toasts or plays sounds (Settings' sound preview
|
||||
aside). `noticeFor` is the whole policy, one `Notice` per event: the
|
||||
toast text and its actor, the `SfxCue`, attention, the feed line for the
|
||||
activity log. Today: every feed line is logged (`Notifier.feedLog`, the
|
||||
last 100); joins, leaves, swaps, chat, being sat out by someone else and
|
||||
Up next skips toast; the sounds are listed under "Sounds"; a nudge also
|
||||
last 100); lines a reconnect replayed (`replayed`) are only logged;
|
||||
joins, leaves, swaps, chat, being sat out by someone else, being sat out
|
||||
by auto sit-out and Up next skips toast (a toast keeps its actor's member
|
||||
id, so its avatar is found by id); the sounds are listed under "Sounds"; a nudge also
|
||||
asks for attention; an available update does nothing (the update band
|
||||
shows it). `Notifier.toasts` holds the toasts, dropped 4 s after they came
|
||||
(`expire`, the session's 1 s tick), cleared on connect. The Library
|
||||
@@ -1163,7 +1253,10 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
|
||||
`LinkResolver` as constructor arguments):
|
||||
- `ShellPort` (`DesktopShell`): window start (remembered bounds, the
|
||||
close request routed to `shutDownApp`), fullscreen, minimize/maximize
|
||||
and their changes, dragging (`WindowDragArea`), close, the way out
|
||||
and their changes, the window's activity for auto sit-out
|
||||
(`watchActivity`: focused, unfocused, minimised, hidden; window_manager's
|
||||
focus, blur, minimise and restore, and the shell's own `hide`, wired to
|
||||
`SessionCoordinator.windowActivity` in `main`), dragging (`WindowDragArea`), close, the way out
|
||||
(save, hide, quit), attention, the clipboard paste (files, else text)
|
||||
and drop targets (fox_utils).
|
||||
- `UrlOpener` (`DesktopUrls`): `xdg-open`, `open`, Windows' `url.dll`.
|
||||
@@ -1233,10 +1326,13 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
(the Host card, titled "Invite friends", "Start hosting") and Stop (no
|
||||
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
|
||||
"ready n/m" (who the ready counter counts: `countsForReady` in
|
||||
`session/party.dart`, on `PresencePolicy`, so not away, gone or
|
||||
errored; hidden under 1100 px), chat with an unread badge, and Leave
|
||||
(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
|
||||
relay pills; "joining…", "sitting out", or for a `gone` member
|
||||
"reconnecting… (2 min)" with how long; 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
|
||||
@@ -1245,8 +1341,9 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
drag area; the window buttons sit at its end.
|
||||
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
|
||||
picture with a gap in the background colour (both scale with the size):
|
||||
green ready, amber buffering (with a spinner), grey not ready, dimmed
|
||||
stale, dimmed with a moon ("away") sitting out (last in the stack); a crown for the host (`members[].host` in the state; your own
|
||||
green ready, amber buffering (with a spinner), grey not ready, a little
|
||||
faded joining, dimmed with a dark ring `gone` (reconnecting, last in
|
||||
the stack), dimmed with a moon ("away") sitting out; a crown for the host (`members[].host` in the state; your own
|
||||
app also knows before the first state) and a tower for the member
|
||||
passing the video on. `memberAvatarImage` is the one picture lookup: yours from the
|
||||
profile, anyone else's from `coordinator.avatars` by `avatarHash`.
|
||||
@@ -1262,7 +1359,7 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
attempt and its error outlive the card; a card closes itself once
|
||||
in a server that is not solo. Join says that a friend gives the address
|
||||
and that no account or Jellyfin is needed.
|
||||
- **Profile** (`profile_card.dart`, `screens/home/profile.dart`): a card
|
||||
- **Profile** (`profile_card.dart`, `session/identity.dart`): a card
|
||||
tinted with your colour: picture (hover "Change" opens `CropPicker`:
|
||||
drag, wheel or slider zoom, previews at 88/42/24 px, exports a 256 px
|
||||
PNG), name edited in place, colour swatches, links to the Settings pages.
|
||||
@@ -1375,7 +1472,8 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
- **Sounds** (`sfx/`): the `Notifier` plays an `SfxCue` for ready,
|
||||
unready, force-ready, join, leave, swap and others' chat (feed
|
||||
events), being sat out by someone (the `kick` sound), a nudge (always,
|
||||
whatever its switch), the idle warning, and connection lost / back (once per drop).
|
||||
whatever its switch), auto sit-out setting you away (`warn`), and
|
||||
connection lost / back (once per drop that lasted a few seconds).
|
||||
Nothing for what the video already says (play, pause, seeks, the end).
|
||||
The sounds are synthesized, not sampled: `synth.dart` renders voices
|
||||
(plucked strings with bends, glass and star bells, a singing bowl,
|
||||
@@ -1703,9 +1801,13 @@ since media_kit_video frees a video output on a thread of its own after
|
||||
dispose returns, and an output still up when the engine stops blocks the
|
||||
plugin's teardown (`~VideoOutput` waits on a texture callback that never
|
||||
comes). Then `SessionState.leave`
|
||||
(Jellyfin stop report, last diagnostics, the goodbye, so others see
|
||||
"left") and the hosted server's stop, 3 s in all; an update restart then
|
||||
closes Hive and starts the new binary. Last the window closes the normal
|
||||
(Jellyfin stop report, last diagnostics, then `bye`, flushed: the app
|
||||
waits up to 600 ms for the server to close the socket, so others see
|
||||
"left" at once) and the hosted server's stop, 3 s in all. Update and
|
||||
restart leaves without `bye` (`shutDownApp(restart: true)`): the member
|
||||
stays `gone`, and the restarted app, with the same client id, takes it
|
||||
back within the grace when it joins again; it then closes Hive (the
|
||||
identity's `seq` is stored) and starts the new binary. Last the window closes the normal
|
||||
way (`setPreventClose(false)` + `close`), so the engine and plugins are
|
||||
torn down inside the platform's main loop, and the runners end the
|
||||
process without `exit()`'s handlers: Linux `_exit` after
|
||||
|
||||
+27
-16
@@ -57,8 +57,9 @@ the timings of each media change (`SwitchTrace`).
|
||||
|
||||
Two instances on one machine: `STARSYNC_PROFILE=<name>` (letters, digits,
|
||||
`-`, `_`; at most 32) gives an instance its own Hive boxes (settings,
|
||||
profile, connect form, window bounds, folders, Jellyfin sign-ins; the
|
||||
client id from restructure step 1 on) and logs under
|
||||
profile, connect form, window bounds, folders, Jellyfin sign-ins, the
|
||||
client id: two instances without it are one install, and the second takes
|
||||
the first's member over) and logs under
|
||||
`<app support>/profiles/<name>/`, and ` [<name>]` in the window title;
|
||||
yt-dlp, shaders and caches stay shared (`AppDirs`, ARCHITECTURE "Desktop
|
||||
UI"). Without it nothing changes. Run one plain instance and one with
|
||||
@@ -77,12 +78,15 @@ and `e2e_check` (simulated viewers against a live server).
|
||||
Three suites, run from each package's folder:
|
||||
|
||||
- `cd packages/core && dart test` (a few seconds): protocol round-trips,
|
||||
the sync engine (clock, gate, correction, end of file), queue model,
|
||||
the sync engine (clock, gate, presence and grace, correction, end of
|
||||
file), the `PresencePolicy` table, queue model,
|
||||
relay pieces, updater, logging redaction, watch rule, yt-dlp and
|
||||
Jellyfin client parsing against captured fixtures.
|
||||
- `cd packages/server && dart test` (well under a minute): live servers
|
||||
on loopback: auth, media and relay byte serving, Jellyfin relay against
|
||||
a fake, queue, session log, isolate `serve`.
|
||||
on loopback: auth and version refusal, identity, takeover and grace
|
||||
(`presence_test` on a fake clock), media and relay byte serving with
|
||||
source drops, Jellyfin relay against a fake, queue, session log,
|
||||
isolate `serve`.
|
||||
- `cd apps/desktop && flutter test test/<file>`: widgets (shell, library,
|
||||
controls, settings, schedule), pure app logic (`StateDiff`, name parser,
|
||||
shader list, sound engine with a fake backend) and the real-mpv `*_e2e`
|
||||
@@ -95,7 +99,9 @@ server `jellyfin_relay_live` against a real Jellyfin; read their headers),
|
||||
`YTDLP_LIVE=1` (downloads yt-dlp and resolves a real tweet). Everything
|
||||
else is offline: Jellyfin and link hosts are local fakes.
|
||||
|
||||
Real-mpv tests: `sync_playback_e2e`, `coord_presence`, `video_reload_e2e`,
|
||||
Real-mpv tests: `sync_playback_e2e`, `coord_presence` (drops through a
|
||||
`FlakyProxy`: a half-open link and return, 10 flaps, `replaced`, `bye`,
|
||||
a dead or restarted server), `video_reload_e2e`,
|
||||
`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `chapters_mpv_e2e`,
|
||||
`ytdl_headers_e2e`, `relay_e2e` (a dedicated server with no file of its
|
||||
own: a guest's file, links passed on or fetched by the server, a late
|
||||
@@ -131,10 +137,15 @@ the server: server `flaky_link_test`. For manual runs with real apps use
|
||||
|
||||
Long durations on a fake clock: `SyncServer(clock:, liveness:)` and
|
||||
`SyncEngine(clock:, liveness:)` take the engine clock and the server's
|
||||
silence limits (`Liveness`, ARCHITECTURE "Sync model"), so a test can
|
||||
pass short limits or step a fake clock through minutes (the 1 s heartbeat
|
||||
still ticks in real time and reads the clock); `Connection(now:,
|
||||
silenceLimit:, reconnectDelay:)` on the app side.
|
||||
limits (`Liveness`: silence, grace, gone notice, joiner hold, joining
|
||||
timeout; ARCHITECTURE "Sync model"), so a test can pass short limits or
|
||||
step a fake clock through minutes: move the clock, then call
|
||||
`ServerHub.heartbeat()` (or `SyncEngine.tick()`) yourself; the real 1 s
|
||||
heartbeat still ticks and reads the same clock. On the app side
|
||||
`Connection(now:, unstableAfter:, silenceLimit:, lostSoundAfter:,
|
||||
reconnectDelay:, identity:)` (`connection_test` drives one against a
|
||||
scripted fake server), and `AutoSitOut` takes the time on every call
|
||||
(`session_logic_test` steps it through minutes).
|
||||
|
||||
Safety-net scenarios: the core flows every restructure step must keep
|
||||
working. What covers them today and which step adds the rest (steps as
|
||||
@@ -142,12 +153,12 @@ in [ROADMAP.md](ROADMAP.md)):
|
||||
|
||||
| Scenario | Covered today | Missing, added in |
|
||||
|---|---|---|
|
||||
| Drop and reconnect while sharing a relay | server `relay_test` "source drops and comes back", `sync_wave_test` (resume after an unclean drop), `flaky_link_test` (blackhole, reset); desktop `relay_e2e` "the source drops uncleanly and resumes", `coord_presence` (dead server, reconnect restores ready) | step 1: the same member back after a closed socket (grace, P1), the source re-dialling its relay (D6), the player and state kept through the drop, through `FlakyProxy` and a fake clock |
|
||||
| Join mid-playback | core `sync_engine_test` "late joiner does not halt playback", "a joiner while playing never holds anyone back"; server `relay_test` "a late joiner gets its own /r/ URL" | step 1: `joining` until in sync, loading in the background while playing (D2, P3), the ~15 s hold while not playing |
|
||||
| Sit out / Away and back | core `sync_engine_test` "sitting out"; server `hardening_test` "anyone sits anyone out"; desktop `server_isolate_test` (host sat out), `notice_table_test` (sat you out) | step 1: presence `away`/`in`, the ready counter (P6), auto sit-out; step 2: notifications while away (P8); step 6: not tracked as watched (P7) |
|
||||
| Swap, also while someone is Away or gone | core `sync_engine_test` "swap" (a stale member stays out), `queue_test` (a swap takes its item); server `relay_test` (a swap drops the buffer); desktop `sync_playback_e2e` | step 1: a swap with a member `away` (stays away) or `gone` (in grace, its items wait); step 5: no commands reach a player mid-switch (P15) |
|
||||
| Up next advance | core `queue_test` (skip, play request timeout); server `queue_test`, `relay_test` "warm relay"; desktop `jellyfin_session_test` (auto-advance swapper, end card countdown), `queue_switch_e2e` | step 5: preparing it through the media pipeline; step 6: the continuation and the countdown as server state with a shared cancel |
|
||||
| Close the app cleanly, incl. `bye` | server `lifecycle_test` (clean close removes the member, server stop closes sockets); desktop `server_isolate_test` (stop frees the port), `coord_presence` (cancelled host setup releases the port) | step 1: `bye` leaves at once, any other close keeps the member in grace, update-and-restart sends no `bye`; step 5: the Windows close smoke job (P32) |
|
||||
| Drop and reconnect while sharing a relay | server `flaky_link_test` (blackhole: `gone` with ready, queue item and relay kept, the same member back; reset), `sync_wave_test` (takeover by client id, `replaced`, `stale`), `presence_test` (grace on a fake clock, restart), `relay_test` "the source drops (D6)" (`sourceGone`, buffer served, same `info` continues, another 4002, a second channel 4003, leave ends it); desktop `relay_e2e` "the source drops uncleanly and comes back" (gone past the server's silence, the relay kept, the source re-dials and continues), `coord_presence` (a half-open drop while playing: same member, no reopen, the state kept; 10 flaps: at most one lost/back sound; `replaced` is terminal; a restarted server gets the ready again), `connection_test` (the hello's id, `seq`, caps, `feedAfter`) | |
|
||||
| Join mid-playback | core `sync_engine_test` "D2: joiners" (never changes the transport while playing, at most 15 s while held, joining ends on error or timeout), `presence_test` (the table); server `relay_test` "a late joiner gets its own /r/ URL"; desktop `coord_presence` (the returning member plays on in sync) | |
|
||||
| Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (host sat out through `presence`), `notice_table_test` (the feed actions one-to-one, matched by id), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out) | step 2: notifications while away (P8); step 6: not tracked as watched (P7) |
|
||||
| Swap, also while someone is Away or gone | core `sync_engine_test` "swap" (a gone member stays out of the gate, comes back joining), "away" (a swap leaves it away), `queue_test` (a swap takes its item, drops the `auto` one); server `queue_test` (a gone adder's item waits), `relay_test` (a swap drops the buffer); desktop `sync_playback_e2e` | step 5: no commands reach a player mid-switch (P15) |
|
||||
| Up next advance | core `queue_test` (skip, play request timeout, `auto` and `cancel`); server `queue_test`, `relay_test` "warm relay"; desktop `jellyfin_session_test` (auto-advance swapper, end card countdown), `queue_switch_e2e` | step 5: preparing it through the media pipeline; step 6: the countdown as server state (`state.advance`) |
|
||||
| Close the app cleanly, incl. `bye` | server `lifecycle_test` (a close keeps the member `gone`, `bye` leaves at once, server stop closes sockets); desktop `server_isolate_test` (stop frees the port), `coord_presence` (cancelled host setup releases the port; leave sends `bye` and others see "left" at once, leaving for a restart keeps the member `gone`) | step 5: the Windows close smoke job (P32) |
|
||||
|
||||
A test file whose first line is `// restructure: rewrite in step N` pins
|
||||
behaviour that step of the restructure ([ROADMAP.md](ROADMAP.md)) changes;
|
||||
|
||||
@@ -76,10 +76,12 @@ window; guests need no Jellyfin or account.
|
||||
6. **Swap.** Anyone can change the video by pasting another link. Everybody
|
||||
stops at 0 and the gate runs again for everyone there; ready flags stay
|
||||
as they were (a setting unreadies you instead).
|
||||
7. **Leave.** Closing or leaving drops you from the server; the others keep
|
||||
going. If you vanish without saying goodbye, you are marked stale after 15 s
|
||||
(stops blocking) and removed after 30 s. The app reconnects by itself and
|
||||
takes over your old place (same member, ready flag and video).
|
||||
7. **Leave.** Closing the app or Leave says goodbye and drops you from the
|
||||
server; the others keep going. If you vanish without a goodbye, after 15 s
|
||||
of silence you are "gone": you stop blocking but keep your place
|
||||
(ready flag, sitting out, Up next items, the video you pass on) for
|
||||
10 minutes. The app keeps playing and reconnects by itself, with an id
|
||||
of its own, and takes that place back.
|
||||
|
||||
There are no roles: anyone can seek, swap, force-ready others, nudge
|
||||
anyone, and sit anyone out (the host too). Someone sitting out stays
|
||||
|
||||
+284
-88
@@ -1,41 +1,93 @@
|
||||
# Protocol (proto = 15)
|
||||
# Protocol (proto = 16)
|
||||
|
||||
Wire format only. Behavior (gate, clock, timeouts) is in
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
|
||||
`packages/core/lib/src/protocol.dart` and `packages/server/lib/src/server_hub.dart`.
|
||||
|
||||
Any wire change bumps `RelayConstants.protoVersion` and edits this file in
|
||||
the same commit. Changes are additive where possible; a client and server
|
||||
with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||
the same commit. Versions are ranges: a server accepts
|
||||
`[protoMin, protoMax]` (`RelayConstants.protoMin` / `protoMax`, both 16
|
||||
today), a client sends its own range (`hello.protoMin` .. `hello.proto`),
|
||||
and the connection uses the highest version both have (`welcome.proto`).
|
||||
Later additions raise `protoMax` only, so they stay additive. Fields
|
||||
marked **reserved** are parsed and documented but nothing produces them
|
||||
yet; the restructure step named next to them does.
|
||||
|
||||
## Transport
|
||||
|
||||
- WebSocket at `/sync`, JSON text frames, each an object with a `t` field.
|
||||
- The first client frame must be `hello` within 10 s. Server pings every 5 s.
|
||||
- Match incoming frames by `t`, never by order: `welcome` can race `state`.
|
||||
- Failures: server sends `{"t":"err","message":...}` and closes.
|
||||
Messages: `bad hello: ...`, `proto mismatch: got X, want Y`, `wrong secret`,
|
||||
`rejected: <reason>` (e.g. avatar too large), `bad message: ...` (a frame
|
||||
with a non-finite number, e.g. NaN position), `bad swap media`,
|
||||
`swap needs a url or the hosted file` (empty swap).
|
||||
- `GET /health` returns JSON (`ok`, `name`, `proto`, `viewers`, `bytesServed`,
|
||||
`uptimeSecs`; unauthenticated). `GET /m/<sid>/<token>/<name>`
|
||||
serves the host's file (Range table in ARCHITECTURE.md).
|
||||
`GET /j/<sid>/<token>/<itemId>` relays the current Jellyfin media and
|
||||
Up next's first one (see
|
||||
"Jellyfin media" below). `/r/<sid>/<token>/...` is the relay of a
|
||||
member's media (see "Relay" below). `GET /a/<sid>/<token>/<hash>` serves
|
||||
a member's picture (see "Avatars" below), `GET /i/<sid>/<token>/<hash>`
|
||||
the current media's art (see "Media art" below). `POST` and `GET
|
||||
/l/<sid>/<token>` send and read the session log (see "Diagnostics"
|
||||
below).
|
||||
- A socket without any frame from its client for 15 s
|
||||
(`RelayConstants.silenceSecs`) is closed by the server (close code
|
||||
4015) and its member is `gone` (see "Presence"). Clients send a
|
||||
`report` every second and a `ping` every second anyway.
|
||||
- Failures: `err` (see "Errors and close codes"); a refused `hello` is
|
||||
followed by a close.
|
||||
- `GET /health` returns JSON (unauthenticated, no member names): `ok`,
|
||||
`name`, `proto` (= `protoMax`), `protoMin`, `protoMax`, `version` (the
|
||||
server's app version, e.g. `1.3.0`), `tls` (**reserved**: always false;
|
||||
a server with TLS would serve the same endpoints over `wss://` /
|
||||
`https://`), `viewers`, `bytesServed`, `uptimeSecs`.
|
||||
- `GET /m/<sid>/<token>/<name>` serves the host's file (Range table in
|
||||
ARCHITECTURE.md). `GET /j/<sid>/<token>/<itemId>` relays the current
|
||||
Jellyfin media and Up next's first one (see "Jellyfin media" below).
|
||||
`/r/<sid>/<token>/...` is the relay of a member's media (see "Relay"
|
||||
below). `GET /a/<sid>/<token>/<hash>` serves a member's picture (see
|
||||
"Avatars" below), `GET /i/<sid>/<token>/<hash>` the current media's art
|
||||
(see "Media art" below). `POST` and `GET /l/<sid>/<token>` send and read
|
||||
the session log (see "Diagnostics" below). A token works from the
|
||||
member's `welcome` until it leaves (through its grace, see "Presence").
|
||||
|
||||
## Errors and close codes
|
||||
|
||||
```
|
||||
{"t":"err","code":"version","min":16,"max":16,"server":"1.3.0","for":"hello",
|
||||
"message":"proto mismatch: got 15, want 16..16"}
|
||||
{"t":"err","code":"bad","for":"seek","message":"bad message: position must be finite"}
|
||||
```
|
||||
|
||||
- `message` is plain text for people; a proto 15 app shows it as is (the
|
||||
version refusal starts with `proto mismatch:` for that reason). `code`
|
||||
is for programs; `for` names the `t` of the frame that failed, when
|
||||
there is one. With `code` `version`: `min`/`max` the server's range,
|
||||
`server` its app version. This shape is stable from proto 16 on.
|
||||
- Codes: `version` (no common version), `secret` (`wrong secret`),
|
||||
`rejected` (understood but refused: `rejected: avatar too large`,
|
||||
`server fetch needs a server password`, `The server can't fetch this
|
||||
link: ...`), `bad` (malformed: `bad hello: ...`, `bad message: ...` for
|
||||
a frame with a non-finite number or a bad queue item, `bad swap media`,
|
||||
`swap needs a url or the hosted file`, `relay fetch needs an http(s)
|
||||
url`), `replaced` (another hello of the same client took the member
|
||||
over; the client stops, "opened in another window"), `stale` (this hello
|
||||
is older than the member's current one), `forbidden` (`presence` `in` for
|
||||
someone else; an `auto` queue item from someone else than the swapper),
|
||||
`rate` (**reserved**; nudges over the cap are still dropped silently),
|
||||
`full` (`Up next is full (100 items)`).
|
||||
- After `err` the socket stays open, except at `hello` and for
|
||||
`replaced`, which are followed by a close.
|
||||
- Close codes (the server's; constants: `CloseCodes` in core):
|
||||
|
||||
| Code | Socket | Meaning |
|
||||
|---|---|---|
|
||||
| 1000 | `/sync` | the member left (`bye`, or its grace ran out) |
|
||||
| 4001 | relay channel | not (or no longer) the source of this relay; the source stops |
|
||||
| 4002 | relay channel | the source is back with another media than the relay's first `info`; the relay ends, the source stops |
|
||||
| 4003 | relay channel | a newer channel of the same source replaced this one; this one stops |
|
||||
| 4010 | `/sync` | refused: `err` `version` |
|
||||
| 4011 | `/sync` | refused: `err` `secret` |
|
||||
| 4012 | `/sync` | refused: `err` `bad` or `rejected` at `hello` |
|
||||
| 4013 | `/sync` | `err` `replaced`: another connection of the same client holds the member |
|
||||
| 4014 | `/sync` | refused: `err` `stale` |
|
||||
| 4015 | `/sync` | silent for 15 s: the member is `gone` |
|
||||
|
||||
## Client to server
|
||||
|
||||
```
|
||||
{"t":"hello","proto":15,"secret":"","user":{"name":"","avatar":""},
|
||||
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
|
||||
"resume":"<token>"}
|
||||
{"t":"hello","protoMin":16,"proto":16,"secret":"","user":{"name":"","avatar":""},
|
||||
"media":{},"hostKey":"...","client":"<install id>","seq":7,"app":"1.3.0",
|
||||
"platform":"linux","caps":{"source":true,"resolve":true,"jellyfin":true},
|
||||
"feedAfter":41}
|
||||
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
|
||||
{"t":"force","target":"all"|"<memberId>","ready":true}
|
||||
{"t":"seek","position":123.4,"seq":3}
|
||||
@@ -47,27 +99,45 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true},"error":"Not allowed (403)"}
|
||||
{"t":"ping","c":1767225600000}
|
||||
{"t":"chat","text":"..."}
|
||||
{"t":"out","target":"<memberId>","out":true}
|
||||
{"t":"presence","target":"<memberId>","state":"away"|"in","reason":"manual"}
|
||||
{"t":"bye"}
|
||||
{"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"
|
||||
{"t":"queue","op":"add"|"move"|"remove"|"play"|"skip"|"cancel",...} see "Queue"
|
||||
```
|
||||
|
||||
Field notes:
|
||||
- `hello.proto` / `hello.protoMin`: the client's version range
|
||||
(`protoMin` absent: `proto` only). The server reads them before
|
||||
anything else in the hello (a hello of another version may not parse)
|
||||
and refuses with `err` `version` when the ranges do not meet.
|
||||
`hello.media` is ignored; a proto 16 client still sends `{}` so a proto
|
||||
15 server, which requires it, answers with its own `proto mismatch`.
|
||||
- `hello.secret`: the server password; a server with an empty secret is open.
|
||||
`hello.media` is carried but not used by the server today.
|
||||
- `hello.hostKey` (optional, omitted when empty): the hosting app
|
||||
generates a random key per hosting, gives it to its server and sends it
|
||||
from its own client. The member whose key equals the server's is the host
|
||||
(`members[].host`). A server without a key (the dedicated server) has no
|
||||
host. Where the connection comes from does not matter.
|
||||
- `hello.resume` (optional, omitted when empty): the token from this
|
||||
client's previous `welcome`, sent on reconnect. If the server still has
|
||||
that member, the connection takes it over: `welcome`
|
||||
returns the same `token` and `memberId`, the member keeps its flags, and
|
||||
there is no `join`/`leave` feed. Otherwise it is ignored and a new member
|
||||
joins.
|
||||
- `hello.client` (optional): a random id per install, 16 to 64
|
||||
characters of `[A-Za-z0-9_-]` (the app uses 22 characters of
|
||||
base64url), else `err` `bad`. Private: never broadcast, never logged.
|
||||
It names the member across connections (see "Presence"); without it
|
||||
every hello is a new member. **Reserved**: deriving it per server (an
|
||||
HMAC of an install secret and the dialled host:port).
|
||||
- `hello.seq` (default 0): increases with every connection this install
|
||||
makes. A hello whose `seq` is lower than the one holding the member
|
||||
gets `err` `stale` and is closed; equal or higher takes the member over.
|
||||
- `hello.app` (the app version, cut to 32 characters): shown as
|
||||
`members[].app`. `hello.platform` (`linux`, `windows`, `macos`,
|
||||
`android`): logged only. `hello.caps`: what the app can do (`source`:
|
||||
serve a relay; `resolve`: run yt-dlp; `jellyfin`: play with its own
|
||||
account), shown as `members[].caps`; unknown entries are ignored.
|
||||
- `hello.feedAfter` (optional): the last `feed.seq` the client saw from
|
||||
this server; `welcome.feed` replays the lines after it. A client should
|
||||
send it only for the same `welcome.instance` (a restarted server counts
|
||||
from 1 again).
|
||||
- `seek.seq` (optional, default 0): increasing per client; echoed in
|
||||
`state.seekSeq` so the sender recognises its own seek.
|
||||
- `report.duration` / `report.mediaId` (optional, omitted when 0): the open
|
||||
@@ -75,7 +145,9 @@ Field notes:
|
||||
the first one whose `mediaId` is current while its media duration is 0.
|
||||
`report.buffered` is the current media's: a client sends 0 while its
|
||||
player does not hold it yet, and the server counts a report whose
|
||||
`mediaId` names an earlier media as `buffered` 0.
|
||||
`mediaId` names an earlier media as `buffered` 0. A `joining` member's
|
||||
report ends its joining once it holds the current media at the server
|
||||
position (within 5 s) with the buffer the gate needs (see "Presence").
|
||||
- `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
|
||||
@@ -92,27 +164,27 @@ Field notes:
|
||||
clears it, and so does a swap. An errored member is out of the ready
|
||||
gate: out of the set, a `ready` or `force` does not put it back; when
|
||||
the error clears it is in the set again like a join (unless `playing`,
|
||||
then from its `ready`). Optional on both sides: older servers ignore it,
|
||||
older clients ignore `members[].error`.
|
||||
then only if it is ready). It also ends a `joining`.
|
||||
- `ping.c`: the client's clock in epoch ms; answered with `pong`.
|
||||
- `user.avatar`: base64 PNG, at most 256 KiB of characters (else
|
||||
`rejected: ...`). `name` is trimmed, empty becomes `?`, cut to 32
|
||||
characters.
|
||||
characters. A takeover applies the hello's name and picture like a
|
||||
`profile`.
|
||||
- `profile`: changes this member's name and/or picture mid-session. A field
|
||||
that is absent (or null) stays as it is; `avatar: ""` removes the picture.
|
||||
Same rules as `hello.user`; a too large avatar gets `err`
|
||||
`rejected: avatar too large` (the socket stays open, nothing changes).
|
||||
- `art`: pictures of the current media (see "Media art").
|
||||
- `chat.text` is cut to 500 characters.
|
||||
- `out`: `out:true` sits the member `target` out (anyone, the host
|
||||
included); `out:false` brings the sender back and only works with its own
|
||||
member id as `target`. An out member is never in the set and never
|
||||
ready: `ready` from it and `force` on it are ignored, a swap leaves it
|
||||
out, the `buffered` of its reports counts as 0 (it keeps sending
|
||||
`report`: that keeps it fresh). Coming back puts it in the set like a
|
||||
join (unless `playing`), unready; it sends `ready` itself. Unknown
|
||||
targets and no-op changes are ignored. Feed: `out` (`target` = the name
|
||||
of who was sat out, absent when the actor sat itself out) and `back`.
|
||||
- `presence`: `target` a member id (absent or empty: oneself). `state`
|
||||
`away` sits `target` out: anyone may, on anyone (the host included); a
|
||||
`gone` target comes back `away`. `state` `in` brings oneself back (through
|
||||
`joining`, see "Presence"); for anyone else it is `err` `forbidden`.
|
||||
`reason` (`manual` default, `idle`, `minimised`, `bye`) goes into the
|
||||
feed line. Unknown targets and no-op changes are ignored.
|
||||
- `bye`: the member leaves now (the Leave button, the app closing): feed
|
||||
`leave` (reason `bye`), its token stops working, the socket closes
|
||||
(1000). Anything else that ends a socket keeps the member (`gone`).
|
||||
- `nudge`: the server sends `{"t":"nudge","from":<memberId>,"name":<name>}`
|
||||
to `target` only (no feed). At most 3 per second and 20 per minute per
|
||||
sender; the rest, nudges to oneself and to unknown targets are dropped
|
||||
@@ -120,29 +192,34 @@ Field notes:
|
||||
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
|
||||
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
|
||||
dropped. `swap.media.relay` makes it relayed media (see "Relay").
|
||||
A swap keeps every member's `ready` and puts every fresh member in the
|
||||
set (`inSet`); clients that want to be unready for the new media send
|
||||
`ready` themselves (gate: ARCHITECTURE.md, "Ready gate").
|
||||
`swap.media.ref` says what it is (see "Media identity"). A swap keeps
|
||||
every member's `ready` and puts every member in the set (`inSet`; who
|
||||
counts is up to presence); clients that want to be unready for the new
|
||||
media send `ready` themselves (gate: ARCHITECTURE.md, "Ready gate").
|
||||
- Frames before `welcome` (other than `hello`) are ignored; unparsable or
|
||||
unknown frames are ignored silently.
|
||||
unknown frames are ignored silently. Frames
|
||||
from a socket another hello took over are ignored.
|
||||
|
||||
## Server to client
|
||||
|
||||
```
|
||||
{"t":"welcome","token":"...","memberId":"m1","mediaUrl":"...","relayUrl":"...",
|
||||
"state":{<state>}}
|
||||
{"t":"welcome","proto":16,"token":"...","memberId":"m1","resumed":false,
|
||||
"instance":"<random per boot>","grace":600,"mediaUrl":"...","relayUrl":"...",
|
||||
"state":{<state>},"feed":[<feed with "replayed":true>,...]}
|
||||
{"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3,
|
||||
"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"?,"rate"?}],
|
||||
"media":{name,size,duration,url?,jellyfin?,relay?,ref?,swapBy?,art?}|absent,
|
||||
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","presence",
|
||||
"goneSince"?,"jf"?,"host"?,"error"?,"rate"?,"app"?,"caps"?}],
|
||||
"seekBy":"m1","seekSeq":3,"relay":true,"relayNext":true,"queue":[<item>]?,
|
||||
"queuePlay":"q3"?}
|
||||
"queuePlay":"q3"?,"hold":{"reason":"joining","members":["m3"],"until":"<ISO>"}?,
|
||||
"advance":{"id":"q3","at":"<ISO>","secs":10}?}
|
||||
{"t":"pong","c":1767225600000,"s":1767225600012}
|
||||
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
|
||||
{"t":"chat","text":"...","from":"<name>"}
|
||||
{"t":"feed","seq":12,"actor":"","actorId":"m2","action":"","target":"","targetId":"m3",
|
||||
"reason":"","at":"<ISO-8601>"}
|
||||
{"t":"chat","text":"...","from":"<name>","fromId":"m2"}
|
||||
{"t":"nudge","from":"m2","name":"Alice"}
|
||||
{"t":"err","message":""}
|
||||
{"t":"err","code":"...","message":"...","for":"..."}
|
||||
```
|
||||
|
||||
- `welcome.token` is private to its owner and appears only in that member's
|
||||
@@ -151,7 +228,15 @@ Field notes:
|
||||
the shared link.
|
||||
`welcome.state` has the same shape as `state`. `relayUrl` is present only
|
||||
when `state.relay` is: this member's `/j/...` URL for the current
|
||||
Jellyfin media (private like `token`).
|
||||
Jellyfin media (private like `token`). `welcome.proto`: the version of
|
||||
this connection. `welcome.resumed`: the hello took over this client's
|
||||
member (same `memberId` and `token`, everything it had kept); false for
|
||||
a new member, also after a server restart. `welcome.instance`: random
|
||||
per server boot (a client that held a member of another instance knows
|
||||
the server restarted, not that its grace ran out). `welcome.grace`:
|
||||
seconds a `gone` member is kept. `welcome.feed` (omitted when empty):
|
||||
the feed lines after `hello.feedAfter`, at most the last 50, oldest
|
||||
first, each with `replayed: true` (log them, do not toast them).
|
||||
- `state` is sent every second and on every change. `position` is the value at
|
||||
`serverTime` (server clock); clients extrapolate while `transport` is
|
||||
`playing`, on the server's clock (see `pong`). `finished`: the media
|
||||
@@ -161,7 +246,8 @@ Field notes:
|
||||
(see "Relay"). `media.duration` is 0 until a client reports
|
||||
it. `media.swapBy`: member id of who swapped it in (omitted once that
|
||||
member left, and for the media the server started with). `media.art`:
|
||||
see "Media art". `seekBy` (member id) and `seekSeq` are present only while the current
|
||||
see "Media art". `media.ref`: see "Media identity". `seekBy` (member id)
|
||||
and `seekSeq` are present only while the current
|
||||
`eventId` came from a seek. `relay` (omitted when false): the server can
|
||||
relay the current Jellyfin media. `relayNext` (omitted when false, proto
|
||||
13): it can relay Up next's first item too (a Jellyfin ref on the same
|
||||
@@ -169,29 +255,98 @@ Field notes:
|
||||
`report.jf`, omitted when it never sent one. `members[].avatarHash`
|
||||
(omitted when the member has no picture) names the picture's bytes (see
|
||||
"Avatars"). `members[].host` (omitted when false): the member sent the
|
||||
server's `hostKey` (see `hello.hostKey`). `members[].out` (omitted when
|
||||
false): the member sits out (see `out`). `members[].error` (omitted when
|
||||
server's `hostKey` (see `hello.hostKey`). `members[].presence` and
|
||||
`goneSince` (ISO, only while `gone`): see "Presence". `members[].inSet`:
|
||||
the member counts for the ready gate right now (in the set, no player
|
||||
error, and its presence counts). `members[].error` (omitted when
|
||||
empty): the member's player cannot open the current media (see
|
||||
`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.
|
||||
`members[].app` / `caps` (omitted when empty): its `hello.app` /
|
||||
`hello.caps`.
|
||||
- `state.hold` (present while `held` with media up): why. `reason`, most
|
||||
pressing first: `joining` (a joiner within its bound holds the start;
|
||||
`until` is when that bound ends, server clock), `ready` (someone in the
|
||||
set is not ready), `buffer` (someone's buffer is short of `bufferNeed`),
|
||||
`source` (the same while the relay's source is `gone`), `start` (nobody
|
||||
counts for the gate: someone has to ready up). `members`: the member
|
||||
ids the gate waits for (empty for `start`).
|
||||
- `state.advance` (**reserved**, step 6): the shared countdown to Up next
|
||||
(see "Queue").
|
||||
- `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`,
|
||||
`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. `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.
|
||||
- `feed`: `seq` increases by one per line for the server's life (a
|
||||
restart counts from 1 again); `actor` / `target` are display names as
|
||||
they were then, `actorId` / `targetId` the member ids (`server` for the
|
||||
server's own lines; omitted when none): match people by id, not by
|
||||
name. `action` values: `ready`, `unready`, `force-ready`, `force-unready`,
|
||||
`force-play`, `force-hold`, `join` (a new member only), `leave` (for
|
||||
good: `reason` `bye` or `grace`), `gone` (10 s after the member went
|
||||
`gone`; a quicker return says nothing), `return` (back within the grace
|
||||
after a `gone` line), `away` (`target` the member set away, absent when
|
||||
the actor set itself; `reason` `idle`/`minimised`/`bye`, empty for
|
||||
manual), `in` (back from away), `swap`, `seek`, `pause`, `resume`,
|
||||
`finished`, `chat`, `rename`, `avatar`, `queue-add`, `queue-remove`,
|
||||
`queue-skip`, `advance-cancel` (`target` the dropped continuation's
|
||||
title, if any), `fetch-fail` (the server could not fetch the current
|
||||
media, `target` its name). `reason` carries `auto`/`manual` for ready.
|
||||
`rename`: `actor` is the new name, `target` the old one. `avatar`: the
|
||||
actor changed only the picture. `queue-*`: `target` is the item's
|
||||
title, `reason` (skip) why it could not play (see "Queue").
|
||||
- Chat is delivered twice: a `chat` frame with the text (`fromId` the
|
||||
sender's member id) and a textless `feed` with `action:"chat"`. Render
|
||||
the `chat` frame only; the reference client drops the textless feed
|
||||
entry.
|
||||
|
||||
## Presence
|
||||
|
||||
The member is lasting: one per client, kept across reconnects. Gate
|
||||
rules and timings in detail: ARCHITECTURE.md, "Sync model".
|
||||
|
||||
- The server maps `hello.client` to one member. A hello with a known
|
||||
client id (and a `seq` not older than the member's) takes that member
|
||||
over: `memberId` and `token` stay, so `/m` `/r` `/j` URLs keep working;
|
||||
the ready flag, presence, queue items, relay, picture all stay; an older
|
||||
socket still open gets `err` `replaced` and closes (4013).
|
||||
`welcome.resumed` is true.
|
||||
- A socket close (or 15 s of silence) is not a leave: the member goes
|
||||
`gone` (`goneSince`) and is kept for the grace, 10 min
|
||||
(`welcome.grace`), with everything it had; a relay it serves goes on
|
||||
(see "Relay"). After the grace it leaves (feed `leave`, reason
|
||||
`grace`), its token stops working; a hello with its client id after
|
||||
that is a new member. `bye` leaves at once.
|
||||
- `members[].presence`: `joining` (new, back from `gone`, or back `in`
|
||||
from `away`, while media is up: it loads in the background until its
|
||||
report holds the current media at the server position with
|
||||
`bufferNeed` buffered, or reports an error, or 60 s pass; then `in`),
|
||||
`in` (watching), `away` (sat out: still in the server, never counted
|
||||
for the gate, the ready counter or watched tracking), `gone`
|
||||
(disconnected, in its grace). A member `away` when it dropped comes
|
||||
back `away`. With no media up a joiner is `in` at once.
|
||||
- What each presence counts for is one table, `PresencePolicy` in core
|
||||
(`presence.dart`), used by the server and the app: the gate counts
|
||||
`in`, and `joining` only while `held` and for at most 15 s after it
|
||||
started joining (never while `playing`: a joiner never pauses anyone);
|
||||
the ready counter and preloads count `joining` and `in`; watched
|
||||
tracking and full social notifications count `in` (`joining` too for
|
||||
the notifications).
|
||||
|
||||
## Media identity
|
||||
|
||||
```
|
||||
media.ref = {"kind":"episode","title":"Show","anilistId":1,"anilistEp":6,
|
||||
"tvdbId":2,"season":1,"ep":6,"absolute":30}
|
||||
```
|
||||
|
||||
- In `swap.media`, `state.media` and `queue[].media`; passed through as
|
||||
is (the server drops non-integer ids and cuts `title` to 200
|
||||
characters). `kind` `episode`, `movie` or `extra` (else no ref); every
|
||||
other field optional; `season` absent for absolute numbering. At most
|
||||
2 KiB of JSON, else `err` `bad`. **Reserved**: nothing produces it yet
|
||||
(step 4).
|
||||
|
||||
## Avatars
|
||||
|
||||
@@ -278,6 +433,8 @@ 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}
|
||||
media.relay = {"source":"m2","live":true,"queueId":"q3"} started in Up next (proto 14)
|
||||
media.relay = {"source":"m2","live":true,"sourceGone":true,"sourceGoneSince":"<ISO>"}
|
||||
its source is gone, in its grace
|
||||
queue[0].media.relay = {"source":"m2","live":true,"tracks":[...]} its warm relay
|
||||
queue[0].media.relay = {"fetch":true,"live":true}
|
||||
```
|
||||
@@ -309,6 +466,12 @@ 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`).
|
||||
- `sourceGone` (omitted when false) and `sourceGoneSince` (ISO): the
|
||||
source member is `gone` (see "Presence"). The relay and its buffer stay
|
||||
through the source's grace; viewers play what is buffered. A source's
|
||||
leave (grace over, `bye`) ends the relay: `live` goes, `sourceGone`
|
||||
stays, its URLs answer 404. A swap ends it as before. The warm relay's
|
||||
`queue[0].media.relay` carries the same fields.
|
||||
- `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
|
||||
@@ -368,11 +531,23 @@ anything else means one file.
|
||||
relay's source token is accepted; anyone else, or the source once its
|
||||
relay is dropped, is closed with code 4001 (the source stops instead
|
||||
of reconnecting). A warm relay's tracks are those of its source's
|
||||
first `info`.
|
||||
first `info`. The source's token works through its grace, so a source
|
||||
back from a drop dials the channel again (before or after its own
|
||||
`hello`) and sends its `info` again: the same as its first (track
|
||||
kinds, byte sizes, piece shapes, and `name` / `duration` where both
|
||||
give them) continues the relay: the buffer stays and the server pulls
|
||||
what it still lacks with new pull ids, from the first byte or piece not
|
||||
buffered (the `pull` frames below are that exchange; nothing else is
|
||||
needed). Another `info` closes the channel with 4002 and ends the
|
||||
relay. A second channel for the same relay replaces the first, which is
|
||||
closed with 4003. The source stops on 4001, 4002 and 4003 and
|
||||
reconnects (with backoff) on anything else.
|
||||
Frames:
|
||||
|
||||
```
|
||||
source -> server {"t":"info","size":<bytes>} first frame, one file
|
||||
source -> server {"t":"info","size":<bytes>,"name":"Ep 06.mkv","duration":1440.5}
|
||||
first frame, one file
|
||||
(name, duration optional)
|
||||
{"t":"info","tracks":[{"kind":"video","size":<bytes>},
|
||||
{"kind":"audio","size":<bytes>}]}
|
||||
first frame, tracks
|
||||
@@ -397,26 +572,31 @@ server -> source {"t":"pull","id":1,"track":0,"start":0,"end":1048575}
|
||||
init and key pieces it uses, `iv` an explicit IV (`0x` + hex), `disc` a
|
||||
discontinuity before it; `seq` (default 0) the media sequence of the
|
||||
first segment. A piece pull's bytes end with `end` like a range pull.
|
||||
An `info` whose tracks differ from the swap's `tracks` (count, bytes or
|
||||
pieces), or a malformed piece list, closes the channel with 4001. A
|
||||
track `size` or piece count/roles different from the one before (a
|
||||
reconnect) drops that track's buffer.
|
||||
A first `info` whose tracks differ from the swap's `tracks` (count,
|
||||
bytes or pieces), or a malformed piece list, closes the channel with
|
||||
4001. A later `info` unlike the first closes it with 4002 (see above).
|
||||
|
||||
## Join and reconnect
|
||||
|
||||
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server
|
||||
position and buffers. A joining member is `ready=false`; it is `inSet`
|
||||
at once unless the server is `playing`, then from its first `ready`
|
||||
(members present at a swap are put in the set by it). A socket the server sees close loses its token; reconnect
|
||||
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.
|
||||
position and buffers. A new member is `ready=false` and `joining` (with
|
||||
media up); it is in the set at once unless the server is `playing`,
|
||||
then from its first `ready` (members present at a swap are put in the
|
||||
set by it), and the gate counts it per "Presence". A reconnect is a
|
||||
`hello` with the same `client` and a higher `seq` (and `feedAfter`):
|
||||
within the grace it is the same member (`welcome.resumed`), its token and
|
||||
media URL unchanged, its ready flag and presence kept; after a
|
||||
non-resumed `welcome` (grace over, server restarted) the client sends
|
||||
its intent again (`ready`, `presence`).
|
||||
|
||||
## Queue (proto 12)
|
||||
|
||||
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,
|
||||
when their adder leaves. `by` is the adder's lasting member id: it
|
||||
survives reconnects, and while the adder is `gone` its `through`/`file`
|
||||
items wait for it (it is still in `members`) instead of being skipped;
|
||||
after it left for good they cannot play. Behavior (who puts an item up, the countdown,
|
||||
skipping) is in [ARCHITECTURE.md](ARCHITECTURE.md), "Up next".
|
||||
|
||||
```
|
||||
@@ -425,11 +605,14 @@ client {"t":"queue","op":"add","at":"end"|"next","item":{<item>},"thumb":"<base6
|
||||
{"t":"queue","op":"remove","id":"q3","why":"Bob left"}
|
||||
{"t":"queue","op":"play","id":"q3"}
|
||||
{"t":"queue","op":"skip"}
|
||||
{"t":"queue","op":"cancel"}
|
||||
{"t":"queue","op":"add","item":{<item>,"auto":true,"for":2}}
|
||||
{"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>"}
|
||||
"by":"m2","byName":"Bob","key":"<adder's handle>","auto":true?,"for":2?}
|
||||
state "advance":{"id":"q3","at":"<server time>","secs":10} reserved
|
||||
```
|
||||
|
||||
- `add`: `item` without `id`, `by`, `byName`, `thumb` (the server sets
|
||||
@@ -461,6 +644,19 @@ state "queue":[<item>,...], "queuePlay":"q3"
|
||||
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.
|
||||
- `auto` (with `for`, the `mediaId` it continues): the continuation (the
|
||||
next episode). Only for the current media (else `err` `bad`), and only
|
||||
from the member who put it up (`media.swapBy`) or, when that member is
|
||||
not `in`, from the first member who is (else `err` `forbidden`). At most
|
||||
one: a newer one replaces it; it stays after the other items (an `add`
|
||||
goes before it, `next` puts an item first, `move` never passes it, it
|
||||
does not move itself); any swap that does not put it up drops it.
|
||||
- `cancel`: drops the `auto` item and the countdown for everyone (feed
|
||||
`advance-cancel`); nothing to drop, nothing happens.
|
||||
- `state.advance` (**reserved**, step 6): the shared countdown when the
|
||||
media finishes and an item is first; at `at` + `secs` the server would
|
||||
set `queuePlay` to `id`, holding while its performer or source is
|
||||
`gone`. The server never starts one yet; `cancel` and any swap clear it.
|
||||
- `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
|
||||
|
||||
+37
-164
@@ -35,15 +35,8 @@ Problems are numbered; delete a line when its fix ships.
|
||||
- D11 Login-only sites: error texts say which cookies were used.
|
||||
|
||||
**Problems:**
|
||||
- P1 A reconnect makes you a new member: the relay, ready, sitting out,
|
||||
your queue items and even your avatar are gone (log: Hoff m23 → m24 had
|
||||
to put ep 06 up again).
|
||||
- P2 Joining a relay whose source left serves broken leftovers; mpv loops
|
||||
on failed seeks; nobody is told the source is gone.
|
||||
- P3 A joiner during playback lands mid-scene (see D2).
|
||||
- P4 Reconnects time out repeatedly on a bad link; no clear
|
||||
"reconnecting" state.
|
||||
- P6 Sat-out people count in the ready counter.
|
||||
- P2 Viewers of a relay whose source is gone: mpv loops on failed seeks
|
||||
past what the server buffered (viewer-side recovery, step 5).
|
||||
- P7 Sat-out people get tracked as watched on Jellyfin.
|
||||
- P8 Sat-out people get the normal notifications.
|
||||
- P9 The watched rule (position coverage) misses real watches (D3).
|
||||
@@ -85,31 +78,14 @@ the automatic continuation and countdown, relay state), the device
|
||||
(client id, library and folders, match corrections, watch history,
|
||||
settings, notification prefs, what this app is sourcing) and the
|
||||
integrations (Jellyfin played/resume, AniList list).
|
||||
1. Connection + Identity + Presence; proto 16 opened (version ranges on
|
||||
both sides, refusal frame, identity, grace, presence, relay resume,
|
||||
ids in the feed); later steps add fields, it is frozen at the 1.3.0
|
||||
release. A drop is not the end of the session: keep the last state
|
||||
(marked stale), the player, media and relay source; the UI reads the
|
||||
connection state (no update band over a running video, no emptied
|
||||
queue, no reopen); reconnect with backoff and jitter; silence 8–10 s
|
||||
with "connection unstable" from ~3 s; lost/back sounds only after a
|
||||
few seconds of drop. New feed actions mapped one-to-one onto today's
|
||||
notices, matched by member id, not name. Presence shown in the strip
|
||||
and party UI (joining, away, gone = dimmed "reconnecting"). The relay
|
||||
source re-dials on return (client half of D6). Auto sit-out with a
|
||||
`ShellPort.watchActivity` (focused/unfocused/minimised/hidden): never
|
||||
when alone; while playing only minimised-for-a-while counts; the
|
||||
client re-sends its intent after a non-resumed welcome. A joiner holds
|
||||
only while not playing, at most ~15 s; while playing never (D2). The
|
||||
app close sends `bye` and flushes it; update-and-restart does not.
|
||||
`Profile` moves to identity. Fixes P1 P3 P4 P6, server half of P2;
|
||||
D1 D2 D5 D6 (source).
|
||||
2. Status (one computed level, incl. the server's hold reason) + Notifier
|
||||
(`noticeFor(event, context)`: self presence, alone, window activity,
|
||||
playing, prefs, replayed; owns its toast timer; system notifications
|
||||
port; library snack bars moved in). Fixes P8 P11 P12 P21 P33, "source
|
||||
left"; D4, D1 notifications, D9 alone policy ("alone" defined once in
|
||||
core next to `PresencePolicy`).
|
||||
left"; D4, D1 notifications, D9 alone policy (`isAlone` in core). Step 1
|
||||
left over for it: the presence notices are today's (sat you out, a
|
||||
toast when auto sit-out sets you away, lost signal and return only in
|
||||
the activity log); a replayed feed only logs.
|
||||
3. Your server: one hub, listeners attached and detached without members
|
||||
reconnecting (old isolate path kept until that's proven); Invite as a
|
||||
value (filtered addresses, version gate from `/health.version`, an `fp`
|
||||
@@ -159,144 +135,42 @@ both first); the dedicated server's fallback folder
|
||||
`~/.cache/watch-together`, the release token file
|
||||
`~/.config/watch-together-release-token`, Jellyfin's client name "Watch
|
||||
Together" (devices are listed under it) and `WatchTogetherApp`; dropping
|
||||
the 1.2.x launchers from the archive once nobody updates from 1.2.x. Auto
|
||||
sit-out: idle or minimised for a while sets you Away, activity sets you
|
||||
In; it replaces the 10-minute auto-unready.
|
||||
the 1.2.x launchers from the archive once nobody updates from 1.2.x.
|
||||
Left of step 1: Update and restart keeps the member `gone` for the
|
||||
restarted app, but the app starts on Home (it does not rejoin by itself)
|
||||
and does not remember what it was passing on, so a relay it served ends
|
||||
with its grace unless it is put up again; worth doing only if updates
|
||||
ever happen mid-session (D8 says never during playback).
|
||||
|
||||
**Open decisions:**
|
||||
- Starting together: today someone has to stay unready so others can
|
||||
join first. Ideas: a short "starting in 30 s, ready up" countdown;
|
||||
waiting for everyone from the last session.
|
||||
|
||||
- Grace length: ~10 min; a source in grace keeps its relay buffer.
|
||||
- Platform decision: "Desktop + mpv only" becomes "mpv only, desktop
|
||||
first" if Android goes ahead.
|
||||
- D10 was dropped on purpose (preload/warm relay/fresh player are folded
|
||||
into step 6, not a separate decision).
|
||||
|
||||
### Protocol 16 (planned)
|
||||
### Protocol 16 (open parts)
|
||||
|
||||
Lands in step 1, written into PROTOCOL.md then. Tags against proto 15:
|
||||
**new**, **changed**, **removed**. Rooms are out: one server, no room id.
|
||||
|
||||
Versions and refusal:
|
||||
- `hello.proto` **changed**: the client's version; the server accepts
|
||||
`[protoMin, protoMax]` (both 16 at 1.3.0; later additions raise
|
||||
`protoMax` only, so they stay additive). `welcome.proto` **new**: the
|
||||
version used on this connection.
|
||||
- Refusal **changed**, stable from 16 on: `{"t":"err","code":"version",
|
||||
"min":16,"max":16,"server":"1.3.0","message":"proto mismatch: got 15,
|
||||
want 16..16"}`, then close (a proto 15 app shows `message`). Every
|
||||
`err` gets `code` **new**: `version`, `secret`, `rejected`, `bad`,
|
||||
`replaced`.
|
||||
- `/health`: `protoMin`, `protoMax`, `tls` **new** (`proto` = `protoMax`).
|
||||
|
||||
Handshake:
|
||||
- `hello.client` **new**: a random id per install (22 characters
|
||||
base64url), private: never broadcast.
|
||||
- `hello.app` (`"1.3.0"`), `hello.platform` (`linux`, `windows`,
|
||||
`android`), `hello.caps` **new**: what this app can do (`source`: serve
|
||||
a relay; `resolve`: run yt-dlp; `jellyfin`: play with its own
|
||||
account); unknown entries ignored.
|
||||
- `hello.resume` **removed** (the client id replaces it); `hello.media`
|
||||
**removed** (unused).
|
||||
- `members[].app` **new**: the member's `hello.app`.
|
||||
|
||||
Lasting member:
|
||||
- The server maps `client` to one member. A new socket with a known
|
||||
client id takes that member over (`memberId` and `token` unchanged, so
|
||||
`/m` `/r` `/j` URLs keep working); the older socket gets `err`
|
||||
`replaced` and closes. `welcome.resumed` **new**: true when it did.
|
||||
- A socket close no longer removes the member **changed**: it goes
|
||||
`gone` and is kept for the grace period (~10 min, `welcome.grace`
|
||||
**new**, seconds) with its ready flag, presence, queue items and
|
||||
relay. After the grace it leaves (feed `leave`).
|
||||
- `{"t":"bye"}` **new**: leave now (Leave button, app close).
|
||||
|
||||
Presence:
|
||||
- `members[].presence` **new**: `joining` | `in` | `away` | `gone`;
|
||||
`members[].goneSince` **new** (ISO, only while `gone`).
|
||||
`members[].out` and `members[].stale` **removed** (`away`; a silent
|
||||
socket is closed and its member is `gone`).
|
||||
- `{"t":"presence","target":"<memberId>","state":"away"|"in"}` **new**,
|
||||
replaces `out` **removed**: anyone sets someone `away`; only the member
|
||||
itself goes back `in`.
|
||||
- Feed **changed**: `away` (was `out`), `in` (was `back`), `gone` (was
|
||||
`stale`), `return` **new** (back within the grace: no `join`), `leave`
|
||||
(after the grace or `bye`), `join` (new members only).
|
||||
- Gate (one `PresencePolicy` in core, used by server and app): only `in`
|
||||
counts for the gate, the ready counter and watched tracking; `away` and
|
||||
`gone` never. A joiner is `joining` until its report holds the current
|
||||
media with `bufferNeed` at the current position, then `in`. While
|
||||
`playing` a joiner never holds (D2): it loads in the background and
|
||||
comes in already in sync. While held it is in the set like a fresh
|
||||
member today, for at most ~15 s, then out of the set until `in`. A
|
||||
returning member (`gone` → back within grace) is `joining` again with
|
||||
its ready flag kept, treated like a joiner; an `away` one stays
|
||||
`away`.
|
||||
|
||||
Relay (D6):
|
||||
- `media.relay.sourceGone` **new**: true while the source member is
|
||||
`gone`. The relay and its buffer stay; viewers play what is buffered.
|
||||
- Source resume **changed**: the source's close no longer ends the relay;
|
||||
only its leave (grace over, `bye`) or a swap does. Back in the grace,
|
||||
it dials `/r/<sid>/<token>/source` again; an `info` equal to the first
|
||||
continues (pulls restart from the first byte or piece not buffered, new
|
||||
pull ids), a different one ends the relay (close 4002 **new**).
|
||||
|
||||
Queue and what's next:
|
||||
- `queue[].by` **changed**: lasting (the member id survives reconnects);
|
||||
a `through`/`file` item of a `gone` adder waits instead of being
|
||||
skipped, until its adder leaves.
|
||||
- `queue[].auto` **new**: the continuation (next episode): `add` with
|
||||
`auto:true`; at most one, after the others, replaced by a newer one,
|
||||
dropped by a swap to something else.
|
||||
- `state.advance` **new**: `{"id":"q3","at":"<server time>","secs":10}`,
|
||||
the shared countdown when the media finishes and an item is first; at
|
||||
`at` + `secs` the server sets `queuePlay` to it.
|
||||
`{"t":"queue","op":"cancel"}` **new** clears it for everyone (feed
|
||||
`advance-cancel` **new**).
|
||||
|
||||
Media identity:
|
||||
- `media.ref` **new**, in `swap.media`, `state.media` and
|
||||
`queue[].media`: `{"kind":"episode"|"movie"|"extra","ids":{"anilist":1,
|
||||
"tvdb":2},"show":"...","season":1,"episode":6}` (MediaRef); passed
|
||||
through as is, at most 2 KiB.
|
||||
|
||||
Transport: paths unchanged. A server with TLS serves the same endpoints
|
||||
over `wss://`/`https://` (`/health.tls`); apps use `wss://` when offered,
|
||||
`ws://` stays allowed.
|
||||
|
||||
**Protocol 16 additions (second review):**
|
||||
- Client range too: `hello.protoMin`/`proto`; the server picks the
|
||||
highest common version; read `proto` before strict hello parsing; 16
|
||||
apps still send `hello.media:{}` so a 15 server answers "proto
|
||||
mismatch"; a 16 app shows a 15 refusal as "the host runs an older
|
||||
Starsync".
|
||||
- Ordered takeover: `hello.seq` (monotonic per client), older hellos get
|
||||
`err stale`; `replaced` is terminal on the client ("opened in another
|
||||
window"); `welcome.instance` per server boot (grace expired vs server
|
||||
restarted).
|
||||
- Error codes `stale`, `forbidden`, `rate`, `full`, plus `err.for`; close
|
||||
codes listed in one place. `/health.version` (no member names).
|
||||
- Silence: no frame for ~15 s closes the socket (member `gone`); the
|
||||
`gone` feed line only after ~10 s gone (a quick return emits nothing);
|
||||
`presence.reason` manual/idle/minimised/bye; away → in while playing
|
||||
goes through `joining`; `joining` ends on a report error or timeout; a
|
||||
second `/source` socket for the same token replaces the first.
|
||||
- Feed: `actorId`/`targetId`; `feed.seq` with `hello.feedAfter` and a
|
||||
bounded replay in `welcome` (marked `replayed`: logged, not toasted).
|
||||
- `state.hold {reason ready|buffer|joining|source|start, members, until}`;
|
||||
`members[].caps`.
|
||||
- Relay: `sourceGoneSince`; "same info" = size, duration, tracks, name.
|
||||
- Queue: `auto` items carry `for: <mediaId>`, added only by the current
|
||||
swapper (or first in); `advance` holds while its performer or source is
|
||||
gone; `cancel` drops the `auto` item too.
|
||||
- `MediaRef` gains `absolute`; `season` may be null.
|
||||
- `client` is redacted from every log; optionally derived per server
|
||||
(HMAC of an install secret and the dialled host:port).
|
||||
- wss: only with a CA-valid certificate in 1.3.0; pinned self-signed via
|
||||
the Invite `fp` later.
|
||||
Opened in step 1: [PROTOCOL.md](PROTOCOL.md) is proto 16 (version
|
||||
ranges and the refusal frame, error codes and close codes, lasting members by client id with takeover and `seq`, grace and
|
||||
`bye`, presence and `PresencePolicy`, `state.hold`, feed ids, `seq` and
|
||||
replay, relay `sourceGone` and source resume, lasting `queue[].by`,
|
||||
`auto` items and `cancel`, `media.ref`, `members[].app`/`caps`), server,
|
||||
core and app. Still open on the wire, frozen at the 1.3.0 release:
|
||||
- `state.advance` (step 6): the server starts the countdown when the
|
||||
media finishes and an item is first, sets `queuePlay` at its end and
|
||||
holds it while the item's performer or source is `gone`. Today it is
|
||||
parsed and broadcast but never started; `auto` items and `cancel` are
|
||||
live.
|
||||
- `media.ref` producers (step 4); the server carries it already.
|
||||
- `hello.client` optionally derived per server (HMAC of an install
|
||||
secret and the dialled host:port).
|
||||
- TLS: `/health.tls` is always false; `wss://` only with a CA-valid
|
||||
certificate in 1.3.0, pinned self-signed via the Invite `fp` later.
|
||||
- `err` code `rate`: reserved, nothing sends it.
|
||||
|
||||
## Backlog
|
||||
|
||||
@@ -331,8 +205,9 @@ over `wss://`/`https://` (`/health.tls`); apps use `wss://` when offered,
|
||||
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
|
||||
uncleanly and resumes" fails about half the time under the full suite.
|
||||
5. **Ops**: Check shaders on Windows; run the relay e2e "the source drops
|
||||
uncleanly and comes back" a few times under the full suite (its
|
||||
predecessor failed about half the time there).
|
||||
|
||||
6. **Windows: first real use.** Check the rename at 1.3.0: a 1.2.x
|
||||
install updates and restarts (`watch_together.exe`, a copy of
|
||||
@@ -387,11 +262,8 @@ over `wss://`/`https://` (`/health.tls`); apps use `wss://` when offered,
|
||||
- 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.
|
||||
- Up next follow-ups (see ARCHITECTURE "Up next"): 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").
|
||||
- Up next follow-ups (see ARCHITECTURE "Up next"): a whole season or
|
||||
a pick list at once ("Add the rest of the season").
|
||||
- Sidecar subtitles.
|
||||
|
||||
## Epic: Jellyfin
|
||||
@@ -400,7 +272,8 @@ What is left (the rest is in [ARCHITECTURE.md](ARCHITECTURE.md), "Jellyfin
|
||||
in a session"):
|
||||
- Auto-advance cancel for everyone (the next episode and Up next): today
|
||||
Cancel is per screen (the designated swapper's Cancel stops the swap; a
|
||||
seek stops all). A shared cancel needs a wire message.
|
||||
seek stops all). The wire has it (`queue` `cancel`, `state.advance`);
|
||||
the app uses it in step 6.
|
||||
- Sidecar (external) subtitle streams of Jellyfin items: only embedded
|
||||
tracks are mapped to mpv today.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export 'src/logging.dart';
|
||||
export 'src/diag_share.dart';
|
||||
export 'src/clock_sync.dart';
|
||||
export 'src/config.dart';
|
||||
export 'src/presence.dart';
|
||||
export 'src/protocol.dart';
|
||||
export 'src/queue.dart';
|
||||
export 'src/relay.dart';
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
abstract class RelayConstants {
|
||||
static const int protoVersion = 15;
|
||||
/// The wire version this build speaks best (`hello.proto`,
|
||||
/// `welcome.proto`); always [protoMax].
|
||||
static const int protoVersion = protoMax;
|
||||
/// The range of wire versions this build accepts (`hello.protoMin` ..
|
||||
/// `hello.proto` on a client, `/health.protoMin` .. `protoMax` on a
|
||||
/// server). Additions within a version family raise [protoMax] only.
|
||||
static const int protoMin = 16;
|
||||
static const int protoMax = 16;
|
||||
|
||||
// Relay (`/r/...`): a member's bytes through the server's buffer.
|
||||
static const int relaySliceBytes = 1 << 20;
|
||||
@@ -44,10 +51,25 @@ abstract class RelayConstants {
|
||||
|
||||
static const int stateHeartbeatSecs = 1;
|
||||
static const int pingIntervalSecs = 5;
|
||||
/// Server liveness defaults ([Liveness]): a member silent this long is
|
||||
/// stale, then removed.
|
||||
static const int staleAfterSecs = 15;
|
||||
static const int dropAfterSecs = 30;
|
||||
/// Server liveness defaults ([Liveness], PROTOCOL.md "Presence"): a
|
||||
/// socket without a frame for [silenceSecs] is closed and its member is
|
||||
/// `gone`; a `gone` member is kept [graceSecs] (`welcome.grace`), then
|
||||
/// it leaves; the feed says `gone` only after [goneNoticeSecs] (a quick
|
||||
/// return says nothing). A joiner holds a HELD gate at most
|
||||
/// [joinerHoldSecs] (D2) and stays `joining` at most [joiningTimeoutSecs].
|
||||
static const int silenceSecs = 15;
|
||||
static const int graceSecs = 600;
|
||||
static const int goneNoticeSecs = 10;
|
||||
static const int joinerHoldSecs = 15;
|
||||
static const int joiningTimeoutSecs = 60;
|
||||
/// Feed lines the server keeps for `hello.feedAfter` (the replay in
|
||||
/// `welcome.feed`).
|
||||
static const int feedReplayLines = 50;
|
||||
/// `hello.client`: an opaque per-install id, base64url, this long.
|
||||
static const int minClientIdLength = 16;
|
||||
static const int maxClientIdLength = 64;
|
||||
/// `media.ref` ([MediaRef]) as JSON: at most this many bytes.
|
||||
static const int maxMediaRefBytes = 2048;
|
||||
|
||||
// Base64-char count of the avatar string, enforced at hello and
|
||||
// profile. 256 KiB leaves wide headroom over any 128x128 pipeline PNG
|
||||
|
||||
@@ -117,7 +117,7 @@ void relogLine(String tag, String line) {
|
||||
// `"name":"value"` or `Name="value"` (Jellyfin's MediaBrowser header).
|
||||
const _secretNames = r'(?:x-emby-token|x-mediabrowser-token|x-emby-authorization|authorization|set-cookie|cookie|'
|
||||
r'api_?key|access_?token|refresh_?token|id_?token|auth_?token|token|password|passwd|secret|host_?key|'
|
||||
r'sessdata|bili_jct|client_?secret)';
|
||||
r'sessdata|bili_jct|client_?secret|client_?id|client)';
|
||||
final _jsonSecret = RegExp('"($_secretNames)"\\s*:\\s*"(?:[^"\\\\]|\\\\.)*"', caseSensitive: false);
|
||||
final _quotedSecret = RegExp('\\b($_secretNames)\\s*=\\s*"[^"]*"', caseSensitive: false);
|
||||
// Header-like `Name: value` runs to the end of the line (a cookie
|
||||
@@ -135,7 +135,8 @@ final _userInfo = RegExp(r'^([a-z]+://)[^/@\s]+@', caseSensitive: false);
|
||||
/// [s] with nothing secret left: every URL's query string down to its
|
||||
/// parameter names (signed CDN links, `api_key`), user:password@ in URLs,
|
||||
/// member tokens in `/r/ /m/ /j/ /a/ /i/ /l/` paths, and the values of
|
||||
/// passwords, tokens, API keys, cookies and auth headers. Applied to
|
||||
/// passwords, tokens, API keys, cookies, auth headers and the per-install
|
||||
/// client id (`hello.client`). Applied to
|
||||
/// every log line ([formatLogLine]).
|
||||
String redactLog(String s) {
|
||||
if (s.isEmpty) return s;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'constants.dart';
|
||||
|
||||
// Presence (D1, PROTOCOL.md "Presence"): where a member stands, and one
|
||||
// table of what each standing counts for. Pure, so the server's engine and
|
||||
// the app read the same rules.
|
||||
|
||||
/// A member's presence, `members[].presence` on the wire ([wire]).
|
||||
enum Presence {
|
||||
/// New, back from `gone`, or back from `away`: loading the current media
|
||||
/// in the background until its reports hold it with `bufferNeed`, then
|
||||
/// [watching]. Wire `joining`.
|
||||
joining,
|
||||
|
||||
/// Watching, in full (D1 "In"). Wire `in` (a Dart keyword, hence the name).
|
||||
watching,
|
||||
|
||||
/// Sat out (by itself or by someone else): still in the server (chat,
|
||||
/// feed, chosen notifications), never counted for the gate, the ready
|
||||
/// counter or watched tracking. Wire `away`.
|
||||
away,
|
||||
|
||||
/// Disconnected and kept for the grace period (`welcome.grace`) with
|
||||
/// everything it had (ready flag, queue items, relay, picture). Wire
|
||||
/// `gone`.
|
||||
gone;
|
||||
|
||||
String get wire => this == watching ? 'in' : name;
|
||||
|
||||
/// Null for anything else.
|
||||
static Presence? parse(Object? v) {
|
||||
if (v == 'in') return watching;
|
||||
for (final p in values) {
|
||||
if (p != watching && p.name == v) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a member set itself (or someone) `away` or `in`
|
||||
/// (`presence.reason`): by hand, idle, its window minimised, or the app
|
||||
/// closing (`bye`, reserved for the app's own record).
|
||||
enum PresenceReason {
|
||||
manual,
|
||||
idle,
|
||||
minimised,
|
||||
bye;
|
||||
|
||||
static PresenceReason parse(Object? v) {
|
||||
for (final r in values) {
|
||||
if (r.name == v) return r;
|
||||
}
|
||||
return manual;
|
||||
}
|
||||
}
|
||||
|
||||
/// What a presence is asked about ([PresencePolicy.countsFor]).
|
||||
enum Purpose {
|
||||
/// The ready gate: who the server waits for before it plays.
|
||||
gate,
|
||||
|
||||
/// "2/3 ready": who the ready counter counts.
|
||||
readyCounter,
|
||||
|
||||
/// Watched tracking (Jellyfin played, history): who is watching.
|
||||
tracking,
|
||||
|
||||
/// Getting Up next ready (warm relay readers, preloads).
|
||||
preload,
|
||||
|
||||
/// Social notifications (joins, queue adds, chat pop-ups) in full.
|
||||
notifySocial,
|
||||
}
|
||||
|
||||
/// The one table of what a presence counts for (D1, D2):
|
||||
///
|
||||
/// | purpose | joining | in | away | gone |
|
||||
/// |----------------|---------|-----|------|------|
|
||||
/// | gate | D2 * | yes | no | no |
|
||||
/// | readyCounter | yes | yes | no | no |
|
||||
/// | tracking | no | yes | no | no |
|
||||
/// | preload | yes | yes | no | no |
|
||||
/// | notifySocial | yes | yes | no | no |
|
||||
///
|
||||
/// \* A `joining` member never holds a PLAYING gate (it loads in the
|
||||
/// background and comes in already in sync); while HELD it holds at most
|
||||
/// [joinerHold] from the moment it started joining, then the others go on
|
||||
/// without it.
|
||||
class PresencePolicy {
|
||||
/// How long a joiner may hold a HELD gate (D2).
|
||||
final Duration joinerHold;
|
||||
const PresencePolicy({this.joinerHold = const Duration(seconds: RelayConstants.joinerHoldSecs)});
|
||||
|
||||
/// Whether a member with [presence] counts for [purpose]. For
|
||||
/// [Purpose.gate] a `joining` member needs the gate's context: [playing]
|
||||
/// (the transport is `playing`) and [joiningFor] (how long it has been
|
||||
/// joining, on the server's clock).
|
||||
bool countsFor(Presence presence, Purpose purpose,
|
||||
{bool playing = false, Duration joiningFor = Duration.zero}) {
|
||||
switch (presence) {
|
||||
case Presence.watching:
|
||||
return true;
|
||||
case Presence.away:
|
||||
case Presence.gone:
|
||||
return false;
|
||||
case Presence.joining:
|
||||
switch (purpose) {
|
||||
case Purpose.gate:
|
||||
return !playing && joiningFor < joinerHold;
|
||||
case Purpose.tracking:
|
||||
return false;
|
||||
case Purpose.readyCounter:
|
||||
case Purpose.preload:
|
||||
case Purpose.notifySocial:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// D9 "alone": no other member is connected. [others] are the presences
|
||||
/// of everyone but oneself; `in`, `joining` and `away` are company, `gone`
|
||||
/// is not. Auto sit-out (never when alone) and the social notifications
|
||||
/// read this one definition.
|
||||
bool isAlone(Iterable<Presence> others) => others.every((p) => p == Presence.gone);
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'constants.dart';
|
||||
import 'errors.dart';
|
||||
import 'presence.dart';
|
||||
import 'queue.dart';
|
||||
|
||||
/// Reads an optional JSON number as a finite double. JSON like 1e400 decodes to
|
||||
@@ -97,6 +100,89 @@ class JellyfinRef {
|
||||
Object.hash(serverId, itemId, mediaSourceId, title, subtitle, runtimeSecs);
|
||||
}
|
||||
|
||||
/// What a media is, independent of where its bytes come from (`media.ref`):
|
||||
/// an episode, a movie or an extra, by AniList and/or TVDB ids. Carried
|
||||
/// as is in `swap.media`, `state.media` and `queue[].media` (the server
|
||||
/// keeps it, at most [RelayConstants.maxMediaRefBytes] of JSON); nothing
|
||||
/// produces it yet (step 4).
|
||||
class MediaRef {
|
||||
static const kinds = {'episode', 'movie', 'extra'};
|
||||
|
||||
/// `episode`, `movie` or `extra`.
|
||||
final String kind;
|
||||
final String title;
|
||||
final int? anilistId;
|
||||
/// The episode number within the AniList entry.
|
||||
final int? anilistEp;
|
||||
final int? tvdbId;
|
||||
/// TVDB season (null: unknown or none, e.g. absolute numbering).
|
||||
final int? season;
|
||||
final int? ep;
|
||||
/// Absolute episode number across seasons.
|
||||
final int? absolute;
|
||||
const MediaRef(
|
||||
{required this.kind,
|
||||
this.title = '',
|
||||
this.anilistId,
|
||||
this.anilistEp,
|
||||
this.tvdbId,
|
||||
this.season,
|
||||
this.ep,
|
||||
this.absolute});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'kind': kind,
|
||||
if (title.isNotEmpty) 'title': title,
|
||||
if (anilistId != null) 'anilistId': anilistId,
|
||||
if (anilistEp != null) 'anilistEp': anilistEp,
|
||||
if (tvdbId != null) 'tvdbId': tvdbId,
|
||||
if (season != null) 'season': season,
|
||||
if (ep != null) 'ep': ep,
|
||||
if (absolute != null) 'absolute': absolute,
|
||||
};
|
||||
|
||||
/// Null when [j] is not a ref (no known `kind`); throws
|
||||
/// [ProtocolException] when it is larger than
|
||||
/// [RelayConstants.maxMediaRefBytes] as JSON. Ids that are not
|
||||
/// non-negative integers are dropped; the title is one line of at most
|
||||
/// 200 characters.
|
||||
static MediaRef? fromJson(Object? j) {
|
||||
if (j is! Map) return null;
|
||||
if (utf8.encode(jsonEncode(j)).length > RelayConstants.maxMediaRefBytes) {
|
||||
throw const ProtocolException('media.ref too large');
|
||||
}
|
||||
final kind = j['kind'];
|
||||
if (kind is! String || !kinds.contains(kind)) return null;
|
||||
int? n(Object? v) => v is num && v.isFinite && v >= 0 && v == v.roundToDouble() ? v.toInt() : null;
|
||||
final title = j['title'] is String ? (j['title'] as String).replaceAll(RegExp(r'\s+'), ' ').trim() : '';
|
||||
return MediaRef(
|
||||
kind: kind,
|
||||
title: title.length > 200 ? title.substring(0, 200) : title,
|
||||
anilistId: n(j['anilistId']),
|
||||
anilistEp: n(j['anilistEp']),
|
||||
tvdbId: n(j['tvdbId']),
|
||||
season: n(j['season']),
|
||||
ep: n(j['ep']),
|
||||
absolute: n(j['absolute']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is MediaRef &&
|
||||
other.kind == kind &&
|
||||
other.title == title &&
|
||||
other.anilistId == anilistId &&
|
||||
other.anilistEp == anilistEp &&
|
||||
other.tvdbId == tvdbId &&
|
||||
other.season == season &&
|
||||
other.ep == ep &&
|
||||
other.absolute == absolute;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(kind, title, anilistId, anilistEp, tvdbId, season, ep, absolute);
|
||||
}
|
||||
|
||||
class HelloMedia {
|
||||
final String name;
|
||||
final int size;
|
||||
@@ -106,13 +192,16 @@ class HelloMedia {
|
||||
final JellyfinRef? jellyfin;
|
||||
/// Relayed media (bytes from the source member through the server).
|
||||
final MediaRelay? relay;
|
||||
/// What it is ([MediaRef]); null when unknown.
|
||||
final MediaRef? ref;
|
||||
const HelloMedia(
|
||||
{required this.name,
|
||||
required this.size,
|
||||
required this.duration,
|
||||
this.url,
|
||||
this.jellyfin,
|
||||
this.relay});
|
||||
this.relay,
|
||||
this.ref});
|
||||
|
||||
HelloMedia copyWith({double? duration, int? size, MediaRelay? relay}) => HelloMedia(
|
||||
name: name,
|
||||
@@ -120,7 +209,8 @@ class HelloMedia {
|
||||
duration: duration ?? this.duration,
|
||||
url: url,
|
||||
jellyfin: jellyfin,
|
||||
relay: relay ?? this.relay);
|
||||
relay: relay ?? this.relay,
|
||||
ref: ref);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'name': name,
|
||||
@@ -129,6 +219,7 @@ class HelloMedia {
|
||||
if (url != null) 'url': url,
|
||||
if (jellyfin != null) 'jellyfin': jellyfin!.toJson(),
|
||||
if (relay != null) 'relay': relay!.toJson(),
|
||||
if (ref != null) 'ref': ref!.toJson(),
|
||||
};
|
||||
|
||||
factory HelloMedia.fromJson(Map<String, Object?> json) {
|
||||
@@ -141,6 +232,7 @@ class HelloMedia {
|
||||
url: json['url'] as String?,
|
||||
jellyfin: jf is Map ? JellyfinRef.fromJson(Map<String, Object?>.from(jf)) : null,
|
||||
relay: relay is Map ? MediaRelay.fromJson(Map<String, Object?>.from(relay)) : null,
|
||||
ref: MediaRef.fromJson(json['ref']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -185,7 +277,10 @@ class RelayTrackSpec {
|
||||
/// buffer asks for bytes, and the media's average bitrate. [queueId]:
|
||||
/// the Up next item this relay was started for (its warm relay) before
|
||||
/// it went up; viewers open its `/r/.../<queueId>/...` URLs, so their
|
||||
/// preloads of it carry on.
|
||||
/// preloads of it carry on. [sourceGone]: the source member is `gone`
|
||||
/// (since [sourceGoneSince]): the relay and its buffer stay, viewers play
|
||||
/// what is buffered; it stays set once the source left for good (the
|
||||
/// relay has ended then: `live` false, `/r` answers 404).
|
||||
class MediaRelay {
|
||||
final String source;
|
||||
final bool fetch;
|
||||
@@ -194,6 +289,8 @@ class MediaRelay {
|
||||
final int rate;
|
||||
final int need;
|
||||
final String queueId;
|
||||
final bool sourceGone;
|
||||
final DateTime? sourceGoneSince;
|
||||
const MediaRelay(
|
||||
{this.source = '',
|
||||
this.fetch = false,
|
||||
@@ -201,7 +298,9 @@ class MediaRelay {
|
||||
this.tracks = const [],
|
||||
this.rate = 0,
|
||||
this.need = 0,
|
||||
this.queueId = ''});
|
||||
this.queueId = '',
|
||||
this.sourceGone = false,
|
||||
this.sourceGoneSince});
|
||||
|
||||
/// The source is measured slower than the media plays (both known):
|
||||
/// below 85 % of [need], so a rate close to it does not flicker.
|
||||
@@ -214,14 +313,26 @@ 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, 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,
|
||||
queueId: queueId);
|
||||
/// [sourceGoneSince] null with [sourceGone] true: since now is unknown,
|
||||
/// keep the earlier one; pass [sourceGone] false to clear both.
|
||||
MediaRelay copyWith(
|
||||
{bool? live,
|
||||
List<RelayTrackSpec>? tracks,
|
||||
int? rate,
|
||||
int? need,
|
||||
bool? sourceGone,
|
||||
DateTime? sourceGoneSince}) =>
|
||||
MediaRelay(
|
||||
source: source,
|
||||
fetch: fetch,
|
||||
live: live ?? this.live,
|
||||
tracks: tracks ?? this.tracks,
|
||||
rate: rate ?? this.rate,
|
||||
need: need ?? this.need,
|
||||
queueId: queueId,
|
||||
sourceGone: sourceGone ?? this.sourceGone,
|
||||
sourceGoneSince:
|
||||
(sourceGone ?? this.sourceGone) ? (sourceGoneSince ?? this.sourceGoneSince) : null);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
if (source.isNotEmpty) 'source': source,
|
||||
@@ -231,6 +342,8 @@ class MediaRelay {
|
||||
if (rate > 0) 'rate': rate,
|
||||
if (need > 0) 'need': need,
|
||||
if (queueId.isNotEmpty) 'queueId': queueId,
|
||||
if (sourceGone) 'sourceGone': true,
|
||||
if (sourceGone && sourceGoneSince != null) 'sourceGoneSince': sourceGoneSince!.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
/// Unknown kinds, a single byte track or too many make it one file.
|
||||
@@ -238,6 +351,7 @@ class MediaRelay {
|
||||
final t = json['tracks'];
|
||||
final specs = t is List ? [for (final e in t) RelayTrackSpec.fromJson(e)] : const <RelayTrackSpec?>[];
|
||||
final ok = !specs.contains(null) && validTracks(specs.cast<RelayTrackSpec>());
|
||||
final gone = json['sourceGone'] == true;
|
||||
return MediaRelay(
|
||||
source: json['source'] is String ? json['source'] as String : '',
|
||||
fetch: json['fetch'] == true,
|
||||
@@ -245,59 +359,167 @@ class MediaRelay {
|
||||
tracks: ok ? List.unmodifiable(specs.cast<RelayTrackSpec>()) : const [],
|
||||
rate: _byteRate(json['rate']),
|
||||
need: _byteRate(json['need']),
|
||||
queueId: json['queueId'] is String ? json['queueId'] as String : '');
|
||||
queueId: json['queueId'] is String ? json['queueId'] as String : '',
|
||||
sourceGone: gone,
|
||||
sourceGoneSince: gone ? DateTime.tryParse('${json['sourceGoneSince']}') : null);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// The highest wire version both sides speak: the client's
|
||||
/// [clientMin]..[clientMax] (`hello.protoMin`..`hello.proto`) against this
|
||||
/// build's [RelayConstants.protoMin]..[RelayConstants.protoMax]; null when
|
||||
/// the ranges do not meet.
|
||||
int? pickProto(int clientMin, int clientMax,
|
||||
{int serverMin = RelayConstants.protoMin, int serverMax = RelayConstants.protoMax}) {
|
||||
final hi = clientMax < serverMax ? clientMax : serverMax;
|
||||
final lo = clientMin > serverMin ? clientMin : serverMin;
|
||||
return hi >= lo ? hi : null;
|
||||
}
|
||||
|
||||
/// What this app can do (`hello.caps`, `members[].caps`): serve a relay
|
||||
/// ([source]), resolve links with yt-dlp ([resolve]), play Jellyfin with
|
||||
/// its own account ([jellyfin]). Unknown entries are ignored. Wire: an
|
||||
/// object of `true` flags, e.g. `{"source":true,"resolve":true}`.
|
||||
class ClientCaps {
|
||||
final bool source;
|
||||
final bool resolve;
|
||||
final bool jellyfin;
|
||||
const ClientCaps({this.source = false, this.resolve = false, this.jellyfin = false});
|
||||
|
||||
bool get isEmpty => !source && !resolve && !jellyfin;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
if (source) 'source': true,
|
||||
if (resolve) 'resolve': true,
|
||||
if (jellyfin) 'jellyfin': true,
|
||||
};
|
||||
|
||||
factory ClientCaps.fromJson(Object? j) => j is Map
|
||||
? ClientCaps(source: j['source'] == true, resolve: j['resolve'] == true, jellyfin: j['jellyfin'] == true)
|
||||
: const ClientCaps();
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is ClientCaps && other.source == source && other.resolve == resolve && other.jellyfin == jellyfin;
|
||||
@override
|
||||
int get hashCode => Object.hash(source, resolve, jellyfin);
|
||||
}
|
||||
|
||||
/// `hello.client`: an opaque per-install id ([RelayConstants.minClientIdLength]
|
||||
/// to [RelayConstants.maxClientIdLength] characters of base64url). Private:
|
||||
/// never broadcast, never logged.
|
||||
bool validClientId(String id) =>
|
||||
id.length >= RelayConstants.minClientIdLength &&
|
||||
id.length <= RelayConstants.maxClientIdLength &&
|
||||
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(id);
|
||||
|
||||
/// The first frame of a `/sync` socket. A proto 16 app sends `media: {}`
|
||||
/// (ignored) so a proto 15 server, which requires it, answers with its
|
||||
/// "proto mismatch" instead of a parse error.
|
||||
class HelloMessage {
|
||||
/// The client's highest wire version (`hello.proto`).
|
||||
final int proto;
|
||||
/// The client's lowest wire version (`hello.protoMin`); defaults to
|
||||
/// [proto] when absent.
|
||||
final int protoMin;
|
||||
final String secret;
|
||||
final HelloUser user;
|
||||
final HelloMedia media;
|
||||
/// The hosting app's own client proves it is the host with this; empty
|
||||
/// for everyone else.
|
||||
final String hostKey;
|
||||
/// Token from this client's previous `welcome`, sent on reconnect: if the
|
||||
/// server still has that member (an unclean drop it has not noticed), the
|
||||
/// connection takes it over instead of leaving a ghost.
|
||||
final String resume;
|
||||
/// Per-install id ([validClientId]): a hello with a known one takes that
|
||||
/// member over. Empty: a fresh member every time (tools, tests).
|
||||
final String client;
|
||||
/// Increasing per [client] (every connect of this install sends a higher
|
||||
/// one): an older hello than the member's last gets `err stale`.
|
||||
final int seq;
|
||||
/// App version (`"1.3.0"`), shown as `members[].app`.
|
||||
final String app;
|
||||
/// `linux`, `windows`, `macos` or `android`; logged, not broadcast.
|
||||
final String platform;
|
||||
final ClientCaps caps;
|
||||
/// The last `feed.seq` this client saw from this server: `welcome.feed`
|
||||
/// replays what came after it (at most [RelayConstants.feedReplayLines]).
|
||||
/// Null: no replay.
|
||||
final int? feedAfter;
|
||||
const HelloMessage(
|
||||
{required this.proto,
|
||||
{this.proto = RelayConstants.protoMax,
|
||||
int? protoMin,
|
||||
required this.secret,
|
||||
required this.user,
|
||||
required this.media,
|
||||
this.hostKey = '',
|
||||
this.resume = ''});
|
||||
this.client = '',
|
||||
this.seq = 0,
|
||||
this.app = '',
|
||||
this.platform = '',
|
||||
this.caps = const ClientCaps(),
|
||||
this.feedAfter})
|
||||
: protoMin = protoMin ?? (proto < RelayConstants.protoMin ? proto : RelayConstants.protoMin);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'hello',
|
||||
'protoMin': protoMin,
|
||||
'proto': proto,
|
||||
'secret': secret,
|
||||
'user': user.toJson(),
|
||||
'media': media.toJson(),
|
||||
'media': const <String, Object?>{},
|
||||
if (hostKey.isNotEmpty) 'hostKey': hostKey,
|
||||
if (resume.isNotEmpty) 'resume': resume,
|
||||
if (client.isNotEmpty) 'client': client,
|
||||
if (seq != 0) 'seq': seq,
|
||||
if (app.isNotEmpty) 'app': app,
|
||||
if (platform.isNotEmpty) 'platform': platform,
|
||||
if (!caps.isEmpty) 'caps': caps.toJson(),
|
||||
if (feedAfter != null) 'feedAfter': feedAfter,
|
||||
};
|
||||
|
||||
/// The client's version range read leniently from a raw hello, before
|
||||
/// [HelloMessage.fromJson]: a hello of another version may not parse.
|
||||
/// Null when it names none.
|
||||
static ({int min, int max})? protoRangeOf(Map<String, Object?> json) {
|
||||
final p = json['proto'];
|
||||
if (p is! num || !p.isFinite) return null;
|
||||
final m = json['protoMin'];
|
||||
final max = p.toInt();
|
||||
final min = m is num && m.isFinite && m.toInt() <= max ? m.toInt() : max;
|
||||
return (min: min, max: max);
|
||||
}
|
||||
|
||||
factory HelloMessage.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'hello') throw const ProtocolException('not a hello message');
|
||||
final user = json['user'];
|
||||
final media = json['media'];
|
||||
if (user is! Map || media is! Map) throw const ProtocolException('hello missing user/media');
|
||||
if (user is! Map) throw const ProtocolException('hello missing user');
|
||||
final client = json['client'] ?? '';
|
||||
if (client is! String || (client.isNotEmpty && !validClientId(client))) {
|
||||
throw const ProtocolException('bad client id');
|
||||
}
|
||||
final proto = finiteInt(json['proto'], 'proto', fallback: RelayConstants.protoMax);
|
||||
final feedAfter = json['feedAfter'];
|
||||
return HelloMessage(
|
||||
proto: finiteInt(json['proto'], 'proto', fallback: RelayConstants.protoVersion),
|
||||
proto: proto,
|
||||
protoMin: finiteInt(json['protoMin'], 'protoMin', fallback: proto),
|
||||
secret: (json['secret'] as String?) ?? '',
|
||||
user: HelloUser.fromJson(Map<String, Object?>.from(user)),
|
||||
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
|
||||
hostKey: (json['hostKey'] as String?) ?? '',
|
||||
resume: (json['resume'] as String?) ?? '',
|
||||
client: client,
|
||||
seq: finiteInt(json['seq'], 'seq'),
|
||||
app: _short(json['app'], 32),
|
||||
platform: _short(json['platform'], 16),
|
||||
caps: ClientCaps.fromJson(json['caps']),
|
||||
feedAfter: feedAfter == null ? null : finiteInt(feedAfter, 'feedAfter'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A short plain string field: one line, at most [max] characters.
|
||||
String _short(Object? v, int max) {
|
||||
if (v is! String) return '';
|
||||
final line = v.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
return line.length > max ? line.substring(0, max) : line;
|
||||
}
|
||||
|
||||
class SyncState {
|
||||
final double position;
|
||||
final bool paused;
|
||||
@@ -328,46 +550,210 @@ class WelcomeMessage {
|
||||
/// server can relay it (`state.relay`); private like [token].
|
||||
final String? relayUrl;
|
||||
final Map<String, Object?>? state;
|
||||
/// The wire version of this connection ([pickProto]).
|
||||
final int proto;
|
||||
/// The hello took over this client's member (same [memberId] and
|
||||
/// [token], everything it had kept); false for a new member.
|
||||
final bool resumed;
|
||||
/// Random per server boot: a client that held a member of another
|
||||
/// instance knows the server restarted (not that its grace ran out).
|
||||
final String instance;
|
||||
/// Seconds a `gone` member is kept ([RelayConstants.graceSecs]).
|
||||
final int grace;
|
||||
/// Feed lines after `hello.feedAfter`, oldest first, each `replayed`.
|
||||
final List<FeedMessage> feed;
|
||||
const WelcomeMessage(
|
||||
{required this.token, this.memberId = '', this.mediaUrl, this.relayUrl, this.state});
|
||||
{required this.token,
|
||||
this.memberId = '',
|
||||
this.mediaUrl,
|
||||
this.relayUrl,
|
||||
this.state,
|
||||
this.proto = RelayConstants.protoMax,
|
||||
this.resumed = false,
|
||||
this.instance = '',
|
||||
this.grace = RelayConstants.graceSecs,
|
||||
this.feed = const []});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'welcome',
|
||||
'proto': proto,
|
||||
'token': token,
|
||||
'memberId': memberId,
|
||||
'resumed': resumed,
|
||||
'instance': instance,
|
||||
'grace': grace,
|
||||
if (mediaUrl != null) 'mediaUrl': mediaUrl,
|
||||
if (relayUrl != null) 'relayUrl': relayUrl,
|
||||
if (state != null) 'state': state,
|
||||
if (feed.isNotEmpty) 'feed': [for (final f in feed) f.toJson()],
|
||||
};
|
||||
|
||||
factory WelcomeMessage.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'welcome') throw const ProtocolException('not a welcome message');
|
||||
final state = json['state'];
|
||||
final feed = json['feed'];
|
||||
return WelcomeMessage(
|
||||
token: (json['token'] as String?) ?? '',
|
||||
memberId: (json['memberId'] as String?) ?? '',
|
||||
mediaUrl: json['mediaUrl'] as String?,
|
||||
relayUrl: json['relayUrl'] as String?,
|
||||
state: state is Map ? Map<String, Object?>.from(state) : null,
|
||||
proto: finiteInt(json['proto'], 'proto', fallback: 0),
|
||||
resumed: json['resumed'] == true,
|
||||
instance: json['instance'] is String ? json['instance'] as String : '',
|
||||
grace: finiteInt(json['grace'], 'grace', fallback: 0),
|
||||
feed: feed is List
|
||||
? [
|
||||
for (final f in feed)
|
||||
if (f is Map && f['t'] == 'feed') FeedMessage.fromJson(Map<String, Object?>.from(f)),
|
||||
]
|
||||
: const [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sits [target] (a member id) out, or with [out] false brings oneself
|
||||
/// back. Out: connected, but not in the ready gate and not playing.
|
||||
class OutIntent {
|
||||
final String target;
|
||||
final bool out;
|
||||
const OutIntent({required this.target, required this.out});
|
||||
/// Error codes of `err.code` (PROTOCOL.md "Errors"). Terminal ones
|
||||
/// ([version], [secret], [replaced], [stale], a refused `hello` with
|
||||
/// [rejected] or [bad]) are followed by a close ([CloseCodes]).
|
||||
enum ErrCode {
|
||||
/// No common wire version: `min`/`max`/`server` say the server's.
|
||||
version,
|
||||
/// Wrong `hello.secret`.
|
||||
secret,
|
||||
/// Understood but refused (an avatar too large, a fetch the server
|
||||
/// cannot do).
|
||||
rejected,
|
||||
/// Malformed (a bad hello, a non-finite number, a bad swap or queue item).
|
||||
bad,
|
||||
/// Another hello with this client id took the member over (the client
|
||||
/// stops: "opened in another window").
|
||||
replaced,
|
||||
/// This hello is older (`hello.seq`) than the member's current one.
|
||||
stale,
|
||||
/// Not allowed for this member (e.g. setting someone else `in`).
|
||||
forbidden,
|
||||
/// Over a rate limit (reserved; nudges over the cap are dropped silently).
|
||||
rate,
|
||||
/// A list is full (Up next at [RelayConstants.maxQueueItems]).
|
||||
full;
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'out', 'target': target, 'out': out};
|
||||
|
||||
factory OutIntent.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'out') throw const ProtocolException('not an out intent');
|
||||
return OutIntent(target: (json['target'] as String?) ?? '', out: (json['out'] as bool?) ?? true);
|
||||
static ErrCode? parse(Object? v) {
|
||||
for (final c in values) {
|
||||
if (c.name == v) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Close codes of the server's sockets (PROTOCOL.md "Close codes").
|
||||
abstract class CloseCodes {
|
||||
/// Relay channel: not (or no longer) the source; the source stops.
|
||||
static const relayNotSource = 4001;
|
||||
/// Relay channel: its `info` differs from the media's first one (another
|
||||
/// file); the relay ends and the source stops.
|
||||
static const relayOtherMedia = 4002;
|
||||
/// Relay channel: a newer channel for the same relay replaced it; the
|
||||
/// source stops.
|
||||
static const relayReplaced = 4003;
|
||||
/// `/sync`: refused at hello, `err` `version`.
|
||||
static const version = 4010;
|
||||
/// `/sync`: refused at hello, `err` `secret`.
|
||||
static const secret = 4011;
|
||||
/// `/sync`: refused at hello, `err` `rejected` or `bad`.
|
||||
static const refused = 4012;
|
||||
/// `/sync`: another hello took the member over (`err` `replaced`).
|
||||
static const replaced = 4013;
|
||||
/// `/sync`: an older hello than the member's (`err` `stale`).
|
||||
static const stale = 4014;
|
||||
/// `/sync`: no frame for the silence limit; the member is `gone`.
|
||||
static const silent = 4015;
|
||||
/// `/sync`: the member left (`bye`, or grace over).
|
||||
static const left = 1000;
|
||||
}
|
||||
|
||||
/// Server to client: `{"t":"err","code":..., "message":..., "for":...}`.
|
||||
/// [message] is plain text for people (a proto 15 app shows it as is);
|
||||
/// [code] is for programs. For [ErrCode.version]: [min]/[max] the server's
|
||||
/// range and [server] its app version. [forType]: the `t` of the frame
|
||||
/// that failed, when there is one (`for` on the wire).
|
||||
class ErrorFrame {
|
||||
final ErrCode? code;
|
||||
final String message;
|
||||
final String? forType;
|
||||
final int? min;
|
||||
final int? max;
|
||||
final String? server;
|
||||
const ErrorFrame({required this.code, required this.message, this.forType, this.min, this.max, this.server});
|
||||
|
||||
/// The stable refusal of a hello whose versions do not meet ours:
|
||||
/// `{"t":"err","code":"version","min":16,"max":16,"server":"1.3.0",
|
||||
/// "message":"proto mismatch: got 15, want 16..16"}`.
|
||||
factory ErrorFrame.version({required int got, required String server}) => ErrorFrame(
|
||||
code: ErrCode.version,
|
||||
message: 'proto mismatch: got $got, want ${RelayConstants.protoMin}..${RelayConstants.protoMax}',
|
||||
forType: 'hello',
|
||||
min: RelayConstants.protoMin,
|
||||
max: RelayConstants.protoMax,
|
||||
server: server);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'err',
|
||||
if (code != null) 'code': code!.name,
|
||||
'message': message,
|
||||
if (forType != null) 'for': forType,
|
||||
if (min != null) 'min': min,
|
||||
if (max != null) 'max': max,
|
||||
if (server != null) 'server': server,
|
||||
};
|
||||
|
||||
/// A proto 15 server's `err` has no [code].
|
||||
factory ErrorFrame.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'err') throw const ProtocolException('not an err frame');
|
||||
final min = json['min'], max = json['max'];
|
||||
return ErrorFrame(
|
||||
code: ErrCode.parse(json['code']),
|
||||
message: json['message'] is String ? json['message'] as String : '',
|
||||
forType: json['for'] is String ? json['for'] as String : null,
|
||||
min: min is num && min.isFinite ? min.toInt() : null,
|
||||
max: max is num && max.isFinite ? max.toInt() : null,
|
||||
server: json['server'] is String ? json['server'] as String : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Client to server: set [target] (a member id; empty = oneself) [state]
|
||||
/// `away` (anyone may, on anyone) or `in` (only oneself; else `err`
|
||||
/// `forbidden`). Coming back `in` goes through `joining`.
|
||||
class PresenceIntent {
|
||||
final String target;
|
||||
/// [Presence.watching] (`in`) or [Presence.away].
|
||||
final Presence state;
|
||||
final PresenceReason reason;
|
||||
const PresenceIntent({this.target = '', required this.state, this.reason = PresenceReason.manual});
|
||||
|
||||
Map<String, Object?> toJson() =>
|
||||
{'t': 'presence', if (target.isNotEmpty) 'target': target, 'state': state.wire, 'reason': reason.name};
|
||||
|
||||
factory PresenceIntent.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'presence') throw const ProtocolException('not a presence intent');
|
||||
final state = Presence.parse(json['state']);
|
||||
if (state != Presence.watching && state != Presence.away) {
|
||||
throw const ProtocolException('presence state must be in or away');
|
||||
}
|
||||
return PresenceIntent(
|
||||
target: json['target'] is String ? json['target'] as String : '',
|
||||
state: state!,
|
||||
reason: PresenceReason.parse(json['reason']));
|
||||
}
|
||||
}
|
||||
|
||||
/// Client to server: leave now (the Leave button, the app closing). The
|
||||
/// member leaves at once (feed `leave`, no grace) and the socket closes.
|
||||
class ByeMessage {
|
||||
const ByeMessage();
|
||||
Map<String, Object?> toJson() => {'t': 'bye'};
|
||||
}
|
||||
|
||||
/// Client to server: poke [target] (a member id). The server passes it on
|
||||
/// to that member only, as a [NudgeNotice].
|
||||
class NudgeIntent {
|
||||
@@ -632,21 +1018,28 @@ class MemberView {
|
||||
/// none.
|
||||
final String avatarHash;
|
||||
final bool ready;
|
||||
/// Counts for the ready gate now: in the set and its presence counts
|
||||
/// ([PresencePolicy], [Purpose.gate]).
|
||||
final bool inSet;
|
||||
final double buffered;
|
||||
final bool stale;
|
||||
/// Where it stands ([Presence]).
|
||||
final Presence presence;
|
||||
/// When it went `gone`; null otherwise.
|
||||
final DateTime? goneSince;
|
||||
/// Last `report.jf` of this member; empty when never reported.
|
||||
final Map<String, bool> jf;
|
||||
/// The hosting app's own member (sent the server's host key).
|
||||
final bool host;
|
||||
/// Sitting out: connected, outside the ready gate, not playing.
|
||||
final bool out;
|
||||
/// 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;
|
||||
/// Its app version (`hello.app`); empty when it sent none.
|
||||
final String app;
|
||||
/// What its app can do (`hello.caps`).
|
||||
final ClientCaps caps;
|
||||
const MemberView({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -654,12 +1047,14 @@ class MemberView {
|
||||
required this.ready,
|
||||
required this.inSet,
|
||||
required this.buffered,
|
||||
required this.stale,
|
||||
this.presence = Presence.watching,
|
||||
this.goneSince,
|
||||
this.jf = const {},
|
||||
this.host = false,
|
||||
this.out = false,
|
||||
this.error = '',
|
||||
this.rate = 0,
|
||||
this.app = '',
|
||||
this.caps = const ClientCaps(),
|
||||
});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -669,28 +1064,105 @@ class MemberView {
|
||||
'ready': ready,
|
||||
'inSet': inSet,
|
||||
'buffered': buffered,
|
||||
'stale': stale,
|
||||
'presence': presence.wire,
|
||||
if (presence == Presence.gone && goneSince != null) 'goneSince': goneSince!.toUtc().toIso8601String(),
|
||||
if (jf.isNotEmpty) 'jf': jf,
|
||||
if (host) 'host': true,
|
||||
if (out) 'out': true,
|
||||
if (error.isNotEmpty) 'error': error,
|
||||
if (rate > 0) 'rate': rate,
|
||||
if (app.isNotEmpty) 'app': app,
|
||||
if (!caps.isEmpty) 'caps': caps.toJson(),
|
||||
};
|
||||
|
||||
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
|
||||
id: (json['id'] as String?) ?? '',
|
||||
name: (json['name'] as String?) ?? '',
|
||||
avatarHash: (json['avatarHash'] as String?) ?? '',
|
||||
ready: (json['ready'] as bool?) ?? false,
|
||||
inSet: (json['inSet'] as bool?) ?? false,
|
||||
buffered: finiteDouble(json['buffered'], 'buffered'),
|
||||
stale: (json['stale'] as bool?) ?? false,
|
||||
jf: jfAccessFromJson(json['jf']) ?? const {},
|
||||
host: json['host'] == true,
|
||||
out: json['out'] == true,
|
||||
error: memberErrorFromJson(json['error']),
|
||||
rate: MediaRelay._byteRate(json['rate']),
|
||||
);
|
||||
factory MemberView.fromJson(Map<String, Object?> json) {
|
||||
final presence = Presence.parse(json['presence']) ?? Presence.watching;
|
||||
return MemberView(
|
||||
id: (json['id'] as String?) ?? '',
|
||||
name: (json['name'] as String?) ?? '',
|
||||
avatarHash: (json['avatarHash'] as String?) ?? '',
|
||||
ready: (json['ready'] as bool?) ?? false,
|
||||
inSet: (json['inSet'] as bool?) ?? false,
|
||||
buffered: finiteDouble(json['buffered'], 'buffered'),
|
||||
presence: presence,
|
||||
goneSince: presence == Presence.gone ? DateTime.tryParse('${json['goneSince']}') : null,
|
||||
jf: jfAccessFromJson(json['jf']) ?? const {},
|
||||
host: json['host'] == true,
|
||||
error: memberErrorFromJson(json['error']),
|
||||
rate: MediaRelay._byteRate(json['rate']),
|
||||
app: _short(json['app'], 32),
|
||||
caps: ClientCaps.fromJson(json['caps']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the server is HELD (`state.hold`), most pressing first: a joiner
|
||||
/// still loading within its bound ([joining], [until] when the bound
|
||||
/// ends), someone not ready ([ready]), someone's buffer short ([buffer]),
|
||||
/// the same while a relayed media's source is `gone` ([source]), or
|
||||
/// nobody in the set to start ([start]: someone has to ready up).
|
||||
enum HoldReason {
|
||||
joining,
|
||||
ready,
|
||||
buffer,
|
||||
source,
|
||||
start;
|
||||
|
||||
static HoldReason? parse(Object? v) {
|
||||
for (final r in values) {
|
||||
if (r.name == v) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// `state.hold`: present while `transport` is `held` with media up.
|
||||
/// [members]: the member ids the gate waits for (empty with
|
||||
/// [HoldReason.start]). [until]: when that wait ends by itself (the
|
||||
/// joiners' bound), on the server's clock.
|
||||
class HoldInfo {
|
||||
final HoldReason reason;
|
||||
final List<String> members;
|
||||
final DateTime? until;
|
||||
const HoldInfo({required this.reason, this.members = const [], this.until});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'reason': reason.name,
|
||||
'members': members,
|
||||
if (until != null) 'until': until!.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
static HoldInfo? fromJson(Object? j) {
|
||||
if (j is! Map) return null;
|
||||
final reason = HoldReason.parse(j['reason']);
|
||||
if (reason == null) return null;
|
||||
final m = j['members'];
|
||||
return HoldInfo(
|
||||
reason: reason,
|
||||
members: m is List ? m.whereType<String>().toList() : const [],
|
||||
until: j['until'] == null ? null : DateTime.tryParse('${j['until']}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// `state.advance` (reserved, step 6): the shared countdown to Up next's
|
||||
/// first item when the media finishes. At [at] + [secs] (server clock) the
|
||||
/// server sets `queuePlay` to [id]; `{"t":"queue","op":"cancel"}` clears
|
||||
/// it for everyone. The server parses and broadcasts it but never starts
|
||||
/// one yet.
|
||||
class QueueAdvance {
|
||||
final String id;
|
||||
final DateTime at;
|
||||
final int secs;
|
||||
const QueueAdvance({required this.id, required this.at, required this.secs});
|
||||
|
||||
Map<String, Object?> toJson() => {'id': id, 'at': at.toUtc().toIso8601String(), 'secs': secs};
|
||||
|
||||
static QueueAdvance? fromJson(Object? j) {
|
||||
if (j is! Map || j['id'] is! String) return null;
|
||||
final at = DateTime.tryParse('${j['at']}');
|
||||
final secs = j['secs'];
|
||||
if (at == null || secs is! num || !secs.isFinite || secs < 0) return null;
|
||||
return QueueAdvance(id: j['id'] as String, at: at, secs: secs.toInt());
|
||||
}
|
||||
}
|
||||
|
||||
class StateBroadcast {
|
||||
@@ -733,6 +1205,10 @@ class StateBroadcast {
|
||||
/// The queued item someone asked to play now, waiting for the member
|
||||
/// who can put it up; null when none.
|
||||
final String? queuePlay;
|
||||
/// Why it is held ([HoldInfo]); null unless `held` with media up.
|
||||
final HoldInfo? hold;
|
||||
/// The shared countdown to Up next (reserved, [QueueAdvance]).
|
||||
final QueueAdvance? advance;
|
||||
const StateBroadcast({
|
||||
required this.eventId,
|
||||
required this.transport,
|
||||
@@ -747,6 +1223,8 @@ class StateBroadcast {
|
||||
this.relayNext = false,
|
||||
this.queue = const [],
|
||||
this.queuePlay,
|
||||
this.hold,
|
||||
this.advance,
|
||||
});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -764,6 +1242,8 @@ class StateBroadcast {
|
||||
if (relayNext) 'relayNext': true,
|
||||
if (queue.isNotEmpty) 'queue': [for (final q in queue) q.toJson()],
|
||||
if (queuePlay != null) 'queuePlay': queuePlay,
|
||||
if (hold != null) 'hold': hold!.toJson(),
|
||||
if (advance != null) 'advance': advance!.toJson(),
|
||||
};
|
||||
|
||||
factory StateBroadcast.fromJson(Map<String, Object?> json) {
|
||||
@@ -791,31 +1271,62 @@ class StateBroadcast {
|
||||
? (json['queue'] as List).map(QueueItem.fromJson).whereType<QueueItem>().toList()
|
||||
: const [],
|
||||
queuePlay: json['queuePlay'] is String ? json['queuePlay'] as String : null,
|
||||
hold: HoldInfo.fromJson(json['hold']),
|
||||
advance: QueueAdvance.fromJson(json['advance']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of the activity feed. [actor] / [target] are display names
|
||||
/// (as they were then); [actorId] / [targetId] the member ids (`server`
|
||||
/// for the server's own lines, empty when none), so clients match people
|
||||
/// by id, not by name. [seq] increases by one per line for the server's
|
||||
/// life; [replayed] marks lines repeated in `welcome.feed` (log them, do
|
||||
/// not toast them).
|
||||
class FeedMessage {
|
||||
final String actor;
|
||||
final String action;
|
||||
final String? target;
|
||||
final String reason;
|
||||
final DateTime at;
|
||||
final String actorId;
|
||||
final String targetId;
|
||||
final int seq;
|
||||
final bool replayed;
|
||||
const FeedMessage({
|
||||
required this.actor,
|
||||
required this.action,
|
||||
this.target,
|
||||
this.reason = '',
|
||||
required this.at,
|
||||
this.actorId = '',
|
||||
this.targetId = '',
|
||||
this.seq = 0,
|
||||
this.replayed = false,
|
||||
});
|
||||
|
||||
FeedMessage copyWith({int? seq, bool? replayed}) => FeedMessage(
|
||||
actor: actor,
|
||||
action: action,
|
||||
target: target,
|
||||
reason: reason,
|
||||
at: at,
|
||||
actorId: actorId,
|
||||
targetId: targetId,
|
||||
seq: seq ?? this.seq,
|
||||
replayed: replayed ?? this.replayed);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'feed',
|
||||
'seq': seq,
|
||||
'actor': actor,
|
||||
if (actorId.isNotEmpty) 'actorId': actorId,
|
||||
'action': action,
|
||||
if (target != null) 'target': target,
|
||||
if (targetId.isNotEmpty) 'targetId': targetId,
|
||||
'reason': reason,
|
||||
'at': at.toIso8601String(),
|
||||
if (replayed) 'replayed': true,
|
||||
};
|
||||
|
||||
factory FeedMessage.fromJson(Map<String, Object?> json) {
|
||||
@@ -826,6 +1337,10 @@ class FeedMessage {
|
||||
target: json['target'] as String?,
|
||||
reason: (json['reason'] as String?) ?? '',
|
||||
at: DateTime.tryParse('${json['at']}') ?? DateTime.now().toUtc(),
|
||||
actorId: json['actorId'] is String ? json['actorId'] as String : '',
|
||||
targetId: json['targetId'] is String ? json['targetId'] as String : '',
|
||||
seq: finiteInt(json['seq'], 'seq'),
|
||||
replayed: json['replayed'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:typed_data';
|
||||
|
||||
import 'constants.dart';
|
||||
import 'errors.dart';
|
||||
import 'presence.dart';
|
||||
import 'protocol.dart';
|
||||
import 'sync_engine.dart';
|
||||
|
||||
@@ -36,7 +37,10 @@ enum QueueHow {
|
||||
/// 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).
|
||||
/// adder's own handle for what it keeps (adder-only items). [auto]: the
|
||||
/// continuation of media [forMedia] (the next episode): at most one, always
|
||||
/// after the others, replaced by a newer one, dropped by any swap that does
|
||||
/// not put it up (wire `auto` and `for`).
|
||||
class QueueItem {
|
||||
final String id;
|
||||
final HelloMedia media;
|
||||
@@ -52,6 +56,9 @@ class QueueItem {
|
||||
final String by;
|
||||
final String byName;
|
||||
final String key;
|
||||
final bool auto;
|
||||
/// The `mediaId` an [auto] item continues; 0 otherwise.
|
||||
final int forMedia;
|
||||
const QueueItem({
|
||||
this.id = '',
|
||||
required this.media,
|
||||
@@ -63,10 +70,12 @@ class QueueItem {
|
||||
this.by = '',
|
||||
this.byName = '',
|
||||
this.key = '',
|
||||
this.auto = false,
|
||||
this.forMedia = 0,
|
||||
});
|
||||
|
||||
QueueItem copyWith({String? id, HelloMedia? media, String? title, String? sub, double? duration,
|
||||
String? thumb, String? by, String? byName, String? key}) =>
|
||||
String? thumb, String? by, String? byName, String? key, bool? auto, int? forMedia}) =>
|
||||
QueueItem(
|
||||
id: id ?? this.id,
|
||||
media: media ?? this.media,
|
||||
@@ -78,6 +87,8 @@ class QueueItem {
|
||||
by: by ?? this.by,
|
||||
byName: byName ?? this.byName,
|
||||
key: key ?? this.key,
|
||||
auto: auto ?? this.auto,
|
||||
forMedia: forMedia ?? this.forMedia,
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -91,6 +102,8 @@ class QueueItem {
|
||||
if (by.isNotEmpty) 'by': by,
|
||||
if (byName.isNotEmpty) 'byName': byName,
|
||||
if (key.isNotEmpty) 'key': key,
|
||||
if (auto) 'auto': true,
|
||||
if (auto) 'for': forMedia,
|
||||
};
|
||||
|
||||
/// Null when [j] is not an item this side can read.
|
||||
@@ -111,6 +124,8 @@ class QueueItem {
|
||||
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 : '',
|
||||
auto: j['auto'] == true,
|
||||
forMedia: j['auto'] == true && j['for'] is num && (j['for'] as num).isFinite ? (j['for'] as num).toInt() : 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -118,8 +133,9 @@ class QueueItem {
|
||||
/// 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).
|
||||
/// is skipped because it cannot play), `play` ([id] now), `skip` (the
|
||||
/// first item now) or `cancel` (drops the [QueueItem.auto] item and the
|
||||
/// countdown, `state.advance`, for everyone).
|
||||
class QueueIntent {
|
||||
final String op;
|
||||
final QueueItem? item;
|
||||
@@ -137,8 +153,9 @@ class QueueIntent {
|
||||
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');
|
||||
const QueueIntent.cancel() : this._('cancel');
|
||||
|
||||
static const ops = {'add', 'move', 'remove', 'play', 'skip'};
|
||||
static const ops = {'add', 'move', 'remove', 'play', 'skip', 'cancel'};
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'queue',
|
||||
@@ -210,26 +227,54 @@ class EngineQueue {
|
||||
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]).
|
||||
/// The continuation item ([QueueItem.auto]), always last; null when none.
|
||||
QueueItem? get auto {
|
||||
final i = _autoIndex;
|
||||
return i < 0 ? null : _items[i].item;
|
||||
}
|
||||
|
||||
int get _autoIndex => _items.indexWhere((e) => e.item.auto);
|
||||
|
||||
/// Adds [item] (already cleaned) with a fresh id, at the end (before the
|
||||
/// [auto] item) or, with [next], first. An [QueueItem.auto] item goes
|
||||
/// last and replaces the earlier one. Null when full
|
||||
/// ([RelayConstants.maxQueueItems]).
|
||||
QueueItem? add(QueueItem item, {bool next = false, String? privateUrl, Uint8List? thumb}) {
|
||||
if (item.auto) dropAuto();
|
||||
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) {
|
||||
if (item.auto) {
|
||||
_items.add(e);
|
||||
} else if (next) {
|
||||
_items.insert(0, e);
|
||||
} else {
|
||||
_items.add(e);
|
||||
final a = _autoIndex;
|
||||
_items.insert(a < 0 ? _items.length : a, e);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/// Drops the [auto] item; returns it (null when there was none).
|
||||
QueueItem? dropAuto() {
|
||||
final i = _autoIndex;
|
||||
if (i < 0) return null;
|
||||
final a = _items.removeAt(i).item;
|
||||
if (play == a.id) _clearPlay();
|
||||
return a;
|
||||
}
|
||||
|
||||
/// 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);
|
||||
// The auto item stays after the others: it does not move, nothing
|
||||
// moves past it.
|
||||
final a = _autoIndex;
|
||||
if (i == a) return false;
|
||||
final last = _items.length - (a < 0 ? 1 : 2);
|
||||
final target = to.clamp(0, last);
|
||||
if (target == i) return false;
|
||||
final e = _items.removeAt(i);
|
||||
_items.insert(target, e);
|
||||
@@ -257,8 +302,10 @@ class EngineQueue {
|
||||
}
|
||||
|
||||
/// A swap happened: [id] (when it came from Up next) leaves the list,
|
||||
/// and any pending play request is over.
|
||||
/// the [auto] item goes too unless it is [id], and any pending play
|
||||
/// request is over.
|
||||
void swapped(String? id) {
|
||||
if (auto?.id != id) dropAuto();
|
||||
if (id != null) {
|
||||
final i = _index(id);
|
||||
if (i >= 0) _items.removeAt(i);
|
||||
@@ -290,20 +337,40 @@ class EngineQueue {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a `queue` message was refused: the `err` the sender gets.
|
||||
typedef QueueRefusal = ({ErrCode code, String message});
|
||||
|
||||
/// The `queue` messages applied to the engine's [SyncEngine.queue], with
|
||||
/// their feed lines (`queue-add`, `queue-remove`, `queue-skip`). Each
|
||||
/// returns whether the queue changed; [applyQueueAdd] an error text for the
|
||||
/// sender instead (null when added).
|
||||
/// their feed lines (`queue-add`, `queue-remove`, `queue-skip`,
|
||||
/// `advance-cancel`). Each returns whether the queue changed;
|
||||
/// [applyQueueAdd] a refusal for the sender instead (null when added).
|
||||
extension EngineQueueOps on SyncEngine {
|
||||
/// [item] is already cleaned by the server (see `cleanQueueAdd`).
|
||||
String? applyQueueAdd(String token, QueueItem item,
|
||||
/// [item] is already cleaned by the server (see `cleanQueueAdd`). An
|
||||
/// [QueueItem.auto] item must continue the current media
|
||||
/// ([QueueItem.forMedia] = [SyncEngine.mediaId]) and come from who put
|
||||
/// it up ([SyncEngine.swapBy]) or, when that member is not `in`, from the
|
||||
/// first member who is.
|
||||
QueueRefusal? applyQueueAdd(String token, QueueItem item,
|
||||
{bool next = false, String? privateUrl, Uint8List? thumb}) {
|
||||
final member = members[token];
|
||||
if (member == null) return null;
|
||||
if (item.auto) {
|
||||
if (media == null || item.forMedia != mediaId) {
|
||||
return (code: ErrCode.bad, message: 'bad message: an auto item must name the current media');
|
||||
}
|
||||
final swapper = members[swapBy];
|
||||
final firstIn = members.values.where((m) => m.presence == Presence.watching).firstOrNull;
|
||||
final allowed = token == swapBy ||
|
||||
((swapper == null || swapper.presence != Presence.watching) && identical(firstIn, member));
|
||||
if (!allowed) {
|
||||
return (code: ErrCode.forbidden, message: 'only who put this media up adds what continues it');
|
||||
}
|
||||
}
|
||||
final added = queue.add(item.copyWith(by: member.id, byName: member.name),
|
||||
next: next, privateUrl: privateUrl, thumb: thumb);
|
||||
if (added == null) return 'Up next is full (${RelayConstants.maxQueueItems} items)';
|
||||
feed.add(FeedMessage(actor: member.name, action: 'queue-add', target: added.title, at: now));
|
||||
if (added == null) return (code: ErrCode.full, message: 'Up next is full (${RelayConstants.maxQueueItems} items)');
|
||||
feed.add(FeedMessage(
|
||||
actor: member.name, actorId: member.id, action: 'queue-add', target: added.title, at: now));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -319,6 +386,7 @@ extension EngineQueueOps on SyncEngine {
|
||||
if (gone == null) return false;
|
||||
feed.add(FeedMessage(
|
||||
actor: member.name,
|
||||
actorId: member.id,
|
||||
action: why.isEmpty ? 'queue-remove' : 'queue-skip',
|
||||
target: gone.title,
|
||||
reason: why,
|
||||
@@ -333,4 +401,18 @@ extension EngineQueueOps on SyncEngine {
|
||||
if (target == null || queue.play == target) return false;
|
||||
return queue.requestPlay(target, now);
|
||||
}
|
||||
|
||||
/// `cancel`: drops the continuation ([EngineQueue.auto]) and the
|
||||
/// countdown ([SyncEngine.advance]) for everyone (feed `advance-cancel`).
|
||||
bool applyQueueCancel(String token) {
|
||||
final member = members[token];
|
||||
if (member == null) return false;
|
||||
final dropped = queue.dropAuto();
|
||||
final hadAdvance = advance != null;
|
||||
advance = null;
|
||||
if (dropped == null && !hadAdvance) return false;
|
||||
feed.add(FeedMessage(
|
||||
actor: member.name, actorId: member.id, action: 'advance-cancel', target: dropped?.title, at: now));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -335,16 +335,26 @@ class _Pull {
|
||||
|
||||
/// Close code the server uses for "you are not (or no longer) the source of
|
||||
/// the current media": the source stops instead of reconnecting.
|
||||
const relayCloseNotSource = 4001;
|
||||
const relayCloseNotSource = CloseCodes.relayNotSource;
|
||||
|
||||
/// Close codes after which a source stops for good: not the source
|
||||
/// (4001), another media than the relay's (4002), replaced by a newer
|
||||
/// channel (4003).
|
||||
const relayStopCodes = {CloseCodes.relayNotSource, CloseCodes.relayOtherMedia, CloseCodes.relayReplaced};
|
||||
|
||||
/// Serves [media] on the relay channel at [url] (`ws://host/r/<sid>/<token>/source`)
|
||||
/// until [close]. Reconnects with backoff when the channel drops; stops for
|
||||
/// good when the server closes it with [relayCloseNotSource].
|
||||
/// until [close]. Reconnects with backoff when the channel drops (the
|
||||
/// server keeps the relay through the source's grace and pulls what it
|
||||
/// still lacks); stops for good on [relayStopCodes]. [name] and
|
||||
/// [duration] (seconds, 0 unknown) go in its `info`, so a re-dial is
|
||||
/// recognised as the same media.
|
||||
class RelaySource {
|
||||
final Uri url;
|
||||
final RelayMedia media;
|
||||
final String name;
|
||||
final double duration;
|
||||
final Duration Function(int attempt) backoff;
|
||||
RelaySource(this.url, this.media, {Duration Function(int attempt)? backoff})
|
||||
RelaySource(this.url, this.media, {this.name = '', this.duration = 0, Duration Function(int attempt)? backoff})
|
||||
: backoff = backoff ?? ((a) => Duration(milliseconds: min(8000, 500 * (1 << min(a, 4)))));
|
||||
|
||||
final window = RelayWindow();
|
||||
@@ -384,6 +394,8 @@ class RelaySource {
|
||||
unawaited(_loop({
|
||||
't': 'info',
|
||||
if (relayTrackSpecs(tracks).isEmpty) 'size': infos.single['size'] else 'tracks': infos,
|
||||
if (name.isNotEmpty) 'name': name,
|
||||
if (duration > 0 && duration.isFinite) 'duration': duration,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -398,14 +410,17 @@ class RelaySource {
|
||||
break;
|
||||
}
|
||||
_ws = ws;
|
||||
// A half-open channel (a dead link without a FIN) is noticed by
|
||||
// its missing pong, closed, and dialled again.
|
||||
ws.pingInterval = const Duration(seconds: RelayConstants.pingIntervalSecs);
|
||||
_log.info('relay channel up: $where${attempt > 0 ? ' (after $attempt retries)' : ''}');
|
||||
attempt = 0;
|
||||
ws.add(jsonEncode(info));
|
||||
await for (final m in ws) {
|
||||
if (m is String) _onText(ws, m);
|
||||
}
|
||||
if (ws.closeCode == relayCloseNotSource) {
|
||||
_log.info('relay channel: not the source any more, stopping ($where, sent $bytesSent bytes)');
|
||||
if (relayStopCodes.contains(ws.closeCode)) {
|
||||
_log.info('relay channel: ${ws.closeCode == CloseCodes.relayNotSource ? 'not the source any more' : ws.closeCode == CloseCodes.relayOtherMedia ? 'another media than the relay\'s' : 'replaced by a newer channel'}, stopping ($where, sent $bytesSent bytes)');
|
||||
_closed = true;
|
||||
} else if (!_closed) {
|
||||
_log.warning('relay channel down: $where, code ${ws.closeCode} ${ws.closeReason ?? ''}; reconnecting');
|
||||
|
||||
@@ -4,26 +4,38 @@ import 'dart:typed_data';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
import 'constants.dart';
|
||||
import 'presence.dart';
|
||||
import 'protocol.dart';
|
||||
import 'queue.dart';
|
||||
|
||||
typedef SyncClock = DateTime Function();
|
||||
|
||||
/// How long the server bears a silent member. [staleAfter] and [dropAfter]
|
||||
/// are measured on the engine's clock ([SyncEngine.clock]) by its 1 s
|
||||
/// sweep, so a test with a fake clock steps through them without waiting;
|
||||
/// How the server treats silence, drops and joiners (PROTOCOL.md
|
||||
/// "Presence"). Everything but [pingInterval] is measured on the engine's
|
||||
/// clock ([SyncEngine.clock]) by its 1 s sweep ([SyncEngine.tick]), so a
|
||||
/// test with a fake clock steps through them without waiting;
|
||||
/// [pingInterval] is the WebSocket's own timer (real time).
|
||||
class Liveness {
|
||||
/// No frame for longer than this: stale (out of the set, feed `stale`).
|
||||
final Duration staleAfter;
|
||||
/// No frame for longer than this: the member is removed (feed `leave`)
|
||||
/// and its socket closed.
|
||||
final Duration dropAfter;
|
||||
/// No frame for longer than this: the socket is closed and the member
|
||||
/// is `gone`.
|
||||
final Duration silence;
|
||||
/// A `gone` member is kept this long (`welcome.grace`), then it leaves.
|
||||
final Duration grace;
|
||||
/// The feed says `gone` only once a member has been gone this long (a
|
||||
/// quick return says nothing).
|
||||
final Duration goneNotice;
|
||||
/// A `joining` member holds a HELD gate at most this long (D2).
|
||||
final Duration joinerHold;
|
||||
/// A `joining` member is `in` after this at the latest.
|
||||
final Duration joiningTimeout;
|
||||
/// WebSocket ping on every `/sync` socket.
|
||||
final Duration pingInterval;
|
||||
const Liveness({
|
||||
this.staleAfter = const Duration(seconds: RelayConstants.staleAfterSecs),
|
||||
this.dropAfter = const Duration(seconds: RelayConstants.dropAfterSecs),
|
||||
this.silence = const Duration(seconds: RelayConstants.silenceSecs),
|
||||
this.grace = const Duration(seconds: RelayConstants.graceSecs),
|
||||
this.goneNotice = const Duration(seconds: RelayConstants.goneNoticeSecs),
|
||||
this.joinerHold = const Duration(seconds: RelayConstants.joinerHoldSecs),
|
||||
this.joiningTimeout = const Duration(seconds: RelayConstants.joiningTimeoutSecs),
|
||||
this.pingInterval = const Duration(seconds: RelayConstants.pingIntervalSecs),
|
||||
});
|
||||
}
|
||||
@@ -63,24 +75,41 @@ String avatarHashOf(List<int> bytes) => sha256.convert(bytes).toString().substri
|
||||
class EngineMember {
|
||||
final String id;
|
||||
final String token;
|
||||
/// `hello.client` (empty: none, the member cannot be taken over).
|
||||
/// Private: never broadcast, never logged.
|
||||
final String client;
|
||||
/// The `hello.seq` of the connection that holds it now.
|
||||
int seq;
|
||||
String name;
|
||||
/// Picture bytes, served at `/a/...`; only [avatarHash] is broadcast.
|
||||
Uint8List? avatarBytes;
|
||||
String avatarHash = '';
|
||||
bool ready = false;
|
||||
/// In the ready gate's set (joined it while not playing, readied, or
|
||||
/// was there at a swap); whether it counts also depends on [presence]
|
||||
/// and [error] ([SyncEngine.countsForGate]).
|
||||
bool inSet = false;
|
||||
double buffered = 0;
|
||||
/// Last reported Jellyfin access by ServerId (`report.jf`).
|
||||
Map<String, bool> jf = const {};
|
||||
/// The hosting app's own member (set by the server from the host key).
|
||||
bool host = false;
|
||||
/// Sitting out: never in the set, never ready, its buffer ignored.
|
||||
bool out = false;
|
||||
/// 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;
|
||||
/// `hello.app`, `hello.platform`, `hello.caps` of its last hello.
|
||||
String app;
|
||||
String platform;
|
||||
ClientCaps caps;
|
||||
Presence presence;
|
||||
/// When [presence] began (joining since, gone since, ...).
|
||||
DateTime presenceSince;
|
||||
/// Gone: whether the feed has said so yet (after [Liveness.goneNotice]).
|
||||
bool goneNoticed = false;
|
||||
/// Gone: it was `away` before, and comes back `away`.
|
||||
bool awayBeforeGone = false;
|
||||
DateTime lastSeen;
|
||||
EngineMember({
|
||||
required this.id,
|
||||
@@ -88,7 +117,14 @@ class EngineMember {
|
||||
required this.name,
|
||||
String avatar = '',
|
||||
required DateTime now,
|
||||
}) : lastSeen = now {
|
||||
this.client = '',
|
||||
this.seq = 0,
|
||||
this.app = '',
|
||||
this.platform = '',
|
||||
this.caps = const ClientCaps(),
|
||||
this.presence = Presence.watching,
|
||||
}) : lastSeen = now,
|
||||
presenceSince = now {
|
||||
setAvatar(avatar);
|
||||
}
|
||||
|
||||
@@ -111,19 +147,24 @@ class EngineMember {
|
||||
return true;
|
||||
}
|
||||
|
||||
MemberView view({required bool stale}) => MemberView(
|
||||
/// When it went `gone`; null otherwise.
|
||||
DateTime? get goneSince => presence == Presence.gone ? presenceSince : null;
|
||||
|
||||
MemberView view({required bool inGate}) => MemberView(
|
||||
id: id,
|
||||
name: name,
|
||||
avatarHash: avatarHash,
|
||||
ready: ready,
|
||||
inSet: inSet,
|
||||
inSet: inGate,
|
||||
buffered: buffered,
|
||||
stale: stale,
|
||||
presence: presence,
|
||||
goneSince: goneSince,
|
||||
jf: jf,
|
||||
host: host,
|
||||
out: out,
|
||||
error: error,
|
||||
rate: rate,
|
||||
app: app,
|
||||
caps: caps,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,22 +189,45 @@ class ArtImage {
|
||||
ArtImage(this.bytes) : hash = avatarHashOf(bytes);
|
||||
}
|
||||
|
||||
/// The feed lines the engine made: [pending] until the server sends them
|
||||
/// ([drain]); each gets the next [FeedMessage.seq]. The last
|
||||
/// [RelayConstants.feedReplayLines] stay for a reconnect's replay ([after]).
|
||||
class EngineFeed {
|
||||
final List<FeedMessage> pending = [];
|
||||
void add(FeedMessage message) => pending.add(message);
|
||||
final List<FeedMessage> _recent = [];
|
||||
int _seq = 0;
|
||||
|
||||
/// The `seq` of the last line (0 before any).
|
||||
int get lastSeq => _seq;
|
||||
|
||||
void add(FeedMessage message) {
|
||||
final line = message.copyWith(seq: ++_seq);
|
||||
pending.add(line);
|
||||
_recent.add(line);
|
||||
if (_recent.length > RelayConstants.feedReplayLines) _recent.removeAt(0);
|
||||
}
|
||||
|
||||
List<FeedMessage> drain() {
|
||||
final out = List<FeedMessage>.from(pending);
|
||||
pending.clear();
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Kept lines after [seq] (`hello.feedAfter`), oldest first, marked
|
||||
/// `replayed`. Lines still [pending] are left out (they go out to
|
||||
/// everyone anyway).
|
||||
List<FeedMessage> after(int seq) => [
|
||||
for (final f in _recent)
|
||||
if (f.seq > seq && !pending.contains(f)) f.copyWith(replayed: true),
|
||||
];
|
||||
}
|
||||
|
||||
class SyncEngine {
|
||||
final SyncClock clock;
|
||||
final Liveness liveness;
|
||||
Duration get staleAfter => liveness.staleAfter;
|
||||
Duration get dropAfter => liveness.dropAfter;
|
||||
final PresencePolicy policy;
|
||||
final EngineFeed feed = EngineFeed();
|
||||
/// Members by token, in join order.
|
||||
final Map<String, EngineMember> members = {};
|
||||
|
||||
int eventId = 0;
|
||||
@@ -181,6 +245,15 @@ class SyncEngine {
|
||||
/// Up next ([EngineQueue]): in `state.queue`, edited by `queue`
|
||||
/// messages, taken from by swaps that name an item.
|
||||
final EngineQueue queue = EngineQueue();
|
||||
/// The shared countdown to Up next (`state.advance`); reserved: only
|
||||
/// `cancel` touches it today.
|
||||
QueueAdvance? advance;
|
||||
/// A member went `gone` (its socket closed or fell silent): the server
|
||||
/// closes the socket and marks a relay it serves.
|
||||
void Function(EngineMember member)? onGone;
|
||||
/// A member left for good (`bye`, grace over): the server forgets its
|
||||
/// token and ends a relay it served.
|
||||
void Function(EngineMember member)? onLeft;
|
||||
int _nextId = 1;
|
||||
// Who sent the seek that produced [_seekEventId], for the echo check.
|
||||
String? _seekBy;
|
||||
@@ -189,6 +262,7 @@ class SyncEngine {
|
||||
|
||||
SyncEngine({SyncClock? clock, this.liveness = const Liveness()})
|
||||
: clock = clock ?? (() => DateTime.now().toUtc()),
|
||||
policy = PresencePolicy(joinerHold: liveness.joinerHold),
|
||||
serverTime = (clock ?? (() => DateTime.now().toUtc()))();
|
||||
|
||||
DateTime get now => clock();
|
||||
@@ -199,8 +273,13 @@ class SyncEngine {
|
||||
return position + t.difference(serverTime).inMilliseconds / 1000.0;
|
||||
}
|
||||
|
||||
bool isStale(EngineMember member, [DateTime? at]) =>
|
||||
(at ?? now).difference(member.lastSeen) > staleAfter;
|
||||
/// Whether [member] counts for the ready gate now: in the set, no
|
||||
/// player error, and its presence counts ([PresencePolicy], D2).
|
||||
bool countsForGate(EngineMember member, [DateTime? at]) =>
|
||||
member.inSet &&
|
||||
member.error.isEmpty &&
|
||||
policy.countsFor(member.presence, Purpose.gate,
|
||||
playing: transport == Transport.playing, joiningFor: (at ?? now).difference(member.presenceSince));
|
||||
|
||||
/// Media length in seconds, null while nobody has reported it.
|
||||
double? get duration {
|
||||
@@ -218,39 +297,160 @@ class SyncEngine {
|
||||
return left.clamp(0.0, full).toDouble();
|
||||
}
|
||||
|
||||
/// Any frame from a member: fresh again. Back in the set: a ready member
|
||||
/// the stale sweep (or a swap while it was stale) took out (ready &&
|
||||
/// !inSet only arises from those), and any member back from stale while
|
||||
/// not playing (like a join then).
|
||||
void _seen(EngineMember member) {
|
||||
final wasStale = isStale(member);
|
||||
member.lastSeen = now;
|
||||
if (!member.out &&
|
||||
member.error.isEmpty &&
|
||||
!member.inSet &&
|
||||
(member.ready || (wasStale && transport != Transport.playing))) {
|
||||
member.inSet = true;
|
||||
}
|
||||
/// Any frame from a member.
|
||||
void _seen(EngineMember member) => member.lastSeen = now;
|
||||
|
||||
/// Any frame from [token]'s socket (a ping, a chat): it is not silent.
|
||||
void touch(String token) {
|
||||
final m = members[token];
|
||||
if (m != null) _seen(m);
|
||||
}
|
||||
|
||||
/// Heartbeat: stale sweep, end of media.
|
||||
FeedMessage _line(String action, {EngineMember? actor, EngineMember? target, String reason = ''}) => FeedMessage(
|
||||
actor: actor?.name ?? 'server',
|
||||
actorId: actor?.id ?? 'server',
|
||||
action: action,
|
||||
target: target?.name,
|
||||
targetId: target?.id ?? '',
|
||||
reason: reason,
|
||||
at: now);
|
||||
|
||||
/// Heartbeat (1 s): silence, joining timeouts, gone notices, grace,
|
||||
/// a stale play request, end of media.
|
||||
void tick() {
|
||||
sweepStale();
|
||||
sweepSilent();
|
||||
sweepPresence();
|
||||
queue.tick(now);
|
||||
evaluate();
|
||||
}
|
||||
|
||||
String applyJoin(String token, String name, String avatar) {
|
||||
/// Joining: loads in the background until its reports hold the media
|
||||
/// (D2); with nothing up there is nothing to load.
|
||||
Presence get _arriving => media == null ? Presence.watching : Presence.joining;
|
||||
|
||||
/// A new member ([client] empty: one that cannot be taken over).
|
||||
String applyJoin(String token, String name, String avatar,
|
||||
{String client = '', int seq = 0, String app = '', String platform = '', ClientCaps caps = const ClientCaps()}) {
|
||||
final id = 'm${_nextId++}';
|
||||
// Joining while not playing: in the set, so the start waits for them;
|
||||
// while playing, out until they ready (a late joiner never pauses).
|
||||
members[token] =
|
||||
EngineMember(id: id, token: token, name: name, avatar: avatar, now: now)
|
||||
..inSet = transport != Transport.playing;
|
||||
feed.add(FeedMessage(actor: name, action: 'join', at: now));
|
||||
// Joining while not playing: in the set, so the start waits for them
|
||||
// (at most [Liveness.joinerHold]); while playing, out until they ready
|
||||
// (a late joiner never pauses).
|
||||
final m = members[token] = EngineMember(
|
||||
id: id,
|
||||
token: token,
|
||||
name: name,
|
||||
avatar: avatar,
|
||||
now: now,
|
||||
client: client,
|
||||
seq: seq,
|
||||
app: app,
|
||||
platform: platform,
|
||||
caps: caps,
|
||||
presence: _arriving)
|
||||
..inSet = transport != Transport.playing;
|
||||
feed.add(_line('join', actor: m));
|
||||
evaluate();
|
||||
return id;
|
||||
}
|
||||
|
||||
/// The member with `hello.client` [client]; null for none (or empty).
|
||||
EngineMember? byClient(String client) {
|
||||
if (client.isEmpty) return null;
|
||||
for (final m in members.values) {
|
||||
if (m.client == client) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// A newer hello of the same client took [token]'s member over (see
|
||||
/// `hello.client`): back from `gone` it is `joining` again (`away` if it
|
||||
/// was), ready flag kept, feed `return` when the feed had said `gone`.
|
||||
/// Returns whether it came back from `gone`.
|
||||
bool applyTakeover(String token,
|
||||
{required int seq, String app = '', String platform = '', ClientCaps caps = const ClientCaps()}) {
|
||||
final m = members[token];
|
||||
if (m == null) return false;
|
||||
m
|
||||
..seq = seq
|
||||
..app = app
|
||||
..platform = platform
|
||||
..caps = caps;
|
||||
_seen(m);
|
||||
if (m.presence != Presence.gone) return false;
|
||||
final noticed = m.goneNoticed;
|
||||
m
|
||||
..goneNoticed = false
|
||||
..presence = m.awayBeforeGone ? Presence.away : _arriving
|
||||
..presenceSince = now
|
||||
..awayBeforeGone = false
|
||||
..inSet = m.error.isEmpty && (m.ready || transport != Transport.playing);
|
||||
if (noticed) feed.add(_line('return', actor: m));
|
||||
evaluate();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// [token]'s socket closed or fell silent: `gone`, kept for the grace
|
||||
/// with everything it had. No feed line yet ([Liveness.goneNotice]).
|
||||
void applyGone(String token) {
|
||||
final m = members[token];
|
||||
if (m == null || m.presence == Presence.gone) return;
|
||||
m
|
||||
..awayBeforeGone = m.presence == Presence.away
|
||||
..presence = Presence.gone
|
||||
..presenceSince = now
|
||||
..goneNoticed = false;
|
||||
evaluate();
|
||||
onGone?.call(m);
|
||||
}
|
||||
|
||||
/// Members with no frame for [Liveness.silence] go `gone`. Returns
|
||||
/// their tokens.
|
||||
List<String> sweepSilent() {
|
||||
final t = now;
|
||||
final silent = [
|
||||
for (final m in members.values)
|
||||
if (m.presence != Presence.gone && t.difference(m.lastSeen) > liveness.silence) m.token,
|
||||
];
|
||||
for (final token in silent) {
|
||||
applyGone(token);
|
||||
}
|
||||
return silent;
|
||||
}
|
||||
|
||||
/// Joining timeouts, the delayed `gone` line, grace expiry (leave).
|
||||
void sweepPresence() {
|
||||
final t = now;
|
||||
for (final m in members.values.toList()) {
|
||||
final since = t.difference(m.presenceSince);
|
||||
switch (m.presence) {
|
||||
case Presence.joining:
|
||||
if (since >= liveness.joiningTimeout) _becomeIn(m);
|
||||
case Presence.gone:
|
||||
if (since >= liveness.grace) {
|
||||
applyLeave(m.token, reason: 'grace');
|
||||
} else if (!m.goneNoticed && since >= liveness.goneNotice) {
|
||||
m.goneNoticed = true;
|
||||
feed.add(_line('gone', actor: m));
|
||||
}
|
||||
case Presence.watching:
|
||||
case Presence.away:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Joining is over: `in`. In the set if it is ready, or (not playing)
|
||||
/// if it was in it; while playing an unready member waits for its own
|
||||
/// `ready`, so coming in never pauses anyone.
|
||||
void _becomeIn(EngineMember m) {
|
||||
if (m.presence != Presence.joining) return;
|
||||
m
|
||||
..presence = Presence.watching
|
||||
..presenceSince = now
|
||||
..inSet = m.error.isEmpty && (m.ready || (transport != Transport.playing && m.inSet));
|
||||
evaluate();
|
||||
}
|
||||
|
||||
/// Name and/or picture change ([ProfileIntent]); null leaves it, an
|
||||
/// empty [avatar] removes the picture. [name] is already cleaned.
|
||||
void applyProfile(String token, {String? name, String? avatar}) {
|
||||
@@ -262,9 +462,10 @@ class SyncEngine {
|
||||
if (renamed) member.name = name;
|
||||
final pictured = avatar != null && member.setAvatar(avatar);
|
||||
if (renamed) {
|
||||
feed.add(FeedMessage(actor: name, action: 'rename', target: oldName, at: now));
|
||||
feed.add(FeedMessage(
|
||||
actor: name, actorId: member.id, action: 'rename', target: oldName, targetId: member.id, at: now));
|
||||
} else if (pictured) {
|
||||
feed.add(FeedMessage(actor: member.name, action: 'avatar', at: now));
|
||||
feed.add(_line('avatar', actor: member));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,19 +510,14 @@ class SyncEngine {
|
||||
if (banner != null) 'banner': banner!.hash,
|
||||
};
|
||||
|
||||
/// A reconnect took over [token]'s member (see `hello.resume`).
|
||||
void applyResume(String token) {
|
||||
final member = members[token];
|
||||
if (member == null) return;
|
||||
_seen(member);
|
||||
evaluate();
|
||||
}
|
||||
|
||||
String? applyLeave(String token) {
|
||||
/// [token]'s member leaves for good: `bye` ([reason] `bye`), or its
|
||||
/// grace ran out (`grace`). Feed `leave`. Returns its id.
|
||||
String? applyLeave(String token, {String reason = ''}) {
|
||||
final removed = members.remove(token);
|
||||
if (removed == null) return null;
|
||||
feed.add(FeedMessage(actor: removed.name, action: 'leave', at: now));
|
||||
feed.add(_line('leave', actor: removed, reason: reason));
|
||||
evaluate();
|
||||
onLeft?.call(removed);
|
||||
return removed.id;
|
||||
}
|
||||
|
||||
@@ -332,74 +528,80 @@ class SyncEngine {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// `presence` from [actorToken] on [targetId] (empty: itself). `away`:
|
||||
/// anyone may set anyone (the host too); `in`: only itself, through
|
||||
/// `joining` while media is up (D2). Returns [ErrCode.forbidden] when
|
||||
/// someone else is set `in`, else null (no-ops and unknown targets
|
||||
/// included).
|
||||
ErrCode? applyPresence(String actorToken, String targetId, Presence state,
|
||||
{PresenceReason reason = PresenceReason.manual}) {
|
||||
final actor = members[actorToken];
|
||||
if (actor == null) return null;
|
||||
_seen(actor);
|
||||
final target = targetId.isEmpty ? actor : byId(targetId);
|
||||
if (target == null) return null;
|
||||
final self = identical(actor, target);
|
||||
final why = reason == PresenceReason.manual ? '' : reason.name;
|
||||
if (state == Presence.away) {
|
||||
if (target.presence == Presence.gone) {
|
||||
if (target.awayBeforeGone) return null;
|
||||
target.awayBeforeGone = true;
|
||||
} else {
|
||||
if (target.presence == Presence.away) return null;
|
||||
target
|
||||
..presence = Presence.away
|
||||
..presenceSince = now;
|
||||
}
|
||||
feed.add(_line('away', actor: actor, target: self ? null : target, reason: why));
|
||||
evaluate();
|
||||
return null;
|
||||
}
|
||||
if (state != Presence.watching) return null;
|
||||
if (!self) return ErrCode.forbidden;
|
||||
if (target.presence != Presence.away) return null;
|
||||
target
|
||||
..presence = _arriving
|
||||
..presenceSince = now
|
||||
..inSet = target.error.isEmpty && (target.ready || transport != Transport.playing);
|
||||
feed.add(_line('in', actor: actor, reason: why));
|
||||
evaluate();
|
||||
return null;
|
||||
}
|
||||
|
||||
void applyReady(String token, bool ready, {String source = 'manual'}) {
|
||||
final member = members[token];
|
||||
if (member == null) return;
|
||||
_seen(member);
|
||||
// Sitting out: back first ([applyOut]), then ready.
|
||||
if (member.out) return;
|
||||
if (member.ready == ready && (member.inSet || member.error.isNotEmpty)) {
|
||||
evaluate();
|
||||
return;
|
||||
}
|
||||
member.ready = ready;
|
||||
if (ready && member.error.isEmpty) member.inSet = true;
|
||||
feed.add(FeedMessage(
|
||||
actor: member.name,
|
||||
action: ready ? 'ready' : 'unready',
|
||||
reason: source,
|
||||
at: now));
|
||||
feed.add(_line(ready ? 'ready' : 'unready', actor: member, reason: source));
|
||||
evaluate();
|
||||
}
|
||||
|
||||
/// Ready (or unready) [target] (a member id) or `all`. Members `away`
|
||||
/// are left alone.
|
||||
void applyForce(String actorToken, String target, bool ready) {
|
||||
final actor = members[actorToken];
|
||||
final actorName = actor?.name ?? '?';
|
||||
if (target == 'all') {
|
||||
for (final member in members.values) {
|
||||
if (member.out) continue;
|
||||
if (member.presence == Presence.away) continue;
|
||||
member.ready = ready;
|
||||
if (ready && member.error.isEmpty) member.inSet = true;
|
||||
}
|
||||
} else {
|
||||
final member = byId(target);
|
||||
if (member == null || member.out) return;
|
||||
if (member == null || member.presence == Presence.away) return;
|
||||
member.ready = ready;
|
||||
if (ready && member.error.isEmpty) member.inSet = true;
|
||||
feed.add(FeedMessage(
|
||||
actor: actorName,
|
||||
action: ready ? 'force-ready' : 'force-unready',
|
||||
target: member.name,
|
||||
at: now));
|
||||
feed.add(_line(ready ? 'force-ready' : 'force-unready', actor: actor, target: member));
|
||||
evaluate();
|
||||
return;
|
||||
}
|
||||
feed.add(FeedMessage(
|
||||
actor: actorName,
|
||||
action: ready ? 'force-play' : 'force-hold',
|
||||
at: now));
|
||||
evaluate();
|
||||
}
|
||||
|
||||
/// Sits the member [targetId] out ([out] true; anyone may, the host
|
||||
/// included), or brings [actorToken]'s own member back ([out] false;
|
||||
/// only oneself). Out: not ready, not in the set, so it never blocks.
|
||||
/// Back: in the set like a fresh join (unless playing), still unready:
|
||||
/// its own `ready` follows once it has buffered.
|
||||
void applyOut(String actorToken, String targetId, bool out) {
|
||||
final actor = members[actorToken];
|
||||
final member = byId(targetId);
|
||||
if (actor == null || member == null) return;
|
||||
_seen(actor);
|
||||
final self = identical(actor, member);
|
||||
if (!out && !self) return;
|
||||
if (member.out == out) return;
|
||||
member.out = out;
|
||||
member.ready = false;
|
||||
member.buffered = 0;
|
||||
member.inSet = !out && member.error.isEmpty && transport != Transport.playing;
|
||||
feed.add(FeedMessage(
|
||||
actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now));
|
||||
feed.add(_line(ready ? 'force-play' : 'force-hold', actor: actor));
|
||||
evaluate();
|
||||
}
|
||||
|
||||
@@ -414,7 +616,7 @@ class SyncEngine {
|
||||
_seekBy = member.id;
|
||||
_seekSeq = seq;
|
||||
_seekEventId = eventId;
|
||||
feed.add(FeedMessage(actor: member.name, action: 'seek', at: now));
|
||||
feed.add(_line('seek', actor: member));
|
||||
// Seeking back from the end: held, and resumes once the gate passes.
|
||||
if (transport == Transport.finished) {
|
||||
transport = Transport.held;
|
||||
@@ -423,11 +625,13 @@ class SyncEngine {
|
||||
}
|
||||
|
||||
/// Returns false (no state change) for media with non-finite numbers.
|
||||
/// [queueId]: the Up next item this swap puts up; it leaves the queue.
|
||||
/// [queueId]: the Up next item this swap puts up; it leaves the queue
|
||||
/// (any other swap drops the continuation item, [EngineQueue.auto]).
|
||||
bool applySwap(String actorToken, HelloMedia next, {String? queueId}) {
|
||||
if (!next.duration.isFinite || next.size < 0) return false;
|
||||
final actor = members[actorToken];
|
||||
queue.swapped(queueId);
|
||||
advance = null;
|
||||
mediaId++;
|
||||
media = next;
|
||||
swapBy = actorToken;
|
||||
@@ -438,14 +642,13 @@ class SyncEngine {
|
||||
eventId++;
|
||||
transport = Transport.held;
|
||||
// Ready flags stay; everyone here now waits for everyone else here now
|
||||
// (a stale member rejoins the set with its next frame if still ready).
|
||||
final t = now;
|
||||
// (who counts is up to presence: `away` and `gone` never do).
|
||||
for (final member in members.values) {
|
||||
member.error = '';
|
||||
member.inSet = !member.out && !isStale(member, t);
|
||||
member.inSet = true;
|
||||
member.buffered = 0;
|
||||
}
|
||||
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now));
|
||||
feed.add(_line('swap', actor: actor));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -460,59 +663,46 @@ class SyncEngine {
|
||||
// A report for an earlier media (sent before the swap reached the
|
||||
// client) says nothing about this one's buffer.
|
||||
final earlier = mediaId != 0 && mediaId != this.mediaId;
|
||||
member.buffered =
|
||||
member.out || earlier || !buffered.isFinite || buffered < 0 ? 0 : buffered;
|
||||
member.buffered = earlier || !buffered.isFinite || buffered < 0 ? 0 : buffered;
|
||||
final m = media;
|
||||
// First report of the length wins; only for the media the client has open.
|
||||
if (m != null && m.duration <= 0 && mediaId == this.mediaId &&
|
||||
duration.isFinite && duration > 0) {
|
||||
media = m.copyWith(duration: duration);
|
||||
}
|
||||
if (member.presence == Presence.joining) {
|
||||
// Joining ends when it cannot open the media (it stays out of the
|
||||
// gate by its error), or once it holds the media at the server's
|
||||
// position with the buffer the gate needs: it comes in in sync.
|
||||
final t = now;
|
||||
if (member.error.isNotEmpty) {
|
||||
_becomeIn(member);
|
||||
} else if (!earlier &&
|
||||
reportEventId >= eventId &&
|
||||
(reportPosition - positionAt(t)).abs() <= RelayConstants.driftSeekThresholdSecs &&
|
||||
member.buffered >= requiredBufferSecs(t)) {
|
||||
_becomeIn(member);
|
||||
}
|
||||
}
|
||||
if (reportEventId < eventId) return;
|
||||
evaluate();
|
||||
}
|
||||
|
||||
/// An error takes the member out of the set (the gate stops waiting for
|
||||
/// it); clearing it puts it back like a join: in the set unless playing
|
||||
/// (then from its next `ready`).
|
||||
/// it); clearing it puts it back like a join: in the set unless playing,
|
||||
/// then only if it is ready (the others hold for its buffer).
|
||||
void _setError(EngineMember member, String error) {
|
||||
if (member.error == error) return;
|
||||
member.error = error;
|
||||
if (error.isNotEmpty) {
|
||||
member.inSet = false;
|
||||
} else if (!member.out) {
|
||||
member.inSet = transport != Transport.playing;
|
||||
} else {
|
||||
member.inSet = member.ready || transport != Transport.playing;
|
||||
}
|
||||
// Leaving the set may let the others start.
|
||||
evaluate();
|
||||
}
|
||||
|
||||
void sweepStale() {
|
||||
var changed = false;
|
||||
for (final member in members.values) {
|
||||
if (isStale(member) && member.inSet) {
|
||||
member.inSet = false;
|
||||
changed = true;
|
||||
feed.add(FeedMessage(actor: member.name, action: 'stale', reason: 'no signal', at: now));
|
||||
}
|
||||
}
|
||||
if (changed) evaluate();
|
||||
}
|
||||
|
||||
List<String> sweepDropped() {
|
||||
final t = now;
|
||||
final dropped = <String>[];
|
||||
for (final member in members.values.toList()) {
|
||||
if (t.difference(member.lastSeen) > dropAfter) {
|
||||
members.remove(member.token);
|
||||
dropped.add(member.token);
|
||||
feed.add(FeedMessage(actor: member.name, action: 'leave', reason: 'silent too long', at: t));
|
||||
}
|
||||
}
|
||||
if (dropped.isNotEmpty) evaluate();
|
||||
return dropped;
|
||||
}
|
||||
|
||||
void evaluate() {
|
||||
final t = now;
|
||||
if (transport == Transport.finished) return;
|
||||
@@ -524,39 +714,65 @@ class SyncEngine {
|
||||
serverTime = t;
|
||||
eventId++;
|
||||
transport = Transport.finished;
|
||||
feed.add(FeedMessage(actor: 'server', action: 'finished', at: t));
|
||||
feed.add(_line('finished'));
|
||||
return;
|
||||
}
|
||||
final blocked = members.values
|
||||
.where((m) => m.inSet)
|
||||
.any((m) => !m.ready || m.buffered < need || isStale(m, t));
|
||||
final blocked =
|
||||
members.values.where((m) => countsForGate(m, t)).any((m) => !m.ready || m.buffered < need);
|
||||
if (blocked) {
|
||||
position = positionAt(t);
|
||||
serverTime = t;
|
||||
eventId++;
|
||||
transport = Transport.held;
|
||||
feed.add(FeedMessage(actor: 'server', action: 'pause', at: t));
|
||||
feed.add(_line('pause'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
final inSet = members.values.where((m) => m.inSet).toList();
|
||||
final inSet = members.values.where((m) => countsForGate(m, t)).toList();
|
||||
if (inSet.isEmpty) return;
|
||||
final allGood = inSet.every(
|
||||
(m) => m.ready && m.buffered >= need && !isStale(m, t));
|
||||
if (allGood) {
|
||||
if (inSet.every((m) => m.ready && m.buffered >= need)) {
|
||||
serverTime = t;
|
||||
eventId++;
|
||||
transport = Transport.playing;
|
||||
feed.add(FeedMessage(actor: 'server', action: 'resume', at: t));
|
||||
feed.add(_line('resume'));
|
||||
}
|
||||
}
|
||||
|
||||
/// `state.hold` while HELD with media up: who the gate waits for and why
|
||||
/// ([HoldReason]). [sourceGone]: the current media's relay source is
|
||||
/// `gone` (a short buffer then reads [HoldReason.source]).
|
||||
HoldInfo? holdInfo({bool sourceGone = false}) {
|
||||
if (transport != Transport.held || media == null) return null;
|
||||
final t = now;
|
||||
final need = requiredBufferSecs(t);
|
||||
final gate = members.values.where((m) => countsForGate(m, t)).toList();
|
||||
if (gate.isEmpty) return const HoldInfo(reason: HoldReason.start);
|
||||
final joining = gate.where((m) => m.presence == Presence.joining && (!m.ready || m.buffered < need)).toList();
|
||||
if (joining.isNotEmpty) {
|
||||
final until = joining.map((m) => m.presenceSince.add(liveness.joinerHold)).reduce((a, b) => a.isBefore(b) ? a : b);
|
||||
return HoldInfo(reason: HoldReason.joining, members: [for (final m in joining) m.id], until: until);
|
||||
}
|
||||
final unready = gate.where((m) => !m.ready).toList();
|
||||
if (unready.isNotEmpty) return HoldInfo(reason: HoldReason.ready, members: [for (final m in unready) m.id]);
|
||||
final short = gate.where((m) => m.buffered < need).toList();
|
||||
if (short.isNotEmpty) {
|
||||
return HoldInfo(
|
||||
reason: sourceGone ? HoldReason.source : HoldReason.buffer, members: [for (final m in short) m.id]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// [relay]: whether the server can relay the current Jellyfin media; the
|
||||
/// engine does not know the server's credential.
|
||||
/// [warm]: the server's warm relay for Up next's first item, shown as
|
||||
/// that item's `media.relay`.
|
||||
StateBroadcast broadcast({bool relay = false, bool relayNext = false, ({String id, MediaRelay relay})? warm}) {
|
||||
/// that item's `media.relay`. [sourceGone]: see [holdInfo].
|
||||
StateBroadcast broadcast(
|
||||
{bool relay = false,
|
||||
bool relayNext = false,
|
||||
({String id, MediaRelay relay})? warm,
|
||||
bool sourceGone = false}) {
|
||||
final bySeek = _seekEventId == eventId;
|
||||
final t = now;
|
||||
return StateBroadcast(
|
||||
eventId: eventId,
|
||||
transport: transport.name,
|
||||
@@ -570,7 +786,7 @@ class SyncEngine {
|
||||
if (members[swapBy]?.id case final id?) 'swapBy': id,
|
||||
if (artJson != null) 'art': artJson,
|
||||
},
|
||||
members: members.values.map((m) => m.view(stale: isStale(m))).toList(),
|
||||
members: [for (final m in members.values) m.view(inGate: countsForGate(m, t))],
|
||||
seekBy: bySeek ? _seekBy : null,
|
||||
seekSeq: bySeek ? _seekSeq : null,
|
||||
relay: relay,
|
||||
@@ -580,6 +796,8 @@ class SyncEngine {
|
||||
warm != null && q.id == warm.id ? q.copyWith(media: q.media.copyWith(relay: warm.relay)) : q,
|
||||
],
|
||||
queuePlay: queue.play,
|
||||
hold: holdInfo(sourceGone: sourceGone),
|
||||
advance: advance,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -60,12 +60,10 @@ void main() {
|
||||
const msg = HelloMessage(
|
||||
proto: 1,
|
||||
secret: 'shh',
|
||||
user: HelloUser(name: 'alice'),
|
||||
media: HelloMedia(name: 'Ep01.mkv', size: 1500000000, duration: 1400.0),
|
||||
);
|
||||
user: HelloUser(name: 'alice'));
|
||||
final back = HelloMessage.fromJson(Map<String, Object?>.from(msg.toJson()));
|
||||
expect(back.user.name, 'alice');
|
||||
expect(back.media.size, 1500000000);
|
||||
expect(msg.toJson()['media'], isEmpty, reason: 'a 15 server needs it to say "proto mismatch"');
|
||||
expect(back.hostKey, isEmpty);
|
||||
expect(msg.toJson().containsKey('hostKey'), isFalse);
|
||||
});
|
||||
@@ -75,9 +73,7 @@ void main() {
|
||||
proto: 1,
|
||||
secret: '',
|
||||
user: HelloUser(name: 'host'),
|
||||
media: HelloMedia(name: '', size: 0, duration: 0),
|
||||
hostKey: 'hk',
|
||||
);
|
||||
hostKey: 'hk');
|
||||
final back = HelloMessage.fromJson(Map<String, Object?>.from(msg.toJson()));
|
||||
expect(back.hostKey, 'hk');
|
||||
});
|
||||
@@ -168,11 +164,9 @@ void main() {
|
||||
final hello = HelloMessage.fromJson(rt(const HelloMessage(
|
||||
proto: 6,
|
||||
secret: '',
|
||||
user: HelloUser(name: 'a'),
|
||||
media: HelloMedia(name: '', size: 0, duration: 0),
|
||||
resume: 'tok')
|
||||
user: HelloUser(name: 'a'))
|
||||
.toJson()));
|
||||
expect(hello.resume, 'tok');
|
||||
expect((hello.protoMin, hello.proto), (6, 6));
|
||||
final seek = SeekIntent.fromJson(rt(const SeekIntent(position: 3, seq: 9).toJson()));
|
||||
expect(seek.seq, 9);
|
||||
final report = ReportMessage.fromJson(rt(const ReportMessage(
|
||||
@@ -210,4 +204,148 @@ void main() {
|
||||
expect(s.bufferNeedAt(t0.add(const Duration(seconds: 3))), closeTo(1.0, 1e-9));
|
||||
expect(s.bufferNeedAt(t0.add(const Duration(seconds: 5))), 0);
|
||||
});
|
||||
|
||||
group('proto 16', () {
|
||||
Map<String, Object?> rt(Map<String, Object?> j) => Map<String, Object?>.from(jsonDecode(jsonEncode(j)) as Map);
|
||||
|
||||
test('version ranges: the highest common one, or none', () {
|
||||
expect(pickProto(16, 16, serverMin: 16, serverMax: 16), 16);
|
||||
expect(pickProto(16, 18, serverMin: 16, serverMax: 17), 17);
|
||||
expect(pickProto(15, 15, serverMin: 16, serverMax: 16), isNull);
|
||||
expect(pickProto(17, 18, serverMin: 16, serverMax: 16), isNull);
|
||||
expect(HelloMessage.protoRangeOf({'proto': 15, 'media': {}}), (min: 15, max: 15));
|
||||
expect(HelloMessage.protoRangeOf({'proto': 17, 'protoMin': 16}), (min: 16, max: 17));
|
||||
expect(HelloMessage.protoRangeOf({'protoMin': 16}), isNull);
|
||||
});
|
||||
|
||||
test('the refusal stays readable by a proto 15 app', () {
|
||||
final err = ErrorFrame.version(got: 15, server: '1.3.0').toJson();
|
||||
expect(err, {
|
||||
't': 'err',
|
||||
'code': 'version',
|
||||
'message': 'proto mismatch: got 15, want ${RelayConstants.protoMin}..${RelayConstants.protoMax}',
|
||||
'for': 'hello',
|
||||
'min': RelayConstants.protoMin,
|
||||
'max': RelayConstants.protoMax,
|
||||
'server': '1.3.0',
|
||||
});
|
||||
final back = ErrorFrame.fromJson(rt(err));
|
||||
expect([back.code, back.min, back.server], [ErrCode.version, RelayConstants.protoMin, '1.3.0']);
|
||||
expect(ErrorFrame.fromJson({'t': 'err', 'message': 'wrong secret'}).code, isNull, reason: 'a 15 server');
|
||||
});
|
||||
|
||||
test('hello: identity, caps, feedAfter; a bad client id is refused', () {
|
||||
const hello = HelloMessage(
|
||||
secret: 's',
|
||||
user: HelloUser(name: 'a'),
|
||||
client: 'AbCdEfGhIjKlMnOpQrStUv',
|
||||
seq: 7,
|
||||
app: '1.3.0',
|
||||
platform: 'linux',
|
||||
caps: ClientCaps(source: true, resolve: true),
|
||||
feedAfter: 41);
|
||||
final json = hello.toJson();
|
||||
expect([json['protoMin'], json['proto'], json['media']], [RelayConstants.protoMin, RelayConstants.protoMax, {}]);
|
||||
expect(json.containsKey('resume'), isFalse);
|
||||
final back = HelloMessage.fromJson(rt(json));
|
||||
expect([back.client, back.seq, back.app, back.platform, back.feedAfter], ['AbCdEfGhIjKlMnOpQrStUv', 7, '1.3.0', 'linux', 41]);
|
||||
expect(back.caps, const ClientCaps(source: true, resolve: true));
|
||||
expect(ClientCaps.fromJson({'source': true, 'teleport': true}).toJson(), {'source': true});
|
||||
for (final bad in ['short', 'has space in it 12345', 'x' * 65, 7]) {
|
||||
expect(() => HelloMessage.fromJson({...json, 'client': bad}), throwsA(isA<ProtocolException>()));
|
||||
}
|
||||
expect(HelloMessage.fromJson({...json}..remove('client')).client, isEmpty);
|
||||
});
|
||||
|
||||
test('welcome: proto, resumed, instance, grace, replayed feed', () {
|
||||
final w = WelcomeMessage(
|
||||
token: 't',
|
||||
memberId: 'm1',
|
||||
proto: 16,
|
||||
resumed: true,
|
||||
instance: 'boot1',
|
||||
grace: 600,
|
||||
feed: [FeedMessage(actor: 'a', action: 'join', at: DateTime.utc(2026), seq: 3, replayed: true)]);
|
||||
final back = WelcomeMessage.fromJson(rt(w.toJson()));
|
||||
expect([back.proto, back.resumed, back.instance, back.grace], [16, true, 'boot1', 600]);
|
||||
expect([back.feed.single.seq, back.feed.single.replayed], [3, true]);
|
||||
});
|
||||
|
||||
test('presence intent, bye', () {
|
||||
const p = PresenceIntent(target: 'm2', state: Presence.away, reason: PresenceReason.idle);
|
||||
expect(p.toJson(), {'t': 'presence', 'target': 'm2', 'state': 'away', 'reason': 'idle'});
|
||||
final back = PresenceIntent.fromJson(rt(p.toJson()));
|
||||
expect([back.target, back.state, back.reason], ['m2', Presence.away, PresenceReason.idle]);
|
||||
expect(PresenceIntent.fromJson({'t': 'presence', 'state': 'in'}).target, isEmpty);
|
||||
expect(() => PresenceIntent.fromJson({'t': 'presence', 'state': 'gone'}), throwsA(isA<ProtocolException>()));
|
||||
expect(const ByeMessage().toJson(), {'t': 'bye'});
|
||||
});
|
||||
|
||||
test('members carry presence, goneSince, app and caps; no out, no stale', () {
|
||||
final since = DateTime.utc(2026, 1, 1, 12);
|
||||
final v = MemberView(
|
||||
id: 'm1',
|
||||
name: 'a',
|
||||
ready: true,
|
||||
inSet: false,
|
||||
buffered: 0,
|
||||
presence: Presence.gone,
|
||||
goneSince: since,
|
||||
app: '1.3.0',
|
||||
caps: const ClientCaps(jellyfin: true));
|
||||
final json = v.toJson();
|
||||
expect([json['presence'], json['goneSince'], json['app'], json['caps']],
|
||||
['gone', since.toIso8601String(), '1.3.0', {'jellyfin': true}]);
|
||||
expect(json.containsKey('out') || json.containsKey('stale'), isFalse);
|
||||
final back = MemberView.fromJson(rt(json));
|
||||
expect([back.presence, back.goneSince, back.caps.jellyfin], [Presence.gone, since, true]);
|
||||
});
|
||||
|
||||
test('media.ref rides along, at most 2 KiB; relay sourceGone', () {
|
||||
const ref = MediaRef(kind: 'episode', title: 'Show', anilistId: 1, anilistEp: 6, tvdbId: 2, ep: 6, absolute: 30);
|
||||
const media = HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y', ref: ref);
|
||||
final back = HelloMedia.fromJson(rt(media.toJson()));
|
||||
expect(back.ref, ref);
|
||||
expect(back.ref!.season, isNull);
|
||||
expect(MediaRef.fromJson({'kind': 'trailer'}), isNull);
|
||||
expect(() => MediaRef.fromJson({'kind': 'movie', 'title': 'x' * 3000}), throwsA(isA<ProtocolException>()));
|
||||
final since = DateTime.utc(2026, 1, 1);
|
||||
final relay = MediaRelay(source: 'm2', sourceGone: true, sourceGoneSince: since);
|
||||
final r = MediaRelay.fromJson(rt(relay.toJson()));
|
||||
expect([r.sourceGone, r.sourceGoneSince], [true, since]);
|
||||
expect(r.copyWith(sourceGone: false).toJson().containsKey('sourceGoneSince'), isFalse);
|
||||
});
|
||||
|
||||
test('state carries hold and the reserved advance', () {
|
||||
final at = DateTime.utc(2026);
|
||||
final s = StateBroadcast(
|
||||
eventId: 1,
|
||||
transport: 'held',
|
||||
position: 0,
|
||||
serverTime: at,
|
||||
mediaId: 1,
|
||||
members: const [],
|
||||
hold: HoldInfo(reason: HoldReason.joining, members: const ['m2'], until: at),
|
||||
advance: QueueAdvance(id: 'q3', at: at, secs: 10));
|
||||
final back = StateBroadcast.fromJson(rt(s.toJson()));
|
||||
expect([back.hold!.reason, back.hold!.members, back.hold!.until], [HoldReason.joining, ['m2'], at]);
|
||||
expect([back.advance!.id, back.advance!.secs], ['q3', 10]);
|
||||
});
|
||||
|
||||
test('close codes are distinct', () {
|
||||
const codes = [
|
||||
CloseCodes.relayNotSource,
|
||||
CloseCodes.relayOtherMedia,
|
||||
CloseCodes.relayReplaced,
|
||||
CloseCodes.version,
|
||||
CloseCodes.secret,
|
||||
CloseCodes.refused,
|
||||
CloseCodes.replaced,
|
||||
CloseCodes.stale,
|
||||
CloseCodes.silent,
|
||||
CloseCodes.left,
|
||||
];
|
||||
expect(codes.toSet(), hasLength(codes.length));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -62,8 +62,8 @@ void main() {
|
||||
mediaId: 2,
|
||||
media: const HelloMedia(name: 'Pilot', size: 0, duration: 0, jellyfin: ref).toJson(),
|
||||
members: const [
|
||||
MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false, jf: {'abc123': false}),
|
||||
MemberView(id: 'm2', name: 'b', ready: false, inSet: false, buffered: 0, stale: false),
|
||||
MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, jf: {'abc123': false}),
|
||||
MemberView(id: 'm2', name: 'b', ready: false, inSet: false, buffered: 0),
|
||||
],
|
||||
relay: true,
|
||||
relayNext: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
@@ -7,6 +8,14 @@ void main() {
|
||||
group('redactLog', () {
|
||||
const tok = '50989dda73c45a4710751f8aff4a1982';
|
||||
|
||||
test('the per-install client id never shows (hello.client)', () {
|
||||
const id = 'AbCdEfGhIjKlMnOpQrStUv';
|
||||
final hello = jsonEncode(const HelloMessage(secret: '', user: HelloUser(name: 'a'), client: id).toJson());
|
||||
expect(redactLog('bad hello: $hello'), isNot(contains(id)));
|
||||
expect(redactLog('client=$id seq=3'), 'client=*** seq=3');
|
||||
expect(redactLog('{"clientId":"$id"}'), isNot(contains(id)));
|
||||
});
|
||||
|
||||
test('signed URLs keep scheme, host, path and parameter names only', () {
|
||||
final out = redactLog('GET https://upos-hz-mirrorakam.akamaized.net/a/b-30080.m4s?deadline=1791826934&upsig=0f61b79b&hdnts=exp~acl -> 403');
|
||||
expect(out, 'GET https://upos-hz-mirrorakam.akamaized.net/a/b-30080.m4s?deadline&upsig&hdnts=… -> 403');
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
const policy = PresencePolicy();
|
||||
|
||||
test('the table: what each presence counts for', () {
|
||||
String row(Presence p) => [
|
||||
for (final purpose in Purpose.values) policy.countsFor(p, purpose) ? 'y' : '-',
|
||||
].join();
|
||||
// gate, readyCounter, tracking, preload, notifySocial (a joiner's gate
|
||||
// cell: held, just joined).
|
||||
expect(Purpose.values, [Purpose.gate, Purpose.readyCounter, Purpose.tracking, Purpose.preload, Purpose.notifySocial]);
|
||||
expect({for (final p in Presence.values) p.wire: row(p)}, {
|
||||
'joining': 'yy-yy',
|
||||
'in': 'yyyyy',
|
||||
'away': '-----',
|
||||
'gone': '-----',
|
||||
});
|
||||
});
|
||||
|
||||
test('D2: a joiner never holds a playing gate, a held one at most joinerHold', () {
|
||||
expect(policy.countsFor(Presence.joining, Purpose.gate, playing: true), isFalse);
|
||||
expect(policy.countsFor(Presence.joining, Purpose.gate, joiningFor: const Duration(seconds: 14)), isTrue);
|
||||
expect(policy.countsFor(Presence.joining, Purpose.gate, joiningFor: const Duration(seconds: 15)), isFalse);
|
||||
const short = PresencePolicy(joinerHold: Duration(seconds: 2));
|
||||
expect(short.countsFor(Presence.joining, Purpose.gate, joiningFor: const Duration(seconds: 2)), isFalse);
|
||||
expect(policy.countsFor(Presence.watching, Purpose.gate, playing: true), isTrue);
|
||||
});
|
||||
|
||||
test('wire names', () {
|
||||
expect([for (final p in Presence.values) p.wire], ['joining', 'in', 'away', 'gone']);
|
||||
for (final p in Presence.values) {
|
||||
expect(Presence.parse(p.wire), p);
|
||||
}
|
||||
expect(Presence.parse('watching'), isNull, reason: 'the wire says in');
|
||||
expect(PresenceReason.parse('minimised'), PresenceReason.minimised);
|
||||
expect(PresenceReason.parse('nonsense'), PresenceReason.manual);
|
||||
});
|
||||
|
||||
test('alone: nobody else connected (gone is not company)', () {
|
||||
expect(isAlone(const []), isTrue);
|
||||
expect(isAlone(const [Presence.gone, Presence.gone]), isTrue);
|
||||
expect(isAlone(const [Presence.gone, Presence.away]), isFalse);
|
||||
expect(isAlone(const [Presence.joining]), isFalse);
|
||||
expect(isAlone(const [Presence.watching]), isFalse);
|
||||
});
|
||||
}
|
||||
@@ -83,7 +83,8 @@ void main() {
|
||||
for (var i = 0; i < RelayConstants.maxQueueItems; i++) {
|
||||
expect(engine.applyQueueAdd('a', link('$i')), isNull);
|
||||
}
|
||||
expect(engine.applyQueueAdd('a', link('over')), contains('full'));
|
||||
final refused = engine.applyQueueAdd('a', link('over'))!;
|
||||
expect([refused.code, refused.message], [ErrCode.full, contains('full')]);
|
||||
expect(engine.queue.length, RelayConstants.maxQueueItems);
|
||||
});
|
||||
|
||||
@@ -118,4 +119,56 @@ void main() {
|
||||
expect((intent.op, intent.next, intent.item!.title), ('add', true, 'x'));
|
||||
expect(() => QueueIntent.fromJson(const {'t': 'queue', 'op': 'nope'}), throwsA(isA<ProtocolException>()));
|
||||
});
|
||||
|
||||
group('auto (the continuation)', () {
|
||||
QueueItem next(String title) => link(title).copyWith(auto: true, forMedia: engine.mediaId);
|
||||
|
||||
setUp(() {
|
||||
engine.applySwap('a', const HelloMedia(name: 'Ep01', size: 0, duration: 0, url: 'https://example.com/1'));
|
||||
});
|
||||
|
||||
test('only the swapper (or, without it, the first in) adds it, for the current media', () {
|
||||
expect(engine.applyQueueAdd('b', next('Ep02'))!.code, ErrCode.forbidden);
|
||||
expect(engine.applyQueueAdd('a', link('Ep02').copyWith(auto: true, forMedia: 99))!.code, ErrCode.bad);
|
||||
expect(engine.applyQueueAdd('a', next('Ep02')), isNull);
|
||||
engine.applyPresence('a', '', Presence.away);
|
||||
expect(engine.applyQueueAdd('b', next('Ep02b')), isNull, reason: 'the swapper is away: bob is the first in');
|
||||
expect(titles(), ['Ep02b'], reason: 'a newer one replaces it');
|
||||
});
|
||||
|
||||
test('it stays after the others; a swap of something else and cancel drop it', () {
|
||||
engine.applyQueueAdd('a', next('Ep02'));
|
||||
engine.applyQueueAdd('b', link('x'));
|
||||
engine.applyQueueAdd('b', link('y'), next: true);
|
||||
expect(titles(), ['y', 'x', 'Ep02']);
|
||||
expect(engine.queue.move(idOf('y'), 99), isTrue);
|
||||
expect(engine.queue.move(idOf('Ep02'), 0), isFalse);
|
||||
expect(titles(), ['x', 'y', 'Ep02'], reason: 'nothing moves past it');
|
||||
final json = engine.broadcast().toJson();
|
||||
final last = ((json['queue'] as List).last as Map);
|
||||
expect([last['auto'], last['for']], [true, engine.mediaId]);
|
||||
expect(QueueItem.fromJson(jsonDecode(jsonEncode(last)))!.forMedia, engine.mediaId);
|
||||
engine.applySwap('b', const HelloMedia(name: 'other', size: 0, duration: 0, url: 'https://example.com/o'),
|
||||
queueId: idOf('y'));
|
||||
expect(titles(), ['x'], reason: 'a swap to something else drops it');
|
||||
engine.applyQueueAdd('b', link('z'));
|
||||
expect(engine.applyQueueAdd('b', next('Ep03')), isNull);
|
||||
engine.feed.drain();
|
||||
expect(engine.applyQueueCancel('a'), isTrue);
|
||||
expect(titles(), ['x', 'z']);
|
||||
final line = engine.feed.drain().single;
|
||||
expect([line.action, line.actorId, line.target], ['advance-cancel', 'm1', 'Ep03']);
|
||||
expect(engine.applyQueueCancel('a'), isFalse);
|
||||
expect(QueueIntent.fromJson(const QueueIntent.cancel().toJson()).op, 'cancel');
|
||||
});
|
||||
|
||||
test('a swap that puts it up keeps the queue going', () {
|
||||
engine.applyQueueAdd('a', next('Ep02'));
|
||||
final id = idOf('Ep02');
|
||||
engine.applySwap('a', const HelloMedia(name: 'Ep02', size: 0, duration: 0, url: 'https://example.com/2'),
|
||||
queueId: id);
|
||||
expect(engine.queue.isEmpty, isTrue);
|
||||
expect(engine.media!.name, 'Ep02');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): the
|
||||
// groups "sitting out", "membership + gate", "presence gate", "stale ready
|
||||
// rejoins" and the member rules of "swap" pin today's presence (out flag,
|
||||
// stale sweep, leave on drop); presence In/Away/Gone with grace replaces
|
||||
// them ("liveness on the engine clock" then steps through the grace). The clock, correction, end-of-file and wire groups stay.
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
@@ -41,52 +36,59 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('sitting out', () {
|
||||
test('an out member never blocks, is never ready, its buffer is ignored', () {
|
||||
group('away', () {
|
||||
test('an away member never blocks; ready, force, swap and reports leave it away', () {
|
||||
join('a', 'alice');
|
||||
final bob = join('b', 'bob');
|
||||
engine.applyReady('a', true);
|
||||
buffer('a', 4.0);
|
||||
expect(engine.transport, Transport.held, reason: 'bob is in the set');
|
||||
engine.applyOut('a', bob, true);
|
||||
expect(engine.applyPresence('a', bob, Presence.away), isNull);
|
||||
final b = engine.members['b']!;
|
||||
expect([b.out, b.inSet, b.ready], [true, false, false]);
|
||||
expect(b.presence, Presence.away);
|
||||
expect(engine.countsForGate(b), isFalse);
|
||||
expect(engine.transport, Transport.playing);
|
||||
final feed = engine.feed.drain().lastWhere((f) => f.action == 'out');
|
||||
expect([feed.actor, feed.target], ['alice', 'bob']);
|
||||
expect(engine.broadcast().members.last.toJson()['out'], isTrue);
|
||||
expect(engine.broadcast().members.first.toJson().containsKey('out'), isFalse);
|
||||
// Ready, force, swap and reports leave it out.
|
||||
final feed = engine.feed.drain().lastWhere((f) => f.action == 'away');
|
||||
expect([feed.actor, feed.actorId, feed.target, feed.targetId], ['alice', 'm1', 'bob', 'm2']);
|
||||
final view = engine.broadcast().members.last.toJson();
|
||||
expect([view['presence'], view['inSet']], ['away', false]);
|
||||
expect(engine.broadcast().members.first.toJson()['presence'], 'in');
|
||||
engine.applyReady('b', true);
|
||||
engine.applyForce('a', 'all', true);
|
||||
buffer('b', 9);
|
||||
engine.applySwap('a', const HelloMedia(name: 'n', size: 1, duration: 0, url: 'u'));
|
||||
expect([b.ready, b.inSet, b.buffered], [false, false, 0]);
|
||||
expect(b.presence, Presence.away);
|
||||
expect(engine.countsForGate(b), isFalse);
|
||||
expect(engine.members['a']!.inSet, isTrue);
|
||||
});
|
||||
|
||||
test('only oneself comes back: in the set unready, ready follows', () {
|
||||
test('only oneself comes back in, through joining: no pause while playing', () {
|
||||
engine.media = const HelloMedia(name: 'f', size: 1, duration: 100);
|
||||
final alice = join('a', 'alice');
|
||||
final bob = join('b', 'bob');
|
||||
engine.applyOut('b', bob, true);
|
||||
expect(engine.feed.drain().last.target, isNull, reason: 'sat out by itself');
|
||||
engine.applyReady('a', true);
|
||||
buffer('a', 4.0);
|
||||
expect(engine.transport, Transport.playing);
|
||||
engine.applyOut('a', bob, false);
|
||||
expect(engine.members['b']!.out, isTrue, reason: 'others cannot bring bob back');
|
||||
engine.applyOut('b', bob, false);
|
||||
final b = engine.members['b']!;
|
||||
expect([b.out, b.inSet, b.ready], [false, false, false], reason: 'playing: in from its ready');
|
||||
expect(engine.transport, Transport.playing);
|
||||
engine.applyReady('b', true);
|
||||
expect(engine.transport, Transport.held, reason: 'now the gate waits for bob');
|
||||
buffer('b', 4.0);
|
||||
engine.applyPresence('b', bob, Presence.away, reason: PresenceReason.idle);
|
||||
final line = engine.feed.drain().last;
|
||||
expect([line.action, line.target, line.reason], ['away', null, 'idle'], reason: 'sat out by itself');
|
||||
engine.applyReady('a', true);
|
||||
expect(engine.transport, Transport.playing);
|
||||
// The host can be sat out like anyone.
|
||||
expect(engine.applyPresence('a', bob, Presence.watching), ErrCode.forbidden);
|
||||
expect(engine.members['b']!.presence, Presence.away, reason: 'others cannot bring bob back');
|
||||
expect(engine.applyPresence('b', '', Presence.watching), isNull);
|
||||
final b = engine.members['b']!;
|
||||
expect(b.presence, Presence.joining);
|
||||
expect(engine.feed.drain().last.action, 'in');
|
||||
engine.applyReady('b', true);
|
||||
t = t.add(const Duration(seconds: 1));
|
||||
expect(engine.transport, Transport.playing, reason: 'a joiner never holds a playing gate (D2)');
|
||||
buffer('b', 4.0);
|
||||
expect(b.presence, Presence.watching);
|
||||
expect(engine.transport, Transport.playing, reason: 'it came in already in sync');
|
||||
// The host can be set away like anyone.
|
||||
engine.members['a']!.host = true;
|
||||
engine.applyOut('b', alice, true);
|
||||
expect(engine.members['a']!.out, isTrue);
|
||||
engine.applyPresence('b', alice, Presence.away);
|
||||
expect(engine.members['a']!.presence, Presence.away);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,7 +179,7 @@ void main() {
|
||||
expect(engine.members['b']!.inSet, isTrue);
|
||||
});
|
||||
|
||||
test('disconnect removes from quorum, playback resumes', () {
|
||||
test('a drop is not a leave: gone, out of the gate, kept with its ready flag', () {
|
||||
join('a', 'alice');
|
||||
join('b', 'bob');
|
||||
engine.applyReady('a', true);
|
||||
@@ -185,25 +187,18 @@ void main() {
|
||||
buffer('a', 4.0);
|
||||
buffer('b', 4.0);
|
||||
expect(engine.transport, Transport.playing);
|
||||
engine.applyLeave('b');
|
||||
engine.applyGone('b');
|
||||
expect(engine.transport, Transport.playing);
|
||||
final b = engine.members['b']!;
|
||||
expect([b.presence, b.ready, b.goneSince], [Presence.gone, true, t]);
|
||||
final view = engine.broadcast().members.last.toJson();
|
||||
expect([view['presence'], view['goneSince'], view['inSet']], ['gone', t.toIso8601String(), false]);
|
||||
engine.applyLeave('b', reason: 'bye');
|
||||
expect(engine.members.containsKey('b'), isFalse);
|
||||
expect(engine.feed.drain().last.reason, 'bye');
|
||||
});
|
||||
|
||||
test('stale ghosts excluded from gate', () { join('a', 'alice');
|
||||
join('b', 'bob');
|
||||
engine.applyReady('a', true);
|
||||
engine.applyReady('b', true);
|
||||
buffer('a', 4.0);
|
||||
buffer('b', 4.0);
|
||||
expect(engine.transport, Transport.playing);
|
||||
t = t.add(const Duration(seconds: 60));
|
||||
engine.sweepStale();
|
||||
expect(engine.transport, Transport.playing);
|
||||
expect(engine.broadcast().members.firstWhere((m) => m.id == 'm2').stale, isTrue);
|
||||
});
|
||||
|
||||
test('silent past drop window removed with leave feed', () {
|
||||
test('silent past the limit goes gone (not a leave)', () {
|
||||
join('a', 'alice');
|
||||
join('b', 'bob');
|
||||
engine.applyReady('a', true);
|
||||
@@ -211,18 +206,34 @@ void main() {
|
||||
buffer('a', 4.0);
|
||||
buffer('b', 4.0);
|
||||
expect(engine.transport, Transport.playing);
|
||||
t = t.add(const Duration(seconds: 30));
|
||||
expect(engine.sweepDropped(), isEmpty);
|
||||
t = t.add(const Duration(seconds: 15));
|
||||
buffer('a', 4.0);
|
||||
t = t.add(const Duration(seconds: 61));
|
||||
expect(engine.sweepSilent(), isEmpty, reason: 'exactly the limit is not past it');
|
||||
t = t.add(const Duration(seconds: 1));
|
||||
buffer('a', 4.0);
|
||||
final dropped = engine.sweepDropped();
|
||||
expect(dropped, ['b']);
|
||||
expect(engine.members.containsKey('b'), isFalse);
|
||||
expect(engine.sweepSilent(), ['b']);
|
||||
expect(engine.members['b']!.presence, Presence.gone);
|
||||
expect(engine.transport, Transport.playing);
|
||||
expect(engine.feed.drain().where((f) => f.action == 'leave'), isEmpty);
|
||||
});
|
||||
|
||||
test('a gone member leaves after the grace (feed leave, reason grace)', () {
|
||||
final left = <String>[];
|
||||
engine.onLeft = (m) => left.add(m.id);
|
||||
join('a', 'alice');
|
||||
join('b', 'bob');
|
||||
engine.applyGone('b');
|
||||
t = t.add(const Duration(minutes: 9, seconds: 59));
|
||||
buffer('a', 0);
|
||||
engine.tick();
|
||||
expect(engine.members.containsKey('b'), isTrue);
|
||||
t = t.add(const Duration(seconds: 1));
|
||||
buffer('a', 0);
|
||||
engine.tick();
|
||||
expect(engine.members.containsKey('b'), isFalse);
|
||||
expect(left, ['m2']);
|
||||
final leaves = engine.feed.drain().where((f) => f.action == 'leave').toList();
|
||||
expect(leaves.single.actor, 'bob');
|
||||
expect(leaves.single.reason, 'silent too long');
|
||||
expect([leaves.single.actor, leaves.single.actorId, leaves.single.reason], ['bob', 'm2', 'grace']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -262,13 +273,14 @@ void main() {
|
||||
expect(engine.transport, Transport.playing);
|
||||
});
|
||||
|
||||
test('stale other exempts the lone watcher', () {
|
||||
test('a silent other (gone) exempts the lone watcher', () {
|
||||
join('a', 'alice');
|
||||
join('b', 'bob');
|
||||
t = t.add(const Duration(seconds: 16));
|
||||
engine.applyReady('a', true);
|
||||
buffer('a', 6.0);
|
||||
engine.tick();
|
||||
expect(engine.members['b']!.presence, Presence.gone);
|
||||
expect(engine.transport, Transport.playing);
|
||||
});
|
||||
|
||||
@@ -420,19 +432,25 @@ void main() {
|
||||
expect(engine.transport, Transport.playing);
|
||||
});
|
||||
|
||||
test('a stale member stays out, rejoins with its next frame if ready', () {
|
||||
test('a gone member stays out of the gate; back, it comes in through joining', () {
|
||||
join('a', 'alice');
|
||||
join('b', 'bob');
|
||||
engine.applyReady('b', true);
|
||||
t = t.add(const Duration(seconds: 16));
|
||||
engine.applyGone('b');
|
||||
engine.applyReady('a', true);
|
||||
engine.applySwap('a', ep2);
|
||||
expect(engine.members['b']!.inSet, isFalse);
|
||||
expect(engine.countsForGate(engine.members['b']!), isFalse);
|
||||
buffer('a', 6.0);
|
||||
expect(engine.transport, Transport.playing);
|
||||
expect(engine.applyTakeover('b', seq: 1), isTrue);
|
||||
final b = engine.members['b']!;
|
||||
expect([b.presence, b.ready], [Presence.joining, true]);
|
||||
buffer('b', 0);
|
||||
expect(engine.members['b']!.inSet, isTrue);
|
||||
expect(engine.transport, Transport.held);
|
||||
expect(engine.transport, Transport.playing, reason: 'returning never pauses a playing gate (D2)');
|
||||
buffer('b', 6.0);
|
||||
expect(b.presence, Presence.watching);
|
||||
expect(engine.countsForGate(b), isTrue);
|
||||
expect(engine.transport, Transport.playing);
|
||||
});
|
||||
|
||||
test("the previous media's buffer never starts the new one", () {
|
||||
@@ -584,8 +602,8 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('stale ready rejoins', () {
|
||||
test('ready member swept while stale is back in the set when reports resume', () {
|
||||
group('return', () {
|
||||
test('a ready member back from gone is in the set again once in sync', () {
|
||||
join('a', 'alice');
|
||||
join('b', 'bob');
|
||||
engine.applyReady('a', true);
|
||||
@@ -593,19 +611,19 @@ void main() {
|
||||
buffer('a', 6.0);
|
||||
buffer('b', 6.0);
|
||||
expect(engine.transport, Transport.playing);
|
||||
// bob goes silent past staleAfter; alice keeps reporting.
|
||||
// bob goes silent past the limit; alice keeps reporting.
|
||||
for (var i = 0; i < 16; i++) {
|
||||
t = t.add(const Duration(seconds: 1));
|
||||
buffer('a', 6.0);
|
||||
engine.sweepStale();
|
||||
engine.tick();
|
||||
}
|
||||
expect(engine.members['b']!.inSet, isFalse);
|
||||
expect(engine.members['b']!.presence, Presence.gone);
|
||||
expect(engine.members['b']!.ready, isTrue);
|
||||
// bob's reports resume: he must be gated again without pressing Space.
|
||||
buffer('b', 6.0);
|
||||
expect(engine.members['b']!.inSet, isTrue);
|
||||
expect(engine.broadcast().members.firstWhere((m) => m.id == 'm2').inSet,
|
||||
isTrue);
|
||||
// No media up: nothing to load, bob is `in` at once and gated again
|
||||
// without pressing Space.
|
||||
engine.applyTakeover('b', seq: 1);
|
||||
expect(engine.members['b']!.presence, Presence.watching);
|
||||
expect(engine.broadcast().members.firstWhere((m) => m.id == 'm2').inSet, isTrue);
|
||||
});
|
||||
|
||||
test('unready late joiner does not join on reports', () {
|
||||
@@ -616,34 +634,184 @@ void main() {
|
||||
buffer('b', 6.0);
|
||||
expect(engine.members['b']!.inSet, isFalse);
|
||||
});
|
||||
|
||||
test('away survives a drop: back away, not joining', () {
|
||||
join('a', 'alice');
|
||||
final bob = join('b', 'bob');
|
||||
engine.applyPresence('b', bob, Presence.away);
|
||||
engine.applyGone('b');
|
||||
expect(engine.members['b']!.presence, Presence.gone);
|
||||
engine.applyTakeover('b', seq: 2);
|
||||
expect(engine.members['b']!.presence, Presence.away);
|
||||
});
|
||||
|
||||
test('the gone line waits; a quick return says nothing, a late one says return', () {
|
||||
join('a', 'alice');
|
||||
join('b', 'bob');
|
||||
engine.feed.drain();
|
||||
engine.applyGone('b');
|
||||
t = t.add(const Duration(seconds: 9));
|
||||
buffer('a', 0);
|
||||
engine.tick();
|
||||
expect(engine.feed.drain().where((f) => f.action == 'gone'), isEmpty);
|
||||
engine.applyTakeover('b', seq: 1);
|
||||
expect(engine.feed.drain().map((f) => f.action).where((a) => a != 'pause' && a != 'resume'), isEmpty,
|
||||
reason: 'a quick return is silent');
|
||||
engine.applyGone('b');
|
||||
t = t.add(const Duration(seconds: 10));
|
||||
buffer('a', 0);
|
||||
engine.tick();
|
||||
final gone = engine.feed.drain().where((f) => f.action == 'gone').toList();
|
||||
expect([gone.single.actor, gone.single.actorId], ['bob', 'm2']);
|
||||
engine.applyTakeover('b', seq: 2);
|
||||
expect(engine.feed.drain().map((f) => f.action), contains('return'));
|
||||
});
|
||||
});
|
||||
|
||||
group('D2: joiners', () {
|
||||
const ep = HelloMedia(name: 'Ep01.mkv', size: 9, duration: 600);
|
||||
|
||||
test('a joiner while playing never changes the transport, even ready', () {
|
||||
engine.media = ep;
|
||||
join('a', 'alice');
|
||||
engine.applyReady('a', true);
|
||||
buffer('a', 6.0);
|
||||
expect(engine.transport, Transport.playing);
|
||||
final events = engine.eventId;
|
||||
join('b', 'bob');
|
||||
final b = engine.members['b']!;
|
||||
expect(b.presence, Presence.joining);
|
||||
engine.applyReady('b', true);
|
||||
buffer('b', 0);
|
||||
t = t.add(const Duration(seconds: 10));
|
||||
engine.applyReport('b', 0, 30, engine.eventId);
|
||||
expect(b.presence, Presence.joining, reason: 'its buffer is at 0, not at the server position');
|
||||
buffer('a', 6.0);
|
||||
expect([engine.transport, engine.eventId], [Transport.playing, events]);
|
||||
buffer('b', 6.0);
|
||||
expect(b.presence, Presence.watching);
|
||||
expect([engine.transport, engine.eventId], [Transport.playing, events], reason: 'in, already in sync');
|
||||
expect(engine.feed.drain().map((f) => f.action), isNot(contains('pause')));
|
||||
});
|
||||
|
||||
test('while held a joiner holds at most joinerHold, then the others start', () {
|
||||
engine.media = ep;
|
||||
join('a', 'alice');
|
||||
buffer('a', 6.0);
|
||||
engine.applyReady('a', true);
|
||||
expect(engine.transport, Transport.playing);
|
||||
engine.applyReady('a', false);
|
||||
expect(engine.transport, Transport.held);
|
||||
join('b', 'bob');
|
||||
engine.applyReady('a', true);
|
||||
expect(engine.transport, Transport.held, reason: 'bob joined while held: he is waited for');
|
||||
final hold = engine.holdInfo()!;
|
||||
expect([hold.reason, hold.members, hold.until], [HoldReason.joining, ['m2'], t.add(const Duration(seconds: 15))]);
|
||||
t = t.add(const Duration(seconds: 14));
|
||||
buffer('a', 6.0);
|
||||
engine.tick();
|
||||
expect(engine.transport, Transport.held);
|
||||
t = t.add(const Duration(seconds: 1));
|
||||
buffer('a', 6.0);
|
||||
engine.tick();
|
||||
expect(engine.transport, Transport.playing, reason: 'bounded hold (D2)');
|
||||
expect(engine.members['b']!.presence, Presence.joining);
|
||||
});
|
||||
|
||||
test('joining ends on a report error or the timeout', () {
|
||||
engine.media = ep;
|
||||
join('a', 'alice');
|
||||
join('b', 'bob');
|
||||
engine.applyReport('a', 0, 0, engine.eventId, mediaId: engine.mediaId, error: 'Not found (404)');
|
||||
expect(engine.members['a']!.presence, Presence.watching);
|
||||
expect(engine.countsForGate(engine.members['a']!), isFalse, reason: 'the error keeps it out');
|
||||
for (var i = 0; i < 59; i++) {
|
||||
t = t.add(const Duration(seconds: 1));
|
||||
buffer('b', 0); // still loading
|
||||
engine.tick();
|
||||
}
|
||||
expect(engine.members['b']!.presence, Presence.joining);
|
||||
t = t.add(const Duration(seconds: 1));
|
||||
buffer('b', 0);
|
||||
engine.tick();
|
||||
expect(engine.members['b']!.presence, Presence.watching);
|
||||
});
|
||||
|
||||
test('hold reasons: ready, buffer, source, start', () {
|
||||
engine.media = ep;
|
||||
join('a', 'alice');
|
||||
buffer('a', 6.0);
|
||||
expect(engine.holdInfo()!.reason, HoldReason.ready);
|
||||
engine.applyReady('a', true);
|
||||
engine.applySwap('a', ep);
|
||||
expect([engine.holdInfo()!.reason, engine.holdInfo()!.members], [HoldReason.buffer, ['m1']]);
|
||||
expect(engine.holdInfo(sourceGone: true)!.reason, HoldReason.source);
|
||||
engine.applyPresence('a', '', Presence.away);
|
||||
expect(engine.holdInfo()!.reason, HoldReason.start);
|
||||
expect(engine.broadcast().toJson()['hold'], {'reason': 'start', 'members': <String>[]});
|
||||
});
|
||||
});
|
||||
|
||||
group('feed', () {
|
||||
test('lines carry ids and a rising seq; a replay keeps the last lines after a seq', () {
|
||||
join('a', 'alice');
|
||||
final bob = join('b', 'bob');
|
||||
engine.applyForce('a', bob, true);
|
||||
final lines = engine.feed.drain();
|
||||
expect([for (final f in lines) f.seq], [1, 2, 3]);
|
||||
expect([lines.last.actorId, lines.last.targetId, lines.last.target], ['m1', 'm2', 'bob']);
|
||||
for (var i = 0; i < 60; i++) {
|
||||
engine.applySeek('a', i.toDouble());
|
||||
}
|
||||
engine.feed.drain();
|
||||
expect(engine.feed.after(2), hasLength(RelayConstants.feedReplayLines), reason: 'bounded');
|
||||
final replay = engine.feed.after(60);
|
||||
expect([for (final f in replay) f.seq], [61, 62, 63]);
|
||||
expect(replay.every((f) => f.replayed), isTrue);
|
||||
final json = replay.first.toJson();
|
||||
expect([json['seq'], json['actorId'], json['replayed']], [61, 'm1', true]);
|
||||
expect(FeedMessage.fromJson(json).replayed, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('liveness on the engine clock', () {
|
||||
test('stale and drop follow Liveness and the fake clock, not wall time', () {
|
||||
test('silence, gone notice, grace follow Liveness and the fake clock, not wall time', () {
|
||||
engine = SyncEngine(
|
||||
clock: clock,
|
||||
liveness: const Liveness(staleAfter: Duration(minutes: 5), dropAfter: Duration(minutes: 10)));
|
||||
liveness: const Liveness(
|
||||
silence: Duration(minutes: 5), goneNotice: Duration(minutes: 1), grace: Duration(minutes: 10)));
|
||||
join('a', 'alice');
|
||||
engine.applyReady('a', true);
|
||||
engine.feed.drain();
|
||||
t = t.add(const Duration(minutes: 5));
|
||||
engine.tick();
|
||||
expect(engine.members['a']!.inSet, isTrue, reason: 'not past staleAfter yet');
|
||||
expect(engine.members['a']!.presence, Presence.watching, reason: 'not past the silence yet');
|
||||
t = t.add(const Duration(seconds: 1));
|
||||
engine.tick();
|
||||
expect(engine.members['a']!.inSet, isFalse);
|
||||
expect(engine.feed.drain().map((f) => f.action), ['stale']);
|
||||
t = t.add(const Duration(minutes: 4, seconds: 59));
|
||||
expect(engine.sweepDropped(), isEmpty, reason: 'exactly dropAfter is not past it');
|
||||
expect(engine.members['a']!.presence, Presence.gone);
|
||||
expect(engine.feed.drain(), isEmpty);
|
||||
t = t.add(const Duration(minutes: 1));
|
||||
engine.tick();
|
||||
expect(engine.feed.drain().map((f) => f.action), ['gone']);
|
||||
t = t.add(const Duration(minutes: 8, seconds: 59));
|
||||
engine.tick();
|
||||
expect(engine.members, isNotEmpty);
|
||||
t = t.add(const Duration(seconds: 1));
|
||||
expect(engine.sweepDropped(), ['a']);
|
||||
engine.tick();
|
||||
expect(engine.members, isEmpty);
|
||||
expect(engine.feed.drain().map((f) => f.action), ['leave']);
|
||||
});
|
||||
|
||||
test('defaults are today\'s 15 s / 30 s', () {
|
||||
expect(engine.staleAfter, const Duration(seconds: 15));
|
||||
expect(engine.dropAfter, const Duration(seconds: 30));
|
||||
expect(engine.liveness.pingInterval, const Duration(seconds: 5));
|
||||
test('defaults: 15 s silence, 10 min grace, 10 s gone notice, 15 s joiner hold', () {
|
||||
final l = engine.liveness;
|
||||
expect([l.silence, l.grace, l.goneNotice, l.joinerHold, l.joiningTimeout, l.pingInterval], const [
|
||||
Duration(seconds: 15),
|
||||
Duration(minutes: 10),
|
||||
Duration(seconds: 10),
|
||||
Duration(seconds: 15),
|
||||
Duration(seconds: 60),
|
||||
Duration(seconds: 5),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -687,6 +855,7 @@ void main() {
|
||||
engine.applyReady('a', true);
|
||||
buffer('a', 6.0);
|
||||
t = t.add(const Duration(seconds: 21));
|
||||
buffer('a', 6.0); // a frame a second keeps it present
|
||||
engine.tick();
|
||||
expect(engine.transport, Transport.finished);
|
||||
engine.applySeek('a', 5);
|
||||
@@ -700,6 +869,7 @@ void main() {
|
||||
engine.applyReady('a', true);
|
||||
buffer('a', 6.0);
|
||||
t = t.add(const Duration(seconds: 21));
|
||||
buffer('a', 6.0); // a frame a second keeps it present
|
||||
engine.tick();
|
||||
expect(engine.transport, Transport.finished);
|
||||
engine.applySwap(
|
||||
|
||||
@@ -30,25 +30,28 @@ bool _httpUrl(String? url) {
|
||||
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: '');
|
||||
media: HelloMedia(name: name, size: 0, duration: 0, jellyfin: clean, ref: media.ref),
|
||||
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: '');
|
||||
item = QueueItem(
|
||||
media: HelloMedia(name: name, size: 0, duration: 0, url: media.url, ref: media.ref), 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)),
|
||||
media: HelloMedia(name: name, size: 0, duration: 0, relay: const MediaRelay(fetch: true), ref: media.ref),
|
||||
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),
|
||||
media: HelloMedia(name: name, size: media.size < 0 ? 0 : media.size, duration: 0, ref: media.ref),
|
||||
how: raw.how,
|
||||
title: '',
|
||||
key: raw.key);
|
||||
@@ -60,7 +63,11 @@ bool _httpUrl(String? url) {
|
||||
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),
|
||||
title: title,
|
||||
sub: cleanQueueText(sanitizeMediaName(raw.sub)),
|
||||
duration: raw.duration,
|
||||
auto: raw.auto,
|
||||
forMedia: raw.auto ? raw.forMedia : 0),
|
||||
privateUrl: privateUrl,
|
||||
);
|
||||
}
|
||||
@@ -75,14 +82,15 @@ void handleQueueIntent(ServerHub hub, String token, RelicWebSocket ws, Map<Strin
|
||||
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,
|
||||
final refused = engine.applyQueueAdd(token, cleaned.item,
|
||||
next: intent.next, privateUrl: cleaned.privateUrl, thumb: thumb);
|
||||
if (error != null) {
|
||||
_log.info('${hub.who(token)}: add "${cleaned.item.title}" (${cleaned.item.how.name}) refused: $error');
|
||||
wsErr(ws, error);
|
||||
if (refused != null) {
|
||||
_log.info('${hub.who(token)}: add "${cleaned.item.title}" (${cleaned.item.how.name}) refused: ${refused.message}');
|
||||
wsErr(ws, refused.code, refused.message, forType: 'queue');
|
||||
return;
|
||||
}
|
||||
_log.info('${hub.who(token)}: add "${cleaned.item.title}" (${cleaned.item.how.name}${intent.next ? ', next' : ''})');
|
||||
_log.info('${hub.who(token)}: add "${cleaned.item.title}" (${cleaned.item.how.name}'
|
||||
'${cleaned.item.auto ? ', continues media ${cleaned.item.forMedia}' : intent.next ? ', next' : ''})');
|
||||
changed = true;
|
||||
case 'move':
|
||||
changed = engine.applyQueueMove(token, intent.id, intent.to);
|
||||
@@ -92,6 +100,8 @@ void handleQueueIntent(ServerHub hub, String token, RelicWebSocket ws, Map<Strin
|
||||
changed = engine.applyQueuePlay(token, intent.id);
|
||||
case 'skip':
|
||||
changed = engine.applyQueuePlay(token, '');
|
||||
case 'cancel':
|
||||
changed = engine.applyQueueCancel(token);
|
||||
}
|
||||
if (intent.op != 'add') {
|
||||
_log.info('${hub.who(token)}: ${intent.op}${intent.id.isEmpty ? '' : ' ${intent.id}'}'
|
||||
|
||||
@@ -75,6 +75,10 @@ class ActiveRelay {
|
||||
RelicWebSocket? _channel;
|
||||
var _channelFeeds = const <ChannelRelayFeed>[];
|
||||
List<_TrackInfo>? _infos;
|
||||
/// The source's first `info` on this relay: a later channel (the source
|
||||
/// back within its grace) must send the same one, else the relay ends
|
||||
/// (4002).
|
||||
_SourceInfo? _firstInfo;
|
||||
/// Shared by the tracks: time any of them waits on the source.
|
||||
final clock = FeedClock();
|
||||
final _rate = SourceRate();
|
||||
@@ -295,7 +299,9 @@ class ActiveRelay {
|
||||
|
||||
void _attachChannel(RelicWebSocket ws, List<_TrackInfo> infos) {
|
||||
final old = _channel;
|
||||
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
|
||||
// A second channel for the same relay replaces the first (the
|
||||
// source's app reconnected before the old link died).
|
||||
if (old != null && !identical(old, ws)) unawaited(_closeWith(old, CloseCodes.relayReplaced, 'replaced'));
|
||||
_channel = ws;
|
||||
final named = tracks.isNotEmpty;
|
||||
final feeds = _channelFeeds = [
|
||||
@@ -317,6 +323,33 @@ class ActiveRelay {
|
||||
}
|
||||
}
|
||||
|
||||
/// A source's `info` as a whole: its tracks, and the optional `name` and
|
||||
/// `duration` it gives. Two are the same media when the tracks agree
|
||||
/// (kinds, byte sizes, piece shapes) and the name and duration agree
|
||||
/// where both give one.
|
||||
typedef _SourceInfo = ({List<_TrackInfo> tracks, String name, double duration});
|
||||
|
||||
bool _sameInfo(_SourceInfo a, _SourceInfo b) {
|
||||
if (a.tracks.length != b.tracks.length) return false;
|
||||
for (var i = 0; i < a.tracks.length; i++) {
|
||||
final x = a.tracks[i], y = b.tracks[i];
|
||||
if (x.kind != y.kind || x.size != y.size || (x.pieces == null) != (y.pieces == null)) return false;
|
||||
if (x.pieces != null && !x.pieces!.sameShape(y.pieces!)) return false;
|
||||
}
|
||||
if (a.name.isNotEmpty && b.name.isNotEmpty && a.name != b.name) return false;
|
||||
if (a.duration > 0 && b.duration > 0 && (a.duration - b.duration).abs() > 0.5) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
_SourceInfo _sourceInfo(Map j, List<_TrackInfo> tracks) {
|
||||
final d = j['duration'];
|
||||
return (
|
||||
tracks: tracks,
|
||||
name: j['name'] is String ? j['name'] as String : '',
|
||||
duration: d is num && d.isFinite && d > 0 ? d.toDouble() : 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// The tracks of a channel's `info` frame (`size`, or `tracks[]` each
|
||||
/// with a `size` or, `"as":"pieces"`, a piece list), or null when
|
||||
/// malformed.
|
||||
@@ -342,9 +375,11 @@ List<_TrackInfo>? _infoTracks(Map j) {
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<void> _closeNotSource(RelicWebSocket ws) async {
|
||||
Future<void> _closeNotSource(RelicWebSocket ws) => _closeWith(ws, CloseCodes.relayNotSource, 'not the source');
|
||||
|
||||
Future<void> _closeWith(RelicWebSocket ws, int code, String reason) async {
|
||||
try {
|
||||
await ws.close(relayCloseNotSource, 'not the source');
|
||||
await ws.close(code, reason);
|
||||
} catch (_) {
|
||||
await ws.tryClose();
|
||||
}
|
||||
@@ -357,12 +392,15 @@ Future<void> _closeNotSource(RelicWebSocket ws) async {
|
||||
/// with tracks (a pieces track's media playlist), and
|
||||
/// `.../<key>/<track>/p/<i>` piece `i` of a pieces track. `<key>` is the
|
||||
/// media id, or the queue id of a warm (or promoted) relay. [relayAt]
|
||||
/// finds the relay of a key, [sourceOf] the relay a token may feed.
|
||||
/// finds the relay of a key, [sourceOf] the relay a token may feed;
|
||||
/// [onOtherMedia] hears of a source back with another media than its
|
||||
/// first `info` (the channel is closed with 4002; the relay should end).
|
||||
void mountRelayRoutes(
|
||||
RelicApp app,
|
||||
SessionMap sessions, {
|
||||
required ActiveRelay? Function(String key) relayAt,
|
||||
required ActiveRelay? Function(String token, String? queueId) sourceOf,
|
||||
void Function(ActiveRelay relay)? onOtherMedia,
|
||||
void Function(String token, MediaLog log)? onLog,
|
||||
}) {
|
||||
app.get('/r/:sid/:token/source', (final req) {
|
||||
@@ -397,12 +435,22 @@ void mountRelayRoutes(
|
||||
case 'info':
|
||||
if (attached) continue;
|
||||
final infos = _infoTracks(j);
|
||||
final first = r._firstInfo;
|
||||
if (infos != null && first != null && !_sameInfo(first, _sourceInfo(j, infos))) {
|
||||
// The source came back with another media: the relay
|
||||
// ends (its buffer is of the first one).
|
||||
_log.warning('relay channel: the source is back with another media (${r.name}); the relay ends');
|
||||
await _closeWith(ws, CloseCodes.relayOtherMedia, 'other media');
|
||||
onOtherMedia?.call(r);
|
||||
return;
|
||||
}
|
||||
if (infos == null || !r._channelInfo(infos)) {
|
||||
// Not the media this swap named: the source stops.
|
||||
_log.warning('relay channel: info does not match the media (${r.name})');
|
||||
await _closeNotSource(ws);
|
||||
return;
|
||||
}
|
||||
r._firstInfo ??= _sourceInfo(j, infos);
|
||||
attached = true;
|
||||
_log.info('relay channel up (${r.name}${r.warm ? ', warm' : ''}, from ${r.sourceId}, '
|
||||
'${infos.map((t) => t.size != null ? '${t.size} bytes' : '${t.pieces!.length} pieces').join(' + ')})');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:relic/relic.dart';
|
||||
@@ -19,15 +20,91 @@ class ServerHub {
|
||||
/// See [AppConfig.hostKey]; empty means nobody is the host.
|
||||
final String hostKey;
|
||||
final SyncEngine engine;
|
||||
/// The `/sync` socket of each member that has one, by token; a `gone`
|
||||
/// member has none.
|
||||
final Map<String, RelicWebSocket> sockets = {};
|
||||
/// The server's Jellyfin login for the `/j/...` relay; null = no relay.
|
||||
JellyfinCredential? jellyfin;
|
||||
/// Reads web pages for "the server fetches it"; null = files only.
|
||||
final YtDlp? ytDlp;
|
||||
/// Random per server boot (`welcome.instance`).
|
||||
final String instance = _randomId();
|
||||
|
||||
ServerHub(this.sessions, this.expectedSecret,
|
||||
{SyncEngine? engine, this.hostKey = '', this.jellyfin, this.ytDlp})
|
||||
: engine = engine ?? SyncEngine();
|
||||
: engine = engine ?? SyncEngine() {
|
||||
this.engine
|
||||
..onGone = _memberGone
|
||||
..onLeft = _memberLeft;
|
||||
}
|
||||
|
||||
static String _randomId() {
|
||||
final r = Random.secure();
|
||||
return base64Url.encode(List<int>.generate(12, (_) => r.nextInt(256))).replaceAll('=', '');
|
||||
}
|
||||
|
||||
/// A member went `gone`: a socket still open (silent) is closed; a
|
||||
/// relay it serves goes on from its buffer (`media.relay.sourceGone`).
|
||||
void _memberGone(EngineMember m) {
|
||||
_hubLog.info('gone ${m.name} (${m.id})');
|
||||
final ws = sockets.remove(m.token);
|
||||
if (ws != null) unawaited(closeWith(ws, CloseCodes.silent, 'silent'));
|
||||
refreshRelaySource();
|
||||
}
|
||||
|
||||
/// A member left for good (`bye`, grace over): its token stops working,
|
||||
/// a relay it serves ends.
|
||||
void _memberLeft(EngineMember m) {
|
||||
_hubLog.info('leave ${m.name} (${m.id})');
|
||||
final ws = sockets.remove(m.token);
|
||||
if (ws != null) unawaited(closeWith(ws, CloseCodes.left, 'left'));
|
||||
session.tokens.remove(m.token);
|
||||
final r = relay;
|
||||
if (r != null && !r.fetch && r.sourceToken == m.token) {
|
||||
_endRelay(r, 'its source left', sourceGone: true, since: m.goneSince ?? engine.now);
|
||||
}
|
||||
}
|
||||
|
||||
/// The current relay ends (its source left, or sent another media):
|
||||
/// the buffer goes, `/r` answers 404, `media.relay` stays with `live`
|
||||
/// false.
|
||||
void _endRelay(ActiveRelay r, String why, {bool sourceGone = false, DateTime? since}) {
|
||||
if (!identical(relay, r)) return;
|
||||
_hubLog.info('relay of "${r.name}" ended: $why');
|
||||
setRelay(null);
|
||||
final m = engine.media;
|
||||
final was = m?.relay;
|
||||
if (m != null && was != null) {
|
||||
engine.media = m.copyWith(
|
||||
relay: was.copyWith(live: false, rate: 0, sourceGone: sourceGone, sourceGoneSince: since));
|
||||
}
|
||||
}
|
||||
|
||||
/// The source of [r] sent an `info` unlike its first (another media):
|
||||
/// the relay ends (warm: dropped, made again from the next `info`).
|
||||
void relayOtherMedia(ActiveRelay r) {
|
||||
if (identical(warm, r)) {
|
||||
_setWarm(null);
|
||||
} else {
|
||||
_endRelay(r, 'its source sent another media');
|
||||
}
|
||||
changed();
|
||||
}
|
||||
|
||||
/// `media.relay.sourceGone` / `sourceGoneSince` follow the current
|
||||
/// relay's source member (`gone` or back).
|
||||
void refreshRelaySource() {
|
||||
final r = relay, m = engine.media;
|
||||
final was = m?.relay;
|
||||
if (r == null || m == null || was == null || r.fetch || r.sourceToken == null) return;
|
||||
final src = engine.members[r.sourceToken];
|
||||
final gone = src == null || src.presence == Presence.gone;
|
||||
if (gone == was.sourceGone) return;
|
||||
_hubLog.info('relay of "${r.name}": source ${gone ? 'gone, viewers play what is buffered' : 'back'}');
|
||||
engine.media = m.copyWith(relay: was.copyWith(sourceGone: gone, sourceGoneSince: src?.goneSince ?? engine.now));
|
||||
}
|
||||
|
||||
bool get _sourceGone => engine.media?.relay?.sourceGone ?? false;
|
||||
|
||||
/// The current media is a Jellyfin ref on the server [jellyfin] logs in to.
|
||||
bool get relayAvailable => _onLogin(session.media?.jellyfin);
|
||||
@@ -61,8 +138,8 @@ class ServerHub {
|
||||
: '$head: $why';
|
||||
}
|
||||
|
||||
StateBroadcast stateBroadcast() =>
|
||||
engine.broadcast(relay: relayAvailable, relayNext: relayNextAvailable, warm: _warmView());
|
||||
StateBroadcast stateBroadcast() => engine.broadcast(
|
||||
relay: relayAvailable, relayNext: relayNextAvailable, warm: _warmView(), sourceGone: _sourceGone);
|
||||
|
||||
/// The relay of the current media (`/r/...`), if it is relayed.
|
||||
ActiveRelay? relay;
|
||||
@@ -109,10 +186,17 @@ class ServerHub {
|
||||
({String id, MediaRelay relay})? _warmView() {
|
||||
final w = warm, id = w?.queueId;
|
||||
if (w == null || id == null) return null;
|
||||
final src = w.sourceToken == null ? null : engine.members[w.sourceToken];
|
||||
final gone = src?.presence == Presence.gone;
|
||||
return (
|
||||
id: id,
|
||||
relay: MediaRelay(
|
||||
source: w.fetch ? '' : w.sourceId, fetch: w.fetch, live: w.live, tracks: w.live ? w.tracks : const []),
|
||||
source: w.fetch ? '' : w.sourceId,
|
||||
fetch: w.fetch,
|
||||
live: w.live,
|
||||
tracks: w.live ? w.tracks : const [],
|
||||
sourceGone: gone,
|
||||
sourceGoneSince: gone ? src?.goneSince : null),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,13 +214,15 @@ class ServerHub {
|
||||
if (warm == null && first != null) _setWarm(_newWarm(first));
|
||||
}
|
||||
|
||||
bool _othersHere(String? memberId) => engine.members.values.any((m) => m.id != memberId);
|
||||
bool _othersHere(String? memberId) =>
|
||||
engine.members.values.any((m) => m.id != memberId && m.presence != Presence.gone);
|
||||
|
||||
/// A Jellyfin [ref] someone here cannot play while someone can, outside
|
||||
/// the server's login.
|
||||
bool _jellyfinNeedsWarm(JellyfinRef ref) =>
|
||||
!_onLogin(ref) &&
|
||||
engine.members.values.any((m) => !m.out && m.jf[ref.serverId] != true) &&
|
||||
engine.members.values
|
||||
.any((m) => engine.policy.countsFor(m.presence, Purpose.preload) && m.jf[ref.serverId] != true) &&
|
||||
engine.members.values.any((m) => m.jf[ref.serverId] == true);
|
||||
|
||||
bool _keepWarm(ActiveRelay w, QueueItem first) {
|
||||
@@ -302,7 +388,7 @@ class ServerHub {
|
||||
|
||||
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
|
||||
|
||||
/// A member for the log: `name (id)`, never its token.
|
||||
/// A member for the log: `name (id)`, never its token or client id.
|
||||
String who(String token) {
|
||||
final m = engine.members[token];
|
||||
if (m != null) return '${m.name} (${m.id})';
|
||||
@@ -310,13 +396,6 @@ class ServerHub {
|
||||
return v != null ? '${v.name} (no member)' : 'unknown member';
|
||||
}
|
||||
|
||||
/// The member a reconnecting client may take over with its old token:
|
||||
/// still known.
|
||||
EngineMember? resumable(String token) {
|
||||
if (token.isEmpty || !session.tokens.containsKey(token)) return null;
|
||||
return engine.members[token];
|
||||
}
|
||||
|
||||
Session get session => sessions.getOrCreate('default', expectedSecret);
|
||||
|
||||
void broadcastState() {
|
||||
@@ -326,6 +405,19 @@ class ServerHub {
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a second: closed sockets, then the engine's sweep (silence,
|
||||
/// joining, gone notices, grace), relay source, warm relay, rates, and
|
||||
/// a state for everyone.
|
||||
void heartbeat() {
|
||||
sweepClosedSockets();
|
||||
engine.tick();
|
||||
refreshRelaySource();
|
||||
syncWarm();
|
||||
sampleRates();
|
||||
broadcastState();
|
||||
broadcastFeed();
|
||||
}
|
||||
|
||||
void broadcastFeed() {
|
||||
for (final feed in engine.feed.drain()) {
|
||||
final raw = jsonEncode(feed.toJson());
|
||||
@@ -335,14 +427,8 @@ class ServerHub {
|
||||
}
|
||||
}
|
||||
|
||||
void dropSilentSockets() {
|
||||
for (final token in engine.sweepDropped()) {
|
||||
final ws = sockets[token];
|
||||
if (ws != null) unawaited(ws.tryClose());
|
||||
forgetSocket(token);
|
||||
}
|
||||
}
|
||||
|
||||
/// Relic's server-side `onDone` does not fire for every dead peer:
|
||||
/// sockets are polled with `isClosed` too.
|
||||
void sweepClosedSockets() {
|
||||
for (final entry in sockets.entries.toList()) {
|
||||
var closed = false;
|
||||
@@ -351,7 +437,7 @@ class ServerHub {
|
||||
} catch (_) {
|
||||
closed = true;
|
||||
}
|
||||
if (closed) forgetSocket(entry.key);
|
||||
if (closed) socketClosed(entry.key, entry.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,24 +451,23 @@ class ServerHub {
|
||||
}
|
||||
}
|
||||
|
||||
void broadcastChat(String from, String text) {
|
||||
void broadcastChat(String from, String text, {String fromId = ''}) {
|
||||
final clean = text.length > RelayConstants.maxChatLength
|
||||
? text.substring(0, RelayConstants.maxChatLength)
|
||||
: text;
|
||||
final raw = jsonEncode(
|
||||
ChatMessage(text: clean).toJson()..addAll({'from': from}));
|
||||
ChatMessage(text: clean).toJson()..addAll({'from': from, if (fromId.isNotEmpty) 'fromId': fromId}));
|
||||
for (final ws in sockets.values.toList()) {
|
||||
ws.trySendText(raw);
|
||||
}
|
||||
engine.feed.add(
|
||||
FeedMessage(actor: from, action: 'chat', at: engine.now));
|
||||
engine.feed.add(FeedMessage(actor: from, actorId: fromId, action: 'chat', at: engine.now));
|
||||
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));
|
||||
engine.feed.add(FeedMessage(actor: 'server', actorId: 'server', action: 'fetch-fail', target: name, at: engine.now));
|
||||
broadcastFeed();
|
||||
}
|
||||
|
||||
@@ -392,18 +477,19 @@ class ServerHub {
|
||||
broadcastFeed();
|
||||
}
|
||||
|
||||
/// Like [forgetSocket], but only while [ws] is still the member's socket:
|
||||
/// after a resume the old socket's close must not remove the member.
|
||||
void forgetSocketOf(String token, RelicWebSocket ws) {
|
||||
/// [ws], [token]'s socket, closed: the member is `gone` (kept for the
|
||||
/// grace). A socket that is no longer the member's (taken over) changes
|
||||
/// nothing.
|
||||
void socketClosed(String token, RelicWebSocket ws) {
|
||||
if (!identical(sockets[token], ws)) return;
|
||||
forgetSocket(token);
|
||||
sockets.remove(token);
|
||||
engine.applyGone(token);
|
||||
changed();
|
||||
}
|
||||
|
||||
void forgetSocket(String token) {
|
||||
if (engine.members.containsKey(token)) _hubLog.info('leave ${who(token)}');
|
||||
engine.applyLeave(token);
|
||||
session.tokens.remove(token);
|
||||
sockets.remove(token);
|
||||
/// `bye`: [token]'s member leaves now.
|
||||
void bye(String token) {
|
||||
engine.applyLeave(token, reason: 'bye');
|
||||
changed();
|
||||
}
|
||||
|
||||
@@ -478,8 +564,20 @@ String sanitizeMediaName(String name) {
|
||||
return 'media';
|
||||
}
|
||||
|
||||
void wsErr(RelicWebSocket ws, String message) {
|
||||
ws.trySendText(jsonEncode({'t': 'err', 'message': message}));
|
||||
/// Sends `err` [code] with [message] (plain text for people) and, when a
|
||||
/// frame failed, its type as `for`. The socket stays open.
|
||||
void wsErr(RelicWebSocket ws, ErrCode code, String message, {String? forType}) {
|
||||
ws.trySendText(jsonEncode(ErrorFrame(code: code, message: message, forType: forType).toJson()));
|
||||
}
|
||||
|
||||
/// Closes [ws] with [code] (see [CloseCodes]); a socket that is already
|
||||
/// gone is fine.
|
||||
Future<void> closeWith(RelicWebSocket ws, int code, String reason) async {
|
||||
try {
|
||||
await ws.close(code, reason);
|
||||
} catch (_) {
|
||||
await ws.tryClose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Jellyfin ids are path segments of upstream requests: keep them plain.
|
||||
@@ -532,9 +630,12 @@ void mountSyncRoute(
|
||||
}
|
||||
|
||||
void handleIntent(String text) {
|
||||
// A socket another hello took over says nothing for the member.
|
||||
if (token != null && !identical(hub.sockets[token], ws)) return;
|
||||
final session = hub.session;
|
||||
final viewer = token == null ? null : session.tokens[token];
|
||||
viewer?.touch(hub.engine.now);
|
||||
if (token != null) hub.engine.touch(token);
|
||||
Map<String, Object?> decoded;
|
||||
try {
|
||||
final parsed = jsonDecode(text);
|
||||
@@ -575,7 +676,12 @@ void mountSyncRoute(
|
||||
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);
|
||||
name: parsed.name,
|
||||
size: parsed.size,
|
||||
duration: parsed.duration,
|
||||
url: hidden,
|
||||
relay: parsed.relay,
|
||||
ref: parsed.ref);
|
||||
}
|
||||
}
|
||||
if (parsed.jellyfin != null) {
|
||||
@@ -594,6 +700,7 @@ void mountSyncRoute(
|
||||
size: parsed.size,
|
||||
duration: parsed.duration,
|
||||
jellyfin: ref,
|
||||
ref: parsed.ref,
|
||||
relay: relayReq == null
|
||||
? null
|
||||
: promoted != null
|
||||
@@ -601,7 +708,7 @@ void mountSyncRoute(
|
||||
: hub.relayFor(token, const MediaRelay()));
|
||||
if (media == null || !hub.engine.applySwap(token, media, queueId: queueId)) {
|
||||
promoted?.dispose();
|
||||
wsErr(ws, 'bad swap media');
|
||||
wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap');
|
||||
return;
|
||||
}
|
||||
session.media = SessionMedia(
|
||||
@@ -627,14 +734,14 @@ void mountSyncRoute(
|
||||
final link = Uri.tryParse(parsed.url ?? '');
|
||||
if (relayReq.fetch &&
|
||||
(link == null || !(link.isScheme('http') || link.isScheme('https')) || link.host.isEmpty)) {
|
||||
wsErr(ws, 'relay fetch needs an http(s) url');
|
||||
wsErr(ws, ErrCode.bad, 'relay fetch needs an http(s) url', forType: 'swap');
|
||||
return;
|
||||
}
|
||||
// The server would fetch any address it can reach (its own
|
||||
// network, a VPN) for whoever asks: only for members who
|
||||
// know the password.
|
||||
if (relayReq.fetch && session.secret.isEmpty) {
|
||||
wsErr(ws, 'server fetch needs a server password');
|
||||
wsErr(ws, ErrCode.rejected, 'server fetch needs a server password', forType: 'swap');
|
||||
return;
|
||||
}
|
||||
// Up next's warm relay of this item carries on as its relay
|
||||
@@ -645,16 +752,17 @@ void mountSyncRoute(
|
||||
name: shown,
|
||||
size: parsed.size,
|
||||
duration: parsed.duration,
|
||||
ref: parsed.ref,
|
||||
relay: promoted != null ? hub.promotedRelay(promoted, queueId!) : hub.relayFor(token, relayReq));
|
||||
if (!hub.engine.applySwap(token, media, queueId: queueId)) {
|
||||
promoted?.dispose();
|
||||
wsErr(ws, 'bad swap media');
|
||||
wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap');
|
||||
return;
|
||||
}
|
||||
session.media = SessionMedia(
|
||||
name: shown, size: parsed.size, duration: parsed.duration, mediaId: hub.engine.mediaId);
|
||||
void failed(String why) {
|
||||
wsErr(ws, hub.fetchFailText(why));
|
||||
wsErr(ws, ErrCode.rejected, hub.fetchFailText(why), forType: 'swap');
|
||||
hub.fetchFailed(shown);
|
||||
}
|
||||
late final ActiveRelay active;
|
||||
@@ -678,17 +786,18 @@ void mountSyncRoute(
|
||||
name: sanitizeMediaName(parsed.name),
|
||||
size: parsed.size,
|
||||
duration: parsed.duration,
|
||||
url: parsed.url));
|
||||
url: parsed.url,
|
||||
ref: parsed.ref));
|
||||
final current = session.media;
|
||||
final matchesFile = current?.file != null &&
|
||||
current!.name == intent.media.name &&
|
||||
current.size == intent.media.size;
|
||||
if (!matchesFile && (intent.media.url ?? '').isEmpty) {
|
||||
wsErr(ws, 'swap needs a url or the hosted file');
|
||||
wsErr(ws, ErrCode.bad, 'swap needs a url or the hosted file', forType: 'swap');
|
||||
return;
|
||||
}
|
||||
if (!hub.engine.applySwap(token, intent.media, queueId: queueId)) {
|
||||
wsErr(ws, 'bad swap media');
|
||||
wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap');
|
||||
return;
|
||||
}
|
||||
hub.setRelay(null);
|
||||
@@ -714,7 +823,7 @@ void mountSyncRoute(
|
||||
final intent = ProfileIntent.fromJson(decoded);
|
||||
final avatar = intent.avatar;
|
||||
if (avatar != null && avatar.length > RelayConstants.maxAvatarBytes) {
|
||||
wsErr(ws, 'rejected: avatar too large');
|
||||
wsErr(ws, ErrCode.rejected, 'rejected: avatar too large', forType: 'profile');
|
||||
return;
|
||||
}
|
||||
final name = intent.name == null ? null : cleanMemberName(intent.name!);
|
||||
@@ -749,12 +858,19 @@ void mountSyncRoute(
|
||||
.toJson()));
|
||||
case 'chat':
|
||||
final chat = ChatMessage.fromJson(decoded);
|
||||
hub.broadcastChat(viewer?.name ?? '?', chat.text);
|
||||
case 'out':
|
||||
hub.broadcastChat(viewer?.name ?? '?', chat.text, fromId: hub.engine.members[token]?.id ?? '');
|
||||
case 'presence':
|
||||
if (token == null) return;
|
||||
final intent = OutIntent.fromJson(decoded);
|
||||
hub.engine.applyOut(token, intent.target, intent.out);
|
||||
final intent = PresenceIntent.fromJson(decoded);
|
||||
final refused = hub.engine.applyPresence(token, intent.target, intent.state, reason: intent.reason);
|
||||
if (refused != null) {
|
||||
wsErr(ws, refused, 'only a member itself comes back in', forType: 'presence');
|
||||
}
|
||||
hub.changed();
|
||||
case 'bye':
|
||||
if (token == null) return;
|
||||
_hubLog.info('bye from ${hub.who(token)}');
|
||||
hub.bye(token);
|
||||
case 'nudge':
|
||||
if (token == null) return;
|
||||
hub.nudge(token, NudgeIntent.fromJson(decoded).target);
|
||||
@@ -764,7 +880,7 @@ void mountSyncRoute(
|
||||
}
|
||||
} on ProtocolException catch (e) {
|
||||
_hubLog.info('${token == null ? 'a socket' : hub.who(token)}: bad $type message: ${e.message}');
|
||||
wsErr(ws, 'bad message: ${e.message}');
|
||||
wsErr(ws, ErrCode.bad, 'bad message: ${e.message}', forType: type is String ? type : null);
|
||||
} catch (e, st) {
|
||||
_hubLog.warning('${token == null ? 'a socket' : hub.who(token)}: $type message failed: $e', e, st);
|
||||
return;
|
||||
@@ -781,13 +897,13 @@ void mountSyncRoute(
|
||||
}
|
||||
},
|
||||
onError: (_) {
|
||||
if (token != null) hub.forgetSocketOf(token, ws);
|
||||
if (token != null) hub.socketClosed(token, ws);
|
||||
},
|
||||
onDone: () {
|
||||
if (!firstText.isCompleted) {
|
||||
firstText.completeError(StateError('closed before hello'));
|
||||
} else if (token != null) {
|
||||
hub.forgetSocketOf(token, ws);
|
||||
hub.socketClosed(token, ws);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -798,64 +914,121 @@ void mountSyncRoute(
|
||||
await close();
|
||||
return;
|
||||
}
|
||||
HelloMessage hello;
|
||||
Future<void> refuse(ErrorFrame err, int code, String log) async {
|
||||
_hubLog.warning('reject: $log');
|
||||
ws.trySendText(jsonEncode(err.toJson()));
|
||||
try {
|
||||
await sub.cancel();
|
||||
} catch (_) {}
|
||||
await closeWith(ws, code, err.code?.name ?? 'refused');
|
||||
}
|
||||
|
||||
// The version first, read leniently: another version's hello may
|
||||
// not parse as ours.
|
||||
final Map<String, Object?> raw0;
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) throw const ProtocolException('hello must be an object');
|
||||
hello = HelloMessage.fromJson(Map<String, Object?>.from(decoded));
|
||||
raw0 = Map<String, Object?>.from(decoded);
|
||||
} catch (e) {
|
||||
_hubLog.warning('reject: bad hello: $e');
|
||||
wsErr(ws, 'bad hello: $e');
|
||||
await close();
|
||||
await refuse(ErrorFrame(code: ErrCode.bad, message: 'bad hello: $e', forType: 'hello'), CloseCodes.refused,
|
||||
'bad hello: $e');
|
||||
return;
|
||||
}
|
||||
if (hello.proto != RelayConstants.protoVersion) {
|
||||
_hubLog.warning(
|
||||
'reject ${hello.user.name}: proto mismatch: got ${hello.proto}, want ${RelayConstants.protoVersion}');
|
||||
wsErr(ws, 'proto mismatch: got ${hello.proto}, want ${RelayConstants.protoVersion}');
|
||||
await close();
|
||||
final range = HelloMessage.protoRangeOf(raw0);
|
||||
final proto = range == null ? null : pickProto(range.min, range.max);
|
||||
if (proto == null) {
|
||||
final got = range?.max ?? 0;
|
||||
await refuse(ErrorFrame.version(got: got, server: appVersion), CloseCodes.version,
|
||||
'proto mismatch: got ${range == null ? 'none' : '${range.min}..${range.max}'}, '
|
||||
'want ${RelayConstants.protoMin}..${RelayConstants.protoMax}');
|
||||
return;
|
||||
}
|
||||
HelloMessage hello;
|
||||
try {
|
||||
hello = HelloMessage.fromJson(raw0);
|
||||
} catch (e) {
|
||||
await refuse(ErrorFrame(code: ErrCode.bad, message: 'bad hello: $e', forType: 'hello'), CloseCodes.refused,
|
||||
'bad hello: $e');
|
||||
return;
|
||||
}
|
||||
final session = hub.session;
|
||||
if (session.secret.isNotEmpty && hello.secret != session.secret) {
|
||||
_hubLog.warning('reject ${hello.user.name}: wrong secret');
|
||||
wsErr(ws, 'wrong secret');
|
||||
await close();
|
||||
await refuse(const ErrorFrame(code: ErrCode.secret, message: 'wrong secret', forType: 'hello'),
|
||||
CloseCodes.secret, '${cleanMemberName(hello.user.name)}: wrong secret');
|
||||
return;
|
||||
}
|
||||
final resumed = hub.resumable(hello.resume);
|
||||
String memberId;
|
||||
if (resumed != null) {
|
||||
// Unclean drop the server has not noticed yet: take the member over
|
||||
// (same token, id, ready flag) and drop the dead socket quietly.
|
||||
token = hello.resume;
|
||||
memberId = resumed.id;
|
||||
final old = hub.sockets.remove(token);
|
||||
if (old != null) unawaited(old.tryClose());
|
||||
hub.engine.applyResume(token);
|
||||
_hubLog.info('resume ${hello.user.name} ($memberId)');
|
||||
if (hello.user.avatar.length > RelayConstants.maxAvatarBytes) {
|
||||
await refuse(
|
||||
const ErrorFrame(code: ErrCode.rejected, message: 'rejected: avatar too large', forType: 'hello'),
|
||||
CloseCodes.refused,
|
||||
'${cleanMemberName(hello.user.name)}: avatar too large');
|
||||
return;
|
||||
}
|
||||
final known = hub.engine.byClient(hello.client);
|
||||
if (known != null && hello.seq < known.seq) {
|
||||
await refuse(
|
||||
const ErrorFrame(
|
||||
code: ErrCode.stale, message: 'an older connection of this app (a newer one holds it)', forType: 'hello'),
|
||||
CloseCodes.stale,
|
||||
'${known.name} (${known.id}): an older hello than the member\'s');
|
||||
return;
|
||||
}
|
||||
final String memberId;
|
||||
final bool resumed;
|
||||
final about = '${hello.app.isEmpty ? 'no version' : hello.app}${hello.platform.isEmpty ? '' : ' on ${hello.platform}'}';
|
||||
if (known != null) {
|
||||
// This app again (a reconnect, or opened anew): it takes its
|
||||
// member over, same token and id; an older socket still open is
|
||||
// told and closed.
|
||||
token = known.token;
|
||||
memberId = known.id;
|
||||
resumed = true;
|
||||
final old = hub.sockets[known.token];
|
||||
hub.sockets[known.token] = ws;
|
||||
if (old != null && !identical(old, ws)) {
|
||||
old.trySendText(jsonEncode(const ErrorFrame(
|
||||
code: ErrCode.replaced, message: 'this member was opened from another connection of the same app')
|
||||
.toJson()));
|
||||
unawaited(closeWith(old, CloseCodes.replaced, 'replaced'));
|
||||
}
|
||||
final back = hub.engine.applyTakeover(known.token,
|
||||
seq: hello.seq, app: hello.app, platform: hello.platform, caps: hello.caps);
|
||||
final name = cleanMemberName(hello.user.name);
|
||||
session.tokens[known.token]?.name = name;
|
||||
hub.engine.applyProfile(known.token, name: name, avatar: hello.user.avatar);
|
||||
_hubLog.info('${back ? 'return' : 'takeover'} ${known.name} ($memberId), $about');
|
||||
} else {
|
||||
try {
|
||||
token = session.issueToken(hello.user.name, hello.user.avatar);
|
||||
} catch (e) {
|
||||
_hubLog.warning('reject ${hello.user.name}: $e');
|
||||
wsErr(ws, 'rejected: $e');
|
||||
await close();
|
||||
await refuse(ErrorFrame(code: ErrCode.rejected, message: 'rejected: $e', forType: 'hello'),
|
||||
CloseCodes.refused, '${cleanMemberName(hello.user.name)}: $e');
|
||||
return;
|
||||
}
|
||||
memberId = hub.engine.applyJoin(token, session.tokens[token]!.name, hello.user.avatar);
|
||||
memberId = hub.engine.applyJoin(token, session.tokens[token]!.name, hello.user.avatar,
|
||||
client: hello.client, seq: hello.seq, app: hello.app, platform: hello.platform, caps: hello.caps);
|
||||
resumed = false;
|
||||
hub.sockets[token] = ws;
|
||||
_hubLog.info('join ${session.tokens[token]!.name} ($memberId), $about');
|
||||
}
|
||||
final isHost = hub.isHostKey(hello.hostKey);
|
||||
session.tokens[token]!.isHost = isHost;
|
||||
hub.engine.members[token]?.host = isHost;
|
||||
final activeToken = token;
|
||||
hub.sockets[activeToken] = ws;
|
||||
final isHost = hub.isHostKey(hello.hostKey);
|
||||
session.tokens[activeToken]!.isHost = isHost;
|
||||
hub.engine.members[activeToken]?.host = isHost;
|
||||
hub.refreshRelaySource();
|
||||
final media = session.media;
|
||||
final relay = hub.relayAvailable;
|
||||
final relayed = hub.relay;
|
||||
final feedAfter = hello.feedAfter;
|
||||
final welcome = WelcomeMessage(
|
||||
token: activeToken,
|
||||
memberId: memberId,
|
||||
proto: proto,
|
||||
resumed: resumed,
|
||||
instance: hub.instance,
|
||||
grace: hub.engine.liveness.grace.inSeconds,
|
||||
feed: feedAfter == null ? const [] : hub.engine.feed.after(feedAfter),
|
||||
// Jellyfin media resolves per member (own account first); only
|
||||
// relayed files and links start from the /r/ URL, and never for
|
||||
// the source itself (it plays its own copy).
|
||||
@@ -885,7 +1058,6 @@ void mountSyncRoute(
|
||||
return;
|
||||
}
|
||||
welcomed = true;
|
||||
_hubLog.info('join ${hello.user.name} ($memberId)');
|
||||
hub.changed();
|
||||
try {
|
||||
await sub.asFuture();
|
||||
|
||||
@@ -67,7 +67,7 @@ class SyncServer {
|
||||
|
||||
/// [ytDlp] replaces the server's own (tests). [clock] and [liveness]
|
||||
/// go to the engine: a test passes a fake clock to step through the
|
||||
/// silence limits ([Liveness]) without waiting.
|
||||
/// silence, grace and joiner limits ([Liveness]) without waiting.
|
||||
SyncServer(this.config,
|
||||
{YtDlp? ytDlp, this.loopbackOnly = false, this.logDir, SyncClock? clock, Liveness liveness = const Liveness()}) {
|
||||
hub = ServerHub(sessions, config.secret,
|
||||
@@ -103,7 +103,11 @@ class SyncServer {
|
||||
body: Body.fromString(jsonEncode({
|
||||
'ok': true,
|
||||
'name': config.name,
|
||||
'proto': RelayConstants.protoVersion,
|
||||
'proto': RelayConstants.protoMax,
|
||||
'protoMin': RelayConstants.protoMin,
|
||||
'protoMax': RelayConstants.protoMax,
|
||||
'version': appVersion,
|
||||
'tls': false,
|
||||
'viewers': viewers,
|
||||
'bytesServed': bytes,
|
||||
'uptimeSecs': uptime,
|
||||
@@ -113,7 +117,8 @@ class SyncServer {
|
||||
final requests = MediaRequestLog();
|
||||
void logRequest(String token, MediaLog log) => requests(hub.who(token), log);
|
||||
mountSessionMediaRoute(_app, sessions, onLog: logRequest);
|
||||
mountRelayRoutes(_app, sessions, relayAt: hub.relayAt, sourceOf: hub.sourceOf, onLog: logRequest);
|
||||
mountRelayRoutes(_app, sessions,
|
||||
relayAt: hub.relayAt, sourceOf: hub.sourceOf, onOtherMedia: hub.relayOtherMedia, onLog: logRequest);
|
||||
mountAvatarRoute(_app, hub);
|
||||
mountArtRoute(_app, hub);
|
||||
mountSessionLogRoutes(_app, hub, () => _sessionLog);
|
||||
@@ -174,12 +179,7 @@ class SyncServer {
|
||||
);
|
||||
_startedAt = DateTime.now();
|
||||
_heartbeat = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
hub.engine.tick();
|
||||
hub.sweepClosedSockets();
|
||||
hub.dropSilentSockets();
|
||||
hub.sampleRates();
|
||||
hub.broadcastState();
|
||||
hub.broadcastFeed();
|
||||
hub.heartbeat();
|
||||
_sessionLog?.flush();
|
||||
});
|
||||
_log.info('server "${config.name}" listening on $boundPort');
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): both
|
||||
// tests pin today's rules (a silent member is stale, then removed; a reset
|
||||
// socket loses its member, so the redial is a new one, P1); with grace they
|
||||
// expect `gone` and the same member back.
|
||||
//
|
||||
// A client reaching the server through FlakyProxy (DEVELOPMENT.md "Tests").
|
||||
// A client reaching the server through FlakyProxy (DEVELOPMENT.md "Tests"):
|
||||
// a dead link makes the member `gone` (kept with everything it had), and
|
||||
// the same app dialling again gets the same member back (P1).
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
@@ -22,7 +19,7 @@ class _Client {
|
||||
Timer? _reports;
|
||||
late final StreamSubscription<dynamic> _sub;
|
||||
|
||||
_Client(int port, {String resume = ''})
|
||||
_Client(int port, {int seq = 1})
|
||||
: ch = WebSocketChannel.connect(Uri.parse('ws://127.0.0.1:$port/sync')) {
|
||||
ch.ready.then((_) {}, onError: (_) {});
|
||||
_sub = ch.stream.listen((m) => frames.add(Map<String, Object?>.from(jsonDecode('$m') as Map)),
|
||||
@@ -31,11 +28,15 @@ class _Client {
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'shh',
|
||||
user: const HelloUser(name: 'alice'),
|
||||
media: const HelloMedia(name: 'f.bin', size: 1, duration: 10.0),
|
||||
resume: resume)
|
||||
client: clientId,
|
||||
seq: seq)
|
||||
.toJson()));
|
||||
}
|
||||
|
||||
static const clientId = 'flakyLinkTestClient01';
|
||||
|
||||
void send(Map<String, Object?> j) => ch.sink.add(jsonEncode(j));
|
||||
|
||||
Map<String, Object?>? get welcome => frames.where((f) => f['t'] == 'welcome').firstOrNull;
|
||||
String get token => welcome!['token'] as String;
|
||||
String get memberId => welcome!['memberId'] as String;
|
||||
@@ -73,7 +74,7 @@ void main() {
|
||||
setUp(() async {
|
||||
// Short limits so the demo runs in a couple of seconds.
|
||||
server = SyncServer(AppConfig(port: 0, secret: 'shh', name: 'test'),
|
||||
liveness: const Liveness(staleAfter: Duration(milliseconds: 600), dropAfter: Duration(milliseconds: 1500)));
|
||||
liveness: const Liveness(silence: Duration(milliseconds: 600)));
|
||||
await server.start();
|
||||
proxy = await FlakyProxy.start(server.boundPort);
|
||||
});
|
||||
@@ -87,25 +88,34 @@ void main() {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
_Client dial({String resume = ''}) {
|
||||
final c = _Client(proxy.port, resume: resume);
|
||||
_Client dial({int seq = 1}) {
|
||||
final c = _Client(proxy.port, seq: seq);
|
||||
clients.add(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
test('a blackholed client goes stale, then is removed; it never hears of it', () async {
|
||||
test('a blackholed client goes gone with its ready flag, queue item and relay; it comes back as itself', () async {
|
||||
final c = dial();
|
||||
await until(() => c.welcome != null, 'welcome');
|
||||
c.startReports();
|
||||
final engine = server.hub.engine;
|
||||
EngineMember? member() => engine.members[c.token];
|
||||
c.send(const ReadyIntent(ready: true).toJson());
|
||||
c.send(const QueueIntent.add(QueueItem(
|
||||
media: HelloMedia(name: 'f.mkv', size: 10, duration: 0), how: QueueHow.file, title: 'f', key: 'key-of-f-1'))
|
||||
.toJson());
|
||||
c.send(const SwapIntent(media: HelloMedia(name: 'mine.mkv', size: 100, duration: 0, relay: MediaRelay())).toJson());
|
||||
await until(() => engine.queue.length == 1 && server.hub.relay != null && member()!.ready, 'ready, queued, relayed');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1200));
|
||||
expect(engine.isStale(member()!), isFalse, reason: 'reports through the proxy keep it fresh');
|
||||
expect(member()!.presence, isNot(Presence.gone), reason: 'reports through the proxy keep it present');
|
||||
|
||||
proxy.last!.blackhole();
|
||||
final seen = c.frames.length;
|
||||
await until(() => member() != null && engine.isStale(member()!), 'stale');
|
||||
await until(() => member() == null, 'removed');
|
||||
await until(() => member()?.presence == Presence.gone, 'gone');
|
||||
expect(member()!.ready, isTrue);
|
||||
expect(engine.queue.items.single.by, c.memberId);
|
||||
expect(server.hub.relay, isNotNull, reason: 'the relay lives through the grace (D6)');
|
||||
expect(engine.media!.relay!.sourceGone, isTrue);
|
||||
expect(server.hub.sockets.containsKey(c.token), isFalse);
|
||||
// Half-open: the server's close never reached the client.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
@@ -113,20 +123,30 @@ void main() {
|
||||
expect(c.frames.length, seen);
|
||||
expect(proxy.last!.isClosed, isFalse);
|
||||
expect(proxy.last!.dropped, greaterThan(0));
|
||||
|
||||
final again = dial(seq: 2);
|
||||
await until(() => again.welcome != null, 'welcome after redial');
|
||||
expect(again.welcome!['resumed'], isTrue);
|
||||
expect([again.memberId, again.token], [c.memberId, c.token]);
|
||||
expect(engine.members, hasLength(1));
|
||||
expect(member()!.presence, Presence.joining, reason: 'media is up: it loads, then comes in (D2)');
|
||||
expect(member()!.ready, isTrue);
|
||||
expect(engine.media!.relay!.sourceGone, isFalse);
|
||||
final actions = [for (final f in again.frames) if (f['t'] == 'feed') f['action']];
|
||||
expect(actions, isNot(contains('join')), reason: 'a return, not a join');
|
||||
});
|
||||
|
||||
test('a reset client sees the drop at once and redials; today it comes back as a new member', () async {
|
||||
test('a reset client sees the drop at once and redials into the same member', () async {
|
||||
final c = dial();
|
||||
await until(() => c.welcome != null, 'welcome');
|
||||
final first = c.memberId;
|
||||
proxy.last!.reset();
|
||||
await until(() => c.ended, 'client sees the reset');
|
||||
await until(() => !server.hub.engine.members.containsKey(c.token), 'server forgets the socket');
|
||||
await until(() => server.hub.engine.members[c.token]?.presence == Presence.gone, 'server sees it gone');
|
||||
|
||||
final again = dial(resume: c.token);
|
||||
final again = dial(seq: 2);
|
||||
await until(() => again.welcome != null, 'welcome after redial');
|
||||
expect(proxy.links, hasLength(2));
|
||||
expect(again.memberId, isNot(first), reason: 'P1: no grace yet');
|
||||
expect([again.memberId, again.token], [c.memberId, c.token]);
|
||||
expect(server.hub.engine.members, hasLength(1));
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): "anyone
|
||||
// sits anyone out" (Away); the rest stays.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -25,7 +23,6 @@ class _Peer {
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: secret,
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0),
|
||||
hostKey: hostKey)
|
||||
.toJson()));
|
||||
await p.next('welcome');
|
||||
@@ -81,6 +78,7 @@ void main() {
|
||||
a.send('{"t":"report","position":1e400,"buffered":1e400,"eventId":0}');
|
||||
final err = await a.next('err');
|
||||
expect('${err['message']}', contains('finite'));
|
||||
expect([err['code'], err['for']], ['bad', 'seek']);
|
||||
expect(server.hub.engine.position, 0);
|
||||
// still alive: a valid seek round-trips through a broadcast
|
||||
a.send('{"t":"seek","position":5}');
|
||||
@@ -125,7 +123,7 @@ void main() {
|
||||
await a.ws.close();
|
||||
});
|
||||
|
||||
test('anyone sits anyone out (host too); nudges reach only the target, capped', () async {
|
||||
test('anyone sets anyone away (host too), only oneself comes back in; nudges reach only the target, capped', () async {
|
||||
await server.stop();
|
||||
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk'));
|
||||
await server.start();
|
||||
@@ -137,19 +135,23 @@ void main() {
|
||||
server.hub.engine.members.values.firstWhere((m) => m.name == name).id;
|
||||
EngineMember of(String name) =>
|
||||
server.hub.engine.members.values.firstWhere((m) => m.name == name);
|
||||
friend.send(jsonEncode(OutIntent(target: idOf('host'), out: true).toJson()));
|
||||
friend.send(jsonEncode(PresenceIntent(target: idOf('host'), state: Presence.away).toJson()));
|
||||
var feed = await friend.next('feed');
|
||||
while (feed['action'] != 'out') {
|
||||
while (feed['action'] != 'away') {
|
||||
feed = await friend.next('feed');
|
||||
}
|
||||
expect([feed['actor'], feed['target']], ['friend', 'host']);
|
||||
expect(of('host').out, isTrue);
|
||||
expect(of('host').inSet, isFalse);
|
||||
expect([feed['actor'], feed['target'], feed['actorId'], feed['targetId']],
|
||||
['friend', 'host', idOf('friend'), idOf('host')]);
|
||||
expect(of('host').presence, Presence.away);
|
||||
expect(server.hub.engine.countsForGate(of('host')), isFalse);
|
||||
// Only oneself comes back.
|
||||
friend.send(jsonEncode(OutIntent(target: idOf('host'), out: false).toJson()));
|
||||
host.send(jsonEncode(OutIntent(target: idOf('host'), out: false).toJson()));
|
||||
friend.send(jsonEncode(PresenceIntent(target: idOf('host'), state: Presence.watching).toJson()));
|
||||
final err = await friend.next('err');
|
||||
expect([err['code'], err['for']], ['forbidden', 'presence']);
|
||||
expect(of('host').presence, Presence.away);
|
||||
host.send(jsonEncode(const PresenceIntent(state: Presence.watching).toJson()));
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
expect(of('host').out, isFalse);
|
||||
expect(of('host').presence, Presence.watching, reason: 'no media up: nothing to load');
|
||||
|
||||
// 5 at once: 3 pass (per second), only to the target.
|
||||
for (var i = 0; i < 5; i++) {
|
||||
@@ -224,8 +226,7 @@ void main() {
|
||||
ws.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: '',
|
||||
user: const HelloUser(name: 'a'),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||
user: const HelloUser(name: 'a'))
|
||||
.toJson()));
|
||||
final w = await p.next('welcome');
|
||||
expect('${w['mediaUrl']}', startsWith('http://127.0.0.1:${server.boundPort}/m/'));
|
||||
@@ -239,8 +240,7 @@ void main() {
|
||||
ws2.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: '',
|
||||
user: const HelloUser(name: 'c'),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||
user: const HelloUser(name: 'c'))
|
||||
.toJson()));
|
||||
final w2 = await p2.next('welcome');
|
||||
expect('${w2['mediaUrl']}', startsWith('http://$lan:${server.boundPort}/m/'));
|
||||
|
||||
@@ -54,8 +54,7 @@ void main() {
|
||||
ws.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: '',
|
||||
user: const HelloUser(name: 'live'),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||
user: const HelloUser(name: 'live'))
|
||||
.toJson()));
|
||||
final welcome = await frames.firstWhere((f) => f['t'] == 'welcome').timeout(const Duration(seconds: 5));
|
||||
ws.add(jsonEncode(SwapIntent(
|
||||
|
||||
@@ -133,9 +133,7 @@ class _Client {
|
||||
c.send(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: '',
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0),
|
||||
).toJson());
|
||||
user: HelloUser(name: name)).toJson());
|
||||
return (c, await c._welcome.future.timeout(const Duration(seconds: 5)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): a
|
||||
// socket close no longer removes the member (explicit `bye`, grace).
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -74,14 +72,22 @@ void main() {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
test('clean client close removes member within seconds, not timeout',
|
||||
() async {
|
||||
test('a clean close is not a leave: the member is gone at once, kept', () async {
|
||||
final client = await _ProbeClient.hello(server.boundPort, 'alice');
|
||||
await _pollBool(
|
||||
() => server.hub.engine.members.length == 1, 'join visible');
|
||||
await _pollBool(() => server.hub.engine.members.length == 1, 'join visible');
|
||||
await client.ws.close();
|
||||
await _pollBool(
|
||||
() => server.hub.engine.members.isEmpty, 'member removed');
|
||||
await _pollBool(() => server.hub.engine.members.values.single.presence == Presence.gone, 'gone');
|
||||
expect(server.session.tokens, hasLength(1), reason: 'its token works through the grace');
|
||||
expect(server.hub.sockets, isEmpty);
|
||||
});
|
||||
|
||||
test('bye leaves at once: member, token and socket go', () async {
|
||||
final client = await _ProbeClient.hello(server.boundPort, 'alice');
|
||||
await _pollBool(() => server.hub.engine.members.length == 1, 'join visible');
|
||||
client.ws.add(jsonEncode(const ByeMessage().toJson()));
|
||||
final outcome = await client.closed.future.timeout(const Duration(seconds: 5));
|
||||
expect(outcome, contains('code=${CloseCodes.left}'));
|
||||
expect(server.hub.engine.members, isEmpty);
|
||||
expect(server.session.tokens, isEmpty);
|
||||
expect(server.hub.sockets, isEmpty);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// Identity, grace and the feed across reconnects, on a fake clock: the
|
||||
// server's limits ([Liveness]) are stepped through, not waited for.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
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 frames = <Map<String, Object?>>[];
|
||||
late final WelcomeMessage welcome;
|
||||
_Peer(this.ws) {
|
||||
ws.listen((m) => frames.add(Map<String, Object?>.from(jsonDecode('$m') as Map)), onError: (_) {});
|
||||
}
|
||||
|
||||
static Future<_Peer> join(int port, String name, {String client = '', int seq = 0, int? feedAfter}) async {
|
||||
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||
final p = _Peer(ws);
|
||||
ws.add(jsonEncode(HelloMessage(
|
||||
secret: 's', user: HelloUser(name: name), client: client, seq: seq, app: '1.3.0', feedAfter: feedAfter)
|
||||
.toJson()));
|
||||
final end = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (!p.frames.any((f) => f['t'] == 'welcome')) {
|
||||
if (DateTime.now().isAfter(end)) fail('no welcome: ${p.frames}');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
p.welcome = WelcomeMessage.fromJson(p.frames.firstWhere((f) => f['t'] == 'welcome'));
|
||||
return p;
|
||||
}
|
||||
|
||||
List<Map<String, Object?>> get feed => [for (final f in frames) if (f['t'] == 'feed') f];
|
||||
}
|
||||
|
||||
Future<void> _until(bool Function() ok, String what) async {
|
||||
final end = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (!ok()) {
|
||||
if (DateTime.now().isAfter(end)) fail('timed out: $what');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
const install = 'presenceTestInstall001';
|
||||
late DateTime t;
|
||||
late SyncServer server;
|
||||
final peers = <_Peer>[];
|
||||
|
||||
Future<_Peer> join(String name, {String client = '', int seq = 0, int? feedAfter}) async {
|
||||
final p = await _Peer.join(server.boundPort, name, client: client, seq: seq, feedAfter: feedAfter);
|
||||
peers.add(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/// Moves the server's clock on and runs one heartbeat.
|
||||
void step(Duration d) {
|
||||
t = t.add(d);
|
||||
server.hub.heartbeat();
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
t = DateTime.utc(2026, 1, 1);
|
||||
server = SyncServer(const AppConfig(port: 0, secret: 's', name: 't'), clock: () => t);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
for (final p in peers) {
|
||||
await p.ws.close();
|
||||
}
|
||||
peers.clear();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
test('a gone member leaves when its grace runs out; it comes back as a new member', () async {
|
||||
final alice = await join('alice', client: install, seq: 1);
|
||||
final bob = await join('bob');
|
||||
await alice.ws.close();
|
||||
await _until(() => server.hub.engine.byClient(install)?.presence == Presence.gone, 'gone');
|
||||
expect(alice.welcome.grace, RelayConstants.graceSecs);
|
||||
// Bob keeps talking; alice says nothing.
|
||||
for (var i = 0; i < 59; i++) {
|
||||
server.hub.engine.touch(bob.welcome.token);
|
||||
step(const Duration(seconds: 10));
|
||||
}
|
||||
expect(server.hub.engine.byClient(install), isNotNull, reason: '9 min 50 s: still kept');
|
||||
expect(server.session.tokens.containsKey(alice.welcome.token), isTrue);
|
||||
server.hub.engine.touch(bob.welcome.token);
|
||||
step(const Duration(seconds: 10));
|
||||
expect(server.hub.engine.byClient(install), isNull);
|
||||
expect(server.session.tokens.containsKey(alice.welcome.token), isFalse, reason: 'its URLs stop working');
|
||||
await _until(() => bob.feed.any((f) => f['action'] == 'leave'), 'leave line');
|
||||
final leave = bob.feed.firstWhere((f) => f['action'] == 'leave');
|
||||
expect([leave['actorId'], leave['reason']], [alice.welcome.memberId, 'grace']);
|
||||
final again = await join('alice', client: install, seq: 2);
|
||||
expect(again.welcome.resumed, isFalse);
|
||||
expect(again.welcome.memberId, isNot(alice.welcome.memberId));
|
||||
});
|
||||
|
||||
test('a quick return says nothing; a slow one says gone, then return; never join', () async {
|
||||
final alice = await join('alice', client: install, seq: 1);
|
||||
final bob = await join('bob');
|
||||
await alice.ws.close();
|
||||
await _until(() => server.hub.engine.byClient(install)?.presence == Presence.gone, 'gone');
|
||||
await _until(
|
||||
() => bob.frames.any((f) =>
|
||||
f['t'] == 'state' && (f['members'] as List).any((m) => (m as Map)['presence'] == 'gone')),
|
||||
'presence in state at once');
|
||||
step(const Duration(seconds: 5));
|
||||
final quick = await join('alice', client: install, seq: 2);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
expect(quick.welcome.resumed, isTrue);
|
||||
expect(bob.feed.map((f) => f['action']), isNot(anyOf(contains('gone'), contains('return'), contains('leave'))));
|
||||
expect(bob.feed.where((f) => f['action'] == 'join'), hasLength(1), reason: "bob's own join only");
|
||||
await quick.ws.close();
|
||||
await _until(() => server.hub.engine.byClient(install)?.presence == Presence.gone, 'gone again');
|
||||
step(const Duration(seconds: 10));
|
||||
await _until(() => bob.feed.any((f) => f['action'] == 'gone'), 'gone line after 10 s');
|
||||
await join('alice', client: install, seq: 3);
|
||||
await _until(() => bob.feed.any((f) => f['action'] == 'return'), 'return line');
|
||||
final ret = bob.feed.firstWhere((f) => f['action'] == 'return');
|
||||
expect([ret['actor'], ret['actorId']], ['alice', alice.welcome.memberId]);
|
||||
});
|
||||
|
||||
test('feed lines carry ids and seq; a reconnect replays what came after feedAfter', () async {
|
||||
final alice = await join('alice', client: install, seq: 1);
|
||||
final bob = await join('bob');
|
||||
bob.ws.add(jsonEncode(const ReadyIntent(ready: true).toJson()));
|
||||
await _until(() => alice.feed.any((f) => f['action'] == 'ready'), 'ready line');
|
||||
final seqs = [for (final f in alice.feed) f['seq'] as int];
|
||||
expect(seqs, orderedEquals([...seqs]..sort()));
|
||||
final ready = alice.feed.firstWhere((f) => f['action'] == 'ready');
|
||||
expect(ready['actorId'], bob.welcome.memberId);
|
||||
final last = seqs.last;
|
||||
await alice.ws.close();
|
||||
bob.ws.add(jsonEncode(const ChatMessage(text: 'while you were out').toJson()));
|
||||
bob.ws.add(jsonEncode(const ReadyIntent(ready: false).toJson()));
|
||||
await _until(() => server.hub.engine.feed.lastSeq >= last + 2, 'lines while away');
|
||||
final again = await join('alice', client: install, seq: 2, feedAfter: last);
|
||||
final replayed = again.welcome.feed;
|
||||
expect(replayed.map((f) => f.action), containsAllInOrder(['chat', 'unready']));
|
||||
expect(replayed.every((f) => f.replayed && f.seq > last), isTrue);
|
||||
expect(again.feed, isEmpty, reason: 'replayed only inside welcome');
|
||||
});
|
||||
|
||||
test('a server restart: resumed false, another instance', () async {
|
||||
final alice = await join('alice', client: install, seq: 1);
|
||||
final before = alice.welcome.instance;
|
||||
expect(before, isNotEmpty);
|
||||
await server.stop();
|
||||
server = SyncServer(const AppConfig(port: 0, secret: 's', name: 't'), clock: () => t);
|
||||
await server.start();
|
||||
final again = await join('alice', client: install, seq: 2);
|
||||
expect(again.welcome.resumed, isFalse);
|
||||
expect(again.welcome.instance, isNot(before));
|
||||
});
|
||||
|
||||
test('members carry presence and app; the client id is never broadcast nor logged', () async {
|
||||
final records = <String>[];
|
||||
final sub = Logger.root.onRecord.listen((r) => records.add('${r.message} ${r.error ?? ''}'));
|
||||
addTearDown(sub.cancel);
|
||||
final alice = await join('alice', client: install, seq: 3);
|
||||
final old = await WebSocket.connect('ws://127.0.0.1:${server.boundPort}/sync');
|
||||
old.add(jsonEncode(const HelloMessage(secret: 's', user: HelloUser(name: 'alice'), client: install, seq: 1).toJson()));
|
||||
await old.drain<void>().timeout(const Duration(seconds: 5));
|
||||
expect(old.closeCode, CloseCodes.stale);
|
||||
await alice.ws.close();
|
||||
await _until(() => server.hub.engine.byClient(install)?.presence == Presence.gone, 'gone');
|
||||
final again = await join('alice', client: install, seq: 4);
|
||||
await _until(() => again.frames.any((f) => f['t'] == 'state'), 'state');
|
||||
final state = again.frames.lastWhere((f) => f['t'] == 'state');
|
||||
final me = (state['members'] as List).cast<Map>().single;
|
||||
expect([me['presence'], me['app']], ['in', '1.3.0']);
|
||||
final everything = [...records, ...again.frames.map(jsonEncode), ...alice.frames.map(jsonEncode)].join('\n');
|
||||
expect(records, isNotEmpty);
|
||||
expect(everything, isNot(contains(install)));
|
||||
expect(redactLog('bad hello: {"client":"$install"}'), isNot(contains(install)));
|
||||
});
|
||||
|
||||
test('server close code for a refused secret; the err names its code', () async {
|
||||
final ws = await WebSocket.connect('ws://127.0.0.1:${server.boundPort}/sync');
|
||||
final frames = <String>[];
|
||||
ws.listen((m) => frames.add('$m'), onError: (_) {});
|
||||
ws.add(jsonEncode(const HelloMessage(secret: 'nope', user: HelloUser(name: 'x')).toJson()));
|
||||
await _until(() => ws.closeCode != null, 'closed');
|
||||
expect(ws.closeCode, CloseCodes.secret);
|
||||
expect((jsonDecode(frames.single) as Map)['code'], 'secret');
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): queue
|
||||
// `by` and adder-only keys follow the lasting client id.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -15,14 +13,15 @@ class _Peer {
|
||||
ws.listen((m) => msgs.add(Map<String, Object?>.from(jsonDecode('$m') as Map)));
|
||||
}
|
||||
|
||||
static Future<_Peer> join(int port, String name) async {
|
||||
static Future<_Peer> join(int port, String name, {String client = '', int seq = 0}) 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))
|
||||
client: client,
|
||||
seq: seq)
|
||||
.toJson());
|
||||
await p.until((m) => m['t'] == 'welcome');
|
||||
return p;
|
||||
@@ -122,4 +121,24 @@ void main() {
|
||||
expect(s.queue.single.media.url, isNull, reason: 'an adder-only item carries no path or link');
|
||||
expect(s.queue.single.key, 'k1234567890');
|
||||
});
|
||||
|
||||
test("an adder's items keep its lasting id through a drop and wait while it is gone", () async {
|
||||
const install = 'queueTestInstallId0001';
|
||||
final a = await _Peer.join(server.boundPort, 'alice', client: install, seq: 1);
|
||||
final b = await _Peer.join(server.boundPort, 'bob');
|
||||
a.send(const QueueIntent.add(QueueItem(
|
||||
media: HelloMedia(name: 'f.mkv', size: 10, duration: 0), how: QueueHow.file, title: 'f', key: 'k1234567890'))
|
||||
.toJson());
|
||||
final added = (await b.state((s) => s.queue.isNotEmpty)).queue.single;
|
||||
final aliceId = added.by;
|
||||
await a.ws.close();
|
||||
// Gone: still a member, so whoever would put the item up waits for it
|
||||
// instead of skipping it.
|
||||
final whileGone = await b.state((s) => s.members.any((m) => m.id == aliceId && m.presence == Presence.gone));
|
||||
expect(whileGone.queue.single.by, aliceId);
|
||||
final again = await _Peer.join(server.boundPort, 'alice', client: install, seq: 2);
|
||||
final back = await again.state((s) => s.members.any((m) => m.id == aliceId && m.presence != Presence.gone));
|
||||
expect(back.queue.single.by, aliceId, reason: 'the same member: its items are its own again');
|
||||
expect(back.members.where((m) => m.name == 'alice'), hasLength(1));
|
||||
});
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class _Member {
|
||||
late WelcomeMessage welcome;
|
||||
_Member(this.ws);
|
||||
|
||||
static Future<_Member> join(int port, String name, {String secret = 'pw'}) async {
|
||||
static Future<_Member> join(int port, String name, {String secret = 'pw', String client = '', int seq = 0}) async {
|
||||
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||
final m = _Member(ws);
|
||||
final w = Completer<String>();
|
||||
@@ -29,8 +29,8 @@ class _Member {
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: secret,
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0),
|
||||
).toJson()));
|
||||
client: client,
|
||||
seq: seq).toJson()));
|
||||
m.welcome = WelcomeMessage.fromJson(
|
||||
Map<String, Object?>.from(jsonDecode(await w.future.timeout(const Duration(seconds: 5))) as Map));
|
||||
return m;
|
||||
@@ -86,8 +86,8 @@ void main() {
|
||||
await tmp.delete(recursive: true);
|
||||
});
|
||||
|
||||
Future<_Member> join(String name) async {
|
||||
final m = await _Member.join(server.boundPort, name);
|
||||
Future<_Member> join(String name, {String client = '', int seq = 0}) async {
|
||||
final m = await _Member.join(server.boundPort, name, client: client, seq: seq);
|
||||
cleanup.add(() => m.ws.close());
|
||||
return m;
|
||||
}
|
||||
@@ -251,6 +251,75 @@ void main() {
|
||||
expect(body, bytes.sublist(RelayBuffer.slice * 4));
|
||||
});
|
||||
|
||||
group('the source drops (D6)', () {
|
||||
const install = 'relaySourceInstall0001';
|
||||
Map? relayOf(_Member m) => m.media?['relay'] as Map?;
|
||||
|
||||
test('gone: the relay stays, sourceGone, the buffer serves; back with the same info it continues', () async {
|
||||
final alice = await join('alice', client: install, seq: 1);
|
||||
final bob = await join('bob');
|
||||
await swapRelay(alice);
|
||||
final src = await startSource(alice);
|
||||
final url = viewerUrl(bob, mediaIdOf(alice));
|
||||
expect((await get(url, range: 'bytes=0-${RelayBuffer.slice * 2 - 1}')).$3, bytes.sublist(0, RelayBuffer.slice * 2));
|
||||
await alice.ws.close();
|
||||
await src.close();
|
||||
await src.done;
|
||||
await _until(() => relayOf(bob)?['sourceGone'] == true, 'sourceGone');
|
||||
expect(relayOf(bob)!['sourceGoneSince'], isA<String>());
|
||||
expect(server.hub.relay, isNotNull, reason: 'the relay lives through the grace');
|
||||
expect((await get(url, range: 'bytes=0-99')).$3, bytes.sublist(0, 100), reason: 'what is buffered serves');
|
||||
final pending = get(url, range: 'bytes=${RelayBuffer.slice * 4}-');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
final again = await join('alice', client: install, seq: 2);
|
||||
expect([again.welcome.resumed, again.welcome.token], [true, alice.welcome.token]);
|
||||
await _until(() => relayOf(bob)?['sourceGone'] == null, 'source back');
|
||||
await startSource(again);
|
||||
final (status, _, body) = await pending.timeout(const Duration(seconds: 10));
|
||||
expect(status, 206);
|
||||
expect(body, bytes.sublist(RelayBuffer.slice * 4), reason: 'the rest is pulled from the source again');
|
||||
});
|
||||
|
||||
test('back with another media: closed with 4002, the relay ends', () async {
|
||||
final alice = await join('alice');
|
||||
final bob = await join('bob');
|
||||
await swapRelay(alice);
|
||||
final src = await startSource(alice);
|
||||
await _until(() => server.hub.relay?.live == true, 'live');
|
||||
await src.close();
|
||||
await src.done;
|
||||
final other = File('${tmp.path}/other.mkv')..writeAsBytesSync(List.filled(1000, 3));
|
||||
final wrong = await startSource(alice, from: RelayMedia.single(FileRelayBytes(other)));
|
||||
await wrong.done.timeout(const Duration(seconds: 5));
|
||||
await _until(() => server.hub.relay == null, 'relay ended');
|
||||
await _until(() => relayOf(bob) != null && relayOf(bob)!['live'] == null, 'not live');
|
||||
expect((await get(viewerUrl(bob, mediaIdOf(alice)), range: 'bytes=0-9')).$1, 404);
|
||||
});
|
||||
|
||||
test('a second channel for the same relay replaces the first (4003)', () async {
|
||||
final alice = await join('alice');
|
||||
await swapRelay(alice);
|
||||
final first = await startSource(alice);
|
||||
await _until(() => server.hub.relay?.live == true, 'live');
|
||||
await startSource(alice);
|
||||
await first.done.timeout(const Duration(seconds: 5));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
expect(server.hub.relay?.live, isTrue, reason: 'the second one feeds it');
|
||||
});
|
||||
|
||||
test('its source leaving for good ends the relay', () async {
|
||||
final alice = await join('alice');
|
||||
final bob = await join('bob');
|
||||
await swapRelay(alice);
|
||||
final src = await startSource(alice);
|
||||
await _until(() => server.hub.relay?.live == true, 'live');
|
||||
alice.send(const ByeMessage().toJson());
|
||||
await src.done.timeout(const Duration(seconds: 5));
|
||||
await _until(() => relayOf(bob)?['sourceGone'] == true && relayOf(bob)?['live'] == null, 'ended');
|
||||
expect(server.hub.relay, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('two tracks: each byte-exact at its own URL, welcome is an EDL of both', () async {
|
||||
final alice = await join('alice');
|
||||
final bob = await join('bob');
|
||||
@@ -554,13 +623,17 @@ void main() {
|
||||
expect(server.hub.warm!.queueId, isNot(id));
|
||||
});
|
||||
|
||||
test('not for an adder alone; dropped when its adder leaves', () async {
|
||||
test('not for an adder alone; kept while its adder is gone, dropped when it leaves', () async {
|
||||
final alice = await join('alice');
|
||||
await add(alice, through('k-abcdefgh'));
|
||||
expect(headRelay(alice), isNull);
|
||||
final bob = await join('bob');
|
||||
await _until(() => headRelay(bob) != null, 'warm relay once someone could read it');
|
||||
await alice.ws.close();
|
||||
await _until(() => headRelay(bob)?['sourceGone'] == true, 'adder gone');
|
||||
expect(server.hub.warm, isNotNull, reason: 'its adder may come back within the grace');
|
||||
server.hub.engine.applyLeave(alice.welcome.token, reason: 'bye');
|
||||
server.hub.changed();
|
||||
await _until(() => bob.lastState!['members'] is List && (bob.lastState!['members'] as List).length == 1, 'left');
|
||||
expect(queueOf(bob), hasLength(1));
|
||||
expect(headRelay(bob), isNull);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence):
|
||||
// "proto mismatch rejected" (min/max range, refusal frame) and "silent
|
||||
// socket dropped and member removed" (Gone with grace); the rest stays.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -43,15 +40,15 @@ void main() {
|
||||
|
||||
Future<String> hello(String secret,
|
||||
{int proto = RelayConstants.protoVersion,
|
||||
int? protoMin,
|
||||
Duration timeout = const Duration(seconds: 5)}) async {
|
||||
final ch = WebSocketChannel.connect(
|
||||
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
||||
final msg = HelloMessage(
|
||||
proto: proto,
|
||||
protoMin: protoMin,
|
||||
secret: secret,
|
||||
user: const HelloUser(name: 'alice'),
|
||||
media:
|
||||
const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson();
|
||||
user: const HelloUser(name: 'alice')).toJson();
|
||||
ch.sink.add(jsonEncode(msg));
|
||||
try {
|
||||
final first = await ch.stream.first.timeout(timeout);
|
||||
@@ -73,7 +70,9 @@ void main() {
|
||||
final decoded = jsonDecode(body) as Map;
|
||||
expect(decoded['ok'], isTrue);
|
||||
expect(decoded['name'], 'test');
|
||||
expect(decoded['proto'], RelayConstants.protoVersion);
|
||||
expect(decoded['proto'], RelayConstants.protoMax);
|
||||
expect([decoded['protoMin'], decoded['protoMax'], decoded['version'], decoded['tls']],
|
||||
[RelayConstants.protoMin, RelayConstants.protoMax, appVersion, false]);
|
||||
expect(decoded['viewers'], 0);
|
||||
expect(decoded['bytesServed'], 0);
|
||||
expect(decoded.containsKey('media'), isFalse, reason: 'no media name leak');
|
||||
@@ -81,7 +80,7 @@ void main() {
|
||||
|
||||
test('wrong secret gets err, right secret gets welcome+token', () async {
|
||||
final bad = await hello('wrong');
|
||||
expect(jsonDecode(bad)['t'], 'err');
|
||||
expect([jsonDecode(bad)['t'], jsonDecode(bad)['code'], jsonDecode(bad)['message']], ['err', 'secret', 'wrong secret']);
|
||||
|
||||
final good = await hello('shh');
|
||||
final decoded = jsonDecode(good) as Map;
|
||||
@@ -96,10 +95,32 @@ void main() {
|
||||
expect(server.session.tokens.length, 1);
|
||||
});
|
||||
|
||||
test('proto mismatch rejected', () async {
|
||||
final reply =
|
||||
await hello('shh', proto: RelayConstants.protoVersion + 99);
|
||||
expect(jsonDecode(reply)['t'], 'err');
|
||||
test('version ranges: a common version is taken, none is refused with code version', () async {
|
||||
final newer = jsonDecode(await hello('shh', protoMin: RelayConstants.protoMin, proto: RelayConstants.protoMax + 3));
|
||||
expect([newer['t'], newer['proto']], ['welcome', RelayConstants.protoMax], reason: 'the highest common one');
|
||||
final ahead = jsonDecode(await hello('shh', protoMin: RelayConstants.protoMax + 1, proto: RelayConstants.protoMax + 2));
|
||||
expect([ahead['t'], ahead['code'], ahead['min'], ahead['max'], ahead['server']],
|
||||
['err', 'version', RelayConstants.protoMin, RelayConstants.protoMax, appVersion]);
|
||||
expect(server.session.tokens, hasLength(1));
|
||||
});
|
||||
|
||||
test('a proto 15 hello gets the stable refusal it can show, then the version close code', () async {
|
||||
final ch = WebSocketChannel.connect(Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
||||
// What a 1.2.x app sends: no protoMin, a media object, a resume token.
|
||||
ch.sink.add(jsonEncode({
|
||||
't': 'hello',
|
||||
'proto': 15,
|
||||
'secret': 'shh',
|
||||
'user': {'name': 'old', 'avatar': ''},
|
||||
'media': {'name': '', 'size': 0, 'duration': 0},
|
||||
'resume': 'deadbeef',
|
||||
}));
|
||||
final frames = await ch.stream.toList().timeout(const Duration(seconds: 5));
|
||||
final err = jsonDecode('${frames.single}') as Map;
|
||||
expect(err['t'], 'err');
|
||||
expect(err['code'], 'version');
|
||||
expect(err['message'], startsWith('proto mismatch: got 15'));
|
||||
expect(ch.closeCode, CloseCodes.version);
|
||||
expect(server.session.tokens, isEmpty);
|
||||
});
|
||||
|
||||
@@ -136,7 +157,7 @@ void main() {
|
||||
await resp.drain();
|
||||
});
|
||||
|
||||
test('silent socket dropped and member removed', () async {
|
||||
test('a silent socket is closed and its member is gone, not removed', () async {
|
||||
final ch = WebSocketChannel.connect(
|
||||
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
||||
final messages = <String>[];
|
||||
@@ -148,9 +169,7 @@ void main() {
|
||||
final msg = HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'shh',
|
||||
user: const HelloUser(name: 'ghost'),
|
||||
media: const HelloMedia(
|
||||
name: 'f.bin', size: 8192, duration: 10.0)).toJson();
|
||||
user: const HelloUser(name: 'ghost')).toJson();
|
||||
ch.sink.add(jsonEncode(msg));
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (messages.isEmpty && DateTime.now().isBefore(deadline)) {
|
||||
@@ -160,18 +179,16 @@ void main() {
|
||||
(jsonDecode(messages.firstWhere((m) => (jsonDecode(m) as Map)['t'] == 'welcome')) as Map)['token']
|
||||
as String;
|
||||
server.hub.engine.members[token]!.lastSeen =
|
||||
DateTime.now().toUtc().subtract(const Duration(seconds: 61));
|
||||
server.hub.dropSilentSockets();
|
||||
DateTime.now().toUtc().subtract(const Duration(seconds: 16));
|
||||
server.hub.engine.tick();
|
||||
final closeDeadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while ((!wsClosed ||
|
||||
server.hub.engine.members.containsKey(token) ||
|
||||
server.session.tokens.containsKey(token)) &&
|
||||
DateTime.now().isBefore(closeDeadline)) {
|
||||
while (!wsClosed && DateTime.now().isBefore(closeDeadline)) {
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
expect(wsClosed, isTrue);
|
||||
expect(server.hub.engine.members.containsKey(token), isFalse);
|
||||
expect(server.session.tokens.containsKey(token), isFalse);
|
||||
expect(ch.closeCode, CloseCodes.silent);
|
||||
expect(server.hub.engine.members[token]!.presence, Presence.gone);
|
||||
expect(server.session.tokens.containsKey(token), isTrue);
|
||||
try {
|
||||
await sub.cancel();
|
||||
} catch (_) {}
|
||||
@@ -193,9 +210,7 @@ void main() {
|
||||
ch.sink.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'shh',
|
||||
user: const HelloUser(name: 'hb'),
|
||||
media: const HelloMedia(
|
||||
name: 'f.bin', size: 8192, duration: 10.0))
|
||||
user: const HelloUser(name: 'hb'))
|
||||
.toJson()));
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final hbToken = server.session.tokens.keys
|
||||
@@ -223,8 +238,7 @@ void main() {
|
||||
ch.sink.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'shh',
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson()));
|
||||
user: HelloUser(name: name)).toJson()));
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (!messages.any((m) => m['t'] == 'welcome')) {
|
||||
if (DateTime.now().isAfter(deadline)) fail('no welcome');
|
||||
@@ -329,8 +343,7 @@ void main() {
|
||||
ch.sink.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'shh',
|
||||
user: HelloUser(name: 'alice', avatar: base64Encode(png)),
|
||||
media: const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson()));
|
||||
user: HelloUser(name: 'alice', avatar: base64Encode(png))).toJson()));
|
||||
final welcome = await waitFor((m) => m['t'] == 'welcome');
|
||||
final token = welcome['token'] as String;
|
||||
final first = member(Map<String, Object?>.from(welcome['state'] as Map));
|
||||
@@ -376,7 +389,7 @@ void main() {
|
||||
ch.sink.add(jsonEncode(
|
||||
ProfileIntent(avatar: 'x' * (RelayConstants.maxAvatarBytes + 1)).toJson()));
|
||||
final err = await waitFor((m) => m['t'] == 'err');
|
||||
expect(err['message'], 'rejected: avatar too large');
|
||||
expect([err['code'], err['for'], err['message']], ['rejected', 'profile', 'rejected: avatar too large']);
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
try {
|
||||
|
||||
@@ -71,8 +71,7 @@ void main() {
|
||||
ch.sink.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 's',
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||
user: HelloUser(name: name))
|
||||
.toJson()));
|
||||
while (await msgs.moveNext()) {
|
||||
final m = jsonDecode('${msgs.current}') as Map;
|
||||
|
||||
@@ -64,17 +64,17 @@ void main() {
|
||||
});
|
||||
|
||||
group('core welcome + proto', () {
|
||||
test('proto 15: avatars by hash, profile intent', () {
|
||||
expect(RelayConstants.protoVersion, 15);
|
||||
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
|
||||
test('avatars by hash, profile intent', () {
|
||||
expect(RelayConstants.protoVersion, RelayConstants.protoMax);
|
||||
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0);
|
||||
expect(v.toJson().containsKey('avatarHash'), isFalse);
|
||||
expect(v.toJson().containsKey('avatar'), isFalse);
|
||||
const h = MemberView(
|
||||
id: 'm1', name: 'a', avatarHash: 'abc', ready: false, inSet: false, buffered: 0, stale: false);
|
||||
id: 'm1', name: 'a', avatarHash: 'abc', ready: false, inSet: false, buffered: 0);
|
||||
expect(MemberView.fromJson(Map<String, Object?>.from(h.toJson())).avatarHash, 'abc');
|
||||
// host: only on the hosting app's member, absent otherwise.
|
||||
expect(v.toJson().containsKey('host'), isFalse);
|
||||
const host = MemberView(id: 'm2', name: 'h', ready: false, inSet: false, buffered: 0, stale: false, host: true);
|
||||
const host = MemberView(id: 'm2', name: 'h', ready: false, inSet: false, buffered: 0, host: true);
|
||||
expect(host.toJson()['host'], isTrue);
|
||||
expect(MemberView.fromJson(Map<String, Object?>.from(host.toJson())).host, isTrue);
|
||||
expect(MemberView.fromJson(Map<String, Object?>.from(v.toJson())).host, isFalse);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): the
|
||||
// resume-token takeover becomes a lasting client id; ping/pong and the
|
||||
// seek echo stay.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -47,12 +44,15 @@ class _Client {
|
||||
final WebSocket ws;
|
||||
final frames = <Map<String, Object?>>[];
|
||||
final _welcome = Completer<Map<String, Object?>>();
|
||||
final closed = Completer<int?>();
|
||||
_Client(this.ws) {
|
||||
ws.listen((m) {
|
||||
final d = Map<String, Object?>.from(jsonDecode('$m') as Map);
|
||||
frames.add(d);
|
||||
if (d['t'] == 'welcome' && !_welcome.isCompleted) _welcome.complete(d);
|
||||
}, onError: (_) {}, onDone: () {});
|
||||
}, onError: (_) {}, onDone: () {
|
||||
if (!closed.isCompleted) closed.complete(ws.closeCode);
|
||||
});
|
||||
}
|
||||
|
||||
StateBroadcast? get lastState {
|
||||
@@ -65,18 +65,24 @@ class _Client {
|
||||
void send(Map<String, Object?> j) => ws.add(jsonEncode(j));
|
||||
|
||||
static Future<(_Client, WelcomeMessage)> join(int port, String name,
|
||||
{String resume = ''}) async {
|
||||
{String client = '', int seq = 0}) async {
|
||||
final c = await dial(port, name, client: client, seq: seq);
|
||||
final w = await c._welcome.future.timeout(const Duration(seconds: 5));
|
||||
return (c, WelcomeMessage.fromJson(w));
|
||||
}
|
||||
|
||||
/// Sends the hello and returns at once (a refused one gets no welcome).
|
||||
static Future<_Client> dial(int port, String name, {String client = '', int seq = 0}) async {
|
||||
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||
final c = _Client(ws);
|
||||
c.send(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'w',
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0),
|
||||
resume: resume,
|
||||
).toJson());
|
||||
final w = await c._welcome.future.timeout(const Duration(seconds: 5));
|
||||
return (c, WelcomeMessage.fromJson(w));
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'w',
|
||||
user: HelloUser(name: name),
|
||||
client: client,
|
||||
seq: seq)
|
||||
.toJson());
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,42 +104,70 @@ void main() {
|
||||
});
|
||||
tearDown(() => server.stop());
|
||||
|
||||
test('reconnect after an unclean drop takes over the old member, no ghost',
|
||||
() async {
|
||||
const alice = 'aliceInstallIdAAAAAAAA';
|
||||
|
||||
test('an unclean drop: the same app takes its member over (id, token, ready), no ghost', () async {
|
||||
final proxy = await _FreezableProxy.start(server.boundPort);
|
||||
final other = (await _Client.join(server.boundPort, 'other')).$1;
|
||||
final (first, welcome) = await _Client.join(proxy.port, 'alice');
|
||||
final (first, welcome) = await _Client.join(proxy.port, 'alice', client: alice, seq: 1);
|
||||
expect(welcome.resumed, isFalse);
|
||||
expect(welcome.instance, server.hub.instance);
|
||||
expect(welcome.proto, RelayConstants.protoMax);
|
||||
expect(welcome.grace, RelayConstants.graceSecs);
|
||||
first.send(const ReadyIntent(ready: true).toJson());
|
||||
await _until(() => server.hub.engine.members[welcome.token]?.ready == true,
|
||||
'ready');
|
||||
await _until(() => server.hub.engine.members[welcome.token]?.ready == true, 'ready');
|
||||
proxy.frozen = true; // unclean drop: the server's socket stays open
|
||||
|
||||
final (second, again) =
|
||||
await _Client.join(server.boundPort, 'alice', resume: welcome.token);
|
||||
final (second, again) = await _Client.join(server.boundPort, 'alice', client: alice, seq: 2);
|
||||
expect(again.resumed, isTrue);
|
||||
expect(again.memberId, welcome.memberId);
|
||||
expect(again.token, welcome.token,
|
||||
reason: 'same token keeps the media URL valid');
|
||||
expect(server.hub.engine.members.length, 2,
|
||||
reason: 'alice once (taken over) plus other, no ghost');
|
||||
expect(server.hub.engine.members[welcome.token]!.ready, isTrue,
|
||||
reason: 'ready survives the takeover');
|
||||
// The frozen old socket closing later must not remove the new one.
|
||||
expect(again.token, welcome.token, reason: 'same token keeps the media URL valid');
|
||||
expect(server.hub.engine.members.length, 2, reason: 'alice once (taken over) plus other, no ghost');
|
||||
expect(server.hub.engine.members[welcome.token]!.ready, isTrue, reason: 'ready survives the takeover');
|
||||
// The frozen old socket closing later must not touch the member.
|
||||
await proxy.close();
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
expect(server.hub.engine.members.containsKey(welcome.token), isTrue);
|
||||
expect(server.hub.engine.members[welcome.token]!.presence, isNot(Presence.gone));
|
||||
expect(server.hub.sockets.containsKey(welcome.token), isTrue);
|
||||
second.send(const ChatMessage(text: 'still here').toJson());
|
||||
await _until(
|
||||
() => other.frames.any((f) => f['t'] == 'chat' && f['text'] == 'still here'),
|
||||
await _until(() => other.frames.any((f) => f['t'] == 'chat' && f['text'] == 'still here'),
|
||||
'chat from the resumed member');
|
||||
await other.ws.close();
|
||||
await second.ws.close();
|
||||
});
|
||||
|
||||
test('unknown resume token joins as a new member', () async {
|
||||
final (a, wa) = await _Client.join(server.boundPort, 'a', resume: 'nope');
|
||||
expect(wa.token, isNot('nope'));
|
||||
test('a takeover tells the older socket: err replaced, then its close code', () async {
|
||||
final (first, welcome) = await _Client.join(server.boundPort, 'alice', client: alice, seq: 1);
|
||||
final (second, again) = await _Client.join(server.boundPort, 'alice', client: alice, seq: 1);
|
||||
expect([again.resumed, again.memberId, again.token], [true, welcome.memberId, welcome.token],
|
||||
reason: 'the same seq takes over too');
|
||||
expect(await first.closed.future.timeout(const Duration(seconds: 5)), CloseCodes.replaced);
|
||||
final err = first.frames.lastWhere((f) => f['t'] == 'err');
|
||||
expect(err['code'], 'replaced');
|
||||
// Frames from the old socket (had it stayed open) no longer count.
|
||||
expect(server.hub.sockets[welcome.token], isNotNull);
|
||||
await second.ws.close();
|
||||
});
|
||||
|
||||
test("an older hello than the member's gets err stale and is closed", () async {
|
||||
final (current, welcome) = await _Client.join(server.boundPort, 'alice', client: alice, seq: 5);
|
||||
final older = await _Client.dial(server.boundPort, 'alice', client: alice, seq: 4);
|
||||
expect(await older.closed.future.timeout(const Duration(seconds: 5)), CloseCodes.stale);
|
||||
expect(older.frames.single['code'], 'stale');
|
||||
expect(server.hub.sockets.containsKey(welcome.token), isTrue, reason: 'the current one stays');
|
||||
expect(server.hub.engine.members, hasLength(1));
|
||||
await current.ws.close();
|
||||
});
|
||||
|
||||
test('without a client id, or with another, it is a new member', () async {
|
||||
final (a, wa) = await _Client.join(server.boundPort, 'a');
|
||||
final (b, wb) = await _Client.join(server.boundPort, 'a');
|
||||
final (c, wc) = await _Client.join(server.boundPort, 'a', client: 'someOtherInstallId0001');
|
||||
expect({wa.memberId, wb.memberId, wc.memberId}, hasLength(3));
|
||||
expect([wa.resumed, wb.resumed, wc.resumed], [false, false, false]);
|
||||
await a.ws.close();
|
||||
await b.ws.close();
|
||||
await c.ws.close();
|
||||
});
|
||||
|
||||
test('ping gets a pong with the client stamp and the server clock', () async {
|
||||
|
||||
+2
-3
@@ -35,10 +35,9 @@ Future<void> main(List<String> argv) async {
|
||||
ws.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: '',
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||
user: HelloUser(name: name))
|
||||
.toJson()));
|
||||
// Stay alive like a real client (silent members get dropped).
|
||||
// Stay alive like a real client (a silent member goes gone).
|
||||
Timer.periodic(const Duration(seconds: 1), (_) => ws.add(jsonEncode({'t': 'report', 'position': 0, 'buffered': 0, 'eventId': 0})));
|
||||
return (ws, await welcome.future);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user