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

+2
View File
@@ -52,6 +52,8 @@ Future<void> main(List<String> args) async {
final ports = SessionState.instance.ports;
await ports.shell.start(
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: shutDownApp);
// Focus, minimise and restore feed auto sit-out.
ports.shell.watchActivity(SessionState.instance.coordinator.windowActivity);
try {
await SessionState.instance.loadForm();
} catch (e, st) {
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart';
import '../session/party.dart' show isPresent;
import 'accounts.dart';
import 'format.dart';
@@ -333,13 +334,13 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
/// Who performs the auto-advance swap, so several counting clients do not
/// swap twice: the lowest member id among members that play the item with
/// their own account (`jf[serverId] == true`, not stale, not out). Only what every
/// their own account (`jf[serverId] == true`, present: `in` or joining). Only what every
/// client sees in `state` decides (who put the item up is not in it: a
/// late joiner never saw that swap), so all clients agree.
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
final eligible = [
for (final m in members)
if (!m.stale && !m.out && m.jf[serverId] == true) m
if (isPresent(m) && m.jf[serverId] == true) m
];
if (eligible.isEmpty) return null;
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
@@ -19,9 +19,12 @@ bool _shuttingDown = false;
bool get shuttingDown => _shuttingDown;
/// Hides the window, releases every player and the sound device, leaves
/// (others see "left", a hosted server stops), runs [beforeQuit], then
/// closes the window. Every step is bounded; a second call does nothing.
Future<void> shutDownApp({Future<void> Function()? beforeQuit}) async {
/// (the `bye` goes out first, so others see "left" at once; a hosted
/// server stops), runs [beforeQuit], then closes the window. [restart]
/// (Update and restart) leaves without the `bye`: the member stays
/// `gone`, and the restarted app, same client id, takes it back within
/// the grace. Every step is bounded; a second call does nothing.
Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = false}) async {
if (_shuttingDown) return;
_shuttingDown = true;
final session = SessionState.instance;
@@ -38,9 +41,9 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit}) async {
// its own after dispose returns; it must be done before the engine
// stops, or the plugin's teardown waits on it for good.
if (Platform.isWindows) await Future.delayed(const Duration(milliseconds: 300));
// Jellyfin stop report, last diagnostics, the goodbye, then the hosted
// server: 3 s in all.
await _step('leave', session.leave(), const Duration(milliseconds: 2500));
// Jellyfin stop report, last diagnostics, the bye (flushed), then the
// hosted server: 3 s in all.
await _step('leave', session.leave(bye: !restart), const Duration(milliseconds: 2500));
await _step('server stop', session.stopServer(), const Duration(milliseconds: 500));
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
@@ -55,6 +55,19 @@ class DesktopShell implements ShellPort {
return () => windowManager.removeListener(l);
}
final _activity = <void Function(WindowActivity)>{};
@override
void Function() watchActivity(void Function(WindowActivity activity) onChange) {
final l = _ActivityWatch(onChange);
_activity.add(onChange);
windowManager.addListener(l);
return () {
_activity.remove(onChange);
windowManager.removeListener(l);
};
}
@override
Future<void> startDragging() => windowManager.startDragging();
@@ -65,7 +78,13 @@ class DesktopShell implements ShellPort {
Future<void> saveWindow() async => _memory?.save();
@override
Future<void> hide() => windowManager.hide();
Future<void> hide() async {
// window_manager has no hide event: tell the watchers here.
for (final f in [..._activity]) {
f(WindowActivity.hidden);
}
await windowManager.hide();
}
@override
Future<void> quit() async {
@@ -131,3 +150,18 @@ class _MaximizeWatch with WindowListener {
@override
void onWindowUnmaximize() => onChange(false);
}
/// Focus, blur, minimise and restore as [WindowActivity] (a restored
/// window is shown; its focus event follows when it gets the keyboard).
class _ActivityWatch with WindowListener {
final void Function(WindowActivity) onChange;
_ActivityWatch(this.onChange);
@override
void onWindowFocus() => onChange(WindowActivity.focused);
@override
void onWindowBlur() => onChange(WindowActivity.unfocused);
@override
void onWindowMinimize() => onChange(WindowActivity.minimised);
@override
void onWindowRestore() => onChange(WindowActivity.unfocused);
}
@@ -1,5 +1,21 @@
import 'package:flutter/widgets.dart';
/// Where the app window stands, for auto sit-out
/// ([ShellPort.watchActivity]).
enum WindowActivity {
/// The window has the keyboard focus.
focused,
/// Shown, but another window has the focus.
unfocused,
/// Minimised to the taskbar.
minimised,
/// Hidden (closing, or a platform that hides instead of minimising).
hidden,
}
/// What the clipboard holds for a paste: its files, else its text.
typedef Paste = ({List<String> files, String? text});
@@ -22,6 +38,10 @@ abstract interface class ShellPort {
/// returns the call that stops it.
void Function() watchMaximized(void Function(bool maximized) onChange);
/// Calls [onChange] whenever the window gains or loses the focus, is
/// minimised, restored or hidden; returns the call that stops it.
void Function() watchActivity(void Function(WindowActivity activity) onChange);
/// Moves the window with the pointer (a press on the title area).
Future<void> startDragging();
@@ -93,7 +93,7 @@ class QueueController {
effect(() {
shownContinuation.value;
coord.settledMediaId.value;
coord.selfOut.value;
coord.selfAway.value;
final r = coord.serverState.value;
untracked(() => _warm(r));
});
@@ -107,6 +107,7 @@ class QueueController {
Future<String?> add(PickedMedia pick, {bool next = false}) async {
coord.markInput();
if (coord.serverState.peek() == null) return 'not in a server';
if (coord.stateStale.peek()) return 'reconnecting — add it again once back';
if (items.peek().length >= RelayConstants.maxQueueItems) {
return 'Up next is full (${RelayConstants.maxQueueItems} items)';
}
@@ -490,7 +491,7 @@ class QueueController {
if (local == null) return "it isn't on this computer any more";
// Only this app can pass it on, and a member sitting out has
// nothing open: back first.
if (coord.selfOut.peek()) coord.joinBack();
if (coord.selfAway.peek()) coord.joinBack();
return coord.swapPicked(local.pick, queueId: item.id);
}
}
@@ -528,7 +529,7 @@ class QueueController {
/// puts up (its swap reads it). Null: nothing.
_WarmTarget? _warmTarget(StateBroadcast? r) {
final t = _warmTargetHere(r);
if (t == null || !coord.selfOut.peek()) return t;
if (t == null || !coord.selfAway.peek()) return t;
return t.probe == null ? null : _WarmTarget(t.id, probe: t.probe);
}
@@ -718,7 +719,7 @@ class QueueController {
final seen = _lastState;
if (r == null || (seen != null && seen.mediaId > r.mediaId)) r = seen;
if (r == null) return;
final out = coord.selfOut.peek();
final out = coord.selfAway.peek();
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
final want = out ? null : _preloadWant;
final playback = coord.playback;
+11 -7
View File
@@ -1,6 +1,7 @@
import 'package:sync_relay_core/core.dart';
import '../jellyfin/session_media.dart' show compareMemberIds;
import '../session/party.dart' show isPresent;
// Who puts an Up next item up. Pure: only what every client sees in
// `state` decides, so all clients agree without a wire message (like the
@@ -12,20 +13,22 @@ import '../jellyfin/session_media.dart' show compareMemberIds;
typedef QueuePerformer = ({String? id, String? why});
/// - A link passed on by its adder, or the adder's file: only the adder
/// (their app has the file, the cookies). Gone: skipped.
/// (their app has the file, the cookies). Gone (disconnected, in its
/// grace): it waits for them; left for good: skipped.
/// - Jellyfin: the adder when it plays that server with its own account,
/// else the lowest member id that does (`jf[serverId] == true`, not
/// stale, not sitting out). Nobody: skipped.
/// else the lowest member id that does (`jf[serverId] == true`,
/// present: `in` or joining). Nobody: skipped.
/// - A link everyone opens, or one the server fetches: the adder when
/// present (not stale or sitting out), else the lowest such member id.
/// present, else the lowest present member id.
QueuePerformer queuePerformer(QueueItem item, List<MemberView> members) {
MemberView? adder;
for (final m in members) {
if (m.id == item.by) adder = m;
}
bool active(MemberView m) => !m.stale && !m.out;
bool active(MemberView m) => isPresent(m);
if (item.how.adderOnly) {
if (adder == null) return (id: null, why: '${_name(item)} left');
if (adder.presence == Presence.gone) return (id: null, why: null);
return (id: adder.id, why: null);
}
final ref = item.media.jellyfin;
@@ -48,8 +51,9 @@ String? autoAdvanceLowest(Iterable<MemberView> members) {
}
/// Who takes an item nobody can put up off the queue (`remove` with why):
/// the lowest member id that is not stale.
String? queueJanitor(List<MemberView> members) => autoAdvanceLowest(members.where((m) => !m.stale));
/// the lowest member id that is connected (not gone).
String? queueJanitor(List<MemberView> members) =>
autoAdvanceLowest(members.where((m) => m.presence != Presence.gone));
/// How [item] plays, in a few words for its row: "through Foxyz" (you:
/// "through you"), "server fetches", "everyone fetches", "Jellyfin",
@@ -1,8 +1,10 @@
import 'package:flutter/foundation.dart';
import '../../session/connection.dart' show connectErrorText;
import '../../session/identity.dart';
import '../../session/session_coordinator.dart';
import '../../state/session_state.dart';
import 'profile.dart';
import '../../sync/sync_client.dart' show HelloRefused;
/// What a card shows under its fields after a failed attempt.
class CardError {
@@ -107,7 +109,9 @@ class ConnectFlow extends ChangeNotifier {
} catch (e) {
if (myAttempt != _attempt) return;
busy = null;
errors[mode] = CardError('Could not connect', '$e');
// A refusal says why in plain words (an older host, a wrong password,
// another window).
errors[mode] = CardError('Could not connect', e is HelloRefused ? connectErrorText(e) : '$e');
_notify();
return;
}
+54 -41
View File
@@ -19,56 +19,74 @@ sealed class FeedEvent extends AppEvent {
String get actor => feed.actor;
/// [feed] as its typed event. [selfName]: this member's name (the feed
/// names members by name); [readyCount]: members ready in the latest
/// state (the pitch of a `ready` sound).
factory FeedEvent.of(FeedMessage f, {String? selfName, int readyCount = 0}) => switch (f.action) {
'join' => MemberJoined(f),
'leave' => MemberLeft(f),
'stale' => MemberStale(f),
'out' => MemberSatOut(f, atYou: f.target != null && f.target == selfName && f.actor != selfName),
'back' => MemberBack(f),
'ready' => ReadyChanged(f, ready: true, readyIndex: readyCount),
'unready' => ReadyChanged(f, ready: false),
'force-ready' => ForcedReady(f, ready: true),
'force-unready' => ForcedReady(f, ready: false),
'force-play' => ForcedTransport(f, play: true),
'force-hold' => ForcedTransport(f, play: false),
'swap' => MediaSwapped(f),
'seek' => Seeked(f),
'pause' => Paused(f),
'resume' => Resumed(f),
'finished' => Finished(f),
'chat' => ChatPosted(f, own: f.actor == selfName),
'rename' => MemberRenamed(f),
'avatar' => AvatarChanged(f),
'queue-add' => QueueAdded(f),
'queue-remove' => QueueRemoved(f),
'queue-skip' => QueueSkipped(f),
'fetch-fail' => FetchFailed(f),
_ => OtherFeed(f),
};
/// [feed] as its typed event. [selfId]: this member's id (people are
/// matched by `actorId` / `targetId`, never by name); [readyCount]:
/// members ready in the latest state (the pitch of a `ready` sound).
factory FeedEvent.of(FeedMessage f, {String selfId = '', int readyCount = 0}) {
bool isSelf(String id) => selfId.isNotEmpty && id == selfId;
return switch (f.action) {
'join' => MemberJoined(f),
'leave' => MemberLeft(f),
'gone' => MemberGone(f),
'return' => MemberReturned(f),
'away' => MemberSatOut(f,
atYou: isSelf(f.targetId) && !isSelf(f.actorId),
autoSelf: isSelf(f.actorId) && f.targetId.isEmpty && f.reason.isNotEmpty),
'in' => MemberBack(f),
'ready' => ReadyChanged(f, ready: true, readyIndex: readyCount),
'unready' => ReadyChanged(f, ready: false),
'force-ready' => ForcedReady(f, ready: true),
'force-unready' => ForcedReady(f, ready: false),
'force-play' => ForcedTransport(f, play: true),
'force-hold' => ForcedTransport(f, play: false),
'swap' => MediaSwapped(f),
'seek' => Seeked(f),
'pause' => Paused(f),
'resume' => Resumed(f),
'finished' => Finished(f),
'chat' => ChatPosted(f, own: isSelf(f.actorId)),
'rename' => MemberRenamed(f),
'avatar' => AvatarChanged(f),
'queue-add' => QueueAdded(f),
'queue-remove' => QueueRemoved(f),
'queue-skip' => QueueSkipped(f),
'fetch-fail' => FetchFailed(f),
_ => OtherFeed(f),
};
}
}
class MemberJoined extends FeedEvent {
const MemberJoined(super.feed);
}
/// Left for good (`reason` `bye`: the app closed or Leave; `grace`: gone
/// too long).
class MemberLeft extends FeedEvent {
const MemberLeft(super.feed);
}
class MemberStale extends FeedEvent {
const MemberStale(super.feed);
/// Lost signal: gone for a while, kept in its grace period.
class MemberGone extends FeedEvent {
const MemberGone(super.feed);
}
/// Someone sat out (`target` sat out by `actor`); [atYou]: someone else
/// sat this member out.
/// Back within its grace after a [MemberGone].
class MemberReturned extends FeedEvent {
const MemberReturned(super.feed);
}
/// Someone sat out (`away`: the actor itself, or `target` sat out by the
/// actor; `reason` `idle` / `minimised` for an automatic one); [atYou]:
/// someone else sat this member out; [autoSelf]: this app's auto sit-out
/// set this member away.
class MemberSatOut extends FeedEvent {
final bool atYou;
const MemberSatOut(super.feed, {this.atYou = false});
final bool autoSelf;
const MemberSatOut(super.feed, {this.atYou = false, this.autoSelf = false});
}
/// Back from sitting out (`in`).
class MemberBack extends FeedEvent {
const MemberBack(super.feed);
}
@@ -180,13 +198,8 @@ class RelayFailed extends AppEvent {
const RelayFailed(this.error);
}
/// Idle: auto-unready (or sit-out, [sitsOut]) in 2 minutes.
class IdleWarning extends AppEvent {
final bool sitsOut;
const IdleWarning({required this.sitsOut});
}
/// The connection to the server dropped (once per drop).
/// The connection to the server dropped and stayed down a few seconds
/// (once per drop).
class ConnectionLost extends AppEvent {
const ConnectionLost();
}
@@ -0,0 +1,126 @@
import 'package:sync_relay_core/core.dart' show Presence, PresenceReason;
import '../platform/ports/shell_port.dart' show WindowActivity;
/// The auto sit-out timings, in one place.
abstract final class AutoSitOutTimes {
/// Not playing and no input for this long: away (`idle`). Watching
/// counts as activity, so a pause after a long watch starts fresh.
static const idle = Duration(minutes: 5);
/// Minimised (or hidden) for this long, playing or not: away
/// (`minimised`).
static const minimised = Duration(minutes: 10);
}
/// What [AutoSitOut.tick] asks the session to do.
sealed class AutoSitOutStep {
const AutoSitOutStep();
}
/// Nothing to do.
final class AutoStay extends AutoSitOutStep {
const AutoStay();
}
/// Sit this member out (`presence` `away` with [reason]).
final class AutoAway extends AutoSitOutStep {
final PresenceReason reason;
const AutoAway(this.reason);
}
/// Bring this member back `in`: it was set away automatically and the
/// user is back.
final class AutoBack extends AutoSitOutStep {
const AutoBack();
}
/// Auto sit-out: idle for
/// [AutoSitOutTimes.idle] while nothing plays, or minimised for
/// [AutoSitOutTimes.minimised] (playing or not), sets this member away;
/// input or the window's focus brings it back, but only from an away
/// this set ([autoAway]); a manual away (own or someone else's) stays.
/// Never when alone (nobody to hold up or to tell). Pure: the session
/// feeds it input, window changes and a 1 s [tick].
class AutoSitOut {
final Duration idleAfter;
final Duration minimisedAfter;
AutoSitOut(DateTime now,
{this.idleAfter = AutoSitOutTimes.idle, this.minimisedAfter = AutoSitOutTimes.minimised})
: _lastActive = now;
DateTime _lastActive;
DateTime? _minimisedSince;
bool _activitySinceAway = false;
bool _autoAway = false;
// An away sent, not yet seen in a state.
DateTime? _sentAt;
static const _echoWait = Duration(seconds: 10);
/// The member is away because this set it (not by hand, not someone
/// else).
bool get autoAway => _autoAway;
/// Keyboard, mouse or any action in the app.
void input(DateTime now) {
_lastActive = now;
_activitySinceAway = true;
}
/// The window's focus or visibility changed.
void window(WindowActivity a, DateTime now) {
switch (a) {
case WindowActivity.minimised:
case WindowActivity.hidden:
_minimisedSince ??= now;
case WindowActivity.focused:
_minimisedSince = null;
input(now);
case WindowActivity.unfocused:
_minimisedSince = null;
}
}
/// The user sat out (or came back) by hand: an away is no longer ours.
void manual() {
_autoAway = false;
_sentAt = null;
}
/// Once a second. [self]: this member's presence in the latest state
/// (null: not in one); [alone]: nobody else connected ([isAlone]);
/// [enabled]: the setting (and not a solo session).
AutoSitOutStep tick(DateTime now,
{required Presence? self, required bool playing, required bool alone, required bool enabled}) {
if (playing) _lastActive = now;
if (self == null || self == Presence.gone) return const AutoStay();
if (self != Presence.away) {
final sent = _sentAt;
if (sent != null && now.difference(sent) < _echoWait) return const AutoStay();
_sentAt = null;
_autoAway = false;
} else {
_sentAt = null;
if (_autoAway && _activitySinceAway && _minimisedSince == null) {
_autoAway = false;
return const AutoBack();
}
return const AutoStay();
}
if (!enabled || alone) return const AutoStay();
final since = _minimisedSince;
final PresenceReason? reason;
if (since != null && now.difference(since) >= minimisedAfter) {
reason = PresenceReason.minimised;
} else if (!playing && now.difference(_lastActive) >= idleAfter) {
reason = PresenceReason.idle;
} else {
reason = null;
}
if (reason == null) return const AutoStay();
_autoAway = true;
_activitySinceAway = false;
_sentAt = now;
return AutoAway(reason);
}
}
+291 -122
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:signals/signals_core.dart';
@@ -9,6 +10,7 @@ import '../screens/home/ui.dart' show appName, displayVersion;
import '../state/settings.dart';
import '../sync/sync_client.dart';
import 'app_events.dart';
import 'identity.dart' show ClientIdentity;
/// Thrown when a connect is superseded by cancel/leave before it finished.
class ConnectCancelled implements Exception {
@@ -74,6 +76,10 @@ sealed class ConnState {
/// The reconnect loop runs ([ConnLost] or [ConnReconnecting]).
bool get retrying => false;
/// A welcomed socket is up ([ConnOnline] or [ConnUnstable]): what the
/// server says is current.
bool get online => false;
}
/// Not connected and not trying: before the first connect, after leave,
@@ -91,15 +97,30 @@ final class ConnConnecting extends ConnState {
String toString() => 'connecting';
}
/// Welcome received.
/// Welcome received; frames arrive.
final class ConnOnline extends ConnState {
const ConnOnline();
@override
bool get online => true;
@override
String toString() => 'online';
}
/// Dropped (the socket closed, or no state for [Connection.silenceLimit]);
/// the next attempt is scheduled.
/// Still the same socket, but nothing came for [Connection.unstableAfter]
/// (the server sends a state every second): the link may be dying. Back
/// to [ConnOnline] with the next frame, [ConnLost] at
/// [Connection.silenceLimit].
final class ConnUnstable extends ConnState {
const ConnUnstable();
@override
bool get online => true;
@override
String toString() => 'unstable';
}
/// Dropped (the socket closed, or nothing came for
/// [Connection.silenceLimit]); the next attempt is scheduled. The session
/// keeps what it had (the last state, the player, a relay it serves).
final class ConnLost extends ConnState {
const ConnLost();
@override
@@ -120,9 +141,10 @@ final class ConnReconnecting extends ConnState {
String toString() => 'reconnecting($attempt)';
}
/// The first dial failed; nothing retries. [code]: `version` or `secret`
/// when the server said so, else empty (offline, timeout, anything else);
/// [message]: in plain words ([connectErrorText]).
/// The server refused this app; nothing retries. [code]: `version` or
/// `secret` when the server said so, else empty (offline, timeout,
/// anything else on the first dial); [message]: in plain words
/// ([connectErrorText]).
final class ConnRefused extends ConnState {
final String code;
final String message;
@@ -131,88 +153,173 @@ final class ConnRefused extends ConnState {
String toString() => 'refused($code: $message)';
}
/// Another socket took this member over. Not produced yet (protocol 16
/// adds the `replaced` refusal).
/// Another connection of this install holds the member (`err` `replaced`,
/// close 4013, or this hello was `stale`): Starsync was opened in another
/// window. Nothing retries ("Retry" takes the member back).
final class ConnReplaced extends ConnState {
const ConnReplaced();
String get message => replacedText;
@override
String toString() => 'replaced';
}
/// [ConnReplaced] in plain words.
const replacedText = 'Starsync was opened in another window';
/// A version refusal in plain words: [err] is the server's `err`
/// `version` (its range in `min`/`max`), or a proto 15 server's codeless
/// `proto mismatch`, which is always an older host.
String versionText(ErrorFrame? err) {
final max = err?.max, min = err?.min;
if (err?.code == null || (max != null && max < RelayConstants.protoMin)) {
return 'the host runs an older Starsync — they need to update';
}
if (min != null && min > RelayConstants.protoMax) return 'the host runs a newer Starsync — update the app';
return 'the host runs a different Starsync version — update both apps';
}
/// A failed dial in plain words.
String connectErrorText(Object e) {
if (e is HelloRefused) {
final err = e.error;
if (err != null && err.code == null && err.message.contains('proto mismatch')) return versionText(err);
return switch (e.code) {
ErrCode.version => versionText(err),
ErrCode.secret => 'wrong server password (401)',
ErrCode.replaced || ErrCode.stale => replacedText,
_ => 'refused: ${err?.message ?? 'closed (${e.closeCode})'}',
};
}
final msg = '$e';
if (msg.contains('proto mismatch')) return 'host runs a different version — update the app';
if (msg.contains('wrong secret')) return 'wrong server password (401)';
if (msg.contains('SocketException') || msg.contains('Connection refused')) {
return 'host offline — check address and port';
}
return 'connect failed: $e';
}
ConnRefused _refused(Object e) {
final msg = '$e';
final code = msg.contains('proto mismatch')
? 'version'
: msg.contains('wrong secret')
? 'secret'
: '';
return ConnRefused(code, connectErrorText(e));
/// The terminal state a failed dial leaves, or null when another attempt
/// may work.
ConnState? _terminalFor(Object e, {required bool first}) {
if (e is HelloRefused) {
final err = e.error;
if (err != null && err.code == null && err.message.contains('proto mismatch')) {
return ConnRefused('version', versionText(err));
}
switch (e.code) {
case ErrCode.replaced:
case ErrCode.stale:
return const ConnReplaced();
case ErrCode.version:
return ConnRefused('version', connectErrorText(e));
case ErrCode.secret:
return ConnRefused('secret', connectErrorText(e));
default:
break;
}
}
return first ? ConnRefused('', connectErrorText(e)) : null;
}
/// What a hello says about this install besides the connect form.
typedef HelloIdentity = ({String client, int seq, ClientCaps caps, int? feedAfter});
/// Builds the `hello` for [params]. Injectable so later protocol versions
/// add fields without changing [Connection].
typedef HelloBuilder = HelloMessage Function(ConnectParams params);
typedef HelloBuilder = HelloMessage Function(ConnectParams params, HelloIdentity id);
/// Today's hello.
HelloMessage defaultHello(ConnectParams params) => HelloMessage(
proto: RelayConstants.protoVersion,
/// Protocol 16's hello: this build's version range, the install's client
/// id and connection count, the app version, platform and what it can do;
/// `feedAfter` on a reconnect to the same server.
HelloMessage defaultHello(ConnectParams params, HelloIdentity id) => HelloMessage(
proto: RelayConstants.protoMax,
protoMin: RelayConstants.protoMin,
secret: params.secret,
user: HelloUser(name: params.name, avatar: params.avatar),
media: const HelloMedia(name: '', size: 0, duration: 0),
hostKey: params.hostKey,
client: id.client,
seq: id.seq,
app: appVersion,
platform: Platform.operatingSystem,
caps: id.caps,
feedAfter: id.feedAfter,
);
/// The session's part of a reconnect: called right before an attempt
/// dials (read what to restore then; the welcome's state replaces it). The
/// function it returns runs once that attempt is online; [stale] turns
/// true when a cancel, retry, leave or new connect superseded it. An error
/// there counts as a failed attempt.
typedef ReconnectHandler = Future<void> Function(WelcomeMessage welcome, bool Function() stale) Function();
/// How a welcome relates to the member this app held before.
typedef Rejoin = ({
/// A reconnect of the running session (not its first dial).
bool reconnect,
/// `welcome.resumed`: the same member, everything kept.
bool resumed,
/// The same server boot as the last welcome (`welcome.instance`).
bool sameServer,
/// The token the session held before this welcome ('' on the first).
String oldToken,
});
/// The session's part of a welcome, called before its state goes out
/// (see [Connection.onWelcome]).
typedef WelcomeHandler = void Function(WelcomeMessage welcome, Rejoin rejoin);
/// The link to the server: the [SyncClient] socket, the first connect,
/// reconnects ([reconnectDelay] between attempts, the previous token
/// offered as `resume`, epochs that make a cancelled attempt stale), the
/// "no state for [silenceLimit] = lost" policy, the 1 s heartbeat (clock
/// ping, diagnostics), the connection lost/back events and [state]. What
/// a reconnect restores in the session is the [onReconnect] handler's.
/// reconnects (backoff with jitter between attempts, epochs that make a
/// cancelled attempt stale), the silence policy ("unstable" after
/// [unstableAfter] without a frame, lost at [silenceLimit]), the 1 s
/// heartbeat (clock ping, which also keeps this member under the
/// server's silence limit, and diagnostics), the connection lost/back
/// events and [state]. The install's identity rides in every hello
/// ([identity]); what a welcome means to the session is its [onWelcome].
class Connection {
final SyncClient client;
/// Where [ConnectionLost] / [ConnectionBack] go (the Notifier).
final void Function(AppEvent event) _notify;
/// Builds each dial's hello.
final HelloBuilder hello;
/// Wait before each reconnect attempt.
/// `hello.client` / `hello.seq` (the stored one in the app; a fresh
/// in-memory one by default, so every test connection is its own
/// install).
final ClientIdentity identity;
/// `hello.caps` at each dial (the session sets it).
ClientCaps Function() caps;
/// Wait before the first reconnect attempt; it doubles per failed
/// attempt up to [maxReconnectDelay], each with ±20 % jitter.
final Duration reconnectDelay;
/// No state broadcast for this long means the connection is lost.
final Duration maxReconnectDelay;
/// No frame for this long: [ConnUnstable].
final Duration unstableAfter;
/// No frame for this long means the connection is lost.
final Duration silenceLimit;
/// The clock [silenceLimit] is measured on (UTC; a test passes a fake
/// one). The heartbeat that checks it stays a real 1 s timer.
/// The lost sound plays only once a drop lasted this long (a quick
/// reconnect says nothing, and no lost/back pair per flap).
final Duration lostSoundAfter;
/// The clock the silence and the lost sound are measured on (UTC; a
/// test passes a fake one). The heartbeat that checks it stays a real
/// 1 s timer.
final DateTime Function() now;
final Random _random;
Connection(
{void Function(AppEvent event)? notify,
SyncClient? client,
this.hello = defaultHello,
this.reconnectDelay = const Duration(milliseconds: 1500),
this.silenceLimit = const Duration(seconds: 5),
DateTime Function()? now})
ClientIdentity? identity,
ClientCaps Function()? caps,
this.reconnectDelay = const Duration(seconds: 1),
this.maxReconnectDelay = const Duration(seconds: 15),
this.unstableAfter = const Duration(seconds: 3),
this.silenceLimit = const Duration(seconds: 9),
this.lostSoundAfter = const Duration(seconds: 4),
DateTime Function()? now,
Random? random})
: _notify = notify ?? ((_) {}),
now = now ?? _utcNow,
identity = identity ?? ClientIdentity.ephemeral(),
caps = caps ?? (() => const ClientCaps(source: true)),
_random = random ?? Random(),
client = client ?? SyncClient() {
this.client.onFrame = () => _lastFrameAt = this.now();
_subs = [
this.client.status.listen(_onStatus),
this.client.states.listen((_) => _lastStateAt = this.now()),
this.client.errors.listen(_onError),
];
}
@@ -224,8 +331,8 @@ class Connection {
/// Every change is notified, a repeat too.
ReadonlySignal<ConnState> get state => _state;
/// Set by the session (see [ReconnectHandler]).
ReconnectHandler? onReconnect;
/// Set by the session (see [WelcomeHandler]).
WelcomeHandler? onWelcome;
/// This app's log lines to the server's session log (Settings: "Share
/// diagnostics with the server"), and "Mark a problem". Runs from
@@ -239,12 +346,6 @@ class Connection {
return (base: DiagUploader.baseOf(uri), token: client.token, offsetMs: client.clockSync.offsetMs);
}
/// No state broadcast for [silenceLimit] (checked every second while not
/// closed): what the server said is stale. Synchronous, before [state]
/// goes lost.
Stream<void> get silence => _silence.stream;
final _silence = StreamController<void>.broadcast(sync: true);
ConnectParams? _params;
/// The last connect's parameters (with [updateProfile]'s changes); a
/// reconnect dials with them.
@@ -254,23 +355,26 @@ class Connection {
int _epoch = 0;
Timer? _reconnectTimer;
Timer? _heartbeat;
// Set by close/cancel: no reconnect, no lost detection.
// Set by close/cancel/a terminal refusal: no reconnect, no lost detection.
bool _closed = false;
bool _everOnline = false;
/// The connection-lost sound played; the reconnected one answers it once.
bool _lostSounded = false;
// Token of the current connection, offered as `resume` on reconnect.
String _resumeToken = '';
DateTime? _lastStateAt;
DateTime? _lastFrameAt;
/// When the current drop began (the last frame for a silent link).
DateTime? _droppedAt;
int _attempt = 0;
/// Non-terminal `err` frames, in plain words.
final _errorTexts = StreamController<String>.broadcast();
// The socket and what the server said, read through.
Stream<StateBroadcast> get states => client.states;
/// Feed entries, chat included.
/// Feed entries, chat included; replayed ones after a reconnect.
Stream<FeedMessage> get feeds => client.feeds;
Stream<NudgeNotice> get nudges => client.nudges;
/// `err` frames after the welcome.
Stream<String> get errors => client.errors;
/// `err` frames after the welcome (a `replaced` one ends the connection
/// instead).
Stream<String> get errors => _errorTexts.stream;
String get token => client.token;
String get memberId => client.memberId;
/// This member's `/j/...` URL from `welcome`.
@@ -304,7 +408,7 @@ class Connection {
void sendChat(String text) => client.sendChat(text);
void sendArt(int mediaId, {String? poster, String? banner}) =>
client.sendArt(mediaId, poster: poster, banner: banner);
void sendOut(String target, bool out) => client.sendOut(target, out);
void sendPresence(PresenceIntent intent) => client.sendPresence(intent);
void sendNudge(String target) => client.sendNudge(target);
/// Name and/or picture change for everyone now; reconnects send them too.
@@ -325,14 +429,14 @@ class Connection {
/// False once a cancel, retry, leave or new connect followed [epoch].
bool isCurrent(int epoch) => epoch == _epoch;
/// A new session with [params]: forgets the last one's resume token and
/// A new session with [params]: forgets the last one's server and
/// starts the diagnostics. [dial] connects.
void begin(ConnectParams params) {
_params = params;
_closed = false;
_everOnline = false;
_lostSounded = false;
_resumeToken = '';
_droppedAt = null;
_attempt = 0;
diag
..share = AppSettings.instance.shareDiagnostics.peek()
@@ -341,125 +445,184 @@ class Connection {
/// The first dial of the session [begin] started. Throws
/// [ConnectCancelled] when [epoch] went stale meanwhile (whoever bumped
/// it also closed the socket); any other failure leaves [state] refused.
/// it also closed the socket); any other failure leaves [state] refused
/// (or replaced).
Future<WelcomeMessage> dial(int epoch) async {
final params = _params!;
_set(const ConnConnecting());
try {
final welcome = await client.connectWith(wsUri: _wsUri(params), hello: hello(params));
final welcome = await _connect(params, reconnect: false);
if (epoch != _epoch) throw const ConnectCancelled();
_online();
return welcome;
} catch (e) {
if (epoch != _epoch) throw const ConnectCancelled();
debugPrint('connect failed: $e');
_set(_refused(e));
_set(_terminalFor(e, first: true)!);
rethrow;
}
}
static Uri _wsUri(ConnectParams p) => Uri.parse('ws://${p.host}:${p.port}/sync');
/// [hello] offering the previous connection's token.
HelloMessage _resumeHello(ConnectParams params) {
final h = hello(params);
return HelloMessage(
proto: h.proto, secret: h.secret, user: h.user, media: h.media, hostKey: h.hostKey, resume: _resumeToken);
Future<WelcomeMessage> _connect(ConnectParams params, {required bool reconnect}) {
final oldToken = client.token;
final before = client.instance;
final id = (
client: identity.clientId,
seq: identity.nextSeq(),
caps: caps(),
feedAfter: reconnect && before.isNotEmpty && client.lastFeedSeq > 0 ? client.lastFeedSeq : null,
);
return client.connectWith(
wsUri: _wsUri(params),
hello: hello(params, id),
onWelcome: (w) {
_lastFrameAt = now();
onWelcome?.call(w, (
reconnect: reconnect,
resumed: w.resumed,
sameServer: before.isNotEmpty && before == w.instance,
oldToken: oldToken,
));
},
);
}
static Uri _wsUri(ConnectParams p) => Uri.parse('ws://${p.host}:${p.port}/sync');
void _online() {
_attempt = 0;
_everOnline = true;
_droppedAt = null;
if (_lostSounded) {
_lostSounded = false;
_notify(const ConnectionBack());
}
_resumeToken = client.token;
_lastStateAt = now();
_lastFrameAt = now();
_set(const ConnOnline());
_startHeartbeat();
_heartbeat ??= Timer.periodic(const Duration(seconds: 1), (_) => _beat());
}
void _set(ConnState s) => _state.set(s, force: true);
void _onStatus(SyncStatus s) {
// Before the first welcome a close is just the dial failing.
if (s != SyncStatus.closed || _closed || !_everOnline) return;
_dropped();
}
/// Lost (said again while already retrying), the lost sound once per
/// drop, and a reconnect unless one is already scheduled or dialling.
void _dropped() {
if (_state.peek().retrying) {
_set(const ConnLost());
} else {
scheduleReconnect();
// Before the first welcome a close is just the dial failing; during a
// reconnect attempt the attempt reports it. A socket error (a reset
// link) may come without a close.
if (s != SyncStatus.closed && s != SyncStatus.error) return;
if (_closed || !_everOnline || !_state.peek().online) return;
switch (s == SyncStatus.closed ? client.closeCode : null) {
case CloseCodes.replaced:
case CloseCodes.stale:
_end(const ConnReplaced());
case CloseCodes.version:
_end(ConnRefused('version', versionText(null)));
case CloseCodes.secret:
_end(const ConnRefused('secret', 'wrong server password (401)'));
default:
_dropped(now());
}
_soundLost();
}
void _startHeartbeat() {
_heartbeat?.cancel();
_heartbeat = Timer.periodic(const Duration(seconds: 1), (_) => _beat());
void _onError(ErrorFrame e) {
if (e.code == ErrCode.replaced) {
_end(const ConnReplaced());
return;
}
if (!_errorTexts.isClosed) _errorTexts.add(e.message);
}
/// Every second while connected: a clock sample, the diagnostics, and
/// the silence check.
/// A terminal state: no more attempts, the socket goes.
void _end(ConnState s) {
debugPrint('connection ended: $s');
_halt(s);
unawaited(client.drop());
}
/// Lost since [since]: the socket goes, a reconnect is scheduled (unless
/// one already is or dials).
void _dropped(DateTime since) {
if (_closed || _state.peek().retrying) return;
_droppedAt = since;
unawaited(client.drop());
scheduleReconnect();
}
/// Every second once online: a ping (a clock sample; it also keeps this
/// member under the server's silence limit while nothing else is sent),
/// the diagnostics, the silence check and the delayed lost sound.
void _beat() {
final t = now();
if (client.token.isNotEmpty) client.sendPing();
final s = _state.peek();
if (s.online) client.sendPing();
diag
..share = AppSettings.instance.shareDiagnostics.peek()
..tick();
final last = _lastStateAt;
if (!_closed && last != null && t.difference(last) >= silenceLimit) {
_silence.add(null);
if (!_state.peek().retrying) _dropped();
if (_closed) return;
final last = _lastFrameAt;
if (s.online && last != null) {
final quiet = t.difference(last);
if (quiet >= silenceLimit) {
debugPrint('connection: nothing for ${quiet.inSeconds} s; reconnecting');
_dropped(last);
} else if (quiet >= unstableAfter) {
if (s is ConnOnline) _set(const ConnUnstable());
} else if (s is ConnUnstable) {
_set(const ConnOnline());
}
}
final since = _droppedAt;
if (_state.peek().retrying && since != null && !_lostSounded && t.difference(since) >= lostSoundAfter) {
_lostSounded = true;
_notify(const ConnectionLost());
}
}
void _soundLost() {
if (!_everOnline || _lostSounded) return;
_lostSounded = true;
_notify(const ConnectionLost());
}
/// Reconnect after [reconnectDelay] ([state] lost), unless closed,
/// never online (unless [manual]) or already retrying.
/// Reconnect after the backoff ([state] lost), unless closed, never
/// online (unless [manual]) or already retrying.
void scheduleReconnect({bool manual = false}) {
if (_closed || _params == null) return;
if (!manual && !_everOnline) return;
if (_state.peek().retrying) return;
_droppedAt ??= now();
_set(const ConnLost());
_armReconnect();
}
/// The wait before attempt [attempt] (1 = the first after a drop).
Duration delayFor(int attempt) {
final base = reconnectDelay.inMilliseconds * pow(2, max(0, attempt - 1));
final capped = min(base.toDouble(), maxReconnectDelay.inMilliseconds.toDouble());
return Duration(milliseconds: (capped * (0.8 + 0.4 * _random.nextDouble())).round());
}
void _armReconnect() {
final epoch = _epoch;
_reconnectTimer?.cancel();
_reconnectTimer = Timer(reconnectDelay, () => _reconnect(epoch));
_reconnectTimer = Timer(delayFor(_attempt + 1), () => _reconnect(epoch));
}
/// One reconnect attempt. An attempt from an older epoch must not touch
/// state (it would kill the newer connection and leave a ghost member).
/// state (it would kill the newer connection).
Future<void> _reconnect(int epoch) async {
_reconnectTimer = null;
bool stale() => epoch != _epoch;
final params = _params;
if (stale() || params == null || _closed) return;
final after = onReconnect?.call();
try {
_set(ConnReconnecting(++_attempt));
final welcome = await client.connectWith(wsUri: _wsUri(params), hello: _resumeHello(params));
await _connect(params, reconnect: true);
// Whoever bumped the epoch also closed (or replaced) the socket.
if (stale()) return;
_online();
if (after != null) await after(welcome, stale);
} catch (e) {
if (stale()) return;
debugPrint('reconnect failed: $e');
_retryAfterFailure(e);
final end = _terminalFor(e, first: false);
if (end != null) {
_end(end);
} else {
_retryAfterFailure(e);
}
}
}
@@ -468,7 +631,7 @@ class Connection {
_set(const ConnClosed());
} else if (!_everOnline) {
// Only a manual retry before any welcome gets here.
_set(_refused(e));
_set(_terminalFor(e, first: true)!);
} else {
if (!_state.peek().retrying) _set(const ConnLost());
_armReconnect();
@@ -477,32 +640,34 @@ class Connection {
/// "Stop reconnecting": no more attempts; one dialling is aborted.
void cancelReconnect() {
_halt();
unawaited(client.disconnect().catchError((_) {}));
_halt(const ConnClosed());
unawaited(client.drop());
}
/// Cancel during setup: aborts the dial.
Future<void> cancelConnect() async {
_halt();
_halt(const ConnClosed());
try {
await client.disconnect();
await client.drop();
} catch (_) {}
}
void _halt() {
void _halt(ConnState s) {
_closed = true;
invalidate();
_heartbeat?.cancel();
_heartbeat = null;
_set(const ConnClosed());
_set(s);
}
/// "Retry": a reconnect after [reconnectDelay] whatever the state.
/// False without a session to retry.
/// "Retry": a reconnect after the first backoff whatever the state
/// (after [ConnReplaced] it takes the member back from the other
/// window). False without a session to retry.
bool retry() {
if (_params == null) return false;
invalidate();
_closed = false;
_attempt = 0;
_set(const ConnClosed());
scheduleReconnect(manual: true);
return true;
@@ -519,10 +684,14 @@ class Connection {
_set(const ConnClosed());
}
/// [stop], the last diagnostics (before the token goes), the socket.
Future<void> close() async {
/// [stop], the last diagnostics (before the token goes), with [bye] the
/// member's `bye` (flushed: the server's close is awaited briefly), then
/// the socket.
Future<void> close({bool bye = false}) async {
stop();
await diag.stop();
// A welcomed socket still open (a dead one costs the short wait).
if (bye && client.isOpen && client.token.isNotEmpty) await client.sendBye();
try {
await client.disconnect();
} catch (_) {}
@@ -533,7 +702,7 @@ class Connection {
for (final s in _subs) {
await s.cancel();
}
await _silence.close();
await _errorTexts.close();
await client.dispose();
}
}
+4 -3
View File
@@ -1,6 +1,7 @@
import 'package:sync_relay_core/core.dart';
import '../jellyfin/session_media.dart';
import 'party.dart';
// Who fetches the current media and how it spreads, from the state alone:
// the members card's source line and each member's route and buffer.
@@ -217,7 +218,7 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam
final source = r.members.where((m) => m.id == sourceId).firstOrNull;
var via = 0, direct = 0;
for (final m in r.members) {
if (m.out || m.id == sourceId) continue;
if (!isPresent(m) || m.id == sourceId) continue;
switch (routeOf(m, kind, sourceId, ref, r.relay)) {
case DeliveryRoute.server:
via++;
@@ -277,7 +278,7 @@ DeliveryRoute? routeOf(MemberView m, DeliveryKind kind, String sourceId, Jellyfi
/// [m]'s route and buffer for [d] (from [deliveryOf] on [r]).
MemberDelivery memberDeliveryOf(MemberView m, StateBroadcast r, Delivery d) {
final counts = !m.out && !m.stale && m.error.isEmpty;
final counts = countsForReady(m);
final need = r.bufferNeed;
final ref = refOfStateMedia(r.media);
final health = !counts
@@ -288,7 +289,7 @@ MemberDelivery memberDeliveryOf(MemberView m, StateBroadcast r, Delivery d) {
? BufferHealth.starving
: BufferHealth.low;
return MemberDelivery(
route: m.out ? null : routeOf(m, d.kind, d.sourceId, ref, r.relay),
route: m.presence == Presence.away ? null : routeOf(m, d.kind, d.sourceId, ref, r.relay),
kind: d.kind,
jellyfin: ref != null,
rate: m.rate,
@@ -1,13 +1,83 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:hive_ce/hive.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show validClientId;
import '../../state/connect_prefs.dart';
import '../../state/session_state.dart';
import '../../widgets/avatar_pipeline.dart';
import '../state/connect_prefs.dart';
import '../state/session_state.dart';
import '../state/settings.dart' show settingsBoxName;
import '../widgets/avatar_pipeline.dart';
// Who this app is: to the server (the per-install client id and its
// rising connection count, [ClientIdentity]) and to people (name, picture
// and colour, [Profile]).
/// `hello.client` and `hello.seq` (PROTOCOL.md "Presence"): a random id
/// per install, kept in the settings box (so per data folder, and per
/// `STARSYNC_PROFILE`), and a number that rises with every connection this
/// install makes. A server maps the id to one lasting member; the higher
/// `seq` wins when two windows of the same install connect.
class ClientIdentity {
static const _keyClient = 'clientId';
static const _keySeq = 'clientSeq';
final Box? _box;
ClientIdentity._(this._box, this.clientId, this._seq);
/// The id: 22 characters of base64url (16 random bytes). Private: never
/// shown, never logged.
final String clientId;
int _seq;
/// The last `seq` handed out.
int get seq => _seq;
/// The stored identity (the settings box, once open); one is made and
/// stored on first use. Without the box (tests, no store): [ephemeral].
factory ClientIdentity.stored() {
final box = Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null;
if (box == null) return ClientIdentity.ephemeral();
var id = box.get(_keyClient);
if (id is! String || !validClientId(id)) {
id = newClientId();
unawaited(_put(box, _keyClient, id));
}
final seq = box.get(_keySeq);
return ClientIdentity._(box, id, seq is int && seq > 0 ? seq : 0);
}
/// A fresh id kept in memory only (each test coordinator is its own
/// install).
factory ClientIdentity.ephemeral() => ClientIdentity._(null, newClientId(), 0);
/// The `seq` for the next hello: one higher than any before, stored at
/// once.
int nextSeq() {
_seq++;
final box = _box;
if (box != null) unawaited(_put(box, _keySeq, _seq));
return _seq;
}
static Future<void> _put(Box box, String key, Object value) async {
try {
await box.put(key, value);
} catch (e) {
debugPrint('save $key failed: $e');
}
}
/// 16 random bytes as 22 characters of base64url.
static String newClientId() {
final r = Random.secure();
return base64Url.encode([for (var i = 0; i < 16; i++) r.nextInt(256)]).replaceAll('=', '');
}
}
/// Colours offered in the profile card; the first is the default.
const profileColors = <Color>[
-43
View File
@@ -1,43 +0,0 @@
enum IdleEvent { none, warn, unready }
/// Idle auto-unready clock. Idle time counts from the later of the user's
/// last input and the last moment playback was running: watching is
/// activity, so a long watch followed by a pause starts a fresh 10 min.
/// The unready also always trails its warning by the full grace period.
class IdleTracker {
static const warnAfter = Duration(minutes: 8);
static const unreadyAfter = Duration(minutes: 10);
DateTime _lastActive;
DateTime? _warnedAt;
IdleTracker(DateTime now) : _lastActive = now;
void activity(DateTime now) {
_lastActive = now;
_warnedAt = null;
}
/// Called once per tick. Warn and unready only matter while self is
/// ready; while playing the ready flag gates nothing.
IdleEvent tick(DateTime now, {required bool playing, required bool selfReady}) {
if (playing) {
activity(now);
return IdleEvent.none;
}
if (!selfReady) return IdleEvent.none;
final warnedAt = _warnedAt;
if (warnedAt != null) {
if (now.difference(warnedAt) >= unreadyAfter - warnAfter) {
activity(now);
return IdleEvent.unready;
}
return IdleEvent.none;
}
if (now.difference(_lastActive) >= warnAfter) {
_warnedAt = now;
return IdleEvent.warn;
}
return IdleEvent.none;
}
}
+15 -7
View File
@@ -15,7 +15,9 @@ class ToastItem {
/// The member a feed or chat toast is about (their name, as the feed
/// carries it; [text] starts with it); null for the app's own toasts.
final String? actor;
const ToastItem({required this.id, required this.text, required this.at, this.actor});
/// [actor]'s member id (feed and chat toasts); '' when unknown.
final String actorId;
const ToastItem({required this.id, required this.text, required this.at, this.actor, this.actorId = ''});
}
/// What one [AppEvent] does: a toast ([toast], about [actor]), a sound,
@@ -34,6 +36,8 @@ class Notice {
/// The policy: what each event does.
Notice noticeFor(AppEvent e) => switch (e) {
// Lines a reconnect replayed (`welcome.feed`): the log only.
FeedEvent(:final feed) when feed.replayed => Notice(log: feed),
// The feed: everything is logged; joins, leaves, swaps and chat
// pop up; the social actions have a sound.
MemberJoined(:final feed) => Notice(
@@ -50,6 +54,10 @@ Notice noticeFor(AppEvent e) => switch (e) {
sound: own ? null : const SfxCue(SfxKind.chat)),
MemberSatOut(:final feed, atYou: true) => Notice(
log: feed, toast: '${feed.actor} sat you out', actor: feed.actor, sound: const SfxCue(SfxKind.kick)),
MemberSatOut(:final feed, autoSelf: true) => Notice(
log: feed,
toast: 'You sit out (${feed.reason == 'minimised' ? 'minimised' : 'idle'}); you are back as soon as you return',
sound: const SfxCue(SfxKind.warn)),
ReadyChanged(:final feed, ready: true, :final readyIndex) =>
Notice(log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)),
ReadyChanged(:final feed, ready: false) => Notice(log: feed, sound: const SfxCue(SfxKind.unready)),
@@ -68,11 +76,6 @@ Notice noticeFor(AppEvent e) => switch (e) {
RelayStarted() =>
const Notice(toast: 'Passing the video on through the server. Keep the app open while it plays.'),
RelayFailed(:final error) => Notice(toast: 'Cannot pass the video on: $error'),
IdleWarning(:final sitsOut) => Notice(
toast: sitsOut
? 'idle — sitting out in 2 min unless you press Space'
: 'idle — auto-unready in 2 min unless you press Space',
sound: const SfxCue(SfxKind.warn)),
ConnectionLost() => const Notice(sound: SfxCue(SfxKind.lost)),
ConnectionBack() => const Notice(sound: SfxCue(SfxKind.back)),
JellyfinProgressFailed(:final signInExpired) =>
@@ -119,7 +122,12 @@ class Notifier {
_toastId++;
toasts.value = [
...toasts.peek(),
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: n.actor),
ToastItem(
id: _toastId,
text: text,
at: DateTime.now().toUtc(),
actor: n.actor,
actorId: n.actor == null ? '' : n.log?.actorId ?? ''),
];
}
final sound = n.sound;
+27
View File
@@ -0,0 +1,27 @@
import 'package:sync_relay_core/core.dart';
// The app's reading of presence (PROTOCOL.md "Presence"): every count,
// list and wait in the UI goes through these, on top of core's
// [PresencePolicy] and [isAlone].
/// What each presence counts for (the one table, shared with the server).
const presencePolicy = PresencePolicy();
/// Counted in "2/3 ready" and waited for by name: its presence counts for
/// the ready counter and its player can open the media.
bool countsForReady(MemberView m) => m.error.isEmpty && presencePolicy.countsFor(m.presence, Purpose.readyCounter);
/// Connected and watching (`in` or `joining`): can do things for the
/// party now (put up an Up next item, the auto-advance swap).
bool isPresent(MemberView m) => m.presence == Presence.watching || m.presence == Presence.joining;
/// Nobody but [selfId] is connected in [members] (D9, core's [isAlone]).
bool aloneIn(List<MemberView> members, String selfId) =>
isAlone([for (final m in members) if (m.id != selfId) m.presence]);
/// "3 min" / "40 s": how long since [since] (a `goneSince`), at [now].
String sinceText(DateTime since, DateTime now) {
final d = now.difference(since);
if (d.inMinutes >= 1) return '${d.inMinutes} min';
return '${d.inSeconds.clamp(0, 59)} s';
}
@@ -24,14 +24,16 @@ import '../sync/avatar_cache.dart';
import '../sync/state_diff.dart';
import '../sync/user_seek.dart';
import '../queue/queue_controller.dart';
import '../platform/ports/shell_port.dart' show WindowActivity;
import 'auto_sit_out.dart';
import 'connection.dart';
import 'delivery.dart';
import 'health.dart';
import 'idle.dart';
import 'media_art.dart';
import 'notifier.dart';
import 'now_playing.dart';
import 'now_playing_info.dart';
import 'party.dart';
import 'switch_trace.dart';
export 'connection.dart' show ConnectCancelled, ConnectParams;
@@ -60,8 +62,8 @@ const autoAdvanceSecs = 10;
/// "Unready when the video changes": a state with a newer [mediaId] than
/// the last one this session saw ([lastMediaId], -1 before the first) while
/// self is ready (or a reconnect is restoring it). A swap keeps the
/// server's ready flags, so the client unreadies itself.
/// self is ready (or a fresh member after a reconnect is restoring it). A
/// swap keeps the server's ready flags, so the client unreadies itself.
bool unreadyForNewMedia(
{required bool enabled,
required int lastMediaId,
@@ -106,10 +108,12 @@ final class _JellyfinOrigin extends _RelayOrigin {
HealthStatus? connectionHealth(ConnState s) => switch (s) {
ConnConnecting() => const HealthStatus(HealthSeverity.info, 'connecting…'),
ConnOnline() => const HealthStatus(HealthSeverity.ok, 'connected'),
ConnUnstable() => const HealthStatus(HealthSeverity.warn, 'connection unstable…'),
ConnLost() => const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'),
ConnReconnecting() => const HealthStatus(HealthSeverity.info, 'reconnecting…'),
ConnRefused(:final message) => HealthStatus(HealthSeverity.error, message),
ConnReplaced() || ConnClosed() => null,
ConnReplaced(:final message) => HealthStatus(HealthSeverity.error, message),
ConnClosed() => null,
};
String? stateMediaUrl(StateBroadcast state) {
@@ -169,13 +173,24 @@ class SessionCoordinator {
final health =
signal(const HealthStatus(HealthSeverity.info, 'idle'));
/// The server's latest state. A drop keeps it (with the player, the
/// media and a relay this member serves): [stateStale] says it is not
/// current; only leaving clears it.
final serverState = signal<StateBroadcast?>(null);
/// [serverState] is from before a drop: the connection is reconnecting
/// or ended (replaced, refused).
late final Computed<bool> stateStale = computed(() {
final s = connection.state.value;
return serverState.value != null && (s.retrying || s is ConnReplaced || s is ConnRefused);
});
/// The activity log ([Notifier.feedLog]).
Signal<List<FeedMessage>> get feedLog => notifier.feedLog;
/// Toasts on screen ([Notifier.toasts]).
Signal<List<ToastItem>> get toasts => notifier.toasts;
/// The reconnect loop runs ([ConnState.retrying]).
late final Computed<bool> reconnecting = computed(() => connection.state.value.retrying);
/// The link is up but went quiet ([ConnUnstable]).
late final Computed<bool> unstable = computed(() => connection.state.value is ConnUnstable);
final starving = signal<String?>(null);
/// Why this player cannot open the current media, in plain words
/// ([playErrorText]); empty while it can. It rides in `report.error`, so
@@ -232,42 +247,40 @@ class SessionCoordinator {
return null;
});
/// Self sits out: the player has nothing open until [joinBack].
late final Computed<bool> selfOut = computed(() => selfMember.value?.out ?? false);
/// Self sits out (`away`): the player has nothing open until [joinBack].
late final Computed<bool> selfAway = computed(() => selfMember.value?.presence == Presence.away);
Timer? _ticker;
StreamSubscription? _stateSub;
StreamSubscription? _feedSub;
StreamSubscription? _nudgeSub;
StreamSubscription? _errSub;
StreamSubscription? _silenceSub;
void Function()? _connectionHealth;
/// A solo session (set by SessionState): no idle unready / sit out,
/// there is nobody to hold up.
/// A solo session (set by SessionState): no auto sit-out, there is
/// nobody to hold up.
bool solo = false;
bool _playerReady = false;
bool _wasPlayAllowed = false;
// Last ready flag the server reported for self; restored after a
// reconnect (the server forgets dropped members).
// Last ready flag the server reported for self; sent again when a
// reconnect lands as a new member (grace over, server restarted).
bool _lastReady = false;
// Set after a reconnect until the server echoes the restored ready, so
// the fresh member's ready=false doesn't overwrite [_lastReady].
// Set after such a reconnect until the server echoes the restored
// ready, so the fresh member's ready=false doesn't overwrite [_lastReady].
bool _restoringReady = false;
// Last out flag the server reported for self; a reconnect that lands
// as a new member sits out again.
bool _lastOut = false;
// Self is `away` in the last state; a reconnect that lands as a new
// member sits out again.
bool _lastAway = false;
// [joinBack] ran while playing: ready once the player has buffered
// (or after [_joinBackWait]), so the others do not pause for the load.
DateTime? _joinBackAt;
static const _joinBackWait = Duration(seconds: 15);
// Last mediaId seen in a state; unlike [_mediaId] it survives a
// reconnect, so only a real swap counts as a new video.
// Last mediaId seen in a state; unlike [_mediaId] it survives sitting
// out, so only a real swap counts as a new video.
int _seenMediaId = -1;
int _mediaId = -1;
// The mediaId whose media the player holds (or is opening); -1 for none.
// Unlike [_mediaId] it survives a lost connection, so a reconnect to the
// same media keeps the player, and a swap stops the previous media.
// A swap stops the previous media.
int _playerMediaId = -1;
// [_playerMediaId] at the last open: the same media again (a reconnect,
// joining back) keeps the player, any other gets a fresh one (see
@@ -282,8 +295,7 @@ class SessionCoordinator {
// [link]: the web link it came from (a relayed one has no `url`).
({HelloMedia media, int after, String? link, Uint8List? art})? _pendingArt;
AniListClient? _aniList;
// What this member serves for media [$1]; survives a reconnect (the
// state then looks new again).
// What this member serves for media [$1].
(int, _RelayOrigin)? _activeOrigin;
RelaySource? _relaySource;
int _relaySourceMediaId = -1;
@@ -308,7 +320,9 @@ class SessionCoordinator {
// True while a media open is in flight: the player's duration may still
// belong to the previous media then.
bool _opening = false;
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
/// Auto sit-out (Settings: "Auto sit-out"): idle or minimised for a
/// while sets this member away, coming back sets it in again.
final AutoSitOut autoSitOut = AutoSitOut(DateTime.now().toUtc());
Map<String, String>? _openHeaders;
// Jellyfin: the resolved ref, the mediaId it belongs to, a generation
@@ -355,7 +369,26 @@ class SessionCoordinator {
required this.ytDlp,
required this.jellyfin,
required this.trackPrefs}) {
connection.onReconnect = _onReconnect;
connection
..onWelcome = _onWelcome
..caps = () => caps;
}
// yt-dlp is there to resolve links (checked at each connect).
bool _canResolve = false;
/// `hello.caps`: this app serves relays; resolves links when its yt-dlp
/// is there; plays Jellyfin with its own account when one is linked.
ClientCaps get caps => ClientCaps(
source: true,
resolve: _canResolve,
jellyfin: jellyfin.accounts.any((a) => jellyfin.statusOf(a.serverId) != JellyfinAccountStatus.expired));
Future<void> _checkResolve() async {
try {
final y = await ytDlp.ytDlp.timeout(const Duration(milliseconds: 500));
_canResolve = y.enabled && (y.path != null || await File(y.managedPath).exists());
} catch (_) {}
}
/// The session's first player: created here on connect, disposed by
@@ -449,7 +482,12 @@ class SessionCoordinator {
}
}
void markInput() => _idle.activity(DateTime.now().toUtc());
/// Keyboard, mouse or an action: activity for auto sit-out.
void markInput() => autoSitOut.input(DateTime.now().toUtc());
/// The window gained or lost the focus, was minimised or restored (the
/// shell port's [WindowActivity], wired in `main`).
void windowActivity(WindowActivity a) => autoSitOut.window(a, DateTime.now().toUtc());
Future<void> connect(ConnectParams params) async {
// Own epoch first: a cancel during any await below makes this stale.
@@ -469,7 +507,7 @@ class SessionCoordinator {
_memberErrors = const {};
_lastReady = false;
_restoringReady = false;
_lastOut = false;
_lastAway = false;
_joinBackAt = null;
_seenMediaId = -1;
_wasPlayAllowed = false;
@@ -486,11 +524,15 @@ class SessionCoordinator {
markInput();
_subscribe();
notifier.clear();
await _checkResolve();
await initPlayer();
if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
// Health follows the connection's state from here (connecting,
// connected; a failed dial leaves its reason).
final welcome = await connection.dial(epoch);
// Reports, the seek check and auto sit-out run from here until leave,
// through drops.
_startTicker();
try {
final url = welcome.mediaUrl;
if (url != null && url.isNotEmpty) {
@@ -503,7 +545,6 @@ class SessionCoordinator {
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
if (media != null) _swap(media);
_startTicker();
} catch (e) {
if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
debugPrint('connect failed: $e');
@@ -522,30 +563,18 @@ class SessionCoordinator {
_errSub ??= connection.errors.listen((msg) {
health.value = HealthStatus(HealthSeverity.warn, msg);
});
_silenceSub ??= connection.silence.listen((_) => _onConnectionSilent());
_connectionHealth ??= connection.state.subscribe((s) {
final h = connectionHealth(s);
if (h != null) health.value = h;
});
}
/// The drop handling: no state for [Connection.silenceLimit], so what
/// the server said is stale. (A closed socket keeps it until then.)
void _onConnectionSilent() {
if (serverState.peek() == null) return;
serverState.value = null;
starving.value = null;
_wasPlayAllowed = false;
_mediaId = -1;
_clearPendingSeek();
diff.reset();
}
/// Opens [url] (with [headers] on every request); false on failure. A
/// new media gets a fresh player (or the preload holding it); the media
/// the player held before (a reconnect) keeps it.
/// the player held before (back from sitting out, a reload) keeps it.
Future<bool> _openMedia(String url, {Map<String, String>? headers}) async {
if (url == _openUrl) return true;
_opens++;
_log.info('media $_mediaId: open ${_urlKind(url, headers)}: ${logUrl(url)}'
'${headers == null || headers.isEmpty ? '' : ' (headers: ${headers.keys.join(', ')})'}');
final keepPlayer = _playerMediaId == _openedFor;
@@ -581,6 +610,12 @@ class SessionCoordinator {
return ok;
}
int _opens = 0;
/// How many times this session opened a media in the player (a drop
/// and return must not add one).
int get opens => _opens;
// The media whose buffer reached the gate's need was reported at once.
int _bufferReportedFor = -1;
@@ -589,7 +624,7 @@ class SessionCoordinator {
void _onBuffered() {
final r = serverState.peek();
if (r == null || r.transport != 'held' || _bufferReportedFor == r.mediaId) return;
if (_lastOut || _opening || _openUrl == null || _playerMediaId != r.mediaId || !_playerReady) return;
if (_lastAway || _opening || _openUrl == null || _playerMediaId != r.mediaId || !_playerReady) return;
if (connection.token.isEmpty || playback.bufferedSecsAhead < r.bufferNeed) return;
_bufferReportedFor = r.mediaId;
_sendReport(r);
@@ -660,9 +695,10 @@ class SessionCoordinator {
/// A relay nobody feeds yet: the server's fetch before it is read, or a
/// member's before its channel is up while that member is still here
/// (once it left, the server's buffer is all there is: open that).
/// (once it is gone or left, the server's buffer is all there is: open
/// that).
bool _waitForSource(StateBroadcast r, MediaRelay relay) =>
!relay.live && (relay.fetch || r.members.any((m) => m.id == relay.source));
!relay.live && !relay.sourceGone && (relay.fetch || r.members.any((m) => m.id == relay.source));
/// Relayed through a member whose channel is down: say who everyone is
/// waiting for (the buffer still plays what it has).
@@ -677,8 +713,13 @@ class SessionCoordinator {
if (waiting) {
final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name;
final gone = memberNames[relay.source] ?? 'the member who put this up';
health.value = HealthStatus(HealthSeverity.warn,
who == null ? '$gone left: playing what the server has' : 'waiting for $who to pass the video on');
health.value = HealthStatus(
HealthSeverity.warn,
who == null
? '$gone left: playing what the server has'
: relay.sourceGone
? '$who lost signal: playing what the server has'
: 'waiting for $who to pass the video on');
_relayWaitShown = true;
} else if (_relayWaitShown) {
_relayWaitShown = false;
@@ -743,9 +784,10 @@ class SessionCoordinator {
/// The server only pulls while someone watches through it. When the
/// media came from Up next item [queueId] whose warm relay this member
/// feeds, that source carries on instead.
void _startRelaySource(int mediaId, RelayMedia media, {bool announce = false, String queueId = ''}) {
// Back after a reconnect on the same media: it is still serving (and
// redials by itself).
void _startRelaySource(int mediaId, RelayMedia media,
{bool announce = false, String queueId = '', String name = '', double duration = 0}) {
// Already serving this media (it redials by itself through a drop and
// goes on once this member is back).
if (_relaySource != null && _relaySourceMediaId == mediaId) {
unawaited(media.close());
return;
@@ -767,8 +809,11 @@ class SessionCoordinator {
}));
return;
}
// The same `info` on every dial (name and length included), so the
// server knows a re-dial after a drop as the same media and continues.
final src = RelaySource(
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${connection.token}/source'), media);
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${connection.token}/source'), media,
name: name, duration: duration);
_relaySource = src;
_relaySourceMediaId = mediaId;
relaying.value = true;
@@ -841,7 +886,7 @@ class SessionCoordinator {
Future<void> _applyState(StateBroadcast r) async {
_trackSelfReady(r);
if (_lastOut) {
if (_lastAway) {
// A newer media than the one this member serves: that relay ended.
if (_relaySourceMediaId != -1 && _relaySourceMediaId != r.mediaId) _stopRelaySource();
await _unloadForOut();
@@ -886,12 +931,12 @@ class SessionCoordinator {
} else if (origin is _FileOrigin) {
// The source first: viewers wait on it, not on this player.
_startRelaySource(r.mediaId, RelayMedia.single(FileRelayBytes(File(origin.path))),
announce: true, queueId: relay?.queueId ?? '');
announce: true, queueId: relay?.queueId ?? '', name: name, duration: _durationOf(r));
await _openMedia(origin.path);
} else if (origin is _LinkOrigin) {
_startRelaySource(
r.mediaId, LinkRelayMedia(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe),
announce: true, queueId: relay?.queueId ?? '');
announce: true, queueId: relay?.queueId ?? '', name: name, duration: _durationOf(r));
await _openMedia(origin.url);
} else if (relay != null && !mine) {
if (_waitForSource(r, relay)) {
@@ -973,20 +1018,18 @@ class SessionCoordinator {
}
/// Mirrors the server's ready flag for self into [_lastReady] (force
/// by others included). After a stale spell the server puts a ready
/// member back in the set by itself.
/// by others included), and whether self is away.
void _trackSelfReady(StateBroadcast r) {
final self = selfMember.peek();
if (self == null) return;
// [_seenMediaId], not [_mediaId]: that one is -1 after a lost
// connection, which would end the restore before its echo.
// [_seenMediaId], not [_mediaId]: that one is -1 while sitting out.
if (_seenMediaId >= 0 && r.mediaId != _seenMediaId) _restoringReady = false;
if (_restoringReady) {
if (self.ready) _restoringReady = false;
} else {
_lastReady = self.ready;
}
_lastOut = self.out;
_lastAway = self.presence == Presence.away;
final last = _seenMediaId;
_seenMediaId = r.mediaId;
// A ready restored by a reconnect may not have echoed yet: unready
@@ -1060,9 +1103,10 @@ class SessionCoordinator {
}
final self = selfMember.peek();
if (self != null && self.ready) {
final present = r.members.where((m) => !m.out).toList();
// Who the ready counter counts (not away, not gone, can play it).
final present = r.members.where(countsForReady).toList();
final waiters = present
.where((m) => m.id != self.id && !m.stale && !(m.inSet && m.ready))
.where((m) => m.id != self.id && !(m.inSet && m.ready))
.map((m) => m.name)
.toList();
if (waiters.isNotEmpty) {
@@ -1085,8 +1129,10 @@ class SessionCoordinator {
void _onFeed(FeedMessage f) {
final event = FeedEvent.of(f,
selfName: selfMember.peek()?.name,
readyCount: f.action == 'ready' ? serverState.peek()?.members.where((m) => m.ready).length ?? 0 : 0);
selfId: connection.memberId,
readyCount: f.action == 'ready'
? serverState.peek()?.members.where((m) => m.ready && countsForReady(m)).length ?? 0
: 0);
notify(event);
// The server gave up fetching what everyone waits for.
if (event is FetchFailed && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) {
@@ -1107,18 +1153,18 @@ class SessionCoordinator {
/// server's gate does not wait for the next tick).
void _sendReport(StateBroadcast r) {
// Duration only once the open media is the server's current one.
// Sitting out: still a report (it keeps this member fresh), empty.
final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId;
// Sitting out: still a report, empty.
final known = !_lastAway && !_opening && _openUrl != null && _mediaId == r.mediaId;
// The buffer counts only while the player holds the server's current
// media: before a swap's media is open it is the previous one's (or
// none), and the start must wait for this member.
final holds = !_lastOut && !_opening && _openUrl != null && _playerMediaId == r.mediaId;
final holds = !_lastAway && !_opening && _openUrl != null && _playerMediaId == r.mediaId;
_checkPlayerError(r.mediaId);
final error = playerError.peek();
final buffered = holds ? playback.bufferedSecsAhead : 0.0;
if (holds && buffered >= r.bufferNeed) switchTrace.mark(r.mediaId, SwitchTrace.buffered);
connection.sendReport(
position: _lastOut ? 0 : playback.positionSecs,
position: _lastAway ? 0 : playback.positionSecs,
buffered: buffered,
eventId: r.eventId,
duration: known ? playback.durationSecs : 0,
@@ -1132,34 +1178,33 @@ class SessionCoordinator {
final now = DateTime.now().toUtc();
notifier.expire(now);
final r = serverState.peek();
if (connection.token.isNotEmpty && r != null && _playerReady) {
final online = connection.state.peek().online;
if (online && connection.token.isNotEmpty && r != null && _playerReady) {
_sendReport(r);
if (!_lastOut) {
_checkSeekLanded(r);
unawaited(_tickJellyfin(r));
}
if (!_lastAway) _checkSeekLanded(r);
}
// No warning when there is nothing to lose (unready), and none while
// PLAYING: the ready flag gates nothing then, and watching counts as
// activity so a pause after a long watch starts a fresh idle clock.
if (r != null) _readyAfterJoinBack(r, now);
final sitOutWhenIdle = AppSettings.instance.idleSitsOut.peek();
final event = solo
? IdleEvent.none
: _idle.tick(now,
playing: serverState.peek()?.transport == 'playing',
selfReady: selfMember.peek()?.ready ?? false);
switch (event) {
case IdleEvent.warn:
notify(IdleWarning(sitsOut: sitOutWhenIdle));
case IdleEvent.unready:
_lastReady = false;
if (sitOutWhenIdle) {
sitOut();
} else {
connection.sendReady(false, source: 'auto');
}
case IdleEvent.none:
// The Jellyfin progress goes on through a drop (the player does).
if (connection.token.isNotEmpty && r != null && _playerReady && !_lastAway) unawaited(_tickJellyfin(r));
if (r != null && online) _readyAfterJoinBack(r, now);
_tickAutoSitOut(r, now, online: online);
}
/// Auto sit-out's second (only while online: the state is current).
void _tickAutoSitOut(StateBroadcast? r, DateTime now, {required bool online}) {
final step = autoSitOut.tick(now,
self: online ? selfMember.peek()?.presence : null,
playing: r?.transport == 'playing',
alone: r == null || aloneIn(r.members, connection.memberId),
enabled: !solo && AppSettings.instance.autoSitOut.peek());
switch (step) {
case AutoAway(:final reason):
_log.info('auto sit-out: away (${reason.name})');
_joinBackAt = null;
connection.sendPresence(PresenceIntent(state: Presence.away, reason: reason));
case AutoBack():
_log.info('auto sit-out: back');
_joinBack(ready: false);
case AutoStay():
break;
}
}
@@ -1170,7 +1215,7 @@ class SessionCoordinator {
final at = _joinBackAt;
if (at == null) return;
final self = selfMember.peek();
if (self == null || self.out) {
if (self == null || self.presence == Presence.away) {
if (now.difference(at) > _joinBackWait) _joinBackAt = null;
return;
}
@@ -1252,12 +1297,13 @@ class SessionCoordinator {
void toggleReady() {
final self = selfMember.peek();
if (self?.out ?? false) {
if (self?.presence == Presence.away) {
joinBack();
return;
}
// Ready but out of the set (dropped out while stale) gates like
// unready, so Space puts self back in instead of unreadying.
// Ready but out of the set (joined while playing, a player error)
// gates like unready, so Space puts self back in instead of
// unreadying.
final next = !((self?.ready ?? false) && (self?.inSet ?? false));
markInput();
_lastReady = next;
@@ -1333,20 +1379,34 @@ class SessionCoordinator {
void sitOut([String? memberId]) {
final target = memberId ?? connection.memberId;
if (target.isEmpty) return;
final self = target == connection.memberId;
if (self) {
_joinBackAt = null;
autoSitOut.manual();
}
markInput();
if (target == connection.memberId) _joinBackAt = null;
connection.sendOut(target, true);
connection.sendPresence(PresenceIntent(target: self ? '' : target, state: Presence.away));
}
/// Back from sitting out: the media opens at the server position and
/// this member readies. Held: at once (the gate waits for the buffer);
/// playing: once buffered, so the others do not pause for the load.
/// Back from sitting out (`in`, through joining): the media opens at
/// the server position and this member readies. Held: at once (the
/// gate waits for the buffer); playing: once buffered, so the others do
/// not pause for the load.
void joinBack() {
final self = selfMember.peek();
if (self == null || !self.out) return;
if (self == null || self.presence != Presence.away) return;
autoSitOut.manual();
markInput();
connection.sendOut(self.id, false);
if (serverState.peek()?.transport == 'playing') {
_joinBack(ready: true);
}
/// `in` again; with [ready] this member readies too (auto sit-out's
/// return keeps the ready flag the server kept).
void _joinBack({required bool ready}) {
connection.sendPresence(const PresenceIntent(state: Presence.watching));
if (!ready) {
_joinBackAt = null;
} else if (serverState.peek()?.transport == 'playing') {
_joinBackAt = DateTime.now().toUtc();
} else {
_joinBackAt = null;
@@ -1365,10 +1425,18 @@ class SessionCoordinator {
/// [queueId]: the Up next item this puts up (the server takes it off
/// the queue); [art]: a picture to send as its art (a queued item's).
void _swap(HelloMedia media, {String? link, String? queueId, Uint8List? art}) {
// A swap sent into a dropped link would be lost without a word.
if (!connection.state.peek().online) {
notify(const ActionFailed('Reconnecting: put it up again once back'));
return;
}
// Putting something up means watching it: back first. Not for Up next
// coming up by itself: whoever puts it up may be sitting out.
final self = selfMember.peek();
if (self != null && self.out && queueId == null) connection.sendOut(self.id, false);
if (self != null && self.presence == Presence.away && queueId == null) {
autoSitOut.manual();
connection.sendPresence(const PresenceIntent(state: Presence.watching));
}
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url, art: art);
connection.sendSwap(media, queueId: queueId);
}
@@ -1722,12 +1790,14 @@ class SessionCoordinator {
// This member put it up: serve it to viewers without an account, with
// its own sign-in, else the pasted link (played directly here too).
if (origin != null) {
final name = (r.media?['name'] as String?) ?? '';
if (plan is OwnPlay) {
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers)),
queueId: stateMediaRelay(r)?.queueId ?? '');
queueId: stateMediaRelay(r)?.queueId ?? '', name: name, duration: _durationOf(r));
} else if (origin.link != null) {
plan = RelayPlay(origin.link!);
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!))));
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!))),
name: name, duration: _durationOf(r));
}
}
jellyfinPlan.value = plan;
@@ -1957,42 +2027,83 @@ class SessionCoordinator {
unawaited(_playJellyfin(ref, r));
}
/// A reconnect's part here (see [ReconnectHandler]): what to restore is
/// read before the attempt dials.
Future<void> Function(WelcomeMessage, bool Function()) _onReconnect() {
final wasOut = _lastOut;
var wantReady = _lastReady && !wasOut;
final seenBefore = _seenMediaId;
return (welcome, stale) async {
final url = welcome.mediaUrl;
if (url != null && url.isNotEmpty && url != _openUrl) {
await _openMedia(url);
}
if (stale()) return;
// The server forgot the dropped member; restore the ready flag it
// last reported (not a local guess: others may have forced it).
// Not when the video changed meanwhile and the user unreadies then.
if (unreadyForNewMedia(
enabled: AppSettings.instance.unreadyOnSwap.peek(),
lastMediaId: seenBefore,
mediaId: connection.lastState?.mediaId ?? seenBefore,
ready: wantReady)) {
wantReady = false;
_lastReady = false;
}
// A new member (the server dropped the old one) sits out again; a
// resumed one still does.
final selfNow = connection.lastState?.members.where((m) => m.id == connection.memberId).firstOrNull;
if (wasOut && !(selfNow?.out ?? false)) connection.sendOut(connection.memberId, true);
if (wantReady) {
_lastReady = true;
_restoringReady = true;
connection.sendReady(true, source: 'manual');
}
_startTicker();
};
/// A welcome's part here (see [WelcomeHandler]), before its state is
/// applied. The first one and a resumed one need nothing: the member
/// kept everything (ready flag, presence, queue items, relay) and the
/// player kept the media. A new member after a drop (grace over, or the
/// server restarted) gets this app's intent again (ready, away), and
/// the media opens again only if it changed or its URL named the old
/// token.
void _onWelcome(WelcomeMessage welcome, Rejoin rejoin) {
if (!rejoin.reconnect) return;
_log.info('reconnected: ${rejoin.resumed ? 'resumed' : 'a new member'}'
'${rejoin.sameServer ? '' : ' (the server restarted)'}');
if (rejoin.resumed) return;
final before = serverState.peek();
final raw = welcome.state;
StateBroadcast? now;
try {
now = raw == null ? null : StateBroadcast.fromJson(raw);
} catch (_) {}
final changed = before == null ||
now == null ||
(rejoin.sameServer ? now.mediaId != before.mediaId : !_sameMedia(before.media, now.media));
final tokenUrl = rejoin.oldToken.isNotEmpty &&
rejoin.oldToken != welcome.token &&
(_openUrl?.contains('/${rejoin.oldToken}/') ?? false);
if (changed || tokenUrl) {
_forgetMedia();
} else if (!rejoin.sameServer && _mediaId != -1) {
// The same media under a restarted server's numbering: the player
// keeps it.
_mediaId = _playerMediaId = _openedFor = now.mediaId;
_seenMediaId = now.mediaId;
}
// The intent the server lost: ready (unless the video changed and the
// user unreadies then), and sitting out.
var wantReady = _lastReady && !_lastAway;
if (changed && wantReady && _seenMediaId >= 0 && AppSettings.instance.unreadyOnSwap.peek()) {
wantReady = false;
_lastReady = false;
}
if (_lastAway) {
connection.sendPresence(PresenceIntent(
state: Presence.away, reason: autoSitOut.autoAway ? PresenceReason.idle : PresenceReason.manual));
}
if (wantReady) {
_restoringReady = true;
connection.sendReady(true, source: 'manual');
}
}
/// Whether two states' `media` are the same media (a restarted server
/// counts its `mediaId` from 1 again).
static bool _sameMedia(Map<String, Object?>? a, Map<String, Object?>? b) {
if (a == null || b == null) return a == b;
final ra = refOfStateMedia(a), rb = refOfStateMedia(b);
if (ra != null || rb != null) return ra != null && rb != null && sameJellyfinItem(ra, rb);
return a['name'] == b['name'] && a['size'] == b['size'] && a['url'] == b['url'] && (a['relay'] == null) == (b['relay'] == null);
}
/// The next state's media is new to this player: it opens again from
/// scratch (a relay this member served for the old one stops).
void _forgetMedia() {
_mediaId = -1;
_playerMediaId = -1;
_openUrl = null;
_openHeaders = null;
_awaitLiveFor = -1;
_wasPlayAllowed = false;
diff.reset();
_clearPendingSeek();
_stopRelaySource();
stopWarmSource();
_activeOrigin = null;
}
/// The media's length from a state (0 unknown).
static double _durationOf(StateBroadcast r) => (r.media?['duration'] as num?)?.toDouble() ?? 0;
/// "Stop reconnecting".
void cancelReconnect() => connection.cancelReconnect();
@@ -2018,12 +2129,16 @@ class SessionCoordinator {
await playback.disposeAll();
}
Future<void> leave({bool silent = false}) async {
/// Out of the session: with [bye] (Leave, closing the app) the member
/// leaves the server at once (others see "left"); without it (Update and
/// restart) it goes `gone` and the restarted app takes it back within
/// the grace.
Future<void> leave({bool silent = false, bool bye = true}) async {
connection.invalidate();
await _teardown(silent: silent);
await _teardown(silent: silent, bye: bye);
}
Future<void> _teardown({bool silent = false}) async {
Future<void> _teardown({bool silent = false, bool bye = true}) async {
connection.stop();
_ticker?.cancel();
_ticker = null;
@@ -2041,8 +2156,8 @@ class SessionCoordinator {
try {
await playback.dispose();
} catch (_) {}
// The last lines go before the token does.
await connection.close();
// The last lines go before the token does, then the bye.
await connection.close(bye: bye);
if (!silent) {
serverState.value = null;
starving.value = null;
@@ -2060,7 +2175,6 @@ class SessionCoordinator {
await _feedSub?.cancel();
await _nudgeSub?.cancel();
await _errSub?.cancel();
await _silenceSub?.cancel();
_connectionHealth?.call();
_bufferSub?.call();
_aniList?.close();
+1 -1
View File
@@ -13,7 +13,7 @@ enum SfxKind {
/// A new video or episode is put up.
swap,
chat,
/// Idle warning: auto-unready soon.
/// Auto sit-out set you away (idle, minimised).
warn,
/// The connection to the server dropped / came back.
lost,
+1 -1
View File
@@ -261,7 +261,7 @@ const soundInfo = {
SfxKind.kick: ('Sat out', 'Someone sits you out'),
SfxKind.swap: ('New video', 'A different video or episode is put up'),
SfxKind.chat: ('Message', 'A chat message from someone else'),
SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'),
SfxKind.warn: ('Auto sit-out', 'You were idle or minimised and now sit out'),
SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'),
SfxKind.back: ('Reconnected', 'Back in the server after a drop'),
SfxKind.nudge: ('Nudge', 'Someone nudges you; always plays (volume still applies)'),
+16 -5
View File
@@ -16,7 +16,7 @@ import '../session/app_events.dart' show ActionFailed, ScheduleResult;
import '../session/session_coordinator.dart' show ConnectParams;
import '../screens/home/health_probe.dart';
import '../screens/home/media_picker.dart';
import '../screens/home/profile.dart';
import '../session/identity.dart';
import '../screens/home/update_indicator.dart';
import '../state/session_state.dart';
import '../state/settings.dart';
@@ -202,7 +202,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// plays again).
var wasOut = false;
_stops.add(effect(() {
final out = coord.selfOut.value;
final out = coord.selfAway.value;
if (wasOut && !out && session.route.peek() == AppRoute.session) {
_later(() {
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
@@ -323,7 +323,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// player shows the join-back card). Once out, × closes the player.
void _closePlayer() {
final coord = SessionState.instance.coordinator;
if (coord.selfOut.peek()) {
if (coord.selfAway.peek()) {
_show(PlayerMode.none);
} else {
coord.sitOut();
@@ -447,6 +447,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// Ctrl+V outside a text field (a focused field pastes on its own).
bool _onKey(KeyEvent e) {
// Any key is activity (auto sit-out).
SessionState.instance.coordinator.markInput();
if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false;
final keys = HardwareKeyboard.instance;
if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false;
@@ -675,9 +677,18 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
}),
),
);
// Any pointer use anywhere is activity (auto sit-out).
final coord = SessionState.instance.coordinator;
return Listener(
onPointerHover: (e) => _pointer = e.localPosition,
onPointerDown: (e) => _pointer = e.localPosition,
onPointerHover: (e) {
_pointer = e.localPosition;
coord.markInput();
},
onPointerDown: (e) {
_pointer = e.localPosition;
coord.markInput();
},
onPointerSignal: (_) => coord.markInput(),
child: _dropTarget(shell),
);
}
+41 -22
View File
@@ -4,8 +4,9 @@ import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/profile.dart';
import '../session/identity.dart';
import '../session/delivery.dart';
import '../session/party.dart';
import '../session/session_coordinator.dart';
import '../state/session_state.dart';
import 'motion.dart';
@@ -17,9 +18,12 @@ class AvatarInfo {
final String name;
final Color color;
final ImageProvider? image;
final bool ready, buffering, relaying, stale, host, self;
/// Sitting out: dimmed, with an "away" mark.
final bool out;
final bool ready, buffering, relaying, host, self;
/// Where they stand: joining (a little faded), away (dimmed with a moon),
/// gone (dimmed, grey ring: reconnecting).
final Presence presence;
/// Since when they are gone; null otherwise.
final DateTime? goneSince;
/// Why their player cannot open the media (`members[].error`): an error
/// mark; empty when it can.
final String error;
@@ -34,13 +38,19 @@ class AvatarInfo {
this.ready = false,
this.buffering = false,
this.relaying = false,
this.stale = false,
this.presence = Presence.watching,
this.goneSince,
this.host = false,
this.self = false,
this.out = false,
this.error = '',
this.delivery = '',
});
/// Sitting out.
bool get away => presence == Presence.away;
/// Disconnected, in their grace period.
bool get gone => presence == Presence.gone;
}
/// Stable colour for someone else (colours are not on the wire).
@@ -87,7 +97,8 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
}
/// Your own avatar from the profile (also offline).
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false, String error = ''}) {
AvatarInfo selfAvatarInfo(
{bool ready = false, bool host = false, Presence presence = Presence.watching, String error = ''}) {
final p = Profile.instance;
final png = p.picture.value;
final name = p.name.value;
@@ -98,7 +109,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = fal
ready: ready,
host: host,
self: true,
out: out,
presence: presence,
error: error,
);
}
@@ -115,14 +126,14 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
color: self ? Profile.instance.color.value : colorForName(m.name),
image: memberAvatarImage(m, coord),
ready: m.ready,
buffering: !m.stale && m.ready && m.error.isEmpty && playing && m.buffered < r!.bufferNeed,
buffering: countsForReady(m) && m.ready && playing && m.buffered < r!.bufferNeed,
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
stale: m.stale,
presence: m.presence,
goneSince: m.goneSince,
// The state marks the host (`members[].host`); your own app also
// knows before the first state.
host: m.host || (self && coord.hostFile != null),
self: self,
out: m.out,
error: m.error,
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
);
@@ -150,8 +161,9 @@ class PartyWatch extends StatelessWidget {
/// Circle avatar with a status ring outside it and a gap in the background
/// colour, so overlapping neighbours never hide the status. Crown = host,
/// tower = passing the video on, spinner = buffering, dimmed = stale,
/// dimmed with a moon = sitting out, red "!" = cannot play the media.
/// tower = passing the video on, spinner = buffering, a little faded =
/// joining, dimmed with a grey ring = gone (reconnecting), dimmed with a
/// moon = sitting out, red "!" = cannot play the media.
class StatusAvatar extends StatelessWidget {
final AvatarInfo info;
final double size;
@@ -168,7 +180,7 @@ class StatusAvatar extends StatelessWidget {
final bg = Theme.of(context).colorScheme.surfaceContainerLowest;
final ringColor = !ring
? bg
: info.stale
: info.gone
? Colors.grey.shade700
: info.buffering
? Colors.amber
@@ -195,7 +207,11 @@ class StatusAvatar extends StatelessWidget {
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
padding: EdgeInsets.all(stroke),
child: Opacity(
opacity: info.stale || info.out ? 0.4 : 1,
opacity: info.gone || info.away
? 0.4
: info.presence == Presence.joining
? 0.7
: 1,
child: CircleAvatar(
radius: size / 2,
backgroundColor: info.color,
@@ -224,7 +240,7 @@ class StatusAvatar extends StatelessWidget {
),
),
),
if (info.out)
if (info.away)
Positioned(
key: const ValueKey('away-mark'),
left: -3,
@@ -281,7 +297,7 @@ class _AvatarStackState extends State<AvatarStack> {
@override
Widget build(BuildContext context) {
final size = widget.size;
int rank(AvatarInfo m) => m.host ? 0 : (m.out ? 3 : m.ready ? 1 : 2);
int rank(AvatarInfo m) => m.host ? 0 : (m.gone ? 4 : m.away ? 3 : m.ready ? 1 : 2);
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
final visible = sorted.take(widget.shown).toList();
final extra = sorted.length - visible.length;
@@ -356,13 +372,16 @@ class Pill extends StatelessWidget {
);
}
/// Someone by display name (feed lines carry names, not ids): from the
/// latest state when still there, else their initial on a stable colour.
AvatarInfo avatarForName(String name, [SessionCoordinator? coord]) {
/// Someone in a feed line or toast: by member [id] when it has one, else
/// by display [name]; from the latest state when still there, else their
/// initial on a stable colour.
AvatarInfo avatarForName(String name, [SessionCoordinator? coord, String id = '']) {
final c = coord ?? SessionState.instance.coordinator;
final r = c.serverState.value;
for (final m in r?.members ?? const <MemberView>[]) {
if (m.name == name) return memberAvatarInfo(m, r, c);
final members = r?.members ?? const <MemberView>[];
// By id when the line has one (names change and repeat), else by name.
for (final m in members) {
if (id.isNotEmpty ? m.id == id : m.name == name) return memberAvatarInfo(m, r, c);
}
return AvatarInfo(name: name, color: colorForName(name));
}
+9 -6
View File
@@ -188,7 +188,7 @@ class ActivityRow extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StatusAvatar(info: avatarForName(item.actor), size: 34, badges: false),
StatusAvatar(info: avatarForName(item.actor, null, item.actorId), size: 34, badges: false),
const SizedBox(width: 10),
Expanded(
child: Column(
@@ -261,7 +261,7 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
case 'join':
return (Icons.login, Colors.green, 'joined');
case 'leave':
final reason = f.reason == 'silent too long' ? ' · timed out' : (f.reason.isEmpty ? '' : ' · ${f.reason}');
final reason = f.reason == 'grace' ? ' · lost signal too long' : '';
return (Icons.logout, Colors.grey, 'left$reason');
case 'ready':
return (Icons.check_circle, Colors.green, 'is ready');
@@ -279,12 +279,15 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
return (Icons.fast_forward, scheme.primary, 'seeked');
case 'swap':
return (Icons.swap_horiz, scheme.primary, 'changed the media');
case 'stale':
case 'gone':
return (Icons.cloud_off, Colors.orange, 'lost signal');
case 'out':
case 'return':
return (Icons.cloud_done, Colors.green, 'is back');
case 'away':
final who = f.target;
return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out');
case 'back':
final why = f.reason.isEmpty ? '' : ' · ${f.reason}';
return (Icons.bedtime, Colors.blueGrey, '${who == null || who.isEmpty ? 'is sitting out' : 'sat $who out'}$why');
case 'in':
return (Icons.login, Colors.green, 'joined back');
case 'queue-add':
return (Icons.playlist_add_rounded, scheme.primary, 'added ${f.target ?? ''} to Up next');
+20 -9
View File
@@ -6,6 +6,7 @@ import 'package:sync_relay_core/core.dart';
import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
import '../session/delivery.dart';
import '../session/party.dart';
import '../state/session_state.dart';
import '../widgets/app_keys.dart';
import '../widgets/app_menu.dart';
@@ -282,8 +283,11 @@ class _ServerLabel extends StatelessWidget {
builder: (context) {
final coord = SessionState.instance.coordinator;
final r = coord.serverState.value;
final members = r?.members ?? const <MemberView>[];
final ready = members.where((m) => m.ready).length;
// The ready counter counts who the presence policy counts (not away,
// not gone, able to play it).
final counted = (r?.members ?? const <MemberView>[]).where(countsForReady).toList();
final ready = counted.where((m) => m.ready).length;
final stale = coord.stateStale.value;
return Column(
key: const ValueKey('server-label'),
mainAxisSize: MainAxisSize.min,
@@ -294,7 +298,11 @@ class _ServerLabel extends StatelessWidget {
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
),
Text(
r == null ? 'connecting…' : 'ready $ready/${members.length}',
r == null
? 'connecting…'
: stale
? 'reconnecting…'
: 'ready $ready/${counted.length}',
style: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: 12,
@@ -586,10 +594,13 @@ class _MemberTile extends StatelessWidget {
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
final m = member;
final status = m.stale
? 'reconnecting…'
: m.out
final since = m.goneSince;
final status = m.presence == Presence.gone
? 'reconnecting…${since == null ? '' : ' (${sinceText(since, DateTime.now().toUtc())})'}'
: m.presence == Presence.away
? 'sitting out'
: m.presence == Presence.joining
? 'joining…'
: m.error.isNotEmpty
? "can't play this: ${m.error}"
: info.buffering
@@ -628,11 +639,11 @@ class _MemberTile extends StatelessWidget {
}
},
items: () => [
if (!m.out) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'),
if (m.presence != Presence.away) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'),
if (!info.self) const AppMenuItem(value: 'nudge', label: 'Nudge'),
if (info.self && m.out)
if (info.self && m.presence == Presence.away)
const AppMenuItem(value: 'back', label: 'Join back')
else if (!m.out)
else if (m.presence != Presence.away)
const AppMenuItem(value: 'out', label: 'Sit out'),
],
),
+12 -5
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../session/health.dart';
import '../session/party.dart';
import '../session/session_coordinator.dart';
import '../state/session_state.dart';
import '../widgets/app_menu.dart' show AppGlass;
@@ -61,6 +62,7 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
StatusView? playerStatusFor({
required HealthStatus health,
bool reconnecting = false,
bool unstable = false,
StateBroadcast? state,
MemberView? self,
bool slowConnect = false,
@@ -70,7 +72,11 @@ StatusView? playerStatusFor({
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
tone: HealthSeverity.warn, action: StatusAction.cancel);
}
if (playerError.isNotEmpty && state?.media != null && !(self?.out ?? false)) {
if (unstable) {
return const StatusView(StatusKind.reconnecting, 'Connection unstable…', tone: HealthSeverity.warn);
}
final away = self?.presence == Presence.away;
if (playerError.isNotEmpty && state?.media != null && !away) {
return StatusView(StatusKind.problem, "Can't play this: $playerError",
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
}
@@ -84,7 +90,7 @@ StatusView? playerStatusFor({
final r = state;
if (r == null || r.media == null || r.transport == 'playing') return null;
// Sitting out: the join-back card says it; nobody waits for you.
if (self != null && self.out) return null;
if (away) return null;
if (r.transport == 'finished') {
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
}
@@ -92,7 +98,7 @@ StatusView? playerStatusFor({
// them; say why instead of "waiting for".
final failed = [
for (final m in r.members)
if (m.error.isNotEmpty && !m.out && (self == null || m.id != self.id)) m,
if (m.error.isNotEmpty && isPresent(m) && (self == null || m.id != self.id)) m,
];
// As the server holds playback: someone in the ready set short of the
// minimum buffer.
@@ -119,8 +125,8 @@ StatusView? playerStatusFor({
return StatusView(StatusKind.you, "Press Space when you're ready",
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
}
// Members sitting out are never waited for nor counted.
final active = [for (final m in r.members) if (!m.stale && !m.out && m.error.isEmpty) m];
// Members sitting out or gone are never waited for nor counted.
final active = [for (final m in r.members) if (countsForReady(m)) m];
final others = [
for (final m in active)
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
@@ -137,6 +143,7 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
return playerStatusFor(
health: c.health.value,
reconnecting: c.reconnecting.value,
unstable: c.unstable.value,
state: c.serverState.value,
self: c.selfMember.value,
slowConnect: slowConnect,
+4 -3
View File
@@ -6,9 +6,10 @@ import 'dart:ui' as ui;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show Presence;
import '../jellyfin/accounts.dart';
import '../screens/home/profile.dart';
import '../session/identity.dart';
import '../state/session_state.dart';
import '../update/update_controller.dart';
import 'avatar.dart';
@@ -80,8 +81,8 @@ class _ProfileCardState extends State<ProfileCard> {
final hosting = online && !session.solo.value && coord.hostFile != null;
final color = _p.color.value;
final png = _p.picture.value;
final out = self?.out ?? false;
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, out: out);
final out = self?.presence == Presence.away;
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, presence: self?.presence ?? Presence.watching);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -302,7 +302,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
_miniOverlay(),
// Sitting out: nothing is loaded; the card covers the
// empty player (which stays mounted for joining back).
if (_coord.selfOut.value) _sitOut(),
if (_coord.selfAway.value) _sitOut(),
],
),
);
+10 -7
View File
@@ -13,6 +13,7 @@ import '../jellyfin/accounts_panel.dart';
import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart';
import '../session/auto_sit_out.dart' show AutoSitOutTimes;
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
import '../state/session_state.dart';
@@ -100,15 +101,17 @@ class SettingsTab extends StatelessWidget {
),
),
SettingsPanel(
title: 'When idle',
title: 'Away',
child: SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('idle-sits-out'),
key: const ValueKey('auto-sit-out'),
contentPadding: EdgeInsets.zero,
title: const Text('When idle, sit out instead of just unready'),
subtitle: const Text('After 10 min away while ready and held, your player unloads the video until you join back (Space)'),
value: AppSettings.instance.idleSitsOut.value,
onChanged: AppSettings.instance.setIdleSitsOut,
title: const Text('Auto sit-out'),
subtitle: Text('Idle for ${AutoSitOutTimes.idle.inMinutes} min while nothing plays, or minimised '
'for ${AutoSitOutTimes.minimised.inMinutes} min, sits you out so nobody waits for you; '
"you're back as soon as you return. Never when you're alone"),
value: AppSettings.instance.autoSitOut.value,
onChanged: AppSettings.instance.setAutoSitOut,
),
),
),
@@ -778,7 +781,7 @@ class NotificationsInfo extends StatelessWidget {
),
line('Sat out or nudged by someone', 'Only on your end, with their picture'),
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
line('App notices', 'Shader changes, the idle warning'),
line('App notices', 'Shader changes, auto sit-out setting you away'),
],
),
),
+1 -1
View File
@@ -80,7 +80,7 @@ class _PartyToastsState extends State<PartyToasts> {
final playing = coord.serverState.value?.transport == 'playing';
final popups = AppSettings.instance.chatPopups.value;
for (final t in fresh) {
final toast = shellToastFor(t, (n) => avatarForName(n, coord));
final toast = shellToastFor(t, (n) => avatarForName(n, coord, t.actorId));
// Chat per Settings → Notifications (it is in the chat either way).
if (toast.chat && !popups.shows(playing: playing)) continue;
_add(toast);
@@ -19,6 +19,7 @@ import '../sfx/synth_sfx.dart';
import '../session/health.dart';
import '../session/notifier.dart';
import '../session/connection.dart' show Connection;
import '../session/identity.dart' show ClientIdentity;
import '../session/session_coordinator.dart';
import '../widgets/avatar_pipeline.dart';
import 'connect_prefs.dart';
@@ -175,7 +176,9 @@ class SessionState {
/// Every toast, sound and activity-log line goes through it.
late final Notifier notifier = Notifier(sfx: sfx, attention: ports.shell.requestAttention);
/// The link to the server; lost/back go to the notifier.
late final Connection connection = Connection(notify: notifier.notify);
/// The link to the server, with this install's stored identity
/// (`hello.client` / `seq`); lost/back go to the notifier.
late final Connection connection = Connection(notify: notifier.notify, identity: ClientIdentity.stored());
late final SessionCoordinator coordinator =
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: ports.links);
final lastForm = ConnectForm();
@@ -513,8 +516,10 @@ class SessionState {
await server?.stop();
}
Future<void> leave() async {
await coordinator.leave();
/// Out of the session (Leave, the app closing). [bye] false: Update
/// and restart, the restarted app takes the member back within its grace.
Future<void> leave({bool bye = true}) async {
await coordinator.leave(bye: bye);
await stopServer();
solo.value = false;
coordinator.solo = false;
+9 -8
View File
@@ -37,7 +37,7 @@ const _keySoundPreset = 'soundPreset';
const _keyWelcomeDismissed = 'welcomeDismissed';
const _keySoundPresetFor = 'soundPresetFor';
const _keyUnreadyOnSwap = 'unreadyOnSwap';
const _keyIdleSitsOut = 'idleSitsOut';
const keyAutoSitOut = 'autoSitOut';
const _keyChatPopups = 'chatPopups';
const _keyShareDiagnostics = 'shareDiagnostics';
@@ -77,8 +77,9 @@ class AppSettings {
/// Session: send ready=false when the video changes (a swap keeps the
/// server's ready flags).
final unreadyOnSwap = signal(false);
/// Session: the idle auto-unready sits this member out instead.
final idleSitsOut = signal(false);
/// Session: auto sit-out (idle while nothing plays, or minimised for a
/// while, sets you away; coming back sets you in).
final autoSitOut = signal(true);
/// The player controls' skin (`controls/`). Applies live.
final controlsSkin = signal(ControlsSkin.standard);
/// The bar's clock: elapsed / total or the time left.
@@ -151,8 +152,8 @@ class AppSettings {
if (advance is bool) autoAdvance.value = advance;
final unready = box.get(_keyUnreadyOnSwap);
if (unready is bool) unreadyOnSwap.value = unready;
final idleOut = box.get(_keyIdleSitsOut);
if (idleOut is bool) idleSitsOut.value = idleOut;
final autoOut = box.get(keyAutoSitOut);
if (autoOut is bool) autoSitOut.value = autoOut;
controlsSkin.value =
ControlsSkin.fromStored(box.get(_keyControlsSkin), legacyMpvControls: box.get(_keyMpvControls));
final time = box.get(_keyTimeDisplay);
@@ -230,9 +231,9 @@ class AppSettings {
_put(_keyUnreadyOnSwap, on);
}
void setIdleSitsOut(bool on) {
idleSitsOut.value = on;
_put(_keyIdleSitsOut, on);
void setAutoSitOut(bool on) {
autoSitOut.value = on;
_put(keyAutoSitOut, on);
}
void setControlsSkin(ControlsSkin skin) {
@@ -5,8 +5,9 @@ import 'package:hive_ce/hive.dart';
// changed formats); the restructure steps add theirs here.
/// The layout this build reads. 1: the layout of 1.2.x, where versioning
/// starts (a box without a version is at 1).
const settingsSchemaVersion = 1;
/// starts (a box without a version is at 1). 2: auto sit-out replaces the
/// idle switch.
const settingsSchemaVersion = 2;
/// The box key holding the stored version.
const settingsSchemaKey = 'schemaVersion';
@@ -20,9 +21,19 @@ class SettingsMigration {
const SettingsMigration(this.to, this.what, this.apply);
}
/// Every step in order of [SettingsMigration.to] (2, 3, ...); empty while
/// version 1 is current.
const settingsMigrations = <SettingsMigration>[];
/// Every step in order of [SettingsMigration.to] (2, 3, ...).
const settingsMigrations = <SettingsMigration>[
SettingsMigration(2, 'auto sit-out replaces "when idle, sit out instead of just unready"', _autoSitOut),
];
/// 2: `idleSitsOut` only chose between two automatic steps after 10 idle
/// minutes (unready, or sit out); both are auto sit-out now, on unless
/// switched off (`autoSitOut`).
Future<void> _autoSitOut(Box box) async {
if (!box.containsKey('idleSitsOut')) return;
await box.delete('idleSitsOut');
if (!box.containsKey('autoSitOut')) await box.put('autoSitOut', true);
}
/// Runs the [steps] past the box's stored version up to [current], in
/// order, storing the version after each, then stores [current]. A box
+141 -43
View File
@@ -6,6 +6,31 @@ import 'package:web_socket_channel/web_socket_channel.dart';
enum SyncStatus { idle, connecting, open, closed, error }
/// The server refused the hello: its `err` ([error]; a proto 15 server's
/// has no code), or the socket closed before a welcome with [closeCode].
class HelloRefused implements Exception {
final ErrorFrame? error;
final int? closeCode;
const HelloRefused({this.error, this.closeCode});
/// The refusal's code: the frame's, else read from the close code.
ErrCode? get code =>
error?.code ??
switch (closeCode) {
CloseCodes.version => ErrCode.version,
CloseCodes.secret => ErrCode.secret,
CloseCodes.replaced => ErrCode.replaced,
CloseCodes.stale => ErrCode.stale,
_ => null,
};
@override
String toString() => error?.message ?? 'closed before welcome ($closeCode)';
}
/// One `/sync` socket at a time: the hello and welcome, the frames as
/// streams, the sends. [token], [memberId] and [lastState] outlive a
/// dropped socket ([drop]); only [disconnect] forgets them.
class SyncClient {
/// Local clock; injectable so tests can run a client whose clock is off.
final DateTime Function() _clock;
@@ -21,19 +46,27 @@ class SyncClient {
WebSocketChannel? _channel;
StreamSubscription? _sub;
Completer<void>? _socketDone;
final StreamController<StateBroadcast> _states = StreamController.broadcast();
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
final StreamController<NudgeNotice> _nudges = StreamController.broadcast();
final StreamController<String> _errors = StreamController.broadcast();
final StreamController<ErrorFrame> _errors = StreamController.broadcast();
final StreamController<SyncStatus> _status = StreamController.broadcast();
Stream<StateBroadcast> get states => _states.stream;
/// Feed lines (chat included, with its text), and after a welcome the
/// lines it replayed (`replayed`).
Stream<FeedMessage> get feeds => _feeds.stream;
/// Someone poked this member (`nudge`).
Stream<NudgeNotice> get nudges => _nudges.stream;
Stream<String> get errors => _errors.stream;
/// `err` frames after the welcome.
Stream<ErrorFrame> get errors => _errors.stream;
Stream<SyncStatus> get status => _status.stream;
/// Called on every frame from the server (the welcome included), before
/// it is handled: the connection's silence clock.
void Function()? onFrame;
/// The `/sync` URL last dialled; its host serves `/a/...` and the media.
Uri? wsUri;
String token = '';
@@ -43,38 +76,51 @@ class SyncClient {
/// relays); private like [token].
String? relayUrl;
StateBroadcast? lastState;
/// `welcome.instance` of the server last joined: a welcome of another
/// one is a restarted (or another) server.
String instance = '';
/// The highest `feed.seq` seen from [instance] (`hello.feedAfter`).
int lastFeedSeq = 0;
/// The close code of the last socket that closed (null: none given).
int? closeCode;
Completer<WelcomeMessage>? _pendingWelcome;
/// A plain hello (tools and tests): [client] / [seq] as given.
Future<WelcomeMessage> connect({
required Uri wsUri,
required String secret,
required String name,
String avatar = '',
HelloMedia? media,
String hostKey = '',
String resume = '',
String client = '',
int seq = 0,
}) =>
connectWith(
wsUri: wsUri,
hello: HelloMessage(
proto: RelayConstants.protoVersion,
secret: secret,
user: HelloUser(name: name, avatar: avatar),
media: media ?? const HelloMedia(name: '', size: 0, duration: 0),
hostKey: hostKey,
resume: resume,
client: client,
seq: seq,
),
);
/// Dials [wsUri], sends [hello] and waits for the welcome (10 s).
Future<WelcomeMessage> connectWith({required Uri wsUri, required HelloMessage hello}) async {
await disconnect();
/// Dials [wsUri], sends [hello] and waits for the welcome (10 s). A
/// socket still open is dropped first. [onWelcome] runs once the welcome
/// is read, before its state and replayed feed go out on the streams.
/// Throws [HelloRefused] when the server refuses.
Future<WelcomeMessage> connectWith(
{required Uri wsUri, required HelloMessage hello, void Function(WelcomeMessage welcome)? onWelcome}) async {
await drop();
clockSync.reset();
closeCode = null;
this.wsUri = wsUri;
_status.add(SyncStatus.connecting);
final ch = WebSocketChannel.connect(wsUri);
unawaited(ch.ready.then((_) {}, onError: (_) {}));
_channel = ch;
final done = _socketDone = Completer<void>();
final welcomeCompleter = Completer<WelcomeMessage>();
_sub = ch.stream.listen(
(raw) => _onText('$raw', welcomeCompleter),
@@ -85,8 +131,12 @@ class SyncClient {
_status.add(SyncStatus.error);
},
onDone: () {
closeCode = ch.closeCode;
if (!done.isCompleted) done.complete();
if (!welcomeCompleter.isCompleted) {
welcomeCompleter.completeError(StateError('closed before welcome'));
welcomeCompleter.completeError(ch.closeCode == null
? StateError('closed before welcome')
: HelloRefused(closeCode: ch.closeCode));
}
_status.add(SyncStatus.closed);
},
@@ -99,28 +149,47 @@ class SyncClient {
const Duration(seconds: 10),
);
_pendingWelcome = null;
final sameServer = instance.isNotEmpty && welcome.instance == instance;
token = welcome.token;
memberId = welcome.memberId;
mediaUrl = welcome.mediaUrl;
relayUrl = welcome.relayUrl;
instance = welcome.instance;
// Another server's lines count from 1 again: its replay is not ours.
if (!sameServer) lastFeedSeq = 0;
onWelcome?.call(welcome);
if (welcome.state != null) {
try {
lastState = StateBroadcast.fromJson(welcome.state!);
_states.add(lastState!);
} catch (_) {}
}
if (sameServer) {
for (final f in welcome.feed) {
_addFeed(f.copyWith(replayed: true));
}
}
_status.add(SyncStatus.open);
sendPing();
return welcome;
} catch (e) {
_pendingWelcome = null;
await disconnect();
await drop();
_status.add(SyncStatus.error);
rethrow;
}
}
void _addFeed(FeedMessage f) {
if (f.seq > lastFeedSeq) lastFeedSeq = f.seq;
// The server delivers every chat twice: a `chat` frame carrying the
// text (turned into a feed line below) and a textless feed line. Keep
// only the former: one send, one row, one toast, one sound.
if (f.action != 'chat') _feeds.add(f);
}
void _onText(String raw, Completer<WelcomeMessage> welcomeCompleter) {
onFrame?.call();
Map<String, Object?> decoded;
try {
final parsed = jsonDecode(raw);
@@ -138,9 +207,13 @@ class SyncClient {
welcomeCompleter.completeError(e);
}
} else if (t == 'err') {
welcomeCompleter.completeError(
ProtocolException('${decoded['message'] ?? 'rejected'}'),
);
ErrorFrame frame;
try {
frame = ErrorFrame.fromJson(decoded);
} catch (_) {
frame = const ErrorFrame(code: null, message: 'rejected');
}
welcomeCompleter.completeError(HelloRefused(error: frame));
}
return;
}
@@ -153,12 +226,7 @@ class SyncClient {
} catch (_) {}
case 'feed':
try {
final f = FeedMessage.fromJson(decoded);
// Server delivers every chat twice: a ChatMessage carrying the
// text (converted to a feed entry in `case 'chat'` below) and a
// textless feed entry. Keep only the former: one send, one row,
// one toast, one sfx.
if (f.action != 'chat') _feeds.add(f);
_addFeed(FeedMessage.fromJson(decoded));
} catch (_) {}
case 'nudge':
try {
@@ -173,21 +241,26 @@ class SyncClient {
receivedMs: _clock().millisecondsSinceEpoch);
} catch (_) {}
case 'err':
_errors.add('${decoded['message'] ?? 'error'}');
case 'chat':
try {
_feeds.add(
FeedMessage(
actor: '${decoded['from'] ?? '?'}',
action: 'chat',
reason: (decoded['text'] as String?) ?? '',
at: DateTime.now().toUtc(),
),
);
_errors.add(ErrorFrame.fromJson(decoded));
} catch (_) {}
case 'chat':
_feeds.add(
FeedMessage(
actor: '${decoded['from'] ?? '?'}',
actorId: decoded['fromId'] is String ? decoded['fromId'] as String : '',
action: 'chat',
reason: (decoded['text'] as String?) ?? '',
at: DateTime.now().toUtc(),
),
);
}
}
/// A socket is open (it may be half-open: the connection's silence
/// check tells).
bool get isOpen => _channel != null;
void _send(Map<String, Object?> json) {
final ch = _channel;
if (ch == null) return;
@@ -209,7 +282,8 @@ class SyncClient {
_send(SeekIntent(position: position, seq: seq).toJson());
}
/// One clock sample; the coordinator sends one per second.
/// One clock sample; the connection sends one per second (it also keeps
/// this member under the server's silence limit).
void sendPing() {
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
}
@@ -265,34 +339,58 @@ class SyncClient {
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
}
/// Sits the member [target] out, or with [out] false brings this
/// member back (only oneself can come back).
void sendOut(String target, bool out) {
_send(OutIntent(target: target, out: out).toJson());
/// Sits someone out, or brings this member back in (see [PresenceIntent]).
void sendPresence(PresenceIntent intent) {
_send(intent.toJson());
}
void sendNudge(String target) {
_send(NudgeIntent(target: target).toJson());
}
Future<void> disconnect() async {
/// `bye`: this member leaves now. Waits (at most [wait]) for the server
/// to close the socket, so the frame went out before ours closes.
Future<void> sendBye({Duration wait = const Duration(milliseconds: 600)}) async {
final done = _socketDone;
if (_channel == null || done == null) return;
_send(const ByeMessage().toJson());
try {
await done.future.timeout(wait);
} catch (_) {}
}
/// Closes the socket (no [SyncStatus] for it) and keeps who this member
/// is ([token], [memberId], [lastState], [instance]): a reconnect takes
/// it over. The close is not waited for (a half-open link never
/// answers it).
Future<void> drop() async {
final pending = _pendingWelcome;
_pendingWelcome = null;
if (pending != null && !pending.isCompleted) {
pending.completeError(StateError('cancelled'));
} try {
await _sub?.cancel();
} catch (_) {}
}
final sub = _sub;
_sub = null;
try {
await _channel?.sink.close();
} catch (_) {}
final ch = _channel;
_channel = null;
_socketDone = null;
try {
await sub?.cancel().timeout(const Duration(milliseconds: 500));
} catch (_) {}
if (ch != null) unawaited(ch.sink.close().timeout(const Duration(seconds: 2), onTimeout: () {}).catchError((_) {}));
}
/// [drop], then forgets the member and the server: the next connect is
/// a new session.
Future<void> disconnect() async {
await drop();
token = '';
memberId = '';
mediaUrl = null;
relayUrl = null;
lastState = null;
instance = '';
lastFeedSeq = 0;
}
Future<void> dispose() async {
@@ -37,7 +37,7 @@ typedef RestartFn = Future<void> Function(String executable, List<String> args);
// relaunched detached with the same args, then the window closes.
Future<void> _closeAndRestart(String executable, List<String> args) async {
final keep = args.where((a) => a != '--upgrade').toList();
await shutDownApp(beforeQuit: () async {
await shutDownApp(restart: true, beforeQuit: () async {
try {
await Hive.close();
} catch (e) {