Restructure step 1: lasting identity, presence and protocol 16
ci / core (push) Successful in 28s
ci / server (push) Successful in 1m5s
ci / updater-windows (push) Successful in 5m48s

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:
foxyzandClaude Opus 5.5 committed 2026-10-15 21:39:47 +03:00
1 parent 51c1c5ab78
commit 9f2586b7ff
85 files changed
+5198 -1902

No files matched your search

+23 -4
View File
@@ -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
+2
View File
@@ -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;
+11 -7
View File
@@ -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;
}
+54 -41
View File
@@ -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);
}
}
+291 -122
View File
@@ -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();
}
}
+4 -3
View File
@@ -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,
@@ -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>[
-43
View File
@@ -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
View File
@@ -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;
+27
View File
@@ -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();
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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 -5
View File
@@ -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),
);
}
+41 -22
View File
@@ -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));
}
+9 -6
View File
@@ -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');
+20 -9
View File
@@ -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'),
],
),
+12 -5
View File
@@ -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,
+4 -3
View File
@@ -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(),
],
),
);
+10 -7
View File
@@ -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'),
],
),
),
+1 -1
View File
@@ -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;
+9 -8
View File
@@ -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
+141 -43
View File
@@ -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) {
+1 -1
View File
@@ -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();
+162
View File
@@ -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();
}
});
}
+394 -219
View File
@@ -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();
}
});
}
+2 -2
View File
@@ -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')!;
+9 -3
View File
@@ -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');
+40 -18
View File
@@ -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,
+3 -3
View File
@@ -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);
+11 -5
View File
@@ -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));
+20 -12
View File
@@ -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', () {
+25 -57
View File
@@ -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();
}
}
}
+3 -8
View File
@@ -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)
+88 -58
View File
@@ -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');
});
});
+18 -2
View File
@@ -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 {
+11 -5
View File
@@ -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));
+3 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+6 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -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';
+27 -5
View File
@@ -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
+3 -2
View File
@@ -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;
+124
View File
@@ -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);
+576 -61
View File
@@ -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,
);
}
}
+100 -18
View File
@@ -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;
}
}
+21 -6
View File
@@ -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');
+366 -148
View File
@@ -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,
);
}
}
+149 -11
View File
@@ -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));
});
});
}
+2 -2
View File
@@ -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,
+9
View File
@@ -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');
+48
View File
@@ -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);
});
}
+54 -1
View File
@@ -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');
});
});
}
+253 -83
View File
@@ -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(
+20 -10
View File
@@ -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}'}'
+52 -4
View File
@@ -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(' + ')})');
+261 -89
View File
@@ -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();
+9 -9
View File
@@ -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');
+41 -21
View File
@@ -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));
});
}
+16 -16
View File
@@ -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)));
}
}
+14 -8
View File
@@ -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);
});
+192
View File
@@ -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');
});
}
+23 -4
View File
@@ -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));
});
}
+79 -6
View File
@@ -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);
+45 -32
View File
@@ -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 {
+1 -2
View File
@@ -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;
+5 -5
View File
@@ -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);
+67 -33
View File
@@ -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
View File
@@ -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);
}