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

+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);