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

+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,
+201
View File
@@ -0,0 +1,201 @@
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 '../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>[
Colors.deepPurple,
Colors.indigo,
Colors.teal,
Colors.pink,
Colors.orange,
Colors.blueGrey,
];
const _keyPicture = 'avatarPng';
const _keyColor = 'profileColor';
/// Sends a mid-session name/picture change (null = unchanged, empty
/// avatar = removed). Swappable so tests can watch it.
typedef ProfilePush = void Function({String? name, String? avatar});
/// The one place a profile change reaches the server.
void pushProfileToSession({String? name, String? avatar}) {
final session = SessionState.instance;
if (session.route.peek() != AppRoute.session) return;
session.coordinator.updateProfile(name: name, avatar: avatar);
}
/// Display name, picture and colour of this app's user. Backed by
/// [SessionState.lastForm] (name) and the connect-form box (the cropped
/// 256 px PNG and the colour); every change is persisted, and while in a
/// server it is pushed through [push] so everyone sees it at once.
class Profile {
static final Profile instance = Profile._();
Profile._();
final name = signal('');
/// The cropped PNG; null shows the initial on [color].
final picture = signal<Uint8List?>(null);
final color = signal<Color>(profileColors.first);
final error = signal<String?>(null);
ProfilePush push = pushProfileToSession;
bool _loaded = false;
Timer? _nameTimer;
/// Reads the stored profile once (the app shell starts it). Old installs
/// stored a picture path: it is shrunk through [encodeAvatar] once and
/// kept as bytes. A caller during the first read does not wait for it
/// (no shared Future: tests run each in its own zone).
Future<void> ensureLoaded() async {
if (_loaded) return;
_loaded = true;
await _load();
}
Future<void> _load() async {
final form = SessionState.instance.lastForm;
name.value = form.name;
try {
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
final png = box.get(_keyPicture);
final c = box.get(_keyColor);
if (c is int) color.value = Color(c);
if (png is Uint8List && png.isNotEmpty) {
picture.value = png;
} else if (form.avatarPath.isNotEmpty) {
final b64 = await encodeAvatar(form.avatarPath);
if (b64.isNotEmpty) picture.value = base64Decode(b64);
form.avatarPath = '';
await _save();
}
} catch (e) {
// No store (tests) or an unreadable old picture: start without.
debugPrint('profile load: $e');
}
}
/// The picture as sent in hello and profile updates; '' when none.
String avatarBase64() {
final png = picture.peek();
return png == null ? '' : avatarWireBase64(png);
}
void setName(String value) {
final v = value.trim();
if (v.isEmpty || v == name.peek()) return;
name.value = v;
SessionState.instance.lastForm.name = v;
unawaited(_save());
// Typing renames once it settles, not on every key.
_nameTimer?.cancel();
_nameTimer = Timer(const Duration(milliseconds: 600), () => push(name: name.peek()));
}
Future<void> setPicture(Uint8List? png) async {
picture.value = png;
error.value = null;
await _save();
try {
push(avatar: png == null ? '' : avatarWireBase64(png));
} on FormatException catch (e) {
error.value = e.message;
}
}
Future<void> setColor(Color c) async {
color.value = c;
await _save();
}
Future<void> _save() async {
try {
await SessionState.instance.saveForm();
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
final png = picture.peek();
await box.putAll({
_keyPicture: png ?? Uint8List(0),
_keyColor: color.peek().toARGB32(),
});
} catch (_) {
error.value = 'could not save profile — try again';
}
}
}
-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();