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

@@ -19,9 +19,12 @@ bool _shuttingDown = false;
bool get shuttingDown => _shuttingDown;
/// Hides the window, releases every player and the sound device, leaves
/// (others see "left", a hosted server stops), runs [beforeQuit], then
/// closes the window. Every step is bounded; a second call does nothing.
Future<void> shutDownApp({Future<void> Function()? beforeQuit}) async {
/// (the `bye` goes out first, so others see "left" at once; a hosted
/// server stops), runs [beforeQuit], then closes the window. [restart]
/// (Update and restart) leaves without the `bye`: the member stays
/// `gone`, and the restarted app, same client id, takes it back within
/// the grace. Every step is bounded; a second call does nothing.
Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = false}) async {
if (_shuttingDown) return;
_shuttingDown = true;
final session = SessionState.instance;
@@ -38,9 +41,9 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit}) async {
// its own after dispose returns; it must be done before the engine
// stops, or the plugin's teardown waits on it for good.
if (Platform.isWindows) await Future.delayed(const Duration(milliseconds: 300));
// Jellyfin stop report, last diagnostics, the goodbye, then the hosted
// server: 3 s in all.
await _step('leave', session.leave(), const Duration(milliseconds: 2500));
// Jellyfin stop report, last diagnostics, the bye (flushed), then the
// hosted server: 3 s in all.
await _step('leave', session.leave(bye: !restart), const Duration(milliseconds: 2500));
await _step('server stop', session.stopServer(), const Duration(milliseconds: 500));
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
@@ -55,6 +55,19 @@ class DesktopShell implements ShellPort {
return () => windowManager.removeListener(l);
}
final _activity = <void Function(WindowActivity)>{};
@override
void Function() watchActivity(void Function(WindowActivity activity) onChange) {
final l = _ActivityWatch(onChange);
_activity.add(onChange);
windowManager.addListener(l);
return () {
_activity.remove(onChange);
windowManager.removeListener(l);
};
}
@override
Future<void> startDragging() => windowManager.startDragging();
@@ -65,7 +78,13 @@ class DesktopShell implements ShellPort {
Future<void> saveWindow() async => _memory?.save();
@override
Future<void> hide() => windowManager.hide();
Future<void> hide() async {
// window_manager has no hide event: tell the watchers here.
for (final f in [..._activity]) {
f(WindowActivity.hidden);
}
await windowManager.hide();
}
@override
Future<void> quit() async {
@@ -131,3 +150,18 @@ class _MaximizeWatch with WindowListener {
@override
void onWindowUnmaximize() => onChange(false);
}
/// Focus, blur, minimise and restore as [WindowActivity] (a restored
/// window is shown; its focus event follows when it gets the keyboard).
class _ActivityWatch with WindowListener {
final void Function(WindowActivity) onChange;
_ActivityWatch(this.onChange);
@override
void onWindowFocus() => onChange(WindowActivity.focused);
@override
void onWindowBlur() => onChange(WindowActivity.unfocused);
@override
void onWindowMinimize() => onChange(WindowActivity.minimised);
@override
void onWindowRestore() => onChange(WindowActivity.unfocused);
}
@@ -1,5 +1,21 @@
import 'package:flutter/widgets.dart';
/// Where the app window stands, for auto sit-out
/// ([ShellPort.watchActivity]).
enum WindowActivity {
/// The window has the keyboard focus.
focused,
/// Shown, but another window has the focus.
unfocused,
/// Minimised to the taskbar.
minimised,
/// Hidden (closing, or a platform that hides instead of minimising).
hidden,
}
/// What the clipboard holds for a paste: its files, else its text.
typedef Paste = ({List<String> files, String? text});
@@ -22,6 +38,10 @@ abstract interface class ShellPort {
/// returns the call that stops it.
void Function() watchMaximized(void Function(bool maximized) onChange);
/// Calls [onChange] whenever the window gains or loses the focus, is
/// minimised, restored or hidden; returns the call that stops it.
void Function() watchActivity(void Function(WindowActivity activity) onChange);
/// Moves the window with the pointer (a press on the title area).
Future<void> startDragging();