Restructure step 1: lasting identity, presence and protocol 16
Server and core: version ranges on both sides with a coded refusal; a client id maps to the same member and token for the server's life; a drop is `gone` for a 10-minute grace keeping ready, presence, queue items, relay and avatar; `bye` leaves at once; ordered takeover (seq, replaced/stale); presence joining/in/away/gone with one PresencePolicy; joiners never pause playback and hold a held gate at most ~15 s; state.hold; feed ids, seq and replay; the relay lives through its source's grace and continues on re-dial; queue `by` lasting, auto items and cancel; MediaRef carried; client ids never logged. App: persisted client id and seq; Connection with unstable/lost/ replaced states, backoff with jitter, no lost/back pair per flap; a drop keeps the session (state, player, media, relay source); presence in the strip and members card; sit out = away intent; feed matched by id; auto sit-out (idle when not playing, minimised for a while; never when alone) replaces auto-unready; bye on close and Leave, not on update restart; relay source re-dials through its grace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
51c1c5ab78
commit
9f2586b7ff
85 files changed
+5198
-1902
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+73
-3
@@ -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>[
|
||||
@@ -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 +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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -43,7 +43,7 @@ void main() {
|
||||
position: 0,
|
||||
serverTime: DateTime.now().toUtc(),
|
||||
mediaId: 0,
|
||||
members: [MemberView(id: 'a', name: 'Alex', ready: false, inSet: false, buffered: 0, stale: false)],
|
||||
members: [MemberView(id: 'a', name: 'Alex', ready: false, inSet: false, buffered: 0)],
|
||||
);
|
||||
session.route.value = AppRoute.session;
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// The Connection against a scripted fake server: what a hello says, a
|
||||
// reconnect's hello, refusals in plain words, the backoff.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/session/connection.dart';
|
||||
import 'package:starsync/src/session/identity.dart';
|
||||
import 'package:starsync/src/sync/sync_client.dart';
|
||||
|
||||
/// A `/sync` endpoint that hands each socket and its hello to [onHello].
|
||||
class _Fake {
|
||||
final HttpServer _server;
|
||||
final hellos = <Map<String, Object?>>[];
|
||||
void Function(WebSocket ws, Map<String, Object?> hello) onHello;
|
||||
_Fake(this._server, this.onHello) {
|
||||
_server.listen((req) async {
|
||||
final ws = await WebSocketTransformer.upgrade(req);
|
||||
var first = true;
|
||||
ws.listen((m) {
|
||||
if (!first) return;
|
||||
first = false;
|
||||
final hello = Map<String, Object?>.from(jsonDecode('$m') as Map);
|
||||
hellos.add(hello);
|
||||
onHello(ws, hello);
|
||||
}, onError: (_) {});
|
||||
});
|
||||
}
|
||||
|
||||
static Future<_Fake> start(void Function(WebSocket ws, Map<String, Object?> hello) onHello) async =>
|
||||
_Fake(await HttpServer.bind(InternetAddress.loopbackIPv4, 0), onHello);
|
||||
|
||||
int get port => _server.port;
|
||||
Future<void> close() => _server.close(force: true);
|
||||
}
|
||||
|
||||
void _welcome(WebSocket ws, {String instance = 'boot-1', bool resumed = false}) => ws.add(jsonEncode(WelcomeMessage(
|
||||
token: 'tok', memberId: 'm1', instance: instance, resumed: resumed,
|
||||
state: StateBroadcast(
|
||||
eventId: 1, transport: 'held', position: 0, serverTime: DateTime.now().toUtc(), mediaId: 0, members: const [])
|
||||
.toJson())
|
||||
.toJson()));
|
||||
|
||||
Future<void> _poll(FutureOr<bool> Function() cond, String what, {Duration timeout = const Duration(seconds: 10)}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (await cond()) return;
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
fail('timed out waiting for $what');
|
||||
}
|
||||
|
||||
ConnectParams _params(int port) => ConnectParams(host: '127.0.0.1', port: port, secret: 's', name: 'me');
|
||||
|
||||
void main() {
|
||||
test('the hello: version range, client id, a rising seq, app, platform, caps; feedAfter only on a reconnect',
|
||||
() async {
|
||||
final fake = await _Fake.start((ws, hello) {
|
||||
_welcome(ws);
|
||||
// One feed line, then the link dies.
|
||||
ws.add(jsonEncode(FeedMessage(actor: 'Ann', action: 'join', at: DateTime.now().toUtc(), seq: 5).toJson()));
|
||||
});
|
||||
final id = ClientIdentity.ephemeral();
|
||||
final c = Connection(identity: id, caps: () => const ClientCaps(source: true, resolve: true),
|
||||
reconnectDelay: const Duration(milliseconds: 50));
|
||||
try {
|
||||
c.begin(_params(fake.port));
|
||||
await c.dial(c.invalidate());
|
||||
await _poll(() => c.client.lastFeedSeq == 5, 'the feed line');
|
||||
final h = fake.hellos.single;
|
||||
expect(h['proto'], RelayConstants.protoMax);
|
||||
expect(h['protoMin'], RelayConstants.protoMin);
|
||||
expect(h['client'], id.clientId);
|
||||
expect((h['client'] as String).length, 22);
|
||||
expect(validClientId(h['client'] as String), isTrue);
|
||||
expect(h['seq'], 1);
|
||||
expect(h['app'], appVersion);
|
||||
expect(h['platform'], Platform.operatingSystem);
|
||||
expect(h['caps'], {'source': true, 'resolve': true});
|
||||
expect(h.containsKey('feedAfter'), isFalse, reason: 'a first join replays nothing');
|
||||
expect(h['media'], <String, Object?>{}, reason: 'a proto 15 server answers its own proto mismatch');
|
||||
|
||||
await c.client.drop();
|
||||
// A dropped socket: no status from it; drive the drop the way the
|
||||
// silence check does.
|
||||
c.scheduleReconnect();
|
||||
await _poll(() => fake.hellos.length == 2 && c.state.peek() is ConnOnline, 'the reconnect');
|
||||
expect(fake.hellos[1]['seq'], 2);
|
||||
expect(fake.hellos[1]['client'], id.clientId);
|
||||
expect(fake.hellos[1]['feedAfter'], 5);
|
||||
} finally {
|
||||
await c.dispose();
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('a proto 15 host refuses in its own words: "the host runs an older Starsync"', () async {
|
||||
final fake = await _Fake.start((ws, hello) {
|
||||
ws.add(jsonEncode({'t': 'err', 'message': 'proto mismatch: got 16, want 15'}));
|
||||
ws.close(1008, 'refused');
|
||||
});
|
||||
final c = Connection();
|
||||
try {
|
||||
c.begin(_params(fake.port));
|
||||
await expectLater(c.dial(c.invalidate()), throwsA(isA<HelloRefused>()));
|
||||
final s = c.state.peek();
|
||||
expect(s, isA<ConnRefused>());
|
||||
expect((s as ConnRefused).code, 'version');
|
||||
expect(s.message, 'the host runs an older Starsync — they need to update');
|
||||
} finally {
|
||||
await c.dispose();
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('refusals in plain words', () {
|
||||
HelloRefused v({int? min, int? max}) =>
|
||||
HelloRefused(error: ErrorFrame(code: ErrCode.version, message: 'proto mismatch', min: min, max: max));
|
||||
expect(connectErrorText(v(min: 15, max: 15)), contains('older Starsync'));
|
||||
expect(connectErrorText(v(min: RelayConstants.protoMax + 1, max: RelayConstants.protoMax + 2)),
|
||||
contains('newer Starsync'));
|
||||
expect(connectErrorText(const HelloRefused(error: ErrorFrame(code: ErrCode.secret, message: 'wrong secret'))),
|
||||
'wrong server password (401)');
|
||||
expect(connectErrorText(const HelloRefused(closeCode: CloseCodes.replaced)), replacedText);
|
||||
expect(connectErrorText(const HelloRefused(error: ErrorFrame(code: ErrCode.stale, message: 'older'))), replacedText);
|
||||
});
|
||||
|
||||
test('backoff: about 1 s first, doubling, capped near 15 s, with jitter', () {
|
||||
final c = Connection();
|
||||
for (var i = 0; i < 20; i++) {
|
||||
final first = c.delayFor(1).inMilliseconds;
|
||||
expect(first, inInclusiveRange(800, 1200));
|
||||
expect(c.delayFor(2).inMilliseconds, inInclusiveRange(1600, 2400));
|
||||
expect(c.delayFor(30).inMilliseconds, inInclusiveRange(12000, 18000));
|
||||
}
|
||||
unawaited(c.dispose());
|
||||
});
|
||||
|
||||
test('replaced is terminal: no more attempts', () async {
|
||||
late WebSocket held;
|
||||
final fake = await _Fake.start((ws, hello) {
|
||||
held = ws;
|
||||
_welcome(ws);
|
||||
});
|
||||
final c = Connection(reconnectDelay: const Duration(milliseconds: 50));
|
||||
try {
|
||||
c.begin(_params(fake.port));
|
||||
await c.dial(c.invalidate());
|
||||
held.add(jsonEncode(const ErrorFrame(code: ErrCode.replaced, message: 'another connection').toJson()));
|
||||
await held.close(CloseCodes.replaced, 'replaced');
|
||||
await _poll(() => c.state.peek() is ConnReplaced, 'replaced');
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
expect(c.state.peek(), isA<ConnReplaced>());
|
||||
expect(fake.hellos, hasLength(1), reason: 'it never dials again by itself');
|
||||
} finally {
|
||||
await c.dispose();
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,18 +1,31 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): reconnect,
|
||||
// resume and ready restore; "feed and chat toasts" in step 2 (Notifier).
|
||||
// restructure: rewrite in step 2 (Status + Notifier): "feed and chat toasts
|
||||
// name their actor exactly".
|
||||
//
|
||||
// The session through drops, takeovers and goodbyes: real coordinators
|
||||
// (real mpv), a real server, and FlakyProxy between a member and it.
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_core/testing.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:starsync/src/jellyfin/accounts.dart';
|
||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||
import 'package:starsync/src/playback/yt_dlp_app.dart';
|
||||
import 'package:starsync/src/screens/home/picked_media.dart';
|
||||
import 'package:starsync/src/session/connection.dart';
|
||||
import 'package:starsync/src/session/identity.dart';
|
||||
import 'package:starsync/src/session/notifier.dart';
|
||||
import 'package:starsync/src/session/session_coordinator.dart';
|
||||
import 'package:starsync/src/sfx/sfx.dart';
|
||||
import 'package:starsync/src/state/session_state.dart';
|
||||
import 'package:starsync/src/sync/sync_client.dart';
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
|
||||
Future<void> _poll(
|
||||
FutureOr<bool> Function() cond,
|
||||
String what, {
|
||||
@@ -26,224 +39,38 @@ Future<void> _poll(
|
||||
fail('timed out waiting for $what');
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'dead server clears server state and reconnects instead of freezing',
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final server = SyncServer(
|
||||
const AppConfig(port: 0, secret: 'presence', name: 'presence'));
|
||||
await server.start();
|
||||
final coord = SessionCoordinator();
|
||||
try {
|
||||
await coord.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.boundPort,
|
||||
secret: 'presence',
|
||||
name: 'watcher',
|
||||
));
|
||||
await coord.playback.player.setVolume(0);
|
||||
await _poll(() => coord.serverState.peek() != null, 'server state to arrive');
|
||||
expect(coord.reconnecting.peek(), isFalse);
|
||||
/// Two minutes of small test video with sound (headless mpv needs audio).
|
||||
Future<String> _media() async {
|
||||
final path = '$_tmp/coord_presence_120s.mkv';
|
||||
if (await File(path).exists()) return path;
|
||||
final r = await Process.run('ffmpeg', [
|
||||
'-y', '-v', 'error',
|
||||
'-f', 'lavfi', '-i', 'testsrc2=size=160x90:rate=12',
|
||||
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=22050',
|
||||
'-t', '120', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '12', '-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac', '-b:a', '32k', path,
|
||||
]);
|
||||
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
|
||||
return path;
|
||||
}
|
||||
|
||||
await server.stop();
|
||||
await _poll(
|
||||
() => coord.reconnecting.peek() == true,
|
||||
'reconnect loop to engage',
|
||||
);
|
||||
await _poll(
|
||||
() => coord.serverState.peek() == null,
|
||||
'stale state (ghost members) to clear',
|
||||
);
|
||||
coord.cancelReconnect();
|
||||
expect(coord.reconnecting.peek(), isFalse);
|
||||
} finally {
|
||||
await coord.dispose();
|
||||
/// Serves [file] with ranges: a link everyone opens directly, so only the
|
||||
/// sync socket goes through the proxy.
|
||||
Future<HttpServer> _origin(String file) async {
|
||||
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
s.listen((req) async {
|
||||
final size = await File(file).length();
|
||||
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mkv');
|
||||
req.response.statusCode = r.status;
|
||||
r.headers.forEach(req.response.headers.set);
|
||||
try {
|
||||
if (r.status == 200 || r.status == 206) {
|
||||
await req.response.addStream(File(file).openRead(r.start, (r.end ?? size - 1) + 1));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'cancel then retry within the reconnect delay dials only once',
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final first = SyncServer(
|
||||
const AppConfig(port: 0, secret: 'race', name: 'race'));
|
||||
await first.start();
|
||||
final port = first.boundPort;
|
||||
final coord = SessionCoordinator();
|
||||
SyncServer? second;
|
||||
final observer = SyncClient();
|
||||
try {
|
||||
await coord.connect(ConnectParams(
|
||||
host: '127.0.0.1', port: port, secret: 'race', name: 'watcher'));
|
||||
await coord.playback.player.setVolume(0);
|
||||
await first.stop();
|
||||
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
||||
second = SyncServer(
|
||||
AppConfig(port: port, secret: 'race', name: 'race'));
|
||||
await second.start();
|
||||
var joins = 0;
|
||||
final sub = observer.feeds.listen((f) {
|
||||
if (f.action == 'join' && f.actor == 'watcher') joins++;
|
||||
});
|
||||
await observer.connect(
|
||||
wsUri: Uri.parse('ws://127.0.0.1:$port/sync'),
|
||||
secret: 'race',
|
||||
name: 'observer');
|
||||
// Within the 1.5 s delay: the first attempt must die with Cancel.
|
||||
coord.cancelReconnect();
|
||||
coord.retry();
|
||||
await _poll(() => !coord.reconnecting.peek() && coord.serverState.peek() != null,
|
||||
'reconnect to land');
|
||||
await Future.delayed(const Duration(seconds: 4));
|
||||
await sub.cancel();
|
||||
expect(joins, 1, reason: 'a stale attempt dialled a second time');
|
||||
expect(
|
||||
second.hub.engine.members.values
|
||||
.where((m) => m.name == 'watcher')
|
||||
.length,
|
||||
1);
|
||||
} finally {
|
||||
await observer.dispose();
|
||||
await coord.dispose();
|
||||
await second?.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'reconnect restores the server-side ready flag (force-all included)',
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final first = SyncServer(
|
||||
const AppConfig(port: 0, secret: 'force', name: 'force'));
|
||||
await first.start();
|
||||
final port = first.boundPort;
|
||||
final coord = SessionCoordinator();
|
||||
final friend = SyncClient();
|
||||
SyncServer? second;
|
||||
try {
|
||||
await coord.connect(ConnectParams(
|
||||
host: '127.0.0.1', port: port, secret: 'force', name: 'watcher'));
|
||||
await coord.playback.player.setVolume(0);
|
||||
await friend.connect(
|
||||
wsUri: Uri.parse('ws://127.0.0.1:$port/sync'),
|
||||
secret: 'force',
|
||||
name: 'friend');
|
||||
// Never pressed Space locally; a friend marks everyone ready.
|
||||
friend.sendForce('all', true);
|
||||
await _poll(() => coord.selfMember.peek()?.ready == true,
|
||||
'forced ready to reach watcher');
|
||||
await friend.disconnect();
|
||||
await first.stop();
|
||||
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
||||
second = SyncServer(
|
||||
AppConfig(port: port, secret: 'force', name: 'force'));
|
||||
await second.start();
|
||||
await _poll(
|
||||
() => second!.hub.engine.members.values
|
||||
.any((m) => m.name == 'watcher' && m.ready),
|
||||
'watcher to come back ready');
|
||||
} finally {
|
||||
await friend.dispose();
|
||||
await coord.dispose();
|
||||
await second?.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'cancel during connect setup aborts without joining',
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final server = SyncServer(
|
||||
const AppConfig(port: 0, secret: '', name: 'cancel'));
|
||||
await server.start();
|
||||
final coord = SessionCoordinator();
|
||||
try {
|
||||
final pending = coord.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.boundPort,
|
||||
secret: '',
|
||||
name: 'watcher'));
|
||||
await coord.cancelConnect();
|
||||
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
expect(server.hub.engine.members, isEmpty);
|
||||
expect(coord.reconnecting.peek(), isFalse);
|
||||
} finally {
|
||||
await coord.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'cancelling a host setup releases the port',
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
Hive.init(
|
||||
'${Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path}/hive_coord_test');
|
||||
final probe = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||
final port = probe.port;
|
||||
await probe.close();
|
||||
final session = SessionState.instance;
|
||||
try {
|
||||
final pending = session.host(
|
||||
ConnectParams(host: '', port: port, secret: '', name: 'host'),
|
||||
ConnectForm());
|
||||
await session.cancelPending();
|
||||
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
|
||||
expect(session.route.peek(), isNot(AppRoute.session));
|
||||
final rebind = await ServerSocket.bind(InternetAddress.anyIPv4, port);
|
||||
await rebind.close();
|
||||
} finally {
|
||||
await session.coordinator.leave(silent: true);
|
||||
await session.stopServer();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'feed and chat toasts name their actor exactly',
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast'));
|
||||
await server.start();
|
||||
final sounds = _RecordingSfx();
|
||||
final coord = SessionCoordinator(sfx: sounds);
|
||||
final friend = SyncClient();
|
||||
try {
|
||||
await coord.connect(ConnectParams(
|
||||
host: '127.0.0.1', port: server.boundPort, secret: 'toast', name: 'Bob'));
|
||||
await coord.playback.player.setVolume(0);
|
||||
await friend.connect(
|
||||
wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), secret: 'toast', name: 'Bob Jr');
|
||||
friend.sendChat('hi there');
|
||||
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob Jr: hi there'), 'chat toast');
|
||||
final toasts = coord.toasts.peek();
|
||||
expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr');
|
||||
expect(toasts.firstWhere((t) => t.text == 'Bob Jr: hi there').actor, 'Bob Jr');
|
||||
// Others' chat makes a sound, your own does not.
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
|
||||
coord.sendChat('mine');
|
||||
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob: mine'), 'own chat');
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
|
||||
expect(sounds.kinds, contains(SfxKind.join));
|
||||
} finally {
|
||||
await friend.dispose();
|
||||
await coord.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
await req.response.close();
|
||||
} catch (_) {}
|
||||
});
|
||||
return s;
|
||||
}
|
||||
|
||||
class _RecordingSfx implements SfxService {
|
||||
@@ -251,3 +78,351 @@ class _RecordingSfx implements SfxService {
|
||||
@override
|
||||
Future<void> play(SfxCue cue) async => kinds.add(cue.kind);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late List<SessionCoordinator> all;
|
||||
|
||||
setUp(() {
|
||||
MediaKit.ensureInitialized();
|
||||
all = [];
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
for (final c in all) {
|
||||
await c.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
/// A member: its own install unless [identity] is given.
|
||||
Future<SessionCoordinator> member(int port, String name,
|
||||
{String secret = 'pw', _RecordingSfx? sfx, ClientIdentity? identity}) async {
|
||||
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await acc.ensureLoaded(validate: false);
|
||||
final n = Notifier(sfx: sfx ?? const NoopSfxService());
|
||||
final c = SessionCoordinator(
|
||||
notifier: n,
|
||||
connection: Connection(notify: n.notify, identity: identity),
|
||||
jellyfin: acc,
|
||||
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
|
||||
ytDlp: AppYtDlp(YtDlp('$_tmp/coord_presence_no_ytdlp', enabled: false)));
|
||||
all.add(c);
|
||||
await c.connect(ConnectParams(host: '127.0.0.1', port: port, secret: secret, name: name));
|
||||
await c.playback.player.setVolume(0);
|
||||
return c;
|
||||
}
|
||||
|
||||
/// [up] puts the video up, everyone readies, it plays.
|
||||
Future<void> playTogether(SessionCoordinator up, List<SessionCoordinator> xs, String link) async {
|
||||
expect(await up.swapPicked(LinkMedia(link, anyway: true)), isNull);
|
||||
await _poll(() => xs.every((x) => x.playback.durationSecs > 100), 'all opened it', timeout: const Duration(seconds: 30));
|
||||
for (final x in xs) {
|
||||
x.toggleReady();
|
||||
}
|
||||
await _poll(() => xs.every((x) => x.playback.player.state.playing), 'all playing');
|
||||
}
|
||||
|
||||
test('a drop and return while playing: same member, nothing reopened, the state kept throughout',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'drop'));
|
||||
await server.start();
|
||||
final origin = await _origin(await _media());
|
||||
final proxy = await FlakyProxy.start(server.boundPort);
|
||||
try {
|
||||
final alice = await member(server.boundPort, 'alice');
|
||||
final sounds = _RecordingSfx();
|
||||
final bob = await member(proxy.port, 'bob', sfx: sounds);
|
||||
await playTogether(alice, [alice, bob], 'http://127.0.0.1:${origin.port}/ep.mkv');
|
||||
final id = bob.connection.memberId;
|
||||
final token = bob.connection.token;
|
||||
final opens = bob.opens;
|
||||
final mediaId = bob.serverState.peek()!.mediaId;
|
||||
|
||||
// Bob's link goes half-open (no FIN): unstable from ~3 s, lost at
|
||||
// ~9 s, then a new dial through a link that works.
|
||||
final link = proxy.links.single;
|
||||
link.blackhole();
|
||||
var sawStale = false, sawUnstable = false, lostState = false;
|
||||
await _poll(() {
|
||||
final s = bob.serverState.peek();
|
||||
if (s == null || s.media == null) lostState = true;
|
||||
if (bob.stateStale.peek()) sawStale = true;
|
||||
if (bob.unstable.peek()) sawUnstable = true;
|
||||
return proxy.links.length > 1 && bob.connection.state.peek() is ConnOnline;
|
||||
}, 'bob back online', timeout: const Duration(seconds: 30));
|
||||
expect(lostState, isFalse, reason: 'the state (and the update band off the video) survives the drop');
|
||||
expect(sawUnstable, isTrue, reason: '"connection unstable" before lost');
|
||||
expect(sawStale, isTrue);
|
||||
expect(bob.connection.memberId, id, reason: 'the same member');
|
||||
expect(bob.connection.token, token);
|
||||
expect(bob.opens, opens, reason: 'the player kept the media');
|
||||
expect(bob.serverState.peek()!.mediaId, mediaId);
|
||||
// Playing on (a drift correction on the return may seek once).
|
||||
await _poll(() => bob.playback.player.state.playing, 'bob playing', timeout: const Duration(seconds: 3));
|
||||
expect(server.hub.engine.members.values.where((m) => m.name == 'bob'), hasLength(1));
|
||||
// A drop that long is heard once each way.
|
||||
await _poll(() => sounds.kinds.contains(SfxKind.back), 'back sound');
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.lost), hasLength(1));
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.back), hasLength(1));
|
||||
// In sync again.
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
final gap = (alice.playback.positionSecs - bob.playback.positionSecs).abs();
|
||||
expect(gap, lessThan(1.5), reason: 'in sync after the return');
|
||||
} finally {
|
||||
await proxy.close();
|
||||
await origin.close(force: true);
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('10 flaps in 60 s: at most one lost/back sound, nothing reopened, one member',
|
||||
timeout: const Timeout(Duration(minutes: 3)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'flaps'));
|
||||
await server.start();
|
||||
final origin = await _origin(await _media());
|
||||
final proxy = await FlakyProxy.start(server.boundPort);
|
||||
try {
|
||||
final alice = await member(server.boundPort, 'alice');
|
||||
final sounds = _RecordingSfx();
|
||||
final bob = await member(proxy.port, 'bob', sfx: sounds);
|
||||
await playTogether(alice, [alice, bob], 'http://127.0.0.1:${origin.port}/ep.mkv');
|
||||
final id = bob.connection.memberId;
|
||||
final opens = bob.opens;
|
||||
final watcher = SyncClient();
|
||||
final feed = <FeedMessage>[];
|
||||
final sub = watcher.feeds.listen(feed.add);
|
||||
await watcher.connect(wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), secret: 'pw', name: 'watcher');
|
||||
// The sync socket: the first link, then each one dialled while
|
||||
// reconnecting (the session log upload and pictures go through the
|
||||
// proxy too).
|
||||
var sync = proxy.links.single;
|
||||
final dials = proxy.connections.listen((l) {
|
||||
if (bob.connection.state.peek().retrying) sync = l;
|
||||
});
|
||||
var drops = 0;
|
||||
final stopCount = bob.connection.state.subscribe((s) {
|
||||
if (s is ConnLost) drops++;
|
||||
});
|
||||
try {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await _poll(() => bob.connection.state.peek() is ConnOnline, 'bob online before flap $i');
|
||||
sync.reset();
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
}
|
||||
await _poll(() => bob.connection.state.peek() is ConnOnline, 'bob online after the flaps');
|
||||
expect(drops, greaterThanOrEqualTo(10), reason: 'every flap dropped the link');
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.lost).length, lessThanOrEqualTo(1));
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.back).length, lessThanOrEqualTo(1));
|
||||
expect(bob.opens, opens, reason: 'no reopen per flap');
|
||||
expect(bob.connection.memberId, id);
|
||||
expect(server.hub.engine.members.values.where((m) => m.name == 'bob'), hasLength(1));
|
||||
expect(feed.where((f) => f.actorId == id && (f.action == 'join' || f.action == 'leave')), isEmpty,
|
||||
reason: 'quick returns say nothing to the others');
|
||||
expect(bob.playback.player.state.playing, isTrue);
|
||||
} finally {
|
||||
stopCount();
|
||||
await dials.cancel();
|
||||
await sub.cancel();
|
||||
await watcher.dispose();
|
||||
}
|
||||
} finally {
|
||||
await proxy.close();
|
||||
await origin.close(force: true);
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('replaced: opened in another window, the older one stops for good',
|
||||
timeout: const Timeout(Duration(minutes: 1)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'replaced'));
|
||||
await server.start();
|
||||
try {
|
||||
final install = ClientIdentity.ephemeral();
|
||||
final first = await member(server.boundPort, 'me', identity: install);
|
||||
final id = first.connection.memberId;
|
||||
final second = await member(server.boundPort, 'me', identity: install);
|
||||
expect(second.connection.memberId, id, reason: 'the newer connection took the member over');
|
||||
await _poll(() => first.connection.state.peek() is ConnReplaced, 'the first one replaced');
|
||||
expect(first.health.peek().message, replacedText);
|
||||
// It does not fight back.
|
||||
await Future.delayed(const Duration(seconds: 4));
|
||||
expect(first.connection.state.peek(), isA<ConnReplaced>());
|
||||
expect(first.reconnecting.peek(), isFalse);
|
||||
expect(second.connection.state.peek(), isA<ConnOnline>());
|
||||
expect(server.hub.engine.members, hasLength(1));
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('leave sends bye (others see "left" at once); update and restart does not',
|
||||
timeout: const Timeout(Duration(minutes: 1)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'bye'));
|
||||
await server.start();
|
||||
final watcher = SyncClient();
|
||||
final feed = <FeedMessage>[];
|
||||
final sub = watcher.feeds.listen(feed.add);
|
||||
try {
|
||||
await watcher.connect(wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), secret: 'pw', name: 'watcher');
|
||||
final leaver = await member(server.boundPort, 'leaver');
|
||||
final id = leaver.connection.memberId;
|
||||
final sw = Stopwatch()..start();
|
||||
await leaver.leave();
|
||||
await _poll(() => feed.any((f) => f.action == 'leave' && f.actorId == id), 'leave line',
|
||||
timeout: const Duration(seconds: 3));
|
||||
expect(sw.elapsed, lessThan(const Duration(seconds: 3)));
|
||||
expect(feed.firstWhere((f) => f.action == 'leave' && f.actorId == id).reason, 'bye');
|
||||
expect(server.hub.engine.byId(id), isNull);
|
||||
|
||||
final restarter = await member(server.boundPort, 'restarter');
|
||||
final rid = restarter.connection.memberId;
|
||||
await restarter.leave(bye: false);
|
||||
await _poll(() => server.hub.engine.byId(rid)?.presence == Presence.gone, 'gone, kept for its grace');
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
expect(feed.where((f) => f.action == 'leave' && f.actorId == rid), isEmpty);
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
await watcher.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('a dead server: the state stays (stale), the app reconnects, and "stop reconnecting" stops',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'presence', name: 'presence'));
|
||||
await server.start();
|
||||
final coord = await member(server.boundPort, 'watcher', secret: 'presence');
|
||||
await _poll(() => coord.serverState.peek() != null, 'server state to arrive');
|
||||
expect(coord.reconnecting.peek(), isFalse);
|
||||
expect(coord.stateStale.peek(), isFalse);
|
||||
|
||||
await server.stop();
|
||||
await _poll(() => coord.reconnecting.peek() == true, 'reconnect loop to engage');
|
||||
expect(coord.serverState.peek(), isNotNull, reason: 'a drop keeps the last state');
|
||||
expect(coord.stateStale.peek(), isTrue);
|
||||
coord.cancelReconnect();
|
||||
expect(coord.reconnecting.peek(), isFalse);
|
||||
});
|
||||
|
||||
test('cancel then retry within the reconnect delay dials only once',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final first = SyncServer(const AppConfig(port: 0, secret: 'race', name: 'race'));
|
||||
await first.start();
|
||||
final port = first.boundPort;
|
||||
SyncServer? second;
|
||||
final observer = SyncClient();
|
||||
try {
|
||||
final coord = await member(port, 'watcher', secret: 'race');
|
||||
await first.stop();
|
||||
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
||||
second = SyncServer(AppConfig(port: port, secret: 'race', name: 'race'));
|
||||
await second.start();
|
||||
var joins = 0;
|
||||
final sub = observer.feeds.listen((f) {
|
||||
if (f.action == 'join' && f.actor == 'watcher') joins++;
|
||||
});
|
||||
await observer.connect(wsUri: Uri.parse('ws://127.0.0.1:$port/sync'), secret: 'race', name: 'observer');
|
||||
// Within the first delay: the first attempt must die with Cancel.
|
||||
coord.cancelReconnect();
|
||||
coord.retry();
|
||||
await _poll(() => !coord.reconnecting.peek() && coord.connection.state.peek().online, 'reconnect to land');
|
||||
await Future.delayed(const Duration(seconds: 4));
|
||||
await sub.cancel();
|
||||
expect(joins, 1, reason: 'a stale attempt dialled a second time');
|
||||
expect(second.hub.engine.members.values.where((m) => m.name == 'watcher').length, 1);
|
||||
} finally {
|
||||
await observer.dispose();
|
||||
await second?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('a restarted server (not resumed): the app sends its ready again (force-all included)',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final first = SyncServer(const AppConfig(port: 0, secret: 'force', name: 'force'));
|
||||
await first.start();
|
||||
final port = first.boundPort;
|
||||
final friend = SyncClient();
|
||||
SyncServer? second;
|
||||
try {
|
||||
final coord = await member(port, 'watcher', secret: 'force');
|
||||
await friend.connect(wsUri: Uri.parse('ws://127.0.0.1:$port/sync'), secret: 'force', name: 'friend');
|
||||
// Never pressed Space locally; a friend marks everyone ready.
|
||||
friend.sendForce('all', true);
|
||||
await _poll(() => coord.selfMember.peek()?.ready == true, 'forced ready to reach watcher');
|
||||
await friend.disconnect();
|
||||
await first.stop();
|
||||
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
||||
second = SyncServer(AppConfig(port: port, secret: 'force', name: 'force'));
|
||||
await second.start();
|
||||
await _poll(() => second!.hub.engine.members.values.any((m) => m.name == 'watcher' && m.ready),
|
||||
'watcher to come back ready');
|
||||
} finally {
|
||||
await friend.dispose();
|
||||
await second?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel'));
|
||||
await server.start();
|
||||
final coord = SessionCoordinator();
|
||||
try {
|
||||
final pending =
|
||||
coord.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: '', name: 'watcher'));
|
||||
await coord.cancelConnect();
|
||||
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
expect(server.hub.engine.members, isEmpty);
|
||||
expect(coord.reconnecting.peek(), isFalse);
|
||||
} finally {
|
||||
await coord.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelling a host setup releases the port', timeout: const Timeout(Duration(minutes: 1)), () async {
|
||||
Hive.init('$_tmp/hive_coord_test');
|
||||
final probe = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||
final port = probe.port;
|
||||
await probe.close();
|
||||
final session = SessionState.instance;
|
||||
try {
|
||||
final pending = session.host(ConnectParams(host: '', port: port, secret: '', name: 'host'), ConnectForm());
|
||||
await session.cancelPending();
|
||||
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
|
||||
expect(session.route.peek(), isNot(AppRoute.session));
|
||||
final rebind = await ServerSocket.bind(InternetAddress.anyIPv4, port);
|
||||
await rebind.close();
|
||||
} finally {
|
||||
await session.coordinator.leave(silent: true);
|
||||
await session.stopServer();
|
||||
}
|
||||
});
|
||||
|
||||
test('feed and chat toasts name their actor exactly', timeout: const Timeout(Duration(minutes: 1)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast'));
|
||||
await server.start();
|
||||
final sounds = _RecordingSfx();
|
||||
final friend = SyncClient();
|
||||
try {
|
||||
final coord = await member(server.boundPort, 'Bob', secret: 'toast', sfx: sounds);
|
||||
await friend.connect(
|
||||
wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), secret: 'toast', name: 'Bob Jr');
|
||||
friend.sendChat('hi there');
|
||||
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob Jr: hi there'), 'chat toast');
|
||||
final toasts = coord.toasts.peek();
|
||||
expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr');
|
||||
final chat = toasts.firstWhere((t) => t.text == 'Bob Jr: hi there');
|
||||
expect(chat.actor, 'Bob Jr');
|
||||
expect(chat.actorId, friend.memberId, reason: 'matched by member id');
|
||||
// Others' chat makes a sound, your own does not.
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
|
||||
coord.sendChat('mine');
|
||||
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob: mine'), 'own chat');
|
||||
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
|
||||
expect(sounds.kinds, contains(SfxKind.join));
|
||||
} finally {
|
||||
await friend.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import 'package:starsync/src/session/delivery.dart';
|
||||
import 'package:starsync/src/shell/delivery_line.dart';
|
||||
|
||||
MemberView _m(String id, String name, {double buffered = 30, bool host = false, int rate = 0}) =>
|
||||
MemberView(id: id, name: name, ready: true, inSet: true, buffered: buffered, stale: false, host: host, rate: rate);
|
||||
MemberView(id: id, name: name, ready: true, inSet: true, buffered: buffered, host: host, rate: rate);
|
||||
|
||||
StateBroadcast _state(Map<String, Object?>? media, List<MemberView> members, {String transport = 'playing'}) =>
|
||||
StateBroadcast(
|
||||
@@ -87,7 +87,7 @@ void main() {
|
||||
test('Jellyfin: own accounts direct, the rest via server', () {
|
||||
const ref = {'serverId': 'srv', 'itemId': '0123456789abcdef0123456789abcdef', 'title': 'Pilot'};
|
||||
MemberView jf(String id, String name, bool own) =>
|
||||
MemberView(id: id, name: name, ready: true, inSet: true, buffered: 30, stale: false, jf: {'srv': own});
|
||||
MemberView(id: id, name: name, ready: true, inSet: true, buffered: 30, jf: {'srv': own});
|
||||
final all = [jf('m1', 'Foxyz', true), jf('m2', 'Bob', true)];
|
||||
final media = {'name': 'Pilot', 'jellyfin': ref, 'relay': const MediaRelay(source: 'm1', live: true).toJson()};
|
||||
final d = deliveryOf(_state(media, all), selfId: 'm2')!;
|
||||
|
||||
@@ -60,9 +60,15 @@ JellyfinMediaSource fixtureSource() => JellyfinPlaybackInfo.fromJson(
|
||||
jsonDecode(jellyfinFixture('playback_info')) as Map<String, dynamic>)
|
||||
.primarySource!;
|
||||
|
||||
MemberView member(String id, String name, {Map<String, bool> jf = const {}, bool stale = false}) =>
|
||||
MemberView member(String id, String name, {Map<String, bool> jf = const {}, bool gone = false}) =>
|
||||
MemberView(
|
||||
id: id, name: name, ready: true, inSet: true, buffered: 10, stale: stale, jf: jf);
|
||||
id: id,
|
||||
name: name,
|
||||
ready: true,
|
||||
inSet: true,
|
||||
buffered: 10,
|
||||
presence: gone ? Presence.gone : Presence.watching,
|
||||
jf: jf);
|
||||
|
||||
void main() {
|
||||
setUp(reports.clear);
|
||||
@@ -371,7 +377,7 @@ void main() {
|
||||
member('m10', 'Ann', jf: {fixtureServerId: true}),
|
||||
member('m2', 'Bob', jf: {fixtureServerId: true}),
|
||||
member('m1', 'Guest', jf: {fixtureServerId: false}),
|
||||
member('m0', 'Old', jf: {fixtureServerId: true}, stale: true),
|
||||
member('m0', 'Old', jf: {fixtureServerId: true}, gone: true),
|
||||
member('m3', 'Fresh'),
|
||||
];
|
||||
expect(autoAdvanceSwapper(members, fixtureServerId), 'm2');
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): the
|
||||
// feed rows follow the new feed actions one-to-one (away/in/gone/return);
|
||||
// step 2 (Status + Notifier): every row, when noticeFor takes a context.
|
||||
// restructure: rewrite in step 2 (Status + Notifier): every row, when
|
||||
// noticeFor takes a context.
|
||||
//
|
||||
// The Notifier policy as a table: one row per AppEvent subtype, what
|
||||
// noticeFor makes of it (toast text and actor, sound, attention, activity-log
|
||||
@@ -14,8 +13,21 @@ import 'package:starsync/src/sfx/sfx.dart';
|
||||
|
||||
final _at = DateTime.utc(2026, 1, 1);
|
||||
|
||||
FeedMessage _feed(String action, {String actor = 'Ann', String? target, String reason = ''}) =>
|
||||
FeedMessage(actor: actor, action: action, target: target, reason: reason, at: _at);
|
||||
/// A feed line; people are named and identified (Ann is m1, Bob m2, this
|
||||
/// member, Me, m9).
|
||||
FeedMessage _feed(String action,
|
||||
{String actor = 'Ann', String? target, String reason = '', bool replayed = false}) =>
|
||||
FeedMessage(
|
||||
actor: actor,
|
||||
actorId: _ids[actor] ?? '',
|
||||
action: action,
|
||||
target: target,
|
||||
targetId: _ids[target] ?? '',
|
||||
reason: reason,
|
||||
at: _at,
|
||||
replayed: replayed);
|
||||
|
||||
const _ids = {'Ann': 'm1', 'Bob': 'm2', 'Me': 'm9', 'server': 'server'};
|
||||
|
||||
/// One row: the event, then what it must do.
|
||||
class _Row {
|
||||
@@ -33,22 +45,36 @@ class _Row {
|
||||
{this.toast, this.actor, this.sound, this.readyIndex = 0, this.attention = false, this.logged = false});
|
||||
}
|
||||
|
||||
/// The feed as the coordinator turns it into events: this member is 'Me',
|
||||
/// two members ready in the latest state.
|
||||
FeedEvent _of(FeedMessage f) => FeedEvent.of(f, selfName: 'Me', readyCount: 2);
|
||||
/// The feed as the coordinator turns it into events: this member is m9
|
||||
/// ('Me'), two members ready in the latest state.
|
||||
FeedEvent _of(FeedMessage f) => FeedEvent.of(f, selfId: 'm9', readyCount: 2);
|
||||
|
||||
final _rows = <_Row>[
|
||||
// ---- the feed (FeedEvent.of, then noticeFor); every line is logged
|
||||
_Row('join', _of(_feed('join')), MemberJoined,
|
||||
toast: 'Ann joined', actor: 'Ann', sound: SfxKind.join, logged: true),
|
||||
_Row('leave', _of(_feed('leave')), MemberLeft,
|
||||
_Row('leave (bye)', _of(_feed('leave', reason: 'bye')), MemberLeft,
|
||||
toast: 'Ann left', actor: 'Ann', sound: SfxKind.leave, logged: true),
|
||||
_Row('stale', _of(_feed('stale')), MemberStale, logged: true),
|
||||
_Row('out, someone else sat out', _of(_feed('out', target: 'Bob')), MemberSatOut, logged: true),
|
||||
_Row('out, sat yourself out', _of(_feed('out', actor: 'Me', target: 'Me')), MemberSatOut, logged: true),
|
||||
_Row('out, someone sat you out', _of(_feed('out', target: 'Me')), MemberSatOut,
|
||||
_Row('leave (grace over)', _of(_feed('leave', reason: 'grace')), MemberLeft,
|
||||
toast: 'Ann left', actor: 'Ann', sound: SfxKind.leave, logged: true),
|
||||
_Row('gone (lost signal)', _of(_feed('gone')), MemberGone, logged: true),
|
||||
_Row('return', _of(_feed('return')), MemberReturned, logged: true),
|
||||
_Row('away, someone sat themselves out', _of(_feed('away')), MemberSatOut, logged: true),
|
||||
_Row('away, someone else auto sat out', _of(_feed('away', reason: 'idle')), MemberSatOut, logged: true),
|
||||
_Row('away, someone sat someone else out', _of(_feed('away', target: 'Bob')), MemberSatOut, logged: true),
|
||||
_Row('away, you sat yourself out', _of(_feed('away', actor: 'Me')), MemberSatOut, logged: true),
|
||||
_Row('away, someone sat you out', _of(_feed('away', target: 'Me')), MemberSatOut,
|
||||
toast: 'Ann sat you out', actor: 'Ann', sound: SfxKind.kick, logged: true),
|
||||
_Row('back', _of(_feed('back')), MemberBack, logged: true),
|
||||
_Row('away, auto sit-out (idle)', _of(_feed('away', actor: 'Me', reason: 'idle')), MemberSatOut,
|
||||
toast: 'You sit out (idle); you are back as soon as you return', sound: SfxKind.warn, logged: true),
|
||||
_Row('away, auto sit-out (minimised)', _of(_feed('away', actor: 'Me', reason: 'minimised')), MemberSatOut,
|
||||
toast: 'You sit out (minimised); you are back as soon as you return', sound: SfxKind.warn, logged: true),
|
||||
_Row('in (back from away)', _of(_feed('in')), MemberBack, logged: true),
|
||||
_Row('a replayed join: logged only', _of(_feed('join', replayed: true)), MemberJoined, logged: true),
|
||||
_Row('a replayed chat: logged only', _of(_feed('chat', reason: 'hi', replayed: true)), ChatPosted, logged: true),
|
||||
_Row('someone with your name is not you', _of(FeedMessage(
|
||||
actor: 'Me', actorId: 'm4', action: 'away', target: 'Me', targetId: 'm9', at: _at)), MemberSatOut,
|
||||
toast: 'Me sat you out', actor: 'Me', sound: SfxKind.kick, logged: true),
|
||||
_Row('ready', _of(_feed('ready')), ReadyChanged, sound: SfxKind.ready, readyIndex: 2, logged: true),
|
||||
_Row('unready', _of(_feed('unready')), ReadyChanged, sound: SfxKind.unready, logged: true),
|
||||
_Row('force-ready', _of(_feed('force-ready')), ForcedReady, sound: SfxKind.forceReady, logged: true),
|
||||
@@ -87,10 +113,6 @@ final _rows = <_Row>[
|
||||
_Row('relay started', const RelayStarted(), RelayStarted,
|
||||
toast: 'Passing the video on through the server. Keep the app open while it plays.'),
|
||||
_Row('relay failed', const RelayFailed('closed'), RelayFailed, toast: 'Cannot pass the video on: closed'),
|
||||
_Row('idle, unready', const IdleWarning(sitsOut: false), IdleWarning,
|
||||
toast: 'idle — auto-unready in 2 min unless you press Space', sound: SfxKind.warn),
|
||||
_Row('idle, sits out', const IdleWarning(sitsOut: true), IdleWarning,
|
||||
toast: 'idle — sitting out in 2 min unless you press Space', sound: SfxKind.warn),
|
||||
_Row('connection lost', const ConnectionLost(), ConnectionLost, sound: SfxKind.lost),
|
||||
_Row('connection back', const ConnectionBack(), ConnectionBack, sound: SfxKind.back),
|
||||
_Row('jellyfin, sign-in expired', const JellyfinProgressFailed(signInExpired: true), JellyfinProgressFailed,
|
||||
|
||||
@@ -272,8 +272,8 @@ void main() {
|
||||
|
||||
testWidgets('the state\'s host flag crowns that member for everyone', (tester) async {
|
||||
online(const [
|
||||
MemberView(id: 'm1', name: 'Hana', ready: true, inSet: true, buffered: 30, stale: false, host: true),
|
||||
MemberView(id: 'm2', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false),
|
||||
MemberView(id: 'm1', name: 'Hana', ready: true, inSet: true, buffered: 30, host: true),
|
||||
MemberView(id: 'm2', name: 'Bob', ready: false, inSet: false, buffered: 0),
|
||||
]);
|
||||
final avatars = {for (final a in partyAvatars(coord)) a.name: a};
|
||||
expect(avatars['Hana']!.host, isTrue);
|
||||
@@ -292,7 +292,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('hosting: Copy invite address lists this machine\'s addresses', (tester) async {
|
||||
online(const [MemberView(id: 'm1', name: 'Me', ready: false, inSet: false, buffered: 0, stale: false)]);
|
||||
online(const [MemberView(id: 'm1', name: 'Me', ready: false, inSet: false, buffered: 0)]);
|
||||
coord.hostFile = (_) async => null;
|
||||
SessionState.instance.lastForm.hostPort = '8080';
|
||||
bigWindow(tester);
|
||||
|
||||
@@ -29,8 +29,14 @@ final png = Uint8List.fromList(const [
|
||||
]);
|
||||
|
||||
MemberView mv(String id, String name,
|
||||
{bool ready = false, bool? inSet, double buffered = 30, bool stale = false, bool out = false}) =>
|
||||
MemberView(id: id, name: name, ready: ready, inSet: inSet ?? ready, buffered: buffered, stale: stale, out: out);
|
||||
{bool ready = false, bool? inSet, double buffered = 30, bool gone = false, bool away = false}) =>
|
||||
MemberView(
|
||||
id: id,
|
||||
name: name,
|
||||
ready: ready,
|
||||
inSet: inSet ?? ready,
|
||||
buffered: buffered,
|
||||
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching);
|
||||
|
||||
StateBroadcast held(List<MemberView> members, {String transport = 'held', double position = 0, int mediaId = 1}) =>
|
||||
StateBroadcast(
|
||||
@@ -103,12 +109,12 @@ void main() {
|
||||
expect(v.self, isTrue);
|
||||
final w = playerStatusFor(
|
||||
health: ok,
|
||||
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', stale: true), mv('o', 'Ola', out: true)]),
|
||||
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', gone: true), mv('o', 'Ola', away: true)]),
|
||||
self: me)!;
|
||||
expect(w.kind, StatusKind.waiting);
|
||||
expect(w.text, 'Waiting for Bob and Mika', reason: 'nobody waits for who sits out');
|
||||
expect(w.detail, '1/3 ready');
|
||||
final outMe = mv('me', 'Me', out: true);
|
||||
final outMe = mv('me', 'Me', away: true);
|
||||
expect(playerStatusFor(health: ok, state: held([outMe, mv('b', 'Bob')]), self: outMe), isNull,
|
||||
reason: 'sitting out: the join-back card speaks');
|
||||
expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
|
||||
@@ -117,7 +123,7 @@ void main() {
|
||||
test("can't play: yours with Retry (reload); others' instead of waiting", () {
|
||||
var v = playerStatusFor(health: ok, state: held([me]), self: me, playerError: 'Not allowed (403)')!;
|
||||
expect((v.kind, v.text, v.action), (StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload));
|
||||
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false,
|
||||
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0,
|
||||
error: 'Not allowed (403)');
|
||||
v = playerStatusFor(health: ok, state: held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!;
|
||||
expect((v.kind, v.text, v.action), (StatusKind.cannotPlay, "Bob can't play this: Not allowed (403)", null));
|
||||
|
||||
@@ -5,9 +5,15 @@ import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/queue/queue_logic.dart';
|
||||
|
||||
MemberView member(String id,
|
||||
{bool stale = false, bool out = false, Map<String, bool> jf = const {}, String name = ''}) =>
|
||||
{bool gone = false, bool away = false, Map<String, bool> jf = const {}, String name = ''}) =>
|
||||
MemberView(
|
||||
id: id, name: name.isEmpty ? id : name, ready: false, inSet: true, buffered: 0, stale: stale, out: out, jf: jf);
|
||||
id: id,
|
||||
name: name.isEmpty ? id : name,
|
||||
ready: false,
|
||||
inSet: true,
|
||||
buffered: 0,
|
||||
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching,
|
||||
jf: jf);
|
||||
|
||||
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
|
||||
id: 'q1',
|
||||
@@ -22,12 +28,14 @@ QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => Queue
|
||||
|
||||
void main() {
|
||||
group('queuePerformer', () {
|
||||
test('a link passed on or a file: only its adder; gone means skipped', () {
|
||||
test('a link passed on or a file: only its adder; gone waits for it, left means skipped', () {
|
||||
for (final how in [QueueHow.through, QueueHow.file]) {
|
||||
expect(queuePerformer(item(how), [member('m1'), member('m2', out: true)]).id, 'm2');
|
||||
final gone = queuePerformer(item(how), [member('m1'), member('m3')]);
|
||||
expect(gone.id, isNull);
|
||||
expect(gone.why, 'Bob left');
|
||||
expect(queuePerformer(item(how), [member('m1'), member('m2', away: true)]).id, 'm2');
|
||||
final waits = queuePerformer(item(how), [member('m1'), member('m2', gone: true)]);
|
||||
expect((waits.id, waits.why), (null, null), reason: 'a gone adder may come back within its grace');
|
||||
final left = queuePerformer(item(how), [member('m1'), member('m3')]);
|
||||
expect(left.id, isNull);
|
||||
expect(left.why, 'Bob left');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -38,22 +46,22 @@ void main() {
|
||||
queuePerformer(item(QueueHow.jellyfin), [member('m10', jf: access), member('m3', jf: access), member('m2')])
|
||||
.id,
|
||||
'm3');
|
||||
expect(queuePerformer(item(QueueHow.jellyfin), [member('m3', jf: access, out: true), member('m2')]).why,
|
||||
expect(queuePerformer(item(QueueHow.jellyfin), [member('m3', jf: access, away: true), member('m2')]).why,
|
||||
isNotNull);
|
||||
});
|
||||
|
||||
test('a link everyone opens or the server fetches: the adder, else the lowest active member', () {
|
||||
for (final how in [QueueHow.direct, QueueHow.server]) {
|
||||
expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2');
|
||||
expect(queuePerformer(item(how), [member('m4'), member('m2', stale: true), member('m3')]).id, 'm3');
|
||||
final nobody = queuePerformer(item(how), [member('m2', out: true)]);
|
||||
expect(queuePerformer(item(how), [member('m4'), member('m2', gone: true), member('m3')]).id, 'm3');
|
||||
final nobody = queuePerformer(item(how), [member('m2', away: true)]);
|
||||
expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('the janitor is the lowest member that is not stale', () {
|
||||
expect(queueJanitor([member('m9'), member('m1', stale: true), member('m2')]), 'm2');
|
||||
test('the janitor is the lowest member that is not gone', () {
|
||||
expect(queueJanitor([member('m9'), member('m1', gone: true), member('m2')]), 'm2');
|
||||
});
|
||||
|
||||
test('labels', () {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): "the
|
||||
// source drops uncleanly and resumes" (resume token, D6 source resume); the
|
||||
// rest stays.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -8,11 +5,13 @@ import 'dart:io';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_core/testing.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:starsync/src/jellyfin/accounts.dart';
|
||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||
import 'package:starsync/src/playback/yt_dlp_app.dart';
|
||||
import 'package:starsync/src/screens/home/picked_media.dart';
|
||||
import 'package:starsync/src/session/connection.dart' show ConnOnline;
|
||||
import 'package:starsync/src/session/session_coordinator.dart';
|
||||
|
||||
// The relay with real coordinators, real mpv and a dedicated server that
|
||||
@@ -197,12 +196,7 @@ void main() {
|
||||
final j = jsonDecode('$f') as Map;
|
||||
if (j['t'] == 'welcome' && !welcome.isCompleted) welcome.complete(j);
|
||||
}, onError: (_) {});
|
||||
ws.add(jsonEncode(const HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'pw',
|
||||
user: HelloUser(name: 'bob'),
|
||||
media: HelloMedia(name: '', size: 0, duration: 0))
|
||||
.toJson()));
|
||||
ws.add(jsonEncode(const HelloMessage(secret: 'pw', user: HelloUser(name: 'bob')).toJson()));
|
||||
final token = (await welcome.future.timeout(const Duration(seconds: 5)))['token'] as String;
|
||||
final size = await File(file).length();
|
||||
ws.add(jsonEncode({
|
||||
@@ -225,10 +219,10 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('the source drops uncleanly and resumes: it keeps its copy and keeps serving',
|
||||
test('the source drops uncleanly and comes back: the others play on, the relay continues (D6)',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final file = await _media();
|
||||
final proxy = await _Blackhole.start(server.boundPort);
|
||||
final proxy = await FlakyProxy.start(server.boundPort);
|
||||
try {
|
||||
final alice = await member('alice');
|
||||
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
@@ -241,17 +235,30 @@ void main() {
|
||||
await _poll(() => server.hub.relay?.live == true, 'source live');
|
||||
await _poll(() => alice.playback.durationSecs > 19 && bob.playback.durationSecs > 19, 'both opened');
|
||||
final token = bob.connection.token;
|
||||
final opens = bob.opens;
|
||||
|
||||
// Bob's network goes silent (no FIN): states stop, he reconnects
|
||||
// with `resume`, the server still has him.
|
||||
proxy.cut();
|
||||
// Bob's network goes silent (no FIN) on every link he has, the sync
|
||||
// socket and his relay channel, and new ones too, long enough for
|
||||
// the server to call him gone: the relay stays (`sourceGone`).
|
||||
proxy.onConnect = (l) => l.blackhole();
|
||||
for (final l in proxy.links) {
|
||||
l.blackhole();
|
||||
}
|
||||
await _poll(() => bob.reconnecting.peek(), 'reconnect engaged', timeout: const Duration(seconds: 15));
|
||||
await _poll(() => !bob.reconnecting.peek() && bob.serverState.peek() != null, 'back',
|
||||
timeout: const Duration(seconds: 20));
|
||||
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.sourceGone ?? false, 'source gone for alice',
|
||||
timeout: const Duration(seconds: 25));
|
||||
expect(alice.serverState.peek()!.media, isNotNull);
|
||||
expect(server.hub.relay, isNotNull, reason: 'the relay is kept through the grace');
|
||||
// His link comes back: the same member, his source dials its channel
|
||||
// again with the same `info` and the relay continues.
|
||||
proxy.onConnect = null;
|
||||
await _poll(() => bob.connection.state.peek() is ConnOnline, 'back', timeout: const Duration(seconds: 30));
|
||||
expect(bob.connection.token, token, reason: 'resumed as the same member');
|
||||
await _poll(() => server.hub.relay?.live == true, 'source redialled', timeout: const Duration(seconds: 15));
|
||||
expect(bob.serverState.peek(), isNotNull, reason: 'the state stayed through the drop');
|
||||
await _poll(() => server.hub.relay?.live == true && !(stateMediaRelay(alice.serverState.peek()!)?.sourceGone ?? true),
|
||||
'source redialled', timeout: const Duration(seconds: 25));
|
||||
expect(bob.relaying.peek(), isTrue);
|
||||
expect(bob.playback.durationSecs, greaterThan(19), reason: 'still his own copy');
|
||||
expect(bob.opens, opens, reason: 'still his own copy, not reopened');
|
||||
|
||||
// Alice can still get bytes the buffer never had.
|
||||
alice.seekTo(17);
|
||||
@@ -261,42 +268,3 @@ void main() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// TCP proxy whose [cut] silently stops forwarding on every connection made
|
||||
/// so far (no FIN, like a dead Wi-Fi); later connections work.
|
||||
class _Blackhole {
|
||||
final ServerSocket _server;
|
||||
final _conns = <(Socket, Socket, List<bool>)>[];
|
||||
_Blackhole(this._server);
|
||||
int get port => _server.port;
|
||||
|
||||
static Future<_Blackhole> start(int target) async {
|
||||
final b = _Blackhole(await ServerSocket.bind(InternetAddress.loopbackIPv4, 0));
|
||||
b._server.listen((c) async {
|
||||
final up = await Socket.connect('127.0.0.1', target);
|
||||
final dead = [false];
|
||||
b._conns.add((c, up, dead));
|
||||
c.listen((d) {
|
||||
if (!dead[0]) up.add(d);
|
||||
}, onDone: () => dead[0] ? null : up.destroy(), onError: (_) {});
|
||||
up.listen((d) {
|
||||
if (!dead[0]) c.add(d);
|
||||
}, onDone: () => dead[0] ? null : c.destroy(), onError: (_) {});
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
void cut() {
|
||||
for (final (_, _, dead) in _conns) {
|
||||
dead[0] = true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await _server.close();
|
||||
for (final (a, b, _) in _conns) {
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,12 +38,7 @@ class _Raw {
|
||||
|
||||
static Future<_Raw> join(int port, String name, {String secret = ''}) async {
|
||||
final r = _Raw(await WebSocket.connect('ws://127.0.0.1:$port/sync'));
|
||||
r.ws.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: secret,
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||
.toJson()));
|
||||
r.ws.add(jsonEncode(HelloMessage(secret: secret, user: HelloUser(name: name)).toJson()));
|
||||
await r.next('welcome');
|
||||
return r;
|
||||
}
|
||||
@@ -135,8 +130,8 @@ void main() {
|
||||
.cast<Map>()
|
||||
.firstWhere((m) => m['name'] == 'host'))['id'];
|
||||
// Anyone may sit the host out; its player unloads, the server serves on.
|
||||
friend.ws.add(jsonEncode(OutIntent(target: '$hostId', out: true).toJson()));
|
||||
await _poll(() => session.coordinator.selfOut.peek(), 'host sat out');
|
||||
friend.ws.add(jsonEncode(PresenceIntent(target: '$hostId', state: Presence.away).toJson()));
|
||||
await _poll(() => session.coordinator.selfAway.peek(), 'host sat out');
|
||||
|
||||
// The host nudges the friend: only the friend hears it.
|
||||
final friendId = ((state['members'] as List)
|
||||
|
||||
@@ -1,78 +1,108 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence):
|
||||
// IdleTracker and unreadyForNewMedia give way to auto sit-out;
|
||||
// linkDisplayName stays.
|
||||
// Pure session logic: auto sit-out on a fake clock, link names, the
|
||||
// unready-on-swap rule.
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:starsync/src/session/idle.dart';
|
||||
import 'package:sync_relay_core/core.dart' show Presence, PresenceReason;
|
||||
import 'package:starsync/src/platform/ports/shell_port.dart' show WindowActivity;
|
||||
import 'package:starsync/src/session/auto_sit_out.dart';
|
||||
import 'package:starsync/src/session/session_coordinator.dart';
|
||||
|
||||
void main() {
|
||||
group('IdleTracker', () {
|
||||
group('AutoSitOut', () {
|
||||
final t0 = DateTime.utc(2026, 1, 1, 12);
|
||||
const idle = AutoSitOutTimes.idle;
|
||||
const minimised = AutoSitOutTimes.minimised;
|
||||
|
||||
List<IdleEvent> run(IdleTracker idle, DateTime from, Duration span,
|
||||
{required bool playing, bool selfReady = true}) {
|
||||
final out = <IdleEvent>[];
|
||||
/// Ticks once a second from [from] for [span]; the steps that were not
|
||||
/// [AutoStay], at their second.
|
||||
List<(int, String)> run(AutoSitOut a, DateTime from, Duration span,
|
||||
{Presence self = Presence.watching, bool playing = false, bool alone = false, bool enabled = true}) {
|
||||
final out = <(int, String)>[];
|
||||
for (var s = 0; s <= span.inSeconds; s++) {
|
||||
final e = idle.tick(from.add(Duration(seconds: s)),
|
||||
playing: playing, selfReady: selfReady);
|
||||
if (e != IdleEvent.none) out.add(e);
|
||||
final step = a.tick(from.add(Duration(seconds: s)),
|
||||
self: self, playing: playing, alone: alone, enabled: enabled);
|
||||
switch (step) {
|
||||
case AutoAway(:final reason):
|
||||
out.add((s, 'away ${reason.name}'));
|
||||
case AutoBack():
|
||||
out.add((s, 'back'));
|
||||
case AutoStay():
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('long watch then pause: no instant warn+unready', () {
|
||||
final idle = IdleTracker(t0);
|
||||
// 15 min of playback without any input.
|
||||
expect(run(idle, t0, const Duration(minutes: 15), playing: true), isEmpty);
|
||||
final paused = t0.add(const Duration(minutes: 15));
|
||||
// First minute after the pause: nothing fires.
|
||||
expect(run(idle, paused, const Duration(minutes: 1), playing: false),
|
||||
isEmpty);
|
||||
test('not playing: idle for the idle time sets you away (idle)', () {
|
||||
final a = AutoSitOut(t0);
|
||||
expect(run(a, t0, idle), [(idle.inSeconds, 'away idle')]);
|
||||
});
|
||||
|
||||
test('pause measures from when playback stopped', () {
|
||||
final idle = IdleTracker(t0);
|
||||
run(idle, t0, const Duration(minutes: 15), playing: true);
|
||||
// Last playing tick was at +15 min; count seconds from there.
|
||||
final paused = t0.add(const Duration(minutes: 15));
|
||||
final events = <(int, IdleEvent)>[];
|
||||
for (var s = 1; s <= 11 * 60; s++) {
|
||||
final e = idle.tick(paused.add(Duration(seconds: s)),
|
||||
playing: false, selfReady: true);
|
||||
if (e != IdleEvent.none) events.add((s, e));
|
||||
}
|
||||
expect(events, [(8 * 60, IdleEvent.warn), (10 * 60, IdleEvent.unready)]);
|
||||
test('never when alone, never when switched off', () {
|
||||
expect(run(AutoSitOut(t0), t0, const Duration(minutes: 30), alone: true), isEmpty);
|
||||
expect(run(AutoSitOut(t0), t0, const Duration(minutes: 30), enabled: false), isEmpty);
|
||||
final m = AutoSitOut(t0)..window(WindowActivity.minimised, t0);
|
||||
expect(run(m, t0, const Duration(minutes: 30), alone: true, playing: true), isEmpty);
|
||||
});
|
||||
|
||||
test('unready always trails the warning by 2 min', () {
|
||||
final idle = IdleTracker(t0);
|
||||
// Idle 20 min while unready (nothing to lose, nothing fires)...
|
||||
expect(
|
||||
run(idle, t0, const Duration(minutes: 20),
|
||||
playing: false, selfReady: false),
|
||||
isEmpty);
|
||||
// ...then someone force-readies us: warn now, unready 2 min later.
|
||||
final forced = t0.add(const Duration(minutes: 20, seconds: 1));
|
||||
expect(idle.tick(forced, playing: false, selfReady: true), IdleEvent.warn);
|
||||
expect(
|
||||
idle.tick(forced.add(const Duration(seconds: 1)),
|
||||
playing: false, selfReady: true),
|
||||
IdleEvent.none);
|
||||
expect(
|
||||
idle.tick(forced.add(const Duration(minutes: 2)),
|
||||
playing: false, selfReady: true),
|
||||
IdleEvent.unready);
|
||||
test('while playing idle input does not count, minimised for a while does', () {
|
||||
final a = AutoSitOut(t0);
|
||||
expect(run(a, t0, const Duration(minutes: 30), playing: true), isEmpty, reason: 'watching is activity');
|
||||
final at = t0.add(const Duration(minutes: 30));
|
||||
a.window(WindowActivity.minimised, at);
|
||||
expect(run(a, at, minimised, playing: true), [(minimised.inSeconds, 'away minimised')]);
|
||||
});
|
||||
|
||||
test('input resets the clock and the warning', () {
|
||||
final idle = IdleTracker(t0);
|
||||
expect(idle.tick(t0.add(const Duration(minutes: 8)),
|
||||
playing: false, selfReady: true), IdleEvent.warn);
|
||||
idle.activity(t0.add(const Duration(minutes: 9)));
|
||||
expect(idle.tick(t0.add(const Duration(minutes: 11)),
|
||||
playing: false, selfReady: true), IdleEvent.none);
|
||||
expect(idle.tick(t0.add(const Duration(minutes: 17)),
|
||||
playing: false, selfReady: true), IdleEvent.warn);
|
||||
test('a pause after a long watch starts a fresh idle clock', () {
|
||||
final a = AutoSitOut(t0);
|
||||
run(a, t0, const Duration(minutes: 30), playing: true);
|
||||
final paused = t0.add(const Duration(minutes: 30, seconds: 1));
|
||||
expect(run(a, paused, idle - const Duration(seconds: 2)), isEmpty);
|
||||
});
|
||||
|
||||
test('back on input or focus, only from an automatic away', () {
|
||||
final a = AutoSitOut(t0);
|
||||
expect(run(a, t0, idle), [(idle.inSeconds, 'away idle')]);
|
||||
final at = t0.add(idle);
|
||||
// The away shows in the next states; nothing until the user is back.
|
||||
expect(run(a, at, const Duration(minutes: 5), self: Presence.away), isEmpty);
|
||||
final later = at.add(const Duration(minutes: 5, seconds: 1));
|
||||
a.input(later);
|
||||
expect(run(a, later, const Duration(seconds: 1), self: Presence.away), [(0, 'back')]);
|
||||
|
||||
// Minimised away: restoring is not enough, the focus brings it back.
|
||||
final b = AutoSitOut(t0)..window(WindowActivity.minimised, t0);
|
||||
expect(run(b, t0, minimised, playing: true), [(minimised.inSeconds, 'away minimised')]);
|
||||
final m = t0.add(minimised);
|
||||
b.window(WindowActivity.unfocused, m);
|
||||
expect(run(b, m, const Duration(seconds: 3), self: Presence.away), isEmpty);
|
||||
b.window(WindowActivity.focused, m);
|
||||
expect(run(b, m, const Duration(seconds: 1), self: Presence.away), [(0, 'back')]);
|
||||
});
|
||||
|
||||
test('a manual away (own or someone else\'s) is never returned automatically', () {
|
||||
// Someone else sat you out: activity does not bring you back.
|
||||
final a = AutoSitOut(t0);
|
||||
a.input(t0);
|
||||
expect(run(a, t0, const Duration(minutes: 20), self: Presence.away), isEmpty);
|
||||
// Auto away, then the user sits out by hand: it stays.
|
||||
final b = AutoSitOut(t0);
|
||||
expect(run(b, t0, idle), [(idle.inSeconds, 'away idle')]);
|
||||
b.manual();
|
||||
final at = t0.add(idle);
|
||||
b.input(at);
|
||||
expect(run(b, at, const Duration(minutes: 1), self: Presence.away), isEmpty);
|
||||
});
|
||||
|
||||
test('an away not yet echoed is not sent again', () {
|
||||
final a = AutoSitOut(t0);
|
||||
// The state still says `in` for a few seconds after the away went out.
|
||||
expect(run(a, t0, idle + const Duration(seconds: 8)), [(idle.inSeconds, 'away idle')]);
|
||||
expect(a.autoAway, isTrue);
|
||||
});
|
||||
|
||||
test('the reasons on the wire', () {
|
||||
expect(PresenceReason.idle.name, 'idle');
|
||||
expect(PresenceReason.minimised.name, 'minimised');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,10 +16,26 @@ void main() {
|
||||
await dir.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('a box without a version is at 1 and gets stamped', () async {
|
||||
test('a box without a version is at 1, runs the steps and gets stamped', () async {
|
||||
final box = await Hive.openBox('settings');
|
||||
expect(await migrateSettings(box), isEmpty);
|
||||
expect(await migrateSettings(box), hasLength(settingsSchemaVersion - 1));
|
||||
expect(box.get(settingsSchemaKey), settingsSchemaVersion);
|
||||
expect(await migrateSettings(box), isEmpty);
|
||||
});
|
||||
|
||||
test('2: the idle switch gives way to auto sit-out, on', () async {
|
||||
for (final old in [true, false]) {
|
||||
final box = await Hive.openBox('settings');
|
||||
await box.put('idleSitsOut', old);
|
||||
await migrateSettings(box);
|
||||
expect(box.containsKey('idleSitsOut'), isFalse);
|
||||
expect(box.get('autoSitOut'), isTrue);
|
||||
await box.deleteFromDisk();
|
||||
}
|
||||
final kept = await Hive.openBox('settings');
|
||||
await kept.putAll({'idleSitsOut': true, 'autoSitOut': false});
|
||||
await migrateSettings(kept);
|
||||
expect(kept.get('autoSitOut'), isFalse, reason: 'a choice already made stays');
|
||||
});
|
||||
|
||||
test('steps run in order, only past the stored version', () async {
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/screens/home/profile.dart';
|
||||
import 'package:starsync/src/session/identity.dart';
|
||||
import 'package:starsync/src/shell/app_shell.dart';
|
||||
import 'package:starsync/src/shell/avatar.dart';
|
||||
import 'package:starsync/src/shell/motion.dart';
|
||||
@@ -38,8 +38,14 @@ class _FakeVideoState extends State<FakeVideo> {
|
||||
Widget build(BuildContext context) => const ColoredBox(color: Colors.black);
|
||||
}
|
||||
|
||||
MemberView member(String id, String name, {bool ready = false, bool stale = false}) =>
|
||||
MemberView(id: id, name: name, ready: ready, inSet: ready, buffered: 30, stale: stale);
|
||||
MemberView member(String id, String name, {bool ready = false, bool gone = false}) =>
|
||||
MemberView(
|
||||
id: id,
|
||||
name: name,
|
||||
ready: ready,
|
||||
inSet: ready,
|
||||
buffered: 30,
|
||||
presence: gone ? Presence.gone : Presence.watching);
|
||||
|
||||
void main() {
|
||||
final session = SessionState.instance;
|
||||
@@ -93,7 +99,7 @@ void main() {
|
||||
member('b', 'Bob'),
|
||||
member('c', 'Cleo', ready: true),
|
||||
member('d', 'Dan'),
|
||||
member('e', 'Eve', stale: true),
|
||||
member('e', 'Eve', gone: true),
|
||||
member('f', 'Finn'),
|
||||
];
|
||||
|
||||
@@ -145,7 +151,7 @@ void main() {
|
||||
expect(find.byKey(const ValueKey('strip-join')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('strip-leave')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('strip-chat')), findsOneWidget);
|
||||
expect(find.text('ready 2/6'), findsOneWidget);
|
||||
expect(find.text('ready 2/5'), findsOneWidget, reason: 'Eve is gone: not counted');
|
||||
final stack = find.byKey(const ValueKey('party-avatars'));
|
||||
// Five shown, the sixth as "+1"; ready ones first.
|
||||
expect(find.descendant(of: stack, matching: find.byType(StatusAvatar)), findsNWidgets(5));
|
||||
|
||||
@@ -130,22 +130,19 @@ void main() {
|
||||
name: 'a',
|
||||
ready: true,
|
||||
inSet: true,
|
||||
buffered: 9,
|
||||
stale: false);
|
||||
buffered: 9);
|
||||
const catchingUp = MemberView(
|
||||
id: 'm2',
|
||||
name: 'b',
|
||||
ready: false,
|
||||
inSet: false,
|
||||
buffered: 0,
|
||||
stale: false);
|
||||
buffered: 0);
|
||||
const unreadyInSet = MemberView(
|
||||
id: 'm3',
|
||||
name: 'c',
|
||||
ready: false,
|
||||
inSet: true,
|
||||
buffered: 9,
|
||||
stale: false);
|
||||
buffered: 9);
|
||||
|
||||
test('only in-set ready members may follow transport to playing', () {
|
||||
final state = _stateWithMembers([ready, catchingUp, unreadyInSet]);
|
||||
|
||||
Reference in new issue
Block a user