From 8b81da40690c8857c0c19abaeac0923d50a1eebb Mon Sep 17 00:00:00 2001 From: foxyz Date: Fri, 9 Oct 2026 21:05:35 +0300 Subject: [PATCH] Protocol 11: sit out replaces kicking; nudge Anyone can sit anyone out (host included); only you bring yourself back. Out members stay in chat and the party, never block or count, and load no media; a card with the live position and Join back (Space) replaces the video. The mini player's x sits you out; optionally idle does too. Nudge: the server forwards it to the target only (3/s, 20/min per sender); the target hears the nudge sound, gets a toast and a taskbar attention request. Kick, its messages, errors and dialog are gone. Co-Authored-By: Claude Opus 5.5 --- README.md | 26 ++- .../lib/src/jellyfin/session_media.dart | 4 +- apps/desktop/lib/src/platform/attention.dart | 19 ++ .../lib/src/screens/home/connect_flow.dart | 11 +- .../lib/src/session/session_coordinator.dart | 206 +++++++++++++----- apps/desktop/lib/src/sfx/sfx.dart | 2 +- apps/desktop/lib/src/sfx/sound_bank.dart | 4 +- apps/desktop/lib/src/shell/app_shell.dart | 55 ++--- apps/desktop/lib/src/shell/avatar.dart | 25 ++- apps/desktop/lib/src/shell/chat_panel.dart | 8 +- apps/desktop/lib/src/shell/party_strip.dart | 41 ++-- apps/desktop/lib/src/shell/player_status.dart | 15 +- apps/desktop/lib/src/shell/profile_card.dart | 16 +- .../desktop/lib/src/shell/session_player.dart | 38 +++- apps/desktop/lib/src/shell/settings_tab.dart | 29 ++- apps/desktop/lib/src/shell/sit_out_card.dart | 106 +++++++++ apps/desktop/lib/src/shell/toasts.dart | 12 +- apps/desktop/lib/src/state/session_state.dart | 18 +- apps/desktop/lib/src/state/settings.dart | 10 + apps/desktop/lib/src/sync/sync_client.dart | 29 +-- apps/desktop/lib/src/widgets/dialogs.dart | 22 -- apps/desktop/linux/runner/my_application.cc | 35 +++ apps/desktop/test/player_settings_test.dart | 4 +- apps/desktop/test/player_status_test.dart | 25 +-- apps/desktop/test/server_isolate_test.dart | 14 +- apps/desktop/test/shell_test.dart | 10 +- .../desktop/windows/runner/flutter_window.cpp | 21 ++ apps/desktop/windows/runner/flutter_window.h | 6 + docs/ARCHITECTURE.md | 74 +++++-- docs/HOW-IT-WORKS.md | 6 +- docs/PROTOCOL.md | 35 ++- packages/core/lib/src/config.dart | 2 +- packages/core/lib/src/constants.dart | 4 + packages/core/lib/src/protocol.dart | 54 +++-- packages/core/lib/src/sync_engine.dart | 37 +++- packages/core/test/sync_engine_test.dart | 49 +++++ packages/server/lib/src/jellyfin_relay.dart | 2 - packages/server/lib/src/media_endpoint.dart | 2 - packages/server/lib/src/relay_routes.dart | 9 - packages/server/lib/src/server_hub.dart | 62 ++---- packages/server/lib/src/session.dart | 24 +- packages/server/test/hardening_test.dart | 55 +++-- packages/server/test/media_endpoint_test.dart | 3 - packages/server/test/server_auth_test.dart | 188 ---------------- packages/server/test/session_test.dart | 28 --- packages/server/test/sync_wave_test.dart | 9 +- 46 files changed, 855 insertions(+), 599 deletions(-) create mode 100644 apps/desktop/lib/src/platform/attention.dart create mode 100644 apps/desktop/lib/src/shell/sit_out_card.dart delete mode 100644 apps/desktop/lib/src/widgets/dialogs.dart diff --git a/README.md b/README.md index cf64461..7ba09a7 100644 --- a/README.md +++ b/README.md @@ -48,17 +48,17 @@ profile: name, picture with a crop editor, colour, and links to the settings), the Library / Schedule / Settings tabs, and the party. Offline the party side has Join and Host; in a server it shows everyone's avatar with a ring for their state (green ready, amber buffering, grey not ready, -dimmed reconnecting; a crown on whoever hosts, a tower on whoever -passes the video on). Click the avatars for the member list (force ready, -kick, "Copy invite address"; when you host it lists this computer's +dimmed reconnecting, dimmed with a moon sitting out; a crown on whoever +hosts, a tower on whoever passes the video on). Click the avatars for the +member list (force ready, Nudge, Sit out, "Copy invite address"; when you host it lists this computer's network addresses with the port, and friends outside your network need that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m", the chat button with unread count, and Leave. Empty strip space drags the window. The player fills the window under the strip. Pick a tab and it shrinks to a mini player in a corner: drag it anywhere and it snaps to the nearest -corner; double-click or ⤢ makes it big again, × stops watching (you stay in -the server, muted) and the strip's screen button brings it back. F or +corner; double-click or ⤢ makes it big again, × sits you out (below) and +× again closes it; the strip's screen button brings it back. F or double-click on the picture goes fullscreen (the strip hides, the party shows in a corner while paused, the window buttons sit in the player's top bar while its controls are up), Esc leaves it. The controls show the @@ -76,11 +76,23 @@ hidden, as a small chip top right. Your name and picture change for everyone in the server right away; the colour is yours only. +**Sitting out.** Step away without leaving: Sit out (your profile, your +row in the member list, or × on the mini player) unloads the video on your +end, and nobody waits for you any more; you stay in chat. Anyone can sit +anyone out, the host too ("Alice sat you out" tells you who). The player +then shows the cover, title and where the others are; Join back (or Space) +loads the video there and readies you. Settings → Party & network → "When idle, +sit out instead of just unready" does it after 10 idle minutes. + +**Nudge.** Nudge in the member list pokes someone, sitting out or not: +only they hear it (a sound, a pop-up, and the taskbar entry asks for +attention when the window is in the background). + ## Host from the app Host in the strip: set a port and an optional password (blank = anyone can join), then Host. The app starts a server inside itself and joins it; you -are the host and nobody can kick you. Nothing is up yet: pick what to +are the host (the crown). Nothing is up yet: pick what to watch in the Library tab (below) and put it up. Leaving or closing the app stops the server. Friends need to reach your machine: open the TCP port on your router (or use a LAN or VPN address). Friends open @@ -142,7 +154,7 @@ then everyone (you included) uses Join. | `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`); replaces the flags | It prints `READY port=... name=...` once listening and stops cleanly on -Ctrl+C or SIGTERM. A dedicated server has no host: anyone can kick anyone. +Ctrl+C or SIGTERM. A dedicated server has no host. To run it as a service, unpack the tarball to `/opt/watch-together` and use [deploy/watch-together.service](deploy/watch-together.service) (copy it to `/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`). diff --git a/apps/desktop/lib/src/jellyfin/session_media.dart b/apps/desktop/lib/src/jellyfin/session_media.dart index 4f98d4a..bd6cf72 100644 --- a/apps/desktop/lib/src/jellyfin/session_media.dart +++ b/apps/desktop/lib/src/jellyfin/session_media.dart @@ -332,13 +332,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). Only what every +/// their own account (`jf[serverId] == true`, not stale, not out). 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 members, String serverId) { final eligible = [ for (final m in members) - if (!m.stale && m.jf[serverId] == true) m + if (!m.stale && !m.out && m.jf[serverId] == true) m ]; if (eligible.isEmpty) return null; eligible.sort((a, b) => compareMemberIds(a.id, b.id)); diff --git a/apps/desktop/lib/src/platform/attention.dart b/apps/desktop/lib/src/platform/attention.dart new file mode 100644 index 0000000..243c3eb --- /dev/null +++ b/apps/desktop/lib/src/platform/attention.dart @@ -0,0 +1,19 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// The runner's window channel (linux/runner/my_application.cc, +/// windows/runner/flutter_window.cpp). +const _window = MethodChannel('starsync/window'); + +/// Asks the desktop to point at the app's window (taskbar flash, urgency +/// hint) when it is not focused; nothing when it is. The runner clears it +/// once the window gets focus. Quietly nothing where the runner lacks it. +Future requestWindowAttention() async { + try { + await _window.invokeMethod('attention'); + } on MissingPluginException { + // Tests, other platforms. + } catch (e) { + debugPrint('window attention: $e'); + } +} diff --git a/apps/desktop/lib/src/screens/home/connect_flow.dart b/apps/desktop/lib/src/screens/home/connect_flow.dart index 27fd503..bcb05fb 100644 --- a/apps/desktop/lib/src/screens/home/connect_flow.dart +++ b/apps/desktop/lib/src/screens/home/connect_flow.dart @@ -23,10 +23,6 @@ class ConnectFlow extends ChangeNotifier { int _attempt = 0; bool _disposed = false; - /// Set when an attempt ends in a kick, so the shell shows the removal - /// dialog. - void Function(String reason)? onKicked; - @override void dispose() { _disposed = true; @@ -110,12 +106,7 @@ class ConnectFlow extends ChangeNotifier { } catch (e) { if (myAttempt != _attempt) return; busy = null; - final kick = _session.coordinator.kickReason.peek(); - if (kick != null && kick.isNotEmpty) { - onKicked?.call(kick); - } else { - errors[mode] = CardError('Could not connect', '$e'); - } + errors[mode] = CardError('Could not connect', '$e'); _notify(); return; } diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 0ebe802..b945487 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -13,6 +13,7 @@ import '../jellyfin/session_media.dart'; import '../jellyfin/track_prefs.dart'; import '../playback/media_marks.dart'; import '../playback/playback_controller.dart'; +import '../platform/attention.dart'; import '../playback/yt_dlp_app.dart'; import '../schedule/anilist.dart'; import '../screens/home/picked_media.dart'; @@ -102,7 +103,7 @@ class ToastItem { const ToastItem({required this.id, required this.text, required this.at, this.actor}); } -const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'}; +const _toastActions = {'join', 'leave', 'swap', 'chat'}; /// "Unready when the video changes": a state with a newer [mediaId] than /// the last one this session saw ([lastMediaId], -1 before the first) while @@ -180,7 +181,6 @@ class SessionCoordinator { final feedLog = signal>([]); final toasts = signal>([]); final reconnecting = signal(false); - final kickReason = signal(null); final starving = signal(null); /// The current media's Jellyfin ref (null for links and files). @@ -226,10 +226,16 @@ class SessionCoordinator { return null; }); + /// Self sits out: the player has nothing open until [joinBack]. + late final Computed selfOut = computed(() => selfMember.value?.out ?? false); + + /// Asks the desktop to point at the window (a nudge); tests swap it. + Future Function() requestAttention = requestWindowAttention; + Timer? _ticker; StreamSubscription? _stateSub; StreamSubscription? _feedSub; - StreamSubscription? _kickSub; + StreamSubscription? _nudgeSub; StreamSubscription? _errSub; StreamSubscription? _statusSub; @@ -242,6 +248,13 @@ class SessionCoordinator { // Set after 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; + // [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. int _seenMediaId = -1; @@ -372,7 +385,6 @@ class SessionCoordinator { if (epoch != _epoch) throw const ConnectCancelled(); _params = params; _manualClose = false; - kickReason.value = null; _wsHost = params.host; _wsPort = params.port; diff.reset(); @@ -380,6 +392,8 @@ class SessionCoordinator { _openUrl = null; _lastReady = false; _restoringReady = false; + _lastOut = false; + _joinBackAt = null; _seenMediaId = -1; _wasPlayAllowed = false; _everConnected = false; @@ -443,12 +457,12 @@ class SessionCoordinator { void _subscribe() { _stateSub ??= sync.states.listen(_onState); _feedSub ??= sync.feeds.listen(_onFeed); - _kickSub ??= sync.kicked.listen(_onKicked); + _nudgeSub ??= sync.nudges.listen(_onNudge); _errSub ??= sync.errors.listen((msg) { health.value = HealthStatus(HealthSeverity.warn, msg); }); _statusSub ??= sync.status.listen((s) { - if (s == SyncStatus.closed && !_manualClose && kickReason.peek() == null) { + if (s == SyncStatus.closed && !_manualClose) { health.value = const HealthStatus( HealthSeverity.warn, 'connection lost — retrying…'); _soundLost(); @@ -613,6 +627,12 @@ class SessionCoordinator { Future _applyState(StateBroadcast r) async { _trackSelfReady(r); + if (_lastOut) { + // A newer media than the one this member serves: that relay ended. + if (_relaySourceMediaId != -1 && _relaySourceMediaId != r.mediaId) _stopRelaySource(); + await _unloadForOut(); + return; + } _seekGate.observe(r, sync.memberId); final newEvent = r.eventId != _lastEventId; _lastEventId = r.eventId; @@ -740,6 +760,7 @@ class SessionCoordinator { } else { _lastReady = self.ready; } + _lastOut = self.out; final last = _seenMediaId; _seenMediaId = r.mediaId; // A ready restored by a reconnect may not have echoed yet: unready @@ -755,6 +776,27 @@ class SessionCoordinator { } } + /// Sitting out: nothing open, nothing pulled. A relay this member + /// serves goes on (it does not use the player). The next state after + /// [joinBack] opens the media as new. + Future _unloadForOut() async { + _wasPlayAllowed = false; + _awaitLiveFor = -1; + starving.value = null; + if (_mediaId == -1 && _openUrl == null) return; + _mediaId = -1; + _openUrl = null; + _openHeaders = null; + _seekGate.reset(); + _clearPendingSeek(); + diff.reset(); + _leaveJellyfinItem(); + if (!_playerReady) return; + try { + await playback.stop(); + } catch (_) {} + } + void _updateStarving(StateBroadcast r, double bufferedAhead) { if (r.transport == 'playing') { starving.value = null; @@ -773,14 +815,15 @@ class SessionCoordinator { } final self = selfMember.peek(); if (self != null && self.ready) { - final waiters = r.members + final present = r.members.where((m) => !m.out).toList(); + final waiters = present .where((m) => m.id != self.id && !m.stale && !(m.inSet && m.ready)) .map((m) => m.name) .toList(); if (waiters.isNotEmpty) { - final readyCount = r.members.where((m) => m.ready).length; + final readyCount = present.where((m) => m.ready).length; starving.value = - 'waiting for ${waiters.join(', ')} ($readyCount/${r.members.length} ready)'; + 'waiting for ${waiters.join(', ')} ($readyCount/${present.length} ready)'; return; } } @@ -809,9 +852,15 @@ class SessionCoordinator { ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor) ]; } + final selfName = selfMember.peek()?.name; + // Sat out by someone else: say who (the feed names members by name). + if (f.action == 'out' && f.target != null && f.target == selfName && f.actor != selfName) { + _pushToast('${f.actor} sat you out', actor: f.actor); + unawaited(sfx.play(const SfxCue(SfxKind.kick))); + } final kind = sfxForFeedAction(f.action); // Your own message needs no sound. - final own = f.action == 'chat' && f.actor == selfMember.peek()?.name; + final own = f.action == 'chat' && f.actor == selfName; if (kind != null && !own) { var readyIndex = 0; if (f.action == 'ready') { @@ -827,8 +876,6 @@ class SessionCoordinator { return '${f.actor} joined'; case 'leave': return '${f.actor} left'; - case 'kick': - return '${f.actor} kicked ${f.target ?? ''}'; case 'swap': return '${f.actor} changed media'; default: @@ -836,26 +883,13 @@ class SessionCoordinator { } } - Future _onKicked(String reason) async { - kickReason.value = reason; - _manualClose = true; - _epoch++; - _reconnectTimer?.cancel(); - _reconnectTimer = null; - reconnecting.value = false; - _ticker?.cancel(); - _ticker = null; - // Stop, not pause: a paused player keeps filling its cache from the - // host's media URL. - _openUrl = null; - await _stopJellyfin(); - try { - await playback.stop(); - } catch (_) {} - await sync.disconnect(); - health.value = HealthStatus( - HealthSeverity.error, - reason.isEmpty ? 'kicked from server' : 'kicked: $reason'); + /// Someone poked this member: sound (even when its switch is off; the + /// volume still applies), a toast, and the window asks for attention. + void _onNudge(NudgeNotice n) { + final name = n.name.isEmpty ? 'Someone' : n.name; + _pushToast('$name nudged you', actor: n.name.isEmpty ? null : n.name); + unawaited(sfx.play(const SfxCue(SfxKind.nudge))); + unawaited(requestAttention()); } void _startTicker() { @@ -871,20 +905,22 @@ class SessionCoordinator { if (sync.token.isNotEmpty) sync.sendPing(); if (sync.token.isNotEmpty && r != null && _playerReady) { // Duration only once the open media is the server's current one. - final known = !_opening && _openUrl != null && _mediaId == r.mediaId; + // Sitting out: still a report (it keeps this member fresh), empty. + final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId; sync.sendReport( - position: playback.positionSecs, - buffered: playback.bufferedSecsAhead, + position: _lastOut ? 0 : playback.positionSecs, + buffered: _lastOut ? 0 : playback.bufferedSecsAhead, eventId: r.eventId, duration: known ? playback.durationSecs : 0, mediaId: known ? r.mediaId : 0, jf: _jfAccessFor(r), ); - _checkSeekLanded(r); - unawaited(_tickJellyfin(r)); + if (!_lastOut) { + _checkSeekLanded(r); + unawaited(_tickJellyfin(r)); + } } if (!_manualClose && - kickReason.peek() == null && _lastStateAt != null && now.difference(_lastStateAt!).inSeconds >= 5) { if (serverState.peek() != null) { @@ -905,6 +941,8 @@ class SessionCoordinator { // 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 = _idle.tick(now, playing: serverState.peek()?.transport == 'playing', selfReady: selfMember.peek()?.ready ?? false); @@ -915,18 +953,46 @@ class SessionCoordinator { ...toasts.peek(), ToastItem( id: _toastId, - text: 'idle — auto-unready in 2 min unless you press Space', + text: sitOutWhenIdle + ? 'idle — sitting out in 2 min unless you press Space' + : 'idle — auto-unready in 2 min unless you press Space', at: now) ]; unawaited(sfx.play(const SfxCue(SfxKind.warn))); case IdleEvent.unready: _lastReady = false; - sync.sendReady(false, source: 'auto'); + if (sitOutWhenIdle) { + sitOut(); + } else { + sync.sendReady(false, source: 'auto'); + } case IdleEvent.none: break; } } + /// After [joinBack] while playing: ready once the player holds what the + /// gate needs, or after [_joinBackWait] (then the gate waits for it). + void _readyAfterJoinBack(StateBroadcast r, DateTime now) { + final at = _joinBackAt; + if (at == null) return; + final self = selfMember.peek(); + if (self == null || self.out) { + if (now.difference(at) > _joinBackWait) _joinBackAt = null; + return; + } + final loaded = _playerReady && !_opening && _openUrl != null && _mediaId == r.mediaId; + if (self.ready || + (loaded && playback.bufferedSecsAhead >= r.bufferNeed) || + now.difference(at) > _joinBackWait) { + _joinBackAt = null; + if (!self.ready) { + _lastReady = true; + sync.sendReady(true, source: 'manual'); + } + } + } + void _clearPendingSeek() { _pendingSeekEvent = null; _pendingSeekAt = null; @@ -993,6 +1059,10 @@ class SessionCoordinator { void toggleReady() { final self = selfMember.peek(); + if (self?.out ?? false) { + joinBack(); + return; + } // Ready but out of the set (dropped out while stale) gates like // unready, so Space puts self back in instead of unreadying. final next = !((self?.ready ?? false) && (self?.inSet ?? false)); @@ -1080,12 +1150,43 @@ class SessionCoordinator { sync.sendProfile(name: name, avatar: avatar); } - void kick(String memberId, {String reason = ''}) { + /// Sits [memberId] out (anyone, the host included), self when null. + void sitOut([String? memberId]) { + final target = memberId ?? sync.memberId; + if (target.isEmpty) return; markInput(); - sync.sendKick(memberId, reason: reason); + if (target == sync.memberId) _joinBackAt = null; + sync.sendOut(target, true); + } + + /// 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. + void joinBack() { + final self = selfMember.peek(); + if (self == null || !self.out) return; + markInput(); + sync.sendOut(self.id, false); + if (serverState.peek()?.transport == 'playing') { + _joinBackAt = DateTime.now().toUtc(); + } else { + _joinBackAt = null; + _lastReady = true; + sync.sendReady(true, source: 'manual'); + } + } + + /// Pokes [memberId]: only they hear it (sitting out or not). + void nudgeMember(String memberId) { + if (memberId.isEmpty || memberId == sync.memberId) return; + markInput(); + sync.sendNudge(memberId); } void _swap(HelloMedia media) { + // Putting something up means watching it: back first. + final self = selfMember.peek(); + if (self != null && self.out) sync.sendOut(self.id, false); _pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0); sync.sendSwap(media); } @@ -1266,11 +1367,11 @@ class SessionCoordinator { /// results), shown like the session's own. void showToast(String text) => _pushToast(text); - void _pushToast(String text) { + void _pushToast(String text, {String? actor}) { _toastId++; toasts.value = [ ...toasts.peek(), - ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc()) + ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: actor) ]; } @@ -1304,7 +1405,7 @@ class SessionCoordinator { if (reporter != null && snap != null) unawaited(reporter.stop(snap)); } - /// Leave / kick / window close: Stopped, bounded so leaving never hangs. + /// Leave / window close: Stopped, bounded so leaving never hangs. Future _stopJellyfin() async { final reporter = _reporter; final snap = _snapNow(); @@ -1592,7 +1693,7 @@ class SessionCoordinator { } void _scheduleReconnect({bool manual = false}) { - if (_manualClose || kickReason.peek() != null || _params == null) return; + if (_manualClose || _params == null) return; if (!manual && !_everConnected) return; if (reconnecting.peek()) return; reconnecting.value = true; @@ -1607,14 +1708,15 @@ class SessionCoordinator { /// would kill the newer connection and leave a ghost member). Future _reconnect(int epoch) async { _reconnectTimer = null; - bool stale() => epoch != _epoch || kickReason.peek() != null; + bool stale() => epoch != _epoch; final params = _params; if (stale() || params == null) return; if (_manualClose) { reconnecting.value = false; return; } - var wantReady = _lastReady; + final wasOut = _lastOut; + var wantReady = _lastReady && !wasOut; final seenBefore = _seenMediaId; try { health.value = const HealthStatus( @@ -1655,6 +1757,10 @@ class SessionCoordinator { wantReady = false; _lastReady = false; } + // A new member (the server dropped the old one) sits out again; a + // resumed one still does. + final selfNow = sync.lastState?.members.where((m) => m.id == sync.memberId).firstOrNull; + if (wasOut && !(selfNow?.out ?? false)) sync.sendOut(sync.memberId, true); if (wantReady) { _lastReady = true; _restoringReady = true; @@ -1700,7 +1806,7 @@ class SessionCoordinator { } void retry() { - if (kickReason.peek() != null || _params == null) return; + if (_params == null) return; _invalidateAttempts(); _manualClose = false; reconnecting.value = false; @@ -1761,7 +1867,7 @@ class SessionCoordinator { } await _stateSub?.cancel(); await _feedSub?.cancel(); - await _kickSub?.cancel(); + await _nudgeSub?.cancel(); await _errSub?.cancel(); await _statusSub?.cancel(); _aniList?.close(); diff --git a/apps/desktop/lib/src/sfx/sfx.dart b/apps/desktop/lib/src/sfx/sfx.dart index d7bdac3..bf5a0b2 100644 --- a/apps/desktop/lib/src/sfx/sfx.dart +++ b/apps/desktop/lib/src/sfx/sfx.dart @@ -8,6 +8,7 @@ enum SfxKind { forceReady, join, leave, + /// Someone sat you out (played by the session, not from a feed action). kick, /// A new video or episode is put up. swap, @@ -31,7 +32,6 @@ SfxKind? sfxForFeedAction(String action) => switch (action) { 'ready' => SfxKind.ready, 'unready' => SfxKind.unready, 'force-ready' => SfxKind.forceReady, - 'kick' => SfxKind.kick, 'join' => SfxKind.join, 'leave' => SfxKind.leave, 'swap' => SfxKind.swap, diff --git a/apps/desktop/lib/src/sfx/sound_bank.dart b/apps/desktop/lib/src/sfx/sound_bank.dart index 12bc23e..b14ecbd 100644 --- a/apps/desktop/lib/src/sfx/sound_bank.dart +++ b/apps/desktop/lib/src/sfx/sound_bank.dart @@ -258,11 +258,11 @@ const soundInfo = { SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'), SfxKind.join: ('Joined', 'Someone joins the server'), SfxKind.leave: ('Left', 'Someone leaves'), - SfxKind.kick: ('Removed', 'Someone is kicked'), + 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.lost: ('Connection lost', 'The server stopped answering; reconnecting'), SfxKind.back: ('Reconnected', 'Back in the server after a drop'), - SfxKind.nudge: ('Nudge', 'Someone nudges you to get your attention'), + SfxKind.nudge: ('Nudge', 'Someone nudges you; always plays (volume still applies)'), }; diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index a8144c1..f911e66 100644 --- a/apps/desktop/lib/src/shell/app_shell.dart +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -112,9 +112,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin /// The next media to come up opens full (just joined, or put up here). bool _autoFull = false; int _lastMediaId = -1; - double? _mutedVolume; bool _dialogOpen = false; - String? _lastKickShown; final _stops = []; // Rebuild the shell on these flips, not on every state broadcast. final _online = computed(() => SessionState.instance.route.value == AppRoute.session); @@ -140,7 +138,6 @@ class AppShellState extends State with SingleTickerProviderStateMixin // Reads linked servers and checks their tokens in the background. _jellyfin.ensureLoaded(); if (widget.autoUpdateCheck) _autoCheck(); - _flow.onKicked = _showKicked; HardwareKeyboard.instance.addHandler(_onKey); final session = SessionState.instance; final coord = session.coordinator; @@ -181,17 +178,17 @@ class AppShellState extends State with SingleTickerProviderStateMixin if (!_chatOpen) setState(() => _unread += chats); })); })); - // Kicked: back offline, and say so. + // Back from sitting out with the player closed: show it (the media + // plays again). + var wasOut = false; _stops.add(effect(() { - final kick = coord.kickReason.value; - if (kick == null) { - _lastKickShown = null; - return; + final out = coord.selfOut.value; + if (wasOut && !out && session.route.peek() == AppRoute.session) { + _later(() { + if (_mode == PlayerMode.none) _show(PlayerMode.mini); + }); } - untracked(() { - if (session.route.value == AppRoute.session) session.route.value = AppRoute.home; - }); - if (kick.isNotEmpty) _later(() => _showKicked(kick)); + wasOut = out; })); // The hosted server died mid-session (SessionState leaves): say so. _stops.add(effect(() { @@ -242,7 +239,6 @@ class AppShellState extends State with SingleTickerProviderStateMixin } void _wentOffline() { - _mutedVolume = null; if (_fullscreen) _toggleFullscreen(); setState(() { _chatOpen = false; @@ -258,7 +254,6 @@ class AppShellState extends State with SingleTickerProviderStateMixin Future _show(PlayerMode m) async { if (m == _mode) return; final seq = ++_modeSeq; - if (m != PlayerMode.none) _unmute(); setState(() { _from = _shown; _mode = m; @@ -282,27 +277,15 @@ class AppShellState extends State with SingleTickerProviderStateMixin _show(PlayerMode.mini); } - /// Mini's ×: stop watching but stay in the server (muted until shown). + /// The player's ×: sit out (stay in the server, nothing loaded; the + /// player shows the join-back card). Once out, × closes the player. void _closePlayer() { - final playback = SessionState.instance.coordinator.playback; - if (playback.alive.peek()) { - try { - _mutedVolume = playback.player.state.volume; - playback.player.setVolume(0); - } catch (_) {} + final coord = SessionState.instance.coordinator; + if (coord.selfOut.peek()) { + _show(PlayerMode.none); + } else { + coord.sitOut(); } - _show(PlayerMode.none); - } - - void _unmute() { - final v = _mutedVolume; - if (v == null) return; - _mutedVolume = null; - final playback = SessionState.instance.coordinator.playback; - if (!playback.alive.peek()) return; - try { - playback.player.setVolume(v); - } catch (_) {} } Future _setWindowFullscreen(bool on) async { @@ -520,12 +503,6 @@ class AppShellState extends State with SingleTickerProviderStateMixin if (ok == true) await SessionState.instance.leave(); } - Future _showKicked(String reason) async { - if (reason == _lastKickShown) return; - _lastKickShown = reason; - await _alert('You were removed', reason.isEmpty ? 'The host removed you from the server.' : reason); - } - Future _alert(String title, String text) async { if (!mounted || _dialogOpen) return; _dialogOpen = true; diff --git a/apps/desktop/lib/src/shell/avatar.dart b/apps/desktop/lib/src/shell/avatar.dart index 93391bf..efccff7 100644 --- a/apps/desktop/lib/src/shell/avatar.dart +++ b/apps/desktop/lib/src/shell/avatar.dart @@ -17,6 +17,8 @@ class AvatarInfo { final Color color; final ImageProvider? image; final bool ready, buffering, relaying, stale, host, self; + /// Sitting out: dimmed, with an "away" mark. + final bool out; const AvatarInfo({ this.id = '', required this.name, @@ -28,6 +30,7 @@ class AvatarInfo { this.stale = false, this.host = false, this.self = false, + this.out = false, }); } @@ -75,7 +78,7 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) { } /// Your own avatar from the profile (also offline). -AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) { +AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false}) { final p = Profile.instance; final png = p.picture.value; final name = p.name.value; @@ -86,6 +89,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) { ready: ready, host: host, self: true, + out: out, ); } @@ -107,6 +111,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator // knows before the first state. host: m.host || (self && coord.hostFile != null), self: self, + out: m.out, ); } @@ -132,7 +137,8 @@ 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. +/// tower = passing the video on, spinner = buffering, dimmed = stale, +/// dimmed with a moon = sitting out. class StatusAvatar extends StatelessWidget { final AvatarInfo info; final double size; @@ -176,7 +182,7 @@ class StatusAvatar extends StatelessWidget { decoration: BoxDecoration(shape: BoxShape.circle, color: bg), padding: EdgeInsets.all(stroke), child: Opacity( - opacity: info.stale ? 0.4 : 1, + opacity: info.stale || info.out ? 0.4 : 1, child: CircleAvatar( radius: size / 2, backgroundColor: info.color, @@ -205,6 +211,17 @@ class StatusAvatar extends StatelessWidget { ), ), ), + if (info.out) + Positioned( + key: const ValueKey('away-mark'), + left: -3, + bottom: -3, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration(shape: BoxShape.circle, color: bg), + child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200), + ), + ), if (badges && info.relaying) Positioned( right: -3, @@ -240,7 +257,7 @@ class _AvatarStackState extends State { @override Widget build(BuildContext context) { final size = widget.size; - int rank(AvatarInfo m) => m.host ? 0 : (m.ready ? 1 : 2); + int rank(AvatarInfo m) => m.host ? 0 : (m.out ? 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; diff --git a/apps/desktop/lib/src/shell/chat_panel.dart b/apps/desktop/lib/src/shell/chat_panel.dart index d167f96..47f2827 100644 --- a/apps/desktop/lib/src/shell/chat_panel.dart +++ b/apps/desktop/lib/src/shell/chat_panel.dart @@ -232,9 +232,11 @@ class ActivityRow extends StatelessWidget { return (Icons.swap_horiz, scheme.primary, 'changed the media'); case 'stale': return (Icons.cloud_off, Colors.orange, 'lost signal'); - case 'kick': - final reason = f.reason.isEmpty ? '' : ' · ${f.reason}'; - return (Icons.gavel, Colors.red, 'kicked ${f.target ?? ''}$reason'); + case 'out': + final who = f.target; + return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out'); + case 'back': + return (Icons.login, Colors.green, 'joined back'); default: final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}'; return (Icons.info_outline, Colors.grey, '${f.action}$target'); diff --git a/apps/desktop/lib/src/shell/party_strip.dart b/apps/desktop/lib/src/shell/party_strip.dart index 6ab26b9..7d925ba 100644 --- a/apps/desktop/lib/src/shell/party_strip.dart +++ b/apps/desktop/lib/src/shell/party_strip.dart @@ -11,7 +11,6 @@ import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge; import '../state/session_state.dart'; import '../update/update_controller.dart'; import '../widgets/app_keys.dart'; -import '../widgets/dialogs.dart'; import '../widgets/window_buttons.dart'; import 'avatar.dart'; import 'motion.dart'; @@ -337,7 +336,8 @@ class StripTab extends StatelessWidget { } /// Who is in the server, from the avatars: status, host / relay pills, -/// per member force ready and kick, and the address to invite friends. +/// per member force ready, nudge and sit out, and the address to invite +/// friends. Future showMembersCard(BuildContext context) => showPopover( context, at: const Alignment(0.7, -0.84), @@ -469,6 +469,8 @@ class _MemberTile extends StatelessWidget { final m = member; final status = m.stale ? 'reconnecting…' + : m.out + ? 'sitting out' : info.buffering ? 'buffering ${m.buffered.toStringAsFixed(1)} s' : m.ready @@ -494,27 +496,30 @@ class _MemberTile extends StatelessWidget { trailing: PopupMenuButton( tooltip: 'More', icon: const Icon(Icons.more_vert, size: 18), - onSelected: (v) async { - if (v == 'force') { - coord.forceTarget(m.id, !m.ready); - } else if (v == 'kick') { - final ok = await showKickConfirmDialog(context, m.name); - if (ok == true) coord.kick(m.id); + onSelected: (v) { + switch (v) { + case 'force': + coord.forceTarget(m.id, !m.ready); + case 'nudge': + coord.nudgeMember(m.id); + case 'out': + coord.sitOut(m.id); + case 'back': + coord.joinBack(); } }, itemBuilder: (_) => [ - PopupMenuItem( - value: 'force', - child: Text(m.ready ? 'Force unready' : 'Force ready'), - ), - if (!info.self && !info.host) + if (!m.out) PopupMenuItem( - value: 'kick', - child: Text( - 'Kick', - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), + value: 'force', + child: Text(m.ready ? 'Force unready' : 'Force ready'), ), + if (!info.self) + const PopupMenuItem(value: 'nudge', child: Text('Nudge')), + if (info.self && m.out) + const PopupMenuItem(value: 'back', child: Text('Join back')) + else if (!m.out) + const PopupMenuItem(value: 'out', child: Text('Sit out')), ], ), ); diff --git a/apps/desktop/lib/src/shell/player_status.dart b/apps/desktop/lib/src/shell/player_status.dart index e1e28ff..cbc70d7 100644 --- a/apps/desktop/lib/src/shell/player_status.dart +++ b/apps/desktop/lib/src/shell/player_status.dart @@ -11,7 +11,7 @@ import 'avatar.dart'; import 'motion.dart'; /// What the player's status pill says, most pressing first. -enum StatusKind { kicked, reconnecting, problem, connecting, finished, loading, you, waiting } +enum StatusKind { reconnecting, problem, connecting, finished, loading, you, waiting } /// The pill's button: stop reconnecting, retry, or mark yourself ready /// (the whole pill). @@ -59,16 +59,10 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1 StatusView? playerStatusFor({ required HealthStatus health, bool reconnecting = false, - String? kickReason, StateBroadcast? state, MemberView? self, bool slowConnect = false, }) { - if (kickReason != null) { - return StatusView(StatusKind.kicked, - kickReason.isEmpty ? 'You were removed from the server' : 'You were removed: $kickReason', - tone: HealthSeverity.error, short: 'Removed from the server'); - } if (reconnecting) { return const StatusView(StatusKind.reconnecting, 'Reconnecting…', tone: HealthSeverity.warn, action: StatusAction.cancel); @@ -82,6 +76,8 @@ 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 (r.transport == 'finished') { return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished'); } @@ -100,7 +96,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'); } - final active = [for (final m in r.members) if (!m.stale) m]; + // Members sitting out are never waited for nor counted. + final active = [for (final m in r.members) if (!m.stale && !m.out) m]; final others = [ for (final m in active) if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m, @@ -117,7 +114,6 @@ StatusView? playerStatusNow({bool slowConnect = false}) { return playerStatusFor( health: c.health.value, reconnecting: c.reconnecting.value, - kickReason: c.kickReason.value, state: c.serverState.value, self: c.selfMember.value, slowConnect: slowConnect, @@ -258,7 +254,6 @@ class _StatusLine extends StatelessWidget { Widget? _lead() { final v = view; switch (v.kind) { - case StatusKind.kicked: case StatusKind.problem: final error = v.tone == HealthSeverity.error; return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded, diff --git a/apps/desktop/lib/src/shell/profile_card.dart b/apps/desktop/lib/src/shell/profile_card.dart index 5fc0eba..5724ce5 100644 --- a/apps/desktop/lib/src/shell/profile_card.dart +++ b/apps/desktop/lib/src/shell/profile_card.dart @@ -81,7 +81,8 @@ class _ProfileCardState extends State { final hosting = online && coord.hostFile != null; final color = _p.color.value; final png = _p.picture.value; - final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting); + final out = self?.out ?? false; + final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, out: out); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -166,7 +167,8 @@ class _ProfileCardState extends State { Pill(online ? 'in a server' : 'offline', online ? Colors.greenAccent : Colors.grey), if (online) - Pill((self?.ready ?? false) ? 'ready' : 'not ready', Colors.lightBlueAccent), + Pill(out ? 'sitting out' : (self?.ready ?? false) ? 'ready' : 'not ready', + Colors.lightBlueAccent), ], ), ], @@ -237,6 +239,16 @@ class _ProfileCardState extends State { Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Column(children: [ + if (self != null) + _link( + out ? Icons.login_rounded : Icons.event_seat_outlined, + out ? 'Join back' : 'Sit out', + trailing: Text(out ? 'Space' : 'stay in the server', style: small), + onTap: () { + out ? coord.joinBack() : coord.sitOut(); + Navigator.of(context).maybePop(); + }, + ), _link( Icons.extension_outlined, 'Jellyfin & integrations', diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index e96dd6f..1c74a7a 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -30,6 +30,7 @@ import 'avatar.dart'; import 'motion.dart'; import 'now_playing_title.dart'; import 'player_status.dart'; +import 'sit_out_card.dart'; import 'start_banner.dart'; /// Where the session's video is shown: nowhere, the floating mini player, @@ -298,6 +299,9 @@ class _SessionPlayerState extends State { if (widget.fullscreen) ...[_pausedParty(), _unreadChip()], ] else _miniOverlay(), + // Sitting out: nothing is loaded; the card covers the + // empty player (which stays mounted for joining back). + if (_coord.selfOut.value) _sitOut(), ], ); }), @@ -784,6 +788,38 @@ class _SessionPlayerState extends State { ), ); + /// [SitOutCard] with the mini's (or the full player's) expand / + /// collapse and close. + Widget _sitOut() => Positioned.fill( + child: Stack( + fit: StackFit.expand, + children: [ + SitOutCard(key: const ValueKey('sit-out-card'), compact: !_full), + Positioned( + top: 4, + right: 4, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + focusNode: _noFocus, + onPressed: _full ? widget.onCollapse : widget.onExpand, + tooltip: _full ? 'Small player' : 'Expand', + icon: Icon(_full ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18), + ), + IconButton( + focusNode: _noFocus, + onPressed: widget.onClose, + tooltip: 'Close the player', + icon: const Icon(Icons.close, color: Colors.white, size: 18), + ), + ], + ), + ), + ], + ), + ); + /// Mini: title, expand and close on hover; ready, time and a seekable /// bar at the bottom. A thin progress line stays while not hovered. Widget _miniOverlay() { @@ -823,7 +859,7 @@ class _SessionPlayerState extends State { IconButton( focusNode: _noFocus, onPressed: widget.onClose, - tooltip: 'Stop watching (stay in the server)', + tooltip: 'Sit out (stay in the server)', icon: const Icon(Icons.close, color: Colors.white, size: 18), ), ], diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart index a144561..39c4745 100644 --- a/apps/desktop/lib/src/shell/settings_tab.dart +++ b/apps/desktop/lib/src/shell/settings_tab.dart @@ -97,6 +97,19 @@ class SettingsTab extends StatelessWidget { ), ), ), + SettingsPanel( + title: 'When idle', + child: SignalBuilder( + builder: (context) => SwitchListTile( + key: const ValueKey('idle-sits-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, + ), + ), + ), ]; case SettingsSection.player: return [ @@ -510,8 +523,9 @@ class NotificationsInfo extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - line('Joins, leaves, kicks, video changes and chat', + line('Joins, leaves, video changes and chat', 'Pop up bottom left for 5 s, with the person\'s picture'), + 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, a frozen video reloaded, the idle warning'), ], @@ -617,10 +631,15 @@ class SoundSettings extends StatelessWidget { DropdownMenuItem(value: p.name, child: Text(name)), ], ), - Switch( - key: ValueKey('sound-on-${kind.name}'), - value: !off.contains(kind.name), - onChanged: volume == 0 ? null : (on) => s.setSoundOn(kind.name, on), + // A nudge always plays (an explicit poke): no switch, + // its place kept so the rows line up. + Visibility.maintain( + visible: kind != SfxKind.nudge, + child: Switch( + key: ValueKey('sound-on-${kind.name}'), + value: !off.contains(kind.name), + onChanged: volume == 0 || kind == SfxKind.nudge ? null : (on) => s.setSoundOn(kind.name, on), + ), ), ], ), diff --git a/apps/desktop/lib/src/shell/sit_out_card.dart b/apps/desktop/lib/src/shell/sit_out_card.dart new file mode 100644 index 0000000..0cee068 --- /dev/null +++ b/apps/desktop/lib/src/shell/sit_out_card.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; + +import '../playback/media_marks.dart' show formatClock; +import '../state/session_state.dart'; +import '../sync/state_diff.dart' show serverPositionAt; +import 'now_playing_title.dart'; +import 'start_banner.dart'; + +/// In place of the video while self sits out: the media's start card +/// (cover, title, episode), where the others are ("12:34 / 24:00", +/// playing or paused) and "Join back" (or Space). [compact]: the mini +/// player. +class SitOutCard extends StatefulWidget { + final bool compact; + const SitOutCard({super.key, this.compact = false}); + + @override + State createState() => _SitOutCardState(); +} + +class _SitOutCardState extends State { + // The position moves on while playing: redraw each second. + late final Timer _tick; + + @override + void initState() { + super.initState(); + _tick = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _tick.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final coord = SessionState.instance.coordinator; + final small = widget.compact; + return ListenableBuilder( + listenable: artChanges(), + builder: (context, _) => SignalBuilder(builder: (context) { + final r = coord.serverState.value; + final now = coord.nowPlaying.value; + final playing = r?.transport == 'playing'; + final duration = (r?.media?['duration'] as num?)?.toDouble() ?? 0; + final at = r == null ? 0.0 : serverPositionAt(r, coord.sync.serverNow()); + Duration secs(double s) => Duration(milliseconds: (s * 1000).round()); + final where = r?.media == null + ? 'Nothing is up' + : duration > 0 + ? '${formatClock(secs(at.clamp(0, duration)))} / ${formatClock(secs(duration))}' + : formatClock(secs(at)); + final text = TextStyle(color: Colors.white, fontSize: small ? 12 : 15); + final panel = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text("You're sitting out", style: text.copyWith(fontWeight: FontWeight.w700, fontSize: small ? 13 : 20)), + SizedBox(height: small ? 4 : 8), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (r?.media != null) ...[ + Icon(playing ? Icons.play_arrow_rounded : Icons.pause_rounded, + color: Colors.white70, size: small ? 14 : 18), + const SizedBox(width: 4), + ], + Text(where, key: const ValueKey('sit-out-position'), style: text.copyWith(color: Colors.white70)), + ], + ), + SizedBox(height: small ? 8 : 16), + FilledButton.icon( + key: const ValueKey('join-back'), + onPressed: coord.joinBack, + icon: Icon(Icons.login_rounded, size: small ? 16 : 20), + label: Text(small ? 'Join back' : 'Join back (Space)'), + style: small + ? FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 12), + ) + : null, + ), + ], + ); + return Stack( + fit: StackFit.expand, + children: [ + if (now != null) StartCard(now: now) else const ColoredBox(color: Color(0xFF0C0D11)), + // Small: the card's title sits at the bottom, so the panel goes up top. + Align( + alignment: small ? const Alignment(0, -0.35) : Alignment.center, + child: panel, + ), + ], + ); + }), + ); + } +} diff --git a/apps/desktop/lib/src/shell/toasts.dart b/apps/desktop/lib/src/shell/toasts.dart index 1ec780d..034af95 100644 --- a/apps/desktop/lib/src/shell/toasts.dart +++ b/apps/desktop/lib/src/shell/toasts.dart @@ -34,11 +34,13 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) { ? Icons.login : rest.startsWith('left') ? Icons.logout - : rest.startsWith('kicked') - ? Icons.gavel - : rest.startsWith('changed') - ? Icons.swap_horiz - : Icons.info_outline; + : rest.startsWith('sat you out') + ? Icons.bedtime + : rest.startsWith('nudged') + ? Icons.notifications_active_outlined + : rest.startsWith('changed') + ? Icons.swap_horiz + : Icons.info_outline; return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon); } diff --git a/apps/desktop/lib/src/state/session_state.dart b/apps/desktop/lib/src/state/session_state.dart index 03f16b5..b08f0d1 100644 --- a/apps/desktop/lib/src/state/session_state.dart +++ b/apps/desktop/lib/src/state/session_state.dart @@ -144,7 +144,7 @@ class ServerCrashed implements Exception { } /// A fresh per-hosting key: the app's own client sends it in hello to be -/// recognised as the (unkickable) host. +/// recognised as the host (`members[].host`). String newHostKey() { final r = Random.secure(); return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]); @@ -159,7 +159,8 @@ class SessionState { final status = signal(const HealthStatus(HealthSeverity.info, 'idle')); final SfxService sfx = SynthSfx( volume: () => AppSettings.instance.soundVolume.peek(), - enabled: (k) => !AppSettings.instance.soundsOff.peek().contains(k.name), + // A nudge is an explicit poke: it ignores its switch (not the volume). + enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name), presetFor: soundPresetFor, ); late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx); @@ -183,7 +184,6 @@ class SessionState { @visibleForTesting ServerIsolate? get hostedServer => _server; - void Function()? _stopKickWatch; // Bumped by host/join/cancelPending: a cancelled setup notices at its // next step and unwinds instead of connecting. int _op = 0; @@ -309,7 +309,6 @@ class SessionState { if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash); rethrow; } - _watchKick(); route.value = AppRoute.session; } finally { await _saveFormQuietly(); @@ -353,15 +352,6 @@ class SessionState { await stopServer(); } - /// A kicked host must not keep serving. - void _watchKick() { - _stopKickWatch?.call(); - _stopKickWatch = effect(() { - // Microtask: stopServer disposes this effect, not from inside it. - if (coordinator.kickReason.value != null) scheduleMicrotask(stopServer); - }); - } - /// Saved whatever the outcome: a failed join is exactly when the user /// wants the form back. Future _saveFormQuietly() async { @@ -387,8 +377,6 @@ class SessionState { Future stopServer() async { coordinator.hostFile = null; - _stopKickWatch?.call(); - _stopKickWatch = null; final server = _server; _server = null; await server?.stop(); diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index 23d8a14..726d026 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -25,6 +25,7 @@ const _keySoundsOff = 'soundsOff'; const _keySoundPreset = 'soundPreset'; const _keySoundPresetFor = 'soundPresetFor'; const _keyUnreadyOnSwap = 'unreadyOnSwap'; +const _keyIdleSitsOut = 'idleSitsOut'; enum HwdecMode { auto, off, custom } @@ -39,6 +40,8 @@ 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); /// Player design: the uosc controls (`controls/uosc/`) instead of the /// standard ones (media_kit's with our bars). Applies live. final mpvControls = signal(false); @@ -95,6 +98,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 mpv = box.get(_keyMpvControls); if (mpv is bool) mpvControls.value = mpv; final hideRe = box.get(_keyHideReReleases); @@ -156,6 +161,11 @@ class AppSettings { _put(_keyUnreadyOnSwap, on); } + void setIdleSitsOut(bool on) { + idleSitsOut.value = on; + _put(_keyIdleSitsOut, on); + } + void setMpvControls(bool on) { mpvControls.value = on; _put(_keyMpvControls, on); diff --git a/apps/desktop/lib/src/sync/sync_client.dart b/apps/desktop/lib/src/sync/sync_client.dart index 7967f2c..0014b79 100644 --- a/apps/desktop/lib/src/sync/sync_client.dart +++ b/apps/desktop/lib/src/sync/sync_client.dart @@ -4,7 +4,7 @@ import 'dart:convert'; import 'package:sync_relay_core/core.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; -enum SyncStatus { idle, connecting, open, kicked, closed, error } +enum SyncStatus { idle, connecting, open, closed, error } class SyncClient { /// Local clock; injectable so tests can run a client whose clock is off. @@ -23,13 +23,14 @@ class SyncClient { StreamSubscription? _sub; final StreamController _states = StreamController.broadcast(); final StreamController _feeds = StreamController.broadcast(); - final StreamController _kicked = StreamController.broadcast(); + final StreamController _nudges = StreamController.broadcast(); final StreamController _errors = StreamController.broadcast(); final StreamController _status = StreamController.broadcast(); Stream get states => _states.stream; Stream get feeds => _feeds.stream; - Stream get kicked => _kicked.stream; + /// Someone poked this member (`nudge`). + Stream get nudges => _nudges.stream; Stream get errors => _errors.stream; Stream get status => _status.stream; @@ -153,14 +154,10 @@ class SyncClient { // one toast, one sfx. if (f.action != 'chat') _feeds.add(f); } catch (_) {} - case 'kicked': + case 'nudge': try { - final msg = KickedMessage.fromJson(decoded); - _kicked.add(msg.reason); - _status.add(SyncStatus.kicked); - } catch (_) { - _kicked.add(''); - } + _nudges.add(NudgeNotice.fromJson(decoded)); + } catch (_) {} case 'pong': try { final pong = PongMessage.fromJson(decoded); @@ -255,8 +252,14 @@ class SyncClient { _send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson()); } - void sendKick(String target, {String reason = ''}) { - _send(KickRequest(target: target, reason: reason).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()); + } + + void sendNudge(String target) { + _send(NudgeIntent(target: target).toJson()); } Future disconnect() async { @@ -283,7 +286,7 @@ class SyncClient { await disconnect(); await _states.close(); await _feeds.close(); - await _kicked.close(); + await _nudges.close(); await _errors.close(); await _status.close(); } diff --git a/apps/desktop/lib/src/widgets/dialogs.dart b/apps/desktop/lib/src/widgets/dialogs.dart deleted file mode 100644 index f0f19c6..0000000 --- a/apps/desktop/lib/src/widgets/dialogs.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:flutter/material.dart'; - -Future showKickConfirmDialog( - BuildContext context, String memberName) { - return showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text('Remove $memberName?'), - content: const Text('They can come straight back in.'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Remove'), - ), - ], - ), - ); -} diff --git a/apps/desktop/linux/runner/my_application.cc b/apps/desktop/linux/runner/my_application.cc index ce91967..d80f05d 100644 --- a/apps/desktop/linux/runner/my_application.cc +++ b/apps/desktop/linux/runner/my_application.cc @@ -10,6 +10,8 @@ struct _MyApplication { GtkApplication parent_instance; char** dart_entrypoint_arguments; + // "starsync/window" (lib/src/platform/attention.dart). + FlMethodChannel* window_channel; }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) @@ -19,6 +21,29 @@ static void first_frame_cb(MyApplication* self, FlView* view) { gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); } +// "starsync/window": `attention` sets the urgency hint while the window is +// not focused; focusing it clears the hint. +static void window_method_cb(FlMethodChannel* channel, FlMethodCall* call, + gpointer user_data) { + GtkWindow* window = GTK_WINDOW(user_data); + g_autoptr(FlMethodResponse) response = nullptr; + if (g_strcmp0(fl_method_call_get_name(call), "attention") == 0) { + if (!gtk_window_is_active(window)) { + gtk_window_set_urgency_hint(window, TRUE); + } + response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } else { + response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + } + fl_method_call_respond(call, response, nullptr); +} + +static gboolean window_focus_in_cb(GtkWidget* widget, GdkEvent* event, + gpointer user_data) { + gtk_window_set_urgency_hint(GTK_WINDOW(widget), FALSE); + return FALSE; +} + // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); @@ -88,6 +113,15 @@ static void my_application_activate(GApplication* application) { fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + self->window_channel = fl_method_channel_new( + fl_engine_get_binary_messenger(fl_view_get_engine(view)), + "starsync/window", FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(self->window_channel, + window_method_cb, window, nullptr); + g_signal_connect(window, "focus-in-event", G_CALLBACK(window_focus_in_cb), + nullptr); + gtk_widget_grab_focus(GTK_WIDGET(view)); } @@ -134,6 +168,7 @@ static void my_application_shutdown(GApplication* application) { static void my_application_dispose(GObject* object) { MyApplication* self = MY_APPLICATION(object); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + g_clear_object(&self->window_channel); G_OBJECT_CLASS(my_application_parent_class)->dispose(object); } diff --git a/apps/desktop/test/player_settings_test.dart b/apps/desktop/test/player_settings_test.dart index 0e9c57c..86bc3a2 100644 --- a/apps/desktop/test/player_settings_test.dart +++ b/apps/desktop/test/player_settings_test.dart @@ -296,10 +296,10 @@ void main() { findsOneWidget); expect(find.descendant(of: find.byKey(const ValueKey('member-m2')), matching: find.text('host')), findsNothing); - // The host cannot be kicked: no Kick in its menu. + // The host can be sat out like anyone. await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-m1')), matching: find.byTooltip('More'))); await tester.pumpAndSettle(); - expect(find.text('Kick'), findsNothing); + expect(find.text('Sit out'), findsOneWidget); }); testWidgets('hosting: Copy invite address lists this machine\'s addresses', (tester) async { diff --git a/apps/desktop/test/player_status_test.dart b/apps/desktop/test/player_status_test.dart index 5ccf2b1..aa03a6e 100644 --- a/apps/desktop/test/player_status_test.dart +++ b/apps/desktop/test/player_status_test.dart @@ -26,8 +26,9 @@ final png = Uint8List.fromList(const [ 0x42, 0x60, 0x82, ]); -MemberView mv(String id, String name, {bool ready = false, bool? inSet, double buffered = 30, bool stale = false}) => - MemberView(id: id, name: name, ready: ready, inSet: inSet ?? ready, buffered: buffered, stale: stale); +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); StateBroadcast held(List members, {String transport = 'held', double position = 0, int mediaId = 1}) => StateBroadcast( @@ -55,17 +56,8 @@ void main() { group('status', () { final me = mv('me', 'Me', ready: true); - test('problems first: removed, reconnecting, health', () { - var v = playerStatusFor( - health: const HealthStatus(HealthSeverity.error, 'kicked: spam'), - kickReason: 'spam', - reconnecting: true, - state: held([me])); - expect(v!.kind, StatusKind.kicked); - expect(v.text, 'You were removed: spam'); - expect(v.tone, HealthSeverity.error); - expect(v.action, isNull); - v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'), + test('problems first: reconnecting, health', () { + var v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'), reconnecting: true); expect(v!.text, 'Reconnecting…'); expect(v.action, StatusAction.cancel); @@ -109,11 +101,14 @@ 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)]), + state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', stale: true), mv('o', 'Ola', out: true)]), self: me)!; expect(w.kind, StatusKind.waiting); - expect(w.text, 'Waiting for Bob and Mika'); + 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); + 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']); }); diff --git a/apps/desktop/test/server_isolate_test.dart b/apps/desktop/test/server_isolate_test.dart index baae6cd..78dd6f8 100644 --- a/apps/desktop/test/server_isolate_test.dart +++ b/apps/desktop/test/server_isolate_test.dart @@ -110,7 +110,7 @@ void main() { } }); - test('host path: isolate server, host unkickable, crash leaves the session', + test('host path: isolate server, host sat out and nudging, crash leaves the session', timeout: const Timeout(Duration(minutes: 2)), () async { MediaKit.ensureInitialized(); Hive.init( @@ -131,16 +131,16 @@ void main() { final hostId = ((state['members'] as List) .cast() .firstWhere((m) => m['name'] == 'host'))['id']; - friend.ws.add(jsonEncode(KickRequest(target: '$hostId').toJson())); - final err = await friend.next('err'); - expect(err['message'], "can't kick the host"); + // 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'); - // The host can kick the friend (plain loopback peers are not hosts). + // The host nudges the friend: only the friend hears it. final friendId = ((state['members'] as List) .cast() .firstWhere((m) => m['name'] == 'friend'))['id']; - session.coordinator.kick('$friendId'); - await friend.next('kicked'); + session.coordinator.nudgeMember('$friendId'); + expect((await friend.next('nudge'))['name'], 'host'); session.hostedServer!.isolate.kill(priority: Isolate.immediate); await _poll(() => session.route.peek() == AppRoute.home, 'leave after crash'); diff --git a/apps/desktop/test/shell_test.dart b/apps/desktop/test/shell_test.dart index 84782af..292a9e0 100644 --- a/apps/desktop/test/shell_test.dart +++ b/apps/desktop/test/shell_test.dart @@ -166,13 +166,13 @@ void main() { expect(find.descendant(of: card, matching: find.text(m.name)), findsOneWidget); } expect(find.descendant(of: card, matching: find.text('reconnecting…')), findsOneWidget); - // The kick menu asks before kicking. + // Someone else: nudge or sit them out (no kicking). await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-b')), matching: find.byTooltip('More'))); await tester.pumpAndSettle(); - await tester.tap(find.text('Kick')); - await tester.pumpAndSettle(); - expect(find.text('Remove Bob?'), findsOneWidget); - await tester.tap(find.text('Cancel')); + expect(find.text('Nudge'), findsOneWidget); + expect(find.text('Sit out'), findsOneWidget); + expect(find.text('Kick'), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); await tester.tapAt(const Offset(40, 700)); await tester.pumpAndSettle(); diff --git a/apps/desktop/windows/runner/flutter_window.cpp b/apps/desktop/windows/runner/flutter_window.cpp index 955ee30..bde63bc 100644 --- a/apps/desktop/windows/runner/flutter_window.cpp +++ b/apps/desktop/windows/runner/flutter_window.cpp @@ -2,6 +2,8 @@ #include +#include + #include "flutter/generated_plugin_registrant.h" FlutterWindow::FlutterWindow(const flutter::DartProject& project) @@ -25,6 +27,24 @@ bool FlutterWindow::OnCreate() { return false; } RegisterPlugins(flutter_controller_->engine()); + window_channel_ = std::make_unique>( + flutter_controller_->engine()->messenger(), "starsync/window", + &flutter::StandardMethodCodec::GetInstance()); + window_channel_->SetMethodCallHandler( + [this](const flutter::MethodCall& call, + std::unique_ptr> result) { + if (call.method_name() != "attention") { + result->NotImplemented(); + return; + } + HWND hwnd = GetHandle(); + if (hwnd != nullptr && GetForegroundWindow() != hwnd) { + // Flashes until the window comes to the front. + FLASHWINFO info{sizeof(FLASHWINFO), hwnd, FLASHW_ALL | FLASHW_TIMERNOFG, 0, 0}; + FlashWindowEx(&info); + } + result->Success(); + }); SetChildContent(flutter_controller_->view()->GetNativeWindow()); flutter_controller_->engine()->SetNextFrameCallback([&]() { @@ -40,6 +60,7 @@ bool FlutterWindow::OnCreate() { } void FlutterWindow::OnDestroy() { + window_channel_ = nullptr; if (flutter_controller_) { flutter_controller_ = nullptr; } diff --git a/apps/desktop/windows/runner/flutter_window.h b/apps/desktop/windows/runner/flutter_window.h index 6da0652..a68609d 100644 --- a/apps/desktop/windows/runner/flutter_window.h +++ b/apps/desktop/windows/runner/flutter_window.h @@ -3,6 +3,8 @@ #include #include +#include +#include #include @@ -28,6 +30,10 @@ class FlutterWindow : public Win32Window { // The Flutter instance hosted by this window. std::unique_ptr flutter_controller_; + + // "starsync/window": `attention` flashes the taskbar entry while the + // window is not in front (lib/src/platform/attention.dart). + std::unique_ptr> window_channel_; }; #endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 622b545..fea24a3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,7 +38,7 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a uncaught async error is logged and the server keeps going. If the isolate dies anyway, the session shows "server crashed: ..." and leaves; the shell says so in a dialog (during setup the host attempt fails with it). - Stop (leave, close, cancel during setup, a failed own join, being kicked) + Stop (leave, close, cancel during setup, a failed own join) sends `stop`, waits up to 3 s for exit, then kills the isolate. - **Dedicated server**: `bin/server.dart` compiled with `dart compile exe` and shipped next to the app as `watch-together-server`. It parses flags @@ -55,11 +55,12 @@ warning is logged). Otherwise one shared secret, compared at hello. Host: when hosting, the app generates a random `hostKey` per hosting, puts it in the server config and sends it in its own `hello`. The member whose -key matches is the host and cannot be kicked. The address a connection -comes from plays no part (so a reverse proxy or tunnel on the same machine -does not make everyone the host). The dedicated server has no key, so it -has no host. Kick = revoke token, send `kicked`, close; the person may -rejoin at once with a new token. +key matches is the host (`members[].host`: the crown, art rights). The +address a connection comes from plays no part (so a reverse proxy or tunnel +on the same machine does not make everyone the host). The dedicated server +has no key, so it has no host. Nobody removes anyone: members sit each +other out ("Sitting out" below), the host included, since the hosted +server and its `/m/` file serving do not need the host's player. ## Sync model @@ -94,8 +95,22 @@ Loosely modeled on Syncplay, with a different readiness design. setting "Unready when the video changes", `AppSettings.unreadyOnSwap`, default off, sends `ready=false` itself on a new `mediaId`; not for the first state of a session, and a reconnect does not restore a ready that - a swap meanwhile would have dropped). Stale, leave and kick remove from - the set. + a swap meanwhile would have dropped). Stale, leave and sitting out + remove from the set. +- **Sitting out** (`EngineMember.out`, `state.members[].out`): a member + stays connected (chat, feed, party, nudges) but is never in the set and + never ready: no `ready` or `force` reaches it, a swap leaves it out, its + reports keep it fresh but their `buffered` counts as 0. Anyone can sit + anyone out (`out` with `out:true`, the host included); only the member + itself comes back (`out:false`): it is then in the set like a fresh join + (unless PLAYING) and still unready. The app: an out member's player has + nothing open (no stream or relay pulls; a relay it serves goes on) and + shows the join-back card (cover, title, episode, the server position and + playing/paused). "Join back" or Space sends `out:false` and then + `ready`: at once while HELD (the gate waits for its buffer), while + PLAYING once the player has buffered `need` (at most 15 s), so the others + do not pause for the load. Putting something up while out comes back + first. A reconnect that lands as a new member sits out again. - **Pause**: while PLAYING, any in-set member who becomes not ready, under `need` buffered or stale flips the server to HELD (feed action `pause` by `server`). Recovery resumes by itself when the gate passes. @@ -137,16 +152,26 @@ Loosely modeled on Syncplay, with a different readiness design. `hello.resume`. If the server still has that member (an unclean drop it has not noticed), the new socket takes it over: same token, member id, ready flag and media URL (the player keeps streaming), no join/leave feed, and the - dead socket is closed without removing the member. An unknown or kicked - token joins as a new member. + dead socket is closed without removing the member. An unknown token joins + as a new member. - **Client policies**: no state broadcast for 5 s means connection lost, clear the server state and reconnect every 1.5 s until cancelled (an epoch counter makes - a cancelled attempt stale) or kicked. After a reconnect the ready flag is + a cancelled attempt stale). After a reconnect the ready flag is restored from the server's state. Cancel during setup aborts the join. Closing the window leaves gracefully (2 s timeout). - **Idle** (`IdleTracker`): measured from the later of the last input and the moment playback stopped. While HELD and self ready: warning at 8 min, auto-unready at 10 min, and never less than 2 min after the warning. + With "When idle, sit out instead of just unready" + (`AppSettings.idleSitsOut`, default off) the 10 min step sits out. +- **Nudge**: `nudge` names a member; the server passes it to that member + only (no feed), at most 3 per second and 20 per minute per sender + (`nudgesPerSec`, `nudgesPerMin`; the rest is dropped silently). The + nudged app plays the nudge sound whatever its per-sound switch (the + volume still applies), toasts "Alice nudged you" and asks the desktop + for attention when its window is not focused: the runners' `starsync/window` + channel (`attention`), the urgency hint on Linux (GTK, cleared on focus) + and `FlashWindowEx` until foreground on Windows. - **Deviations from Syncplay**: clock never follows the slowest watcher; no pause-on-leave; anyone can force ready/unready (all or one); presence gate; the clock is server time (clients correct for their offset to it). @@ -192,7 +217,7 @@ Range table (`resolveMediaRequest`, tested byte-exact): | `bytes=-n` | 206 last n bytes | | unsatisfiable | 416, `Content-Range: bytes */size` | | host has no file | 503 + `Retry-After: 2` | -| bad / unknown token, kicked, unknown session | 401, 403, 410 | +| bad / unknown token, unknown session | 401, 410 | Always `Accept-Ranges: bytes`; `Content-Type` from the extension (`contentTypeForFile`). Relic hands the Range over as a typed `RangeHeader`; @@ -365,7 +390,7 @@ errors leave it out). - Available (`state.relay`) only while the current media is a ref whose `serverId` equals the credential's. Only that item is relayed, only to a - valid, unkicked viewer token: no open proxy. + valid viewer token: no open proxy. - Each request opens `/Videos/{id}/stream?static=true&mediaSourceId=` with `Authorization: MediaBrowser` built from exactly the configured fields (Jellyfin rewrites a token's device fields from the header), forwards @@ -573,7 +598,7 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, - **Progress** (`JellyfinProgressReporter`, `OwnPlay` only): Playing once the file is open and mpv has picked its tracks; Progress every 10 s and at once on pause, resume, a seek (position more than 3 s from where playback - should be) or a track change; Stopped on swap, leave, kick, window close + should be) or a track change; Stopped on swap, leave, sitting out, window close (bounded to 1.5 s inside the graceful leave) and at `finished` (position = duration, so Jellyfin marks it played); seeking back from the end sends Playing again. Reports carry `PositionTicks`, `IsPaused` and mpv's @@ -631,7 +656,8 @@ always scrolls the page). Space never presses a focused button ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and "ready n/m" (hidden under 1100 px), chat with an unread badge, and Leave (asks first). The stack opens the members card (status, host / relay - pills, "via host", force ready, kick for all but the host, "Copy invite + pills, "via host", "sitting out"; per member force ready, Nudge and Sit + out (anyone, the host too), your own row Sit out / Join back; "Copy invite address": the dialled address when joined; hosting, `inviteAddresses` of `NetworkInterface.list` (IPv4, no loopback or link-local, private ranges first) with the Host port, and a note that friends outside the @@ -640,7 +666,7 @@ always scrolls the page). Space never presses a focused button - **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the picture with a gap in the background colour (both scale with the size): green ready, amber buffering (with a spinner), grey not ready, dimmed - stale; a crown for the host (`members[].host` in the state; your own + stale, dimmed with a moon ("away") sitting out (last in the stack); a crown for the host (`members[].host` in the state; your own app also knows before the first state) and a tower for the member passing the video on. `memberAvatarImage` is the one picture lookup: yours from the profile, anyone else's from `coordinator.avatars` by `avatarHash`. @@ -712,8 +738,9 @@ always scrolls the page). Space never presses a focused button Link (online the relay mode is picked there). Images are ignored. Nothing happens while a dialog or popover is over the shell. - **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready, - unready, force-ready, join, leave, kick, swap and others' chat (feed - events), the idle warning, and connection lost / back (once per drop). + unready, force-ready, join, leave, swap and others' chat (feed + events), being sat out by someone (the `kick` sound), a nudge (always, + whatever its switch), the idle warning, and connection lost / back (once per drop). Nothing for what the video already says (play, pause, seeks, the end). The sounds are synthesized, not sampled: `synth.dart` renders voices (plucked strings with bends, glass and star bells, a singing bowl, @@ -735,8 +762,10 @@ always scrolls the page). Space never presses a focused button snaps to the nearest corner with the shared curve). A mode change fades the layer out, moves it while invisible and fades it in (with the tabs when the full player comes or goes), so the video is never mounted twice - nor remounted. A tab sends the full player to mini; × on the mini closes - it (muted, still in the server) and the strip brings it back. Fullscreen + nor remounted. A tab sends the full player to mini; × on the mini sits + out (the player unloads and shows the join-back card, `SitOutCard`, over + the still mounted `Video`); × on that card closes the player, the strip + brings it back, and joining back with it closed shows the mini. Fullscreen is `windowManager.setFullScreen` with the strip hidden (party avatars in a corner while paused); F, Esc and double-click on the picture toggle it. The player keys (Settings → Keyboard shortcuts) work while the player is @@ -902,7 +931,7 @@ fullscreen has no drag area and shows the buttons in the player's top bar. Every failure must show in the UI with a cause and a next step; nothing stalls silently. The app has a `HealthStatus` signal (severity + message) -for connecting, reconnecting, wrong password, version mismatch, kicked, +for connecting, reconnecting, wrong password, version mismatch, host offline, and starvation ("waiting for X - N.N s buffered"). Errors are logged with a stack trace at the boundary, then turned into a displayable state. The server's `GET /health` returns JSON (`ok`, `name`, @@ -950,7 +979,8 @@ this repo, written by `tool/release.sh`. - No transcoding: bandwidth is plentiful and the Jellyfin box should not be loaded. - No roles, friends trust model: simplest thing that fits the use. - Blank password = open server: the owner chooses between open and private. -- The host cannot be kicked: the server dies with the host anyway. +- Sit out instead of kick: friends never need to throw someone out, only to + stop waiting for them; the person stays in chat and can be nudged back. - Server runs inside the app on an isolate, and ships as a separate compiled server: a Flutter binary cannot run headless, so a dedicated server needs its own build of `packages/server`. An isolate keeps the UI responsive and diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index 6e8be51..3d1efe4 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -74,8 +74,10 @@ Friends join either way with Join in the strip at the top of the window. (stops blocking) and removed after 30 s. The app reconnects by itself and takes over your old place (same member, ready flag and video). -There are no roles: anyone can seek, swap, force-ready others and kick -anyone except the host. +There are no roles: anyone can seek, swap, force-ready others, nudge +anyone, and sit anyone out (the host too). Someone sitting out stays +connected and in the chat, but their app loads nothing and nobody waits +for them until they join back. ## Where video comes from diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 71be675..cab8776 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -43,7 +43,8 @@ with different `proto` refuse each other at `hello` (`proto mismatch`). "duration":1440.5,"mediaId":2,"jf":{"":true}} {"t":"ping","c":1767225600000} {"t":"chat","text":"..."} -{"t":"kick","target":"","reason":""} +{"t":"out","target":"","out":true} +{"t":"nudge","target":""} {"t":"profile","name":"...","avatar":""} {"t":"art","mediaId":2,"poster":"","banner":""} ``` @@ -54,11 +55,11 @@ Field notes: - `hello.hostKey` (optional, omitted when empty): the hosting app generates a random key per hosting, gives it to its server and sends it from its own client. The member whose key equals the server's is the host - and cannot be kicked. A server without a key (the dedicated server) has no + (`members[].host`). A server without a key (the dedicated server) has no host. Where the connection comes from does not matter. - `hello.resume` (optional, omitted when empty): the token from this client's previous `welcome`, sent on reconnect. If the server still has - that member and it is not kicked, the connection takes it over: `welcome` + that member, the connection takes it over: `welcome` returns the same `token` and `memberId`, the member keeps its flags, and there is no `join`/`leave` feed. Otherwise it is ignored and a new member joins. @@ -81,6 +82,19 @@ Field notes: `rejected: avatar too large` (the socket stays open, nothing changes). - `art`: pictures of the current media (see "Media art"). - `chat.text` is cut to 500 characters. +- `out`: `out:true` sits the member `target` out (anyone, the host + included); `out:false` brings the sender back and only works with its own + member id as `target`. An out member is never in the set and never + ready: `ready` from it and `force` on it are ignored, a swap leaves it + out, the `buffered` of its reports counts as 0 (it keeps sending + `report`: that keeps it fresh). Coming back puts it in the set like a + join (unless `playing`), unready; it sends `ready` itself. Unknown + targets and no-op changes are ignored. Feed: `out` (`target` = the name + of who was sat out, absent when the actor sat itself out) and `back`. +- `nudge`: the server sends `{"t":"nudge","from":,"name":}` + to `target` only (no feed). At most 3 per second and 20 per minute per + sender; the rest, nudges to oneself and to unknown targets are dropped + silently. - `swap.media.url` is the link; `name`, `size`, `duration` are informational. `swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is dropped. `swap.media.relay` makes it relayed media (see "Relay"). @@ -99,12 +113,12 @@ Field notes: "serverTime":"","mediaId":1, "media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent, "members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?, - "host"?}], + "host"?,"out"?}], "seekBy":"m1","seekSeq":3,"relay":true} {"t":"pong","c":1767225600000,"s":1767225600012} {"t":"feed","actor":"","action":"","target":"","reason":"","at":""} {"t":"chat","text":"...","from":""} -{"t":"kicked","reason":""} +{"t":"nudge","from":"m2","name":"Alice"} {"t":"err","message":""} ``` @@ -130,12 +144,13 @@ Field notes: `report.jf`, omitted when it never sent one. `members[].avatarHash` (omitted when the member has no picture) names the picture's bytes (see "Avatars"). `members[].host` (omitted when false): the member sent the - server's `hostKey` (see `hello.hostKey`). + server's `hostKey` (see `hello.hostKey`). `members[].out` (omitted when + false): the member sits out (see `out`). - `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms (the clock `serverTime` uses). With `t3` the local receive time, the offset is `s - (c + t3) / 2`, accurate to half the round trip. - `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`, - `force-play`, `force-hold`, `kick`, `join`, `leave`, `stale`, `swap`, + `force-play`, `force-hold`, `out`, `back`, `join`, `leave`, `stale`, `swap`, `seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`. `target` and `reason` are optional; `reason` carries `auto`/`manual` for ready and `silent too long` or `no signal` for leave/stale. `rename`: `actor` is the @@ -154,7 +169,7 @@ Field notes: - `GET /a///`: the bytes with their image content type, `cache-control: private, max-age=31536000, immutable` (a hash names fixed bytes; clients fetch each hash once, from the host they dialled). - Errors: 401 bad token, 403 kicked, 404 no current member has that + Errors: 401 bad token, 404 no current member has that picture, 410 unknown session. Pictures go with their member (leave, change, removal). @@ -204,7 +219,7 @@ state media.art = {"poster":"","banner":""} file) with its own credential and streams it through. `Range` is forwarded as sent; status, `Content-Range`, `Content-Length`, `Content-Type` and `Accept-Ranges` come back as Jellyfin sent them. Errors: 401 bad token, - 403 kicked or not the current item, 404 no relay for the current media, + 403 not the current item, 404 no relay for the current media, 410 unknown session, 502 Jellyfin unreachable or rejecting the server's credential. Bytes count toward `bytesServed`. - Pasted Jellyfin links sent as plain `url` stay plain links. @@ -261,7 +276,7 @@ to the entry. - `GET /r////`: the media (one file), Range table as for `/m`. `GET /r/////`: track `i` (from 0) of a media with `tracks` (track 0 of a one-file media - too). Errors: 401 bad token, 403 kicked, 404 not the current relayed + too). Errors: 401 bad token, 404 not the current relayed media (a player still on an older `mediaId` gets 404, never the new bytes) or no such track (the one-file URL of a media with `tracks`), 410 unknown session, 503 + `retry-after` the source never connected or the diff --git a/packages/core/lib/src/config.dart b/packages/core/lib/src/config.dart index 4e6d12a..8101d7f 100644 --- a/packages/core/lib/src/config.dart +++ b/packages/core/lib/src/config.dart @@ -101,7 +101,7 @@ class AppConfig { /// client dialled. final String advertiseHost; /// Secret the hosting app's own client sends in hello to be marked host - /// (unkickable). Empty means no member is the host (dedicated server). + /// (`members[].host`). Empty means no member is the host (dedicated server). final String hostKey; /// Optional login for relaying Jellyfin media to guests; null = no relay. final JellyfinCredential? jellyfin; diff --git a/packages/core/lib/src/constants.dart b/packages/core/lib/src/constants.dart index 015165f..898e00d 100644 --- a/packages/core/lib/src/constants.dart +++ b/packages/core/lib/src/constants.dart @@ -50,4 +50,8 @@ abstract class RelayConstants { static const int maxBannerBytes = 768 * 1024; static const int maxNameLength = 32; static const int maxChatLength = 500; + /// Nudges one member may send: at most this many per second and per + /// minute; the server drops the rest silently. + static const int nudgesPerSec = 3; + static const int nudgesPerMin = 20; } diff --git a/packages/core/lib/src/protocol.dart b/packages/core/lib/src/protocol.dart index e15ad35..9d08e72 100644 --- a/packages/core/lib/src/protocol.dart +++ b/packages/core/lib/src/protocol.dart @@ -288,31 +288,46 @@ class WelcomeMessage { } } -class KickedMessage { - final String reason; - const KickedMessage({this.reason = ''}); +/// Sits [target] (a member id) out, or with [out] false brings oneself +/// back. Out: connected, but not in the ready gate and not playing. +class OutIntent { + final String target; + final bool out; + const OutIntent({required this.target, required this.out}); - Map toJson() => {'t': 'kicked', 'reason': reason}; + Map toJson() => {'t': 'out', 'target': target, 'out': out}; - factory KickedMessage.fromJson(Map json) { - if (json['t'] != 'kicked') throw const ProtocolException('not a kicked message'); - return KickedMessage(reason: (json['reason'] as String?) ?? ''); + factory OutIntent.fromJson(Map json) { + if (json['t'] != 'out') throw const ProtocolException('not an out intent'); + return OutIntent(target: (json['target'] as String?) ?? '', out: (json['out'] as bool?) ?? true); } } -class KickRequest { +/// Client to server: poke [target] (a member id). The server passes it on +/// to that member only, as a [NudgeNotice]. +class NudgeIntent { final String target; - final String reason; - const KickRequest({required this.target, this.reason = ''}); + const NudgeIntent({required this.target}); - Map toJson() => {'t': 'kick', 'target': target, 'reason': reason}; + Map toJson() => {'t': 'nudge', 'target': target}; - factory KickRequest.fromJson(Map json) { - if (json['t'] != 'kick') throw const ProtocolException('not a kick request'); - return KickRequest( - target: (json['target'] as String?) ?? '', - reason: (json['reason'] as String?) ?? '', - ); + factory NudgeIntent.fromJson(Map json) { + if (json['t'] != 'nudge') throw const ProtocolException('not a nudge intent'); + return NudgeIntent(target: (json['target'] as String?) ?? ''); + } +} + +/// Server to the nudged member: who poked it ([from] member id, [name]). +class NudgeNotice { + final String from; + final String name; + const NudgeNotice({required this.from, required this.name}); + + Map toJson() => {'t': 'nudge', 'from': from, 'name': name}; + + factory NudgeNotice.fromJson(Map json) { + if (json['t'] != 'nudge') throw const ProtocolException('not a nudge notice'); + return NudgeNotice(from: (json['from'] as String?) ?? '', name: (json['name'] as String?) ?? ''); } } @@ -536,6 +551,8 @@ class MemberView { final Map jf; /// The hosting app's own member (sent the server's host key). final bool host; + /// Sitting out: connected, outside the ready gate, not playing. + final bool out; const MemberView({ required this.id, required this.name, @@ -546,6 +563,7 @@ class MemberView { required this.stale, this.jf = const {}, this.host = false, + this.out = false, }); Map toJson() => { @@ -558,6 +576,7 @@ class MemberView { 'stale': stale, if (jf.isNotEmpty) 'jf': jf, if (host) 'host': true, + if (out) 'out': true, }; factory MemberView.fromJson(Map json) => MemberView( @@ -570,6 +589,7 @@ class MemberView { stale: (json['stale'] as bool?) ?? false, jf: jfAccessFromJson(json['jf']) ?? const {}, host: json['host'] == true, + out: json['out'] == true, ); } diff --git a/packages/core/lib/src/sync_engine.dart b/packages/core/lib/src/sync_engine.dart index a77f5e9..e40e7ce 100644 --- a/packages/core/lib/src/sync_engine.dart +++ b/packages/core/lib/src/sync_engine.dart @@ -54,6 +54,8 @@ class EngineMember { Map jf = const {}; /// The hosting app's own member (set by the server from the host key). bool host = false; + /// Sitting out: never in the set, never ready, its buffer ignored. + bool out = false; DateTime lastSeen; EngineMember({ required this.id, @@ -94,6 +96,7 @@ class EngineMember { stale: stale, jf: jf, host: host, + out: out, ); } @@ -193,7 +196,8 @@ class SyncEngine { void _seen(EngineMember member) { final wasStale = isStale(member); member.lastSeen = now; - if (!member.inSet && + if (!member.out && + !member.inSet && (member.ready || (wasStale && transport != Transport.playing))) { member.inSet = true; } @@ -301,6 +305,8 @@ class SyncEngine { final member = members[token]; if (member == null) return; _seen(member); + // Sitting out: back first ([applyOut]), then ready. + if (member.out) return; if (member.ready == ready && member.inSet) { evaluate(); return; @@ -320,12 +326,13 @@ class SyncEngine { final actorName = actor?.name ?? '?'; if (target == 'all') { for (final member in members.values) { + if (member.out) continue; member.ready = ready; if (ready) member.inSet = true; } } else { final member = byId(target); - if (member == null) return; + if (member == null || member.out) return; member.ready = ready; if (ready) member.inSet = true; feed.add(FeedMessage( @@ -343,6 +350,28 @@ class SyncEngine { evaluate(); } + /// Sits the member [targetId] out ([out] true; anyone may, the host + /// included), or brings [actorToken]'s own member back ([out] false; + /// only oneself). Out: not ready, not in the set, so it never blocks. + /// Back: in the set like a fresh join (unless playing), still unready: + /// its own `ready` follows once it has buffered. + void applyOut(String actorToken, String targetId, bool out) { + final actor = members[actorToken]; + final member = byId(targetId); + if (actor == null || member == null) return; + _seen(actor); + final self = identical(actor, member); + if (!out && !self) return; + if (member.out == out) return; + member.out = out; + member.ready = false; + member.buffered = 0; + member.inSet = !out && transport != Transport.playing; + feed.add(FeedMessage( + actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now)); + evaluate(); + } + void applySeek(String token, double seekPosition, {int seq = 0}) { final member = members[token]; if (member == null) return; @@ -379,7 +408,7 @@ class SyncEngine { // (a stale member rejoins the set with its next frame if still ready). final t = now; for (final member in members.values) { - member.inSet = !isStale(member, t); + member.inSet = !member.out && !isStale(member, t); member.buffered = 0; } feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now)); @@ -393,7 +422,7 @@ class SyncEngine { if (!reportPosition.isFinite) return; _seen(member); if (jf != null) member.jf = Map.unmodifiable(jf); - member.buffered = !buffered.isFinite || buffered < 0 ? 0 : buffered; + member.buffered = member.out || !buffered.isFinite || buffered < 0 ? 0 : buffered; final m = media; // First report of the length wins; only for the media the client has open. if (m != null && m.duration <= 0 && mediaId == this.mediaId && diff --git a/packages/core/test/sync_engine_test.dart b/packages/core/test/sync_engine_test.dart index 19ac5e4..b7dbae1 100644 --- a/packages/core/test/sync_engine_test.dart +++ b/packages/core/test/sync_engine_test.dart @@ -36,6 +36,55 @@ void main() { }); }); + group('sitting out', () { + test('an out member never blocks, is never ready, its buffer is ignored', () { + join('a', 'alice'); + final bob = join('b', 'bob'); + engine.applyReady('a', true); + buffer('a', 4.0); + expect(engine.transport, Transport.held, reason: 'bob is in the set'); + engine.applyOut('a', bob, true); + final b = engine.members['b']!; + expect([b.out, b.inSet, b.ready], [true, false, false]); + expect(engine.transport, Transport.playing); + final feed = engine.feed.drain().lastWhere((f) => f.action == 'out'); + expect([feed.actor, feed.target], ['alice', 'bob']); + expect(engine.broadcast().members.last.toJson()['out'], isTrue); + expect(engine.broadcast().members.first.toJson().containsKey('out'), isFalse); + // Ready, force, swap and reports leave it out. + engine.applyReady('b', true); + engine.applyForce('a', 'all', true); + buffer('b', 9); + engine.applySwap('a', const HelloMedia(name: 'n', size: 1, duration: 0, url: 'u')); + expect([b.ready, b.inSet, b.buffered], [false, false, 0]); + expect(engine.members['a']!.inSet, isTrue); + }); + + test('only oneself comes back: in the set unready, ready follows', () { + final alice = join('a', 'alice'); + final bob = join('b', 'bob'); + engine.applyOut('b', bob, true); + expect(engine.feed.drain().last.target, isNull, reason: 'sat out by itself'); + engine.applyReady('a', true); + buffer('a', 4.0); + expect(engine.transport, Transport.playing); + engine.applyOut('a', bob, false); + expect(engine.members['b']!.out, isTrue, reason: 'others cannot bring bob back'); + engine.applyOut('b', bob, false); + final b = engine.members['b']!; + expect([b.out, b.inSet, b.ready], [false, false, false], reason: 'playing: in from its ready'); + expect(engine.transport, Transport.playing); + engine.applyReady('b', true); + expect(engine.transport, Transport.held, reason: 'now the gate waits for bob'); + buffer('b', 4.0); + expect(engine.transport, Transport.playing); + // The host can be sat out like anyone. + engine.members['a']!.host = true; + engine.applyOut('b', alice, true); + expect(engine.members['a']!.out, isTrue); + }); + }); + group('membership + gate', () { test('join while held lands in the set, playback stays held', () { join('a', 'alice'); diff --git a/packages/server/lib/src/jellyfin_relay.dart b/packages/server/lib/src/jellyfin_relay.dart index 24b1d23..fd5691d 100644 --- a/packages/server/lib/src/jellyfin_relay.dart +++ b/packages/server/lib/src/jellyfin_relay.dart @@ -53,8 +53,6 @@ void mountJellyfinRelayRoute( switch (session.gate(token)) { case MediaGate.ok: break; - case MediaGate.forbidden: - return _plain(403); default: return _plain(401); } diff --git a/packages/server/lib/src/media_endpoint.dart b/packages/server/lib/src/media_endpoint.dart index b29a861..1b9a72b 100644 --- a/packages/server/lib/src/media_endpoint.dart +++ b/packages/server/lib/src/media_endpoint.dart @@ -58,8 +58,6 @@ MediaResolution resolveMediaRequest({ return MediaResolution(status: 410, headers: base(contentType), start: 0, end: 0); case MediaGate.unauthorized: return MediaResolution(status: 401, headers: base(contentType), start: 0, end: 0); - case MediaGate.forbidden: - return MediaResolution(status: 403, headers: base(contentType), start: 0, end: 0); case MediaGate.offline: return MediaResolution( status: 503, diff --git a/packages/server/lib/src/relay_routes.dart b/packages/server/lib/src/relay_routes.dart index 3b0eac2..31c36c4 100644 --- a/packages/server/lib/src/relay_routes.dart +++ b/packages/server/lib/src/relay_routes.dart @@ -139,13 +139,6 @@ class ActiveRelay { if (ch != null) unawaited(_closeNotSource(ch)); } - /// Source member gone for good (kicked): stop its channel. - void dropChannel() { - final ch = _channel; - _channel = null; - if (ch != null) unawaited(_closeNotSource(ch)); - } - void _attachChannel(RelicWebSocket ws, List sizes) { final old = _channel; if (old != null && !identical(old, ws)) unawaited(old.tryClose()); @@ -290,8 +283,6 @@ void mountRelayRoutes( switch (session.gate(token)) { case MediaGate.ok: break; - case MediaGate.forbidden: - return _plain(403); default: return _plain(401); } diff --git a/packages/server/lib/src/server_hub.dart b/packages/server/lib/src/server_hub.dart index 6464cca..0083e75 100644 --- a/packages/server/lib/src/server_hub.dart +++ b/packages/server/lib/src/server_hub.dart @@ -66,11 +66,9 @@ class ServerHub { bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey; /// The member a reconnecting client may take over with its old token: - /// still known and not kicked. + /// still known. EngineMember? resumable(String token) { - if (token.isEmpty) return null; - final viewer = session.tokens[token]; - if (viewer == null || viewer.kicked) return null; + if (token.isEmpty || !session.tokens.containsKey(token)) return null; return engine.members[token]; } @@ -150,24 +148,25 @@ class ServerHub { void forgetSocket(String token) { engine.applyLeave(token); - final viewer = session.tokens[token]; - if (viewer != null && !viewer.kicked) session.tokens.remove(token); + session.tokens.remove(token); sockets.remove(token); changed(); } - Future kickById(String targetId, String reason) async { - final member = engine.byId(targetId); - if (member == null) throw const AuthException('unknown kick target'); - if (session.tokens[member.token]?.isHost ?? false) { - throw const AuthException("can't kick the host"); - } - await session.kick(member.token, reason: reason); + /// Passes a nudge from [fromToken] to the member [targetId] only; + /// over the sender's rate cap, to itself or to nobody it is dropped. + void nudge(String fromToken, String targetId) { + final from = engine.members[fromToken]; + final target = engine.byId(targetId); + final viewer = session.tokens[fromToken]; + if (from == null || target == null || viewer == null || identical(from, target)) return; + if (!viewer.takeNudge(engine.now)) return; + sockets[target.token]?.trySendText(jsonEncode(NudgeNotice(from: from.id, name: from.name).toJson())); } } /// `GET /a///`: a current member's picture by its -/// content hash, for members (valid, unkicked token) only. The bytes for a +/// content hash, for members (valid token) only. The bytes for a /// hash never change, so clients may cache them for good. void mountAvatarRoute(RelicApp app, ServerHub hub) => _mountPictureRoute(app, hub, 'a', hub.engine.avatarFor); @@ -188,8 +187,6 @@ void _mountPictureRoute(RelicApp app, ServerHub hub, String prefix, Uint8List? F switch (session.gate(token)) { case MediaGate.unauthorized: return status(401); - case MediaGate.forbidden: - return status(403); default: break; } @@ -415,7 +412,7 @@ void mountSyncRoute( return; } final name = intent.name == null ? null : cleanMemberName(intent.name!); - // Chat and kick name the sender by its token's name. + // Chat names the sender by its token's name. if (name != null) viewer.name = name; hub.engine.applyProfile(token, name: name, avatar: avatar); hub.changed(); @@ -441,22 +438,14 @@ void mountSyncRoute( case 'chat': final chat = ChatMessage.fromJson(decoded); hub.broadcastChat(viewer?.name ?? '?', chat.text); - case 'kick': - final kickReq = KickRequest.fromJson(decoded); - final member = hub.engine.byId(kickReq.target); - if (member == null) { - wsErr(ws, 'unknown kick target'); - return; - } - if (session.tokens[member.token]?.isHost ?? false) { - wsErr(ws, "can't kick the host"); - return; - } - final by = viewer?.name ?? '?'; - unawaited(hub.session.kick(member.token, - reason: kickReq.reason.isEmpty - ? 'kicked by $by' - : kickReq.reason)); + case 'out': + if (token == null) return; + final intent = OutIntent.fromJson(decoded); + hub.engine.applyOut(token, intent.target, intent.out); + hub.changed(); + case 'nudge': + if (token == null) return; + hub.nudge(token, NudgeIntent.fromJson(decoded).target); } } on ProtocolException catch (e) { wsErr(ws, 'bad message: ${e.message}'); @@ -544,13 +533,6 @@ void mountSyncRoute( hub.engine.members[token]?.host = isHost; final activeToken = token; hub.sockets[activeToken] = ws; - session.tokens[activeToken]!.onKick = (reason) async { - if (hub.relay?.sourceToken == activeToken) hub.relay!.dropChannel(); - ws.trySendText(jsonEncode(KickedMessage(reason: reason).toJson())); - await Future.delayed(const Duration(milliseconds: 200)); - await ws.tryClose(); - hub.forgetSocketOf(activeToken, ws); - }; final media = session.media; final relay = hub.relayAvailable; final relayed = hub.relay; diff --git a/packages/server/lib/src/session.dart b/packages/server/lib/src/session.dart index a781191..127b537 100644 --- a/packages/server/lib/src/session.dart +++ b/packages/server/lib/src/session.dart @@ -3,21 +3,31 @@ import 'dart:math'; import 'package:sync_relay_core/core.dart'; -enum MediaGate { ok, gone, unauthorized, forbidden, offline } +enum MediaGate { ok, gone, unauthorized, offline } class ViewerToken { final String token; /// Cleaned ([cleanMemberName]); follows `profile` renames. String name; - bool kicked = false; /// Sent the server's host key in hello: the hosting app's own client. bool isHost = false; int bytesServed = 0; DateTime lastSeen; - Future Function(String reason)? onKick; + /// When this member's recent nudges went out, for the rate cap. + final List nudges = []; ViewerToken({required this.token, required this.name}) : lastSeen = DateTime.now().toUtc(); + /// Takes one nudge at [now] if under the caps + /// ([RelayConstants.nudgesPerSec] / [RelayConstants.nudgesPerMin]). + bool takeNudge(DateTime now) { + nudges.removeWhere((t) => now.difference(t) >= const Duration(minutes: 1)); + final lastSec = nudges.where((t) => now.difference(t) < const Duration(seconds: 1)).length; + if (nudges.length >= RelayConstants.nudgesPerMin || lastSec >= RelayConstants.nudgesPerSec) return false; + nudges.add(now); + return true; + } + bool isStale(Duration timeout, [DateTime? now]) => (now ?? DateTime.now().toUtc()).difference(lastSeen) > timeout; @@ -66,17 +76,9 @@ class Session { MediaGate gate(String? token) { if (token == null || !tokens.containsKey(token)) return MediaGate.unauthorized; - if (tokens[token]!.kicked) return MediaGate.forbidden; return MediaGate.ok; } - Future kick(String token, {String reason = ''}) async { - final viewer = tokens[token]; - if (viewer == null) throw const AuthException('unknown token'); - viewer.kicked = true; - await viewer.onKick?.call(reason); - } - void addBytes(String? token, int n) { if (token != null) tokens[token]?.bytesServed += n; } diff --git a/packages/server/test/hardening_test.dart b/packages/server/test/hardening_test.dart index b4cb760..0e6a324 100644 --- a/packages/server/test/hardening_test.dart +++ b/packages/server/test/hardening_test.dart @@ -123,29 +123,56 @@ void main() { await a.ws.close(); }); - test('host (hostKey) cannot be kicked; loopback without it can', () async { + test('anyone sits anyone out (host too); nudges reach only the target, capped', () async { await server.stop(); server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk')); await server.start(); final host = await _Peer.join('127.0.0.1', server.boundPort, 'host', hostKey: 'hk'); final friend = await _Peer.join('127.0.0.1', server.boundPort, 'friend'); - final guesser = - await _Peer.join('127.0.0.1', server.boundPort, 'guesser', hostKey: 'nope'); + final third = await _Peer.join('127.0.0.1', server.boundPort, 'third'); String idOf(String name) => server.hub.engine.members.values.firstWhere((m) => m.name == name).id; - friend.send(jsonEncode(KickRequest(target: idOf('host')).toJson())); - final err = await friend.next('err'); - expect(err['message'], "can't kick the host"); - expect(host.closed, isFalse); - await expectLater( - server.hub.kickById(idOf('host'), 'x'), throwsA(isA())); - // Same machine, no key (or a wrong one): an ordinary member. - host.send(jsonEncode(KickRequest(target: idOf('guesser')).toJson())); - await guesser.next('kicked'); - await server.hub.kickById(idOf('friend'), 'x'); - await friend.next('kicked'); + EngineMember of(String name) => + server.hub.engine.members.values.firstWhere((m) => m.name == name); + friend.send(jsonEncode(OutIntent(target: idOf('host'), out: true).toJson())); + var feed = await friend.next('feed'); + while (feed['action'] != 'out') { + feed = await friend.next('feed'); + } + expect([feed['actor'], feed['target']], ['friend', 'host']); + expect(of('host').out, isTrue); + expect(of('host').inSet, isFalse); + // Only oneself comes back. + friend.send(jsonEncode(OutIntent(target: idOf('host'), out: false).toJson())); + host.send(jsonEncode(OutIntent(target: idOf('host'), out: false).toJson())); + await Future.delayed(const Duration(milliseconds: 200)); + expect(of('host').out, isFalse); + + // 5 at once: 3 pass (per second), only to the target. + for (var i = 0; i < 5; i++) { + friend.send(jsonEncode(NudgeIntent(target: idOf('host')).toJson())); + } + await Future.delayed(const Duration(milliseconds: 300)); + final got = host.msgs.where((m) => m['t'] == 'nudge').toList(); + expect(got, hasLength(3)); + expect(got.first, {'t': 'nudge', 'from': idOf('friend'), 'name': 'friend'}); + expect(third.msgs.where((m) => m['t'] == 'nudge'), isEmpty); + expect(friend.msgs.where((m) => m['t'] == 'nudge'), isEmpty); await host.ws.close(); + await friend.ws.close(); + await third.ws.close(); + }); + + test('nudge cap: 20 per minute', () { + final v = ViewerToken(token: 't', name: 'n'); + final t0 = DateTime.utc(2026); + var passed = 0; + for (var i = 0; i < 30; i++) { + if (v.takeNudge(t0.add(Duration(milliseconds: 500 * i)))) passed++; + } + expect(passed, 20); + expect(v.takeNudge(t0.add(const Duration(seconds: 61))), isTrue); }); test('state marks the host member for everyone', () async { diff --git a/packages/server/test/media_endpoint_test.dart b/packages/server/test/media_endpoint_test.dart index 8bbe559..93c0766 100644 --- a/packages/server/test/media_endpoint_test.dart +++ b/packages/server/test/media_endpoint_test.dart @@ -54,9 +54,6 @@ void main() { resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.unauthorized) .status, 401); - expect( - resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.forbidden).status, - 403); final off = resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.offline); expect(off.status, 503); diff --git a/packages/server/test/server_auth_test.dart b/packages/server/test/server_auth_test.dart index 809b0c6..4d81d4f 100644 --- a/packages/server/test/server_auth_test.dart +++ b/packages/server/test/server_auth_test.dart @@ -133,189 +133,6 @@ void main() { await resp.drain(); }); - test('kick: WS drops and media goes 403', () async { - final ch = WebSocketChannel.connect( - Uri.parse('ws://127.0.0.1:${server.boundPort}/sync')); - final firstCompleter = Completer(); - var wsClosed = false; - final sub = ch.stream.listen( - (m) { - if (!firstCompleter.isCompleted) firstCompleter.complete('$m'); - }, - onDone: () => wsClosed = true, - ); - final msg = HelloMessage( - proto: RelayConstants.protoVersion, - secret: 'shh', - user: const HelloUser(name: 'mallory'), - media: - const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson(); - ch.sink.add(jsonEncode(msg)); - final first = - await firstCompleter.future.timeout(const Duration(seconds: 5)); - final token = (jsonDecode(first) as Map)['token'] as String; - - await server.session.kick(token); - final deadline = DateTime.now().add(const Duration(seconds: 5)); - while (!wsClosed && DateTime.now().isBefore(deadline)) { - await Future.delayed(const Duration(milliseconds: 50)); - } - expect(wsClosed, isTrue); - try { - await sub.cancel(); - } catch (_) {} - try { - await ch.sink.close(); - } catch (_) {} - - final name = file.path.split(Platform.pathSeparator).last; - final resp = await mediaGet('/m/default/$token/$name'); - expect(resp.statusCode, 403); - await resp.drain(); - }); - - test('kick delivers reason then drops; instant rejoin works', () async { - final ch = WebSocketChannel.connect( - Uri.parse('ws://127.0.0.1:${server.boundPort}/sync')); - final messages = []; - var wsClosed = false; - final sub = ch.stream.listen( - (m) => messages.add('$m'), - onDone: () => wsClosed = true, - ); - HelloMessage helloFor(String who) => HelloMessage( - proto: RelayConstants.protoVersion, - secret: 'shh', - user: HelloUser(name: who), - media: const HelloMedia( - name: 'f.bin', size: 8192, duration: 10.0)); - ch.sink.add(jsonEncode(helloFor(' sleepy').toJson())); - Map? welcome; - final welcomeDeadline = DateTime.now().add(const Duration(seconds: 5)); - while (DateTime.now().isBefore(welcomeDeadline)) { - for (final m in messages) { - final decoded = jsonDecode(m) as Map; - if (decoded['t'] == 'welcome') { - welcome = Map.from(decoded); - } - } - if (welcome != null) break; - await Future.delayed(const Duration(milliseconds: 50)); - } - expect(welcome, isNotNull); - final token = welcome!['token'] as String; - - await server.session.kick(token, reason: 'idle too long'); - final deadline = DateTime.now().add(const Duration(seconds: 5)); - Map? kicked; - while (DateTime.now().isBefore(deadline)) { - for (final m in messages.skip(1)) { - final decoded = jsonDecode(m) as Map; - if (decoded['t'] == 'kicked') { - kicked = Map.from(decoded); - } - } - if (wsClosed && kicked != null) break; - await Future.delayed(const Duration(milliseconds: 50)); - } - expect(wsClosed, isTrue); - expect(kicked, isNotNull); - expect(kicked!['reason'], 'idle too long'); - try { - await sub.cancel(); - } catch (_) {} - try { - await ch.sink.close(); - } catch (_) {} - - final rejoin = await hello('shh'); - final decoded = jsonDecode(rejoin) as Map; - expect(decoded['t'], 'welcome'); - final token2 = decoded['token'] as String; - expect(token2, isNot(token)); - final name = file.path.split(Platform.pathSeparator).last; - final resp = await mediaGet('/m/default/$token2/$name'); - expect(resp.statusCode, 200); - await resp.drain(); - }); - - test('kick via socket: peer kicked, unknown target errors', () async { - Future<(WebSocketChannel, String, String, List)> join(String who, - [String host = '127.0.0.1']) async { - final ch = WebSocketChannel.connect( - Uri.parse('ws://$host:${server.boundPort}/sync')); - final messages = []; - ch.stream.listen((m) => messages.add('$m')); - final msg = HelloMessage( - proto: RelayConstants.protoVersion, - secret: 'shh', - user: HelloUser(name: who), - media: const HelloMedia( - name: 'f.bin', size: 8192, duration: 10.0)).toJson(); - ch.sink.add(jsonEncode(msg)); - Map? welcome; - final deadline = DateTime.now().add(const Duration(seconds: 5)); - while (DateTime.now().isBefore(deadline)) { - for (final m in messages) { - final decoded = jsonDecode(m) as Map; - if (decoded['t'] == 'welcome') { - welcome = Map.from(decoded); - } - } - if (welcome != null) break; - await Future.delayed(const Duration(milliseconds: 50)); - } - expect(welcome, isNotNull, - reason: 'no welcome for $who: $messages'); - final w = welcome!; - return (ch, w['token'] as String, w['memberId'] as String, messages); - } - - Map? findType(List messages, String type) { - for (final m in messages) { - final decoded = jsonDecode(m) as Map; - if (decoded['t'] == type) return Map.from(decoded); - } - return null; - } - - Future?> waitType( - List messages, String type, Duration timeout) async { - final deadline = DateTime.now().add(timeout); - while (DateTime.now().isBefore(deadline)) { - final found = findType(messages, type); - if (found != null) return found; - await Future.delayed(const Duration(milliseconds: 50)); - } - return findType(messages, type); - } - - final (a, _, _, messagesA) = await join('alice'); - final (b, tokenB, idB, messagesB) = await join('bob'); - try { - a.sink.add(jsonEncode( - KickRequest(target: idB, reason: 'afk?').toJson())); - final kicked = await waitType( - messagesB, 'kicked', const Duration(seconds: 5)); - expect(kicked, isNotNull); - expect(kicked!['reason'], 'afk?'); - expect(server.session.gate(tokenB), MediaGate.forbidden); - - a.sink.add( - jsonEncode(const KickRequest(target: 'm99').toJson())); - final err = await waitType( - messagesA, 'err', const Duration(seconds: 5)); - expect(err, isNotNull); - } finally { - try { - await a.sink.close(); - } catch (_) {} - try { - await b.sink.close(); - } catch (_) {} - } - }); - test('silent socket dropped and member removed', () async { final ch = WebSocketChannel.connect( Uri.parse('ws://127.0.0.1:${server.boundPort}/sync')); @@ -557,11 +374,6 @@ void main() { ProfileIntent(avatar: 'x' * (RelayConstants.maxAvatarBytes + 1)).toJson())); final err = await waitFor((m) => m['t'] == 'err'); expect(err['message'], 'rejected: avatar too large'); - - await server.session.kick(token); - final kicked = await avatarGet(token, hash); - expect(kicked.statusCode, 403); - await kicked.drain(); } finally { await sub.cancel(); try { diff --git a/packages/server/test/session_test.dart b/packages/server/test/session_test.dart index 903a292..382e8ab 100644 --- a/packages/server/test/session_test.dart +++ b/packages/server/test/session_test.dart @@ -21,22 +21,6 @@ void main() { expect(s.gate(t), MediaGate.ok); }); - test('kick unknown throws, kick known forbids + drops', () async { - final s = Session(id: 'default', secret: 'shh'); - expect(() => s.kick('nope'), throwsA(isA())); - final t = s.issueToken('mallory', ''); - var dropped = false; - String? gotReason; - s.tokens[t]!.onKick = (reason) async { - dropped = true; - gotReason = reason; - }; - await s.kick(t, reason: 'idle'); - expect(s.gate(t), MediaGate.forbidden); - expect(dropped, isTrue); - expect(gotReason, 'idle'); - }); - test('oversize avatar rejected', () { final s = Session(id: 'default', secret: 'shh'); final big = 'x' * (RelayConstants.maxAvatarBytes + 1); @@ -122,18 +106,6 @@ void main() { expect(() => WelcomeMessage.fromJson({'t': 'hello'}), throwsA(isA())); }); - test('kicked + kick request round-trip', () { - const k = KickedMessage(reason: 'idle'); - final back = KickedMessage.fromJson(Map.from(k.toJson())); - expect(back.reason, 'idle'); - const r = KickRequest(target: 'abc', reason: 'afk?'); - final backR = KickRequest.fromJson(Map.from(r.toJson())); - expect(backR.target, 'abc'); - expect(backR.reason, 'afk?'); - expect(() => KickedMessage.fromJson({'t': 'err'}), throwsA(isA())); - expect(() => KickRequest.fromJson({'t': 'err'}), throwsA(isA())); - }); - test('lastSeen touch + stale helper', () { final s = Session(id: 'default', secret: 'shh'); final t = s.issueToken('alice', ''); diff --git a/packages/server/test/sync_wave_test.dart b/packages/server/test/sync_wave_test.dart index 5bc765d..fe86594 100644 --- a/packages/server/test/sync_wave_test.dart +++ b/packages/server/test/sync_wave_test.dart @@ -127,17 +127,10 @@ void main() { await second.ws.close(); }); - test('unknown or kicked resume token joins as a new member', () async { + test('unknown resume token joins as a new member', () async { final (a, wa) = await _Client.join(server.boundPort, 'a', resume: 'nope'); expect(wa.token, isNot('nope')); - final (b, wb) = await _Client.join(server.boundPort, 'b'); - await server.hub.kickById(wb.memberId, 'bye'); - final (c, wc) = await _Client.join(server.boundPort, 'b', resume: wb.token); - expect(wc.token, isNot(wb.token)); - expect(wc.memberId, isNot(wb.memberId)); await a.ws.close(); - await b.ws.close(); - await c.ws.close(); }); test('ping gets a pong with the client stamp and the server clock', () async {