Restructure step 1: lasting identity, presence and protocol 16

Server and core: version ranges on both sides with a coded refusal; a
client id maps to the same member and token for the server's life; a
drop is `gone` for a 10-minute grace keeping ready, presence, queue
items, relay and avatar; `bye` leaves at once; ordered takeover (seq,
replaced/stale); presence joining/in/away/gone with one PresencePolicy;
joiners never pause playback and hold a held gate at most ~15 s;
state.hold; feed ids, seq and replay; the relay lives through its
source's grace and continues on re-dial; queue `by` lasting, auto
items and cancel; MediaRef carried; client ids never logged.

App: persisted client id and seq; Connection with unstable/lost/
replaced states, backoff with jitter, no lost/back pair per flap; a drop
keeps the session (state, player, media, relay source); presence in the
strip and members card; sit out = away intent; feed matched by id;
auto sit-out (idle when not playing, minimised for a while; never when
alone) replaces auto-unready; bye on close and Leave, not on update
restart; relay source re-dials through its grace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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

@@ -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",