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 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 21:05:35 +03:00
1 parent dbfd351ee4
commit 8b81da4069
46 files changed
+855 -599

No files matched your search

+19 -7
View File
@@ -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 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 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, with a ring for their state (green ready, amber buffering, grey not ready,
dimmed reconnecting; a crown on whoever hosts, a tower on whoever dimmed reconnecting, dimmed with a moon sitting out; a crown on whoever
passes the video on). Click the avatars for the member list (force ready, hosts, a tower on whoever passes the video on). Click the avatars for the
kick, "Copy invite address"; when you host it lists this computer's 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 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 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. count, and Leave. Empty strip space drags the window.
The player fills the window under the strip. Pick a tab and it shrinks to 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 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 corner; double-click or ⤢ makes it big again, × sits you out (below) and
the server, muted) and the strip's screen button brings it back. F or × 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 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 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 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 Your name and picture change for everyone in the server right away; the
colour is yours only. 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 from the app
Host in the strip: set a port and an optional password (blank = anyone can 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 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 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 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 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 | | `--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 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 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 [deploy/watch-together.service](deploy/watch-together.service) (copy it to
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`). `/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`).
@@ -332,13 +332,13 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
/// Who performs the auto-advance swap, so several counting clients do not /// 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 /// 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 /// 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. /// late joiner never saw that swap), so all clients agree.
String? autoAdvanceSwapper(List<MemberView> members, String serverId) { String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
final eligible = [ final eligible = [
for (final m in members) 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; if (eligible.isEmpty) return null;
eligible.sort((a, b) => compareMemberIds(a.id, b.id)); eligible.sort((a, b) => compareMemberIds(a.id, b.id));
@@ -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<void> requestWindowAttention() async {
try {
await _window.invokeMethod<void>('attention');
} on MissingPluginException {
// Tests, other platforms.
} catch (e) {
debugPrint('window attention: $e');
}
}
@@ -23,10 +23,6 @@ class ConnectFlow extends ChangeNotifier {
int _attempt = 0; int _attempt = 0;
bool _disposed = false; bool _disposed = false;
/// Set when an attempt ends in a kick, so the shell shows the removal
/// dialog.
void Function(String reason)? onKicked;
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;
@@ -110,12 +106,7 @@ class ConnectFlow extends ChangeNotifier {
} catch (e) { } catch (e) {
if (myAttempt != _attempt) return; if (myAttempt != _attempt) return;
busy = null; busy = null;
final kick = _session.coordinator.kickReason.peek(); errors[mode] = CardError('Could not connect', '$e');
if (kick != null && kick.isNotEmpty) {
onKicked?.call(kick);
} else {
errors[mode] = CardError('Could not connect', '$e');
}
_notify(); _notify();
return; return;
} }
@@ -13,6 +13,7 @@ import '../jellyfin/session_media.dart';
import '../jellyfin/track_prefs.dart'; import '../jellyfin/track_prefs.dart';
import '../playback/media_marks.dart'; import '../playback/media_marks.dart';
import '../playback/playback_controller.dart'; import '../playback/playback_controller.dart';
import '../platform/attention.dart';
import '../playback/yt_dlp_app.dart'; import '../playback/yt_dlp_app.dart';
import '../schedule/anilist.dart'; import '../schedule/anilist.dart';
import '../screens/home/picked_media.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 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 /// "Unready when the video changes": a state with a newer [mediaId] than
/// the last one this session saw ([lastMediaId], -1 before the first) while /// the last one this session saw ([lastMediaId], -1 before the first) while
@@ -180,7 +181,6 @@ class SessionCoordinator {
final feedLog = signal<List<FeedMessage>>([]); final feedLog = signal<List<FeedMessage>>([]);
final toasts = signal<List<ToastItem>>([]); final toasts = signal<List<ToastItem>>([]);
final reconnecting = signal(false); final reconnecting = signal(false);
final kickReason = signal<String?>(null);
final starving = signal<String?>(null); final starving = signal<String?>(null);
/// The current media's Jellyfin ref (null for links and files). /// The current media's Jellyfin ref (null for links and files).
@@ -226,10 +226,16 @@ class SessionCoordinator {
return null; return null;
}); });
/// Self sits out: the player has nothing open until [joinBack].
late final Computed<bool> selfOut = computed(() => selfMember.value?.out ?? false);
/// Asks the desktop to point at the window (a nudge); tests swap it.
Future<void> Function() requestAttention = requestWindowAttention;
Timer? _ticker; Timer? _ticker;
StreamSubscription? _stateSub; StreamSubscription? _stateSub;
StreamSubscription? _feedSub; StreamSubscription? _feedSub;
StreamSubscription? _kickSub; StreamSubscription? _nudgeSub;
StreamSubscription? _errSub; StreamSubscription? _errSub;
StreamSubscription? _statusSub; StreamSubscription? _statusSub;
@@ -242,6 +248,13 @@ class SessionCoordinator {
// Set after a reconnect until the server echoes the restored ready, so // Set after a reconnect until the server echoes the restored ready, so
// the fresh member's ready=false doesn't overwrite [_lastReady]. // the fresh member's ready=false doesn't overwrite [_lastReady].
bool _restoringReady = false; 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 // Last mediaId seen in a state; unlike [_mediaId] it survives a
// reconnect, so only a real swap counts as a new video. // reconnect, so only a real swap counts as a new video.
int _seenMediaId = -1; int _seenMediaId = -1;
@@ -372,7 +385,6 @@ class SessionCoordinator {
if (epoch != _epoch) throw const ConnectCancelled(); if (epoch != _epoch) throw const ConnectCancelled();
_params = params; _params = params;
_manualClose = false; _manualClose = false;
kickReason.value = null;
_wsHost = params.host; _wsHost = params.host;
_wsPort = params.port; _wsPort = params.port;
diff.reset(); diff.reset();
@@ -380,6 +392,8 @@ class SessionCoordinator {
_openUrl = null; _openUrl = null;
_lastReady = false; _lastReady = false;
_restoringReady = false; _restoringReady = false;
_lastOut = false;
_joinBackAt = null;
_seenMediaId = -1; _seenMediaId = -1;
_wasPlayAllowed = false; _wasPlayAllowed = false;
_everConnected = false; _everConnected = false;
@@ -443,12 +457,12 @@ class SessionCoordinator {
void _subscribe() { void _subscribe() {
_stateSub ??= sync.states.listen(_onState); _stateSub ??= sync.states.listen(_onState);
_feedSub ??= sync.feeds.listen(_onFeed); _feedSub ??= sync.feeds.listen(_onFeed);
_kickSub ??= sync.kicked.listen(_onKicked); _nudgeSub ??= sync.nudges.listen(_onNudge);
_errSub ??= sync.errors.listen((msg) { _errSub ??= sync.errors.listen((msg) {
health.value = HealthStatus(HealthSeverity.warn, msg); health.value = HealthStatus(HealthSeverity.warn, msg);
}); });
_statusSub ??= sync.status.listen((s) { _statusSub ??= sync.status.listen((s) {
if (s == SyncStatus.closed && !_manualClose && kickReason.peek() == null) { if (s == SyncStatus.closed && !_manualClose) {
health.value = const HealthStatus( health.value = const HealthStatus(
HealthSeverity.warn, 'connection lost — retrying…'); HealthSeverity.warn, 'connection lost — retrying…');
_soundLost(); _soundLost();
@@ -613,6 +627,12 @@ class SessionCoordinator {
Future<void> _applyState(StateBroadcast r) async { Future<void> _applyState(StateBroadcast r) async {
_trackSelfReady(r); _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); _seekGate.observe(r, sync.memberId);
final newEvent = r.eventId != _lastEventId; final newEvent = r.eventId != _lastEventId;
_lastEventId = r.eventId; _lastEventId = r.eventId;
@@ -740,6 +760,7 @@ class SessionCoordinator {
} else { } else {
_lastReady = self.ready; _lastReady = self.ready;
} }
_lastOut = self.out;
final last = _seenMediaId; final last = _seenMediaId;
_seenMediaId = r.mediaId; _seenMediaId = r.mediaId;
// A ready restored by a reconnect may not have echoed yet: unready // 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<void> _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) { void _updateStarving(StateBroadcast r, double bufferedAhead) {
if (r.transport == 'playing') { if (r.transport == 'playing') {
starving.value = null; starving.value = null;
@@ -773,14 +815,15 @@ class SessionCoordinator {
} }
final self = selfMember.peek(); final self = selfMember.peek();
if (self != null && self.ready) { 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)) .where((m) => m.id != self.id && !m.stale && !(m.inSet && m.ready))
.map((m) => m.name) .map((m) => m.name)
.toList(); .toList();
if (waiters.isNotEmpty) { if (waiters.isNotEmpty) {
final readyCount = r.members.where((m) => m.ready).length; final readyCount = present.where((m) => m.ready).length;
starving.value = starving.value =
'waiting for ${waiters.join(', ')} ($readyCount/${r.members.length} ready)'; 'waiting for ${waiters.join(', ')} ($readyCount/${present.length} ready)';
return; return;
} }
} }
@@ -809,9 +852,15 @@ class SessionCoordinator {
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor) 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); final kind = sfxForFeedAction(f.action);
// Your own message needs no sound. // 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) { if (kind != null && !own) {
var readyIndex = 0; var readyIndex = 0;
if (f.action == 'ready') { if (f.action == 'ready') {
@@ -827,8 +876,6 @@ class SessionCoordinator {
return '${f.actor} joined'; return '${f.actor} joined';
case 'leave': case 'leave':
return '${f.actor} left'; return '${f.actor} left';
case 'kick':
return '${f.actor} kicked ${f.target ?? ''}';
case 'swap': case 'swap':
return '${f.actor} changed media'; return '${f.actor} changed media';
default: default:
@@ -836,26 +883,13 @@ class SessionCoordinator {
} }
} }
Future<void> _onKicked(String reason) async { /// Someone poked this member: sound (even when its switch is off; the
kickReason.value = reason; /// volume still applies), a toast, and the window asks for attention.
_manualClose = true; void _onNudge(NudgeNotice n) {
_epoch++; final name = n.name.isEmpty ? 'Someone' : n.name;
_reconnectTimer?.cancel(); _pushToast('$name nudged you', actor: n.name.isEmpty ? null : n.name);
_reconnectTimer = null; unawaited(sfx.play(const SfxCue(SfxKind.nudge)));
reconnecting.value = false; unawaited(requestAttention());
_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');
} }
void _startTicker() { void _startTicker() {
@@ -871,20 +905,22 @@ class SessionCoordinator {
if (sync.token.isNotEmpty) sync.sendPing(); if (sync.token.isNotEmpty) sync.sendPing();
if (sync.token.isNotEmpty && r != null && _playerReady) { if (sync.token.isNotEmpty && r != null && _playerReady) {
// Duration only once the open media is the server's current one. // 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( sync.sendReport(
position: playback.positionSecs, position: _lastOut ? 0 : playback.positionSecs,
buffered: playback.bufferedSecsAhead, buffered: _lastOut ? 0 : playback.bufferedSecsAhead,
eventId: r.eventId, eventId: r.eventId,
duration: known ? playback.durationSecs : 0, duration: known ? playback.durationSecs : 0,
mediaId: known ? r.mediaId : 0, mediaId: known ? r.mediaId : 0,
jf: _jfAccessFor(r), jf: _jfAccessFor(r),
); );
_checkSeekLanded(r); if (!_lastOut) {
unawaited(_tickJellyfin(r)); _checkSeekLanded(r);
unawaited(_tickJellyfin(r));
}
} }
if (!_manualClose && if (!_manualClose &&
kickReason.peek() == null &&
_lastStateAt != null && _lastStateAt != null &&
now.difference(_lastStateAt!).inSeconds >= 5) { now.difference(_lastStateAt!).inSeconds >= 5) {
if (serverState.peek() != null) { if (serverState.peek() != null) {
@@ -905,6 +941,8 @@ class SessionCoordinator {
// No warning when there is nothing to lose (unready), and none while // No warning when there is nothing to lose (unready), and none while
// PLAYING: the ready flag gates nothing then, and watching counts as // PLAYING: the ready flag gates nothing then, and watching counts as
// activity so a pause after a long watch starts a fresh idle clock. // 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, final event = _idle.tick(now,
playing: serverState.peek()?.transport == 'playing', playing: serverState.peek()?.transport == 'playing',
selfReady: selfMember.peek()?.ready ?? false); selfReady: selfMember.peek()?.ready ?? false);
@@ -915,18 +953,46 @@ class SessionCoordinator {
...toasts.peek(), ...toasts.peek(),
ToastItem( ToastItem(
id: _toastId, 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) at: now)
]; ];
unawaited(sfx.play(const SfxCue(SfxKind.warn))); unawaited(sfx.play(const SfxCue(SfxKind.warn)));
case IdleEvent.unready: case IdleEvent.unready:
_lastReady = false; _lastReady = false;
sync.sendReady(false, source: 'auto'); if (sitOutWhenIdle) {
sitOut();
} else {
sync.sendReady(false, source: 'auto');
}
case IdleEvent.none: case IdleEvent.none:
break; 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() { void _clearPendingSeek() {
_pendingSeekEvent = null; _pendingSeekEvent = null;
_pendingSeekAt = null; _pendingSeekAt = null;
@@ -993,6 +1059,10 @@ class SessionCoordinator {
void toggleReady() { void toggleReady() {
final self = selfMember.peek(); final self = selfMember.peek();
if (self?.out ?? false) {
joinBack();
return;
}
// Ready but out of the set (dropped out while stale) gates like // Ready but out of the set (dropped out while stale) gates like
// unready, so Space puts self back in instead of unreadying. // unready, so Space puts self back in instead of unreadying.
final next = !((self?.ready ?? false) && (self?.inSet ?? false)); final next = !((self?.ready ?? false) && (self?.inSet ?? false));
@@ -1080,12 +1150,43 @@ class SessionCoordinator {
sync.sendProfile(name: name, avatar: avatar); 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(); 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) { 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); _pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0);
sync.sendSwap(media); sync.sendSwap(media);
} }
@@ -1266,11 +1367,11 @@ class SessionCoordinator {
/// results), shown like the session's own. /// results), shown like the session's own.
void showToast(String text) => _pushToast(text); void showToast(String text) => _pushToast(text);
void _pushToast(String text) { void _pushToast(String text, {String? actor}) {
_toastId++; _toastId++;
toasts.value = [ toasts.value = [
...toasts.peek(), ...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)); 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<void> _stopJellyfin() async { Future<void> _stopJellyfin() async {
final reporter = _reporter; final reporter = _reporter;
final snap = _snapNow(); final snap = _snapNow();
@@ -1592,7 +1693,7 @@ class SessionCoordinator {
} }
void _scheduleReconnect({bool manual = false}) { void _scheduleReconnect({bool manual = false}) {
if (_manualClose || kickReason.peek() != null || _params == null) return; if (_manualClose || _params == null) return;
if (!manual && !_everConnected) return; if (!manual && !_everConnected) return;
if (reconnecting.peek()) return; if (reconnecting.peek()) return;
reconnecting.value = true; reconnecting.value = true;
@@ -1607,14 +1708,15 @@ class SessionCoordinator {
/// would kill the newer connection and leave a ghost member). /// would kill the newer connection and leave a ghost member).
Future<void> _reconnect(int epoch) async { Future<void> _reconnect(int epoch) async {
_reconnectTimer = null; _reconnectTimer = null;
bool stale() => epoch != _epoch || kickReason.peek() != null; bool stale() => epoch != _epoch;
final params = _params; final params = _params;
if (stale() || params == null) return; if (stale() || params == null) return;
if (_manualClose) { if (_manualClose) {
reconnecting.value = false; reconnecting.value = false;
return; return;
} }
var wantReady = _lastReady; final wasOut = _lastOut;
var wantReady = _lastReady && !wasOut;
final seenBefore = _seenMediaId; final seenBefore = _seenMediaId;
try { try {
health.value = const HealthStatus( health.value = const HealthStatus(
@@ -1655,6 +1757,10 @@ class SessionCoordinator {
wantReady = false; wantReady = false;
_lastReady = 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) { if (wantReady) {
_lastReady = true; _lastReady = true;
_restoringReady = true; _restoringReady = true;
@@ -1700,7 +1806,7 @@ class SessionCoordinator {
} }
void retry() { void retry() {
if (kickReason.peek() != null || _params == null) return; if (_params == null) return;
_invalidateAttempts(); _invalidateAttempts();
_manualClose = false; _manualClose = false;
reconnecting.value = false; reconnecting.value = false;
@@ -1761,7 +1867,7 @@ class SessionCoordinator {
} }
await _stateSub?.cancel(); await _stateSub?.cancel();
await _feedSub?.cancel(); await _feedSub?.cancel();
await _kickSub?.cancel(); await _nudgeSub?.cancel();
await _errSub?.cancel(); await _errSub?.cancel();
await _statusSub?.cancel(); await _statusSub?.cancel();
_aniList?.close(); _aniList?.close();
+1 -1
View File
@@ -8,6 +8,7 @@ enum SfxKind {
forceReady, forceReady,
join, join,
leave, leave,
/// Someone sat you out (played by the session, not from a feed action).
kick, kick,
/// A new video or episode is put up. /// A new video or episode is put up.
swap, swap,
@@ -31,7 +32,6 @@ SfxKind? sfxForFeedAction(String action) => switch (action) {
'ready' => SfxKind.ready, 'ready' => SfxKind.ready,
'unready' => SfxKind.unready, 'unready' => SfxKind.unready,
'force-ready' => SfxKind.forceReady, 'force-ready' => SfxKind.forceReady,
'kick' => SfxKind.kick,
'join' => SfxKind.join, 'join' => SfxKind.join,
'leave' => SfxKind.leave, 'leave' => SfxKind.leave,
'swap' => SfxKind.swap, 'swap' => SfxKind.swap,
+2 -2
View File
@@ -258,11 +258,11 @@ const soundInfo = {
SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'), SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'),
SfxKind.join: ('Joined', 'Someone joins the server'), SfxKind.join: ('Joined', 'Someone joins the server'),
SfxKind.leave: ('Left', 'Someone leaves'), 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.swap: ('New video', 'A different video or episode is put up'),
SfxKind.chat: ('Message', 'A chat message from someone else'), SfxKind.chat: ('Message', 'A chat message from someone else'),
SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'), SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'),
SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'), SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'),
SfxKind.back: ('Reconnected', 'Back in the server after a drop'), 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)'),
}; };
+16 -39
View File
@@ -112,9 +112,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// The next media to come up opens full (just joined, or put up here). /// The next media to come up opens full (just joined, or put up here).
bool _autoFull = false; bool _autoFull = false;
int _lastMediaId = -1; int _lastMediaId = -1;
double? _mutedVolume;
bool _dialogOpen = false; bool _dialogOpen = false;
String? _lastKickShown;
final _stops = <void Function()>[]; final _stops = <void Function()>[];
// Rebuild the shell on these flips, not on every state broadcast. // Rebuild the shell on these flips, not on every state broadcast.
final _online = computed(() => SessionState.instance.route.value == AppRoute.session); final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
@@ -140,7 +138,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// Reads linked servers and checks their tokens in the background. // Reads linked servers and checks their tokens in the background.
_jellyfin.ensureLoaded(); _jellyfin.ensureLoaded();
if (widget.autoUpdateCheck) _autoCheck(); if (widget.autoUpdateCheck) _autoCheck();
_flow.onKicked = _showKicked;
HardwareKeyboard.instance.addHandler(_onKey); HardwareKeyboard.instance.addHandler(_onKey);
final session = SessionState.instance; final session = SessionState.instance;
final coord = session.coordinator; final coord = session.coordinator;
@@ -181,17 +178,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (!_chatOpen) setState(() => _unread += chats); 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(() { _stops.add(effect(() {
final kick = coord.kickReason.value; final out = coord.selfOut.value;
if (kick == null) { if (wasOut && !out && session.route.peek() == AppRoute.session) {
_lastKickShown = null; _later(() {
return; if (_mode == PlayerMode.none) _show(PlayerMode.mini);
});
} }
untracked(() { wasOut = out;
if (session.route.value == AppRoute.session) session.route.value = AppRoute.home;
});
if (kick.isNotEmpty) _later(() => _showKicked(kick));
})); }));
// The hosted server died mid-session (SessionState leaves): say so. // The hosted server died mid-session (SessionState leaves): say so.
_stops.add(effect(() { _stops.add(effect(() {
@@ -242,7 +239,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
} }
void _wentOffline() { void _wentOffline() {
_mutedVolume = null;
if (_fullscreen) _toggleFullscreen(); if (_fullscreen) _toggleFullscreen();
setState(() { setState(() {
_chatOpen = false; _chatOpen = false;
@@ -258,7 +254,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
Future<void> _show(PlayerMode m) async { Future<void> _show(PlayerMode m) async {
if (m == _mode) return; if (m == _mode) return;
final seq = ++_modeSeq; final seq = ++_modeSeq;
if (m != PlayerMode.none) _unmute();
setState(() { setState(() {
_from = _shown; _from = _shown;
_mode = m; _mode = m;
@@ -282,27 +277,15 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_show(PlayerMode.mini); _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() { void _closePlayer() {
final playback = SessionState.instance.coordinator.playback; final coord = SessionState.instance.coordinator;
if (playback.alive.peek()) { if (coord.selfOut.peek()) {
try { _show(PlayerMode.none);
_mutedVolume = playback.player.state.volume; } else {
playback.player.setVolume(0); coord.sitOut();
} catch (_) {}
} }
_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<void> _setWindowFullscreen(bool on) async { Future<void> _setWindowFullscreen(bool on) async {
@@ -520,12 +503,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (ok == true) await SessionState.instance.leave(); if (ok == true) await SessionState.instance.leave();
} }
Future<void> _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<void> _alert(String title, String text) async { Future<void> _alert(String title, String text) async {
if (!mounted || _dialogOpen) return; if (!mounted || _dialogOpen) return;
_dialogOpen = true; _dialogOpen = true;
+21 -4
View File
@@ -17,6 +17,8 @@ class AvatarInfo {
final Color color; final Color color;
final ImageProvider? image; final ImageProvider? image;
final bool ready, buffering, relaying, stale, host, self; final bool ready, buffering, relaying, stale, host, self;
/// Sitting out: dimmed, with an "away" mark.
final bool out;
const AvatarInfo({ const AvatarInfo({
this.id = '', this.id = '',
required this.name, required this.name,
@@ -28,6 +30,7 @@ class AvatarInfo {
this.stale = false, this.stale = false,
this.host = false, this.host = false,
this.self = 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). /// 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 p = Profile.instance;
final png = p.picture.value; final png = p.picture.value;
final name = p.name.value; final name = p.name.value;
@@ -86,6 +89,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
ready: ready, ready: ready,
host: host, host: host,
self: true, self: true,
out: out,
); );
} }
@@ -107,6 +111,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
// knows before the first state. // knows before the first state.
host: m.host || (self && coord.hostFile != null), host: m.host || (self && coord.hostFile != null),
self: self, 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 /// Circle avatar with a status ring outside it and a gap in the background
/// colour, so overlapping neighbours never hide the status. Crown = host, /// 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 { class StatusAvatar extends StatelessWidget {
final AvatarInfo info; final AvatarInfo info;
final double size; final double size;
@@ -176,7 +182,7 @@ class StatusAvatar extends StatelessWidget {
decoration: BoxDecoration(shape: BoxShape.circle, color: bg), decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
padding: EdgeInsets.all(stroke), padding: EdgeInsets.all(stroke),
child: Opacity( child: Opacity(
opacity: info.stale ? 0.4 : 1, opacity: info.stale || info.out ? 0.4 : 1,
child: CircleAvatar( child: CircleAvatar(
radius: size / 2, radius: size / 2,
backgroundColor: info.color, 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) if (badges && info.relaying)
Positioned( Positioned(
right: -3, right: -3,
@@ -240,7 +257,7 @@ class _AvatarStackState extends State<AvatarStack> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final size = widget.size; 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 sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
final visible = sorted.take(widget.shown).toList(); final visible = sorted.take(widget.shown).toList();
final extra = sorted.length - visible.length; final extra = sorted.length - visible.length;
+5 -3
View File
@@ -232,9 +232,11 @@ class ActivityRow extends StatelessWidget {
return (Icons.swap_horiz, scheme.primary, 'changed the media'); return (Icons.swap_horiz, scheme.primary, 'changed the media');
case 'stale': case 'stale':
return (Icons.cloud_off, Colors.orange, 'lost signal'); return (Icons.cloud_off, Colors.orange, 'lost signal');
case 'kick': case 'out':
final reason = f.reason.isEmpty ? '' : ' · ${f.reason}'; final who = f.target;
return (Icons.gavel, Colors.red, 'kicked ${f.target ?? ''}$reason'); 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: default:
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}'; final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
return (Icons.info_outline, Colors.grey, '${f.action}$target'); return (Icons.info_outline, Colors.grey, '${f.action}$target');
+23 -18
View File
@@ -11,7 +11,6 @@ import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
import '../widgets/app_keys.dart'; import '../widgets/app_keys.dart';
import '../widgets/dialogs.dart';
import '../widgets/window_buttons.dart'; import '../widgets/window_buttons.dart';
import 'avatar.dart'; import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
@@ -337,7 +336,8 @@ class StripTab extends StatelessWidget {
} }
/// Who is in the server, from the avatars: status, host / relay pills, /// 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<void> showMembersCard(BuildContext context) => showPopover<void>( Future<void> showMembersCard(BuildContext context) => showPopover<void>(
context, context,
at: const Alignment(0.7, -0.84), at: const Alignment(0.7, -0.84),
@@ -469,6 +469,8 @@ class _MemberTile extends StatelessWidget {
final m = member; final m = member;
final status = m.stale final status = m.stale
? 'reconnecting…' ? 'reconnecting…'
: m.out
? 'sitting out'
: info.buffering : info.buffering
? 'buffering ${m.buffered.toStringAsFixed(1)} s' ? 'buffering ${m.buffered.toStringAsFixed(1)} s'
: m.ready : m.ready
@@ -494,27 +496,30 @@ class _MemberTile extends StatelessWidget {
trailing: PopupMenuButton<String>( trailing: PopupMenuButton<String>(
tooltip: 'More', tooltip: 'More',
icon: const Icon(Icons.more_vert, size: 18), icon: const Icon(Icons.more_vert, size: 18),
onSelected: (v) async { onSelected: (v) {
if (v == 'force') { switch (v) {
coord.forceTarget(m.id, !m.ready); case 'force':
} else if (v == 'kick') { coord.forceTarget(m.id, !m.ready);
final ok = await showKickConfirmDialog(context, m.name); case 'nudge':
if (ok == true) coord.kick(m.id); coord.nudgeMember(m.id);
case 'out':
coord.sitOut(m.id);
case 'back':
coord.joinBack();
} }
}, },
itemBuilder: (_) => [ itemBuilder: (_) => [
PopupMenuItem( if (!m.out)
value: 'force',
child: Text(m.ready ? 'Force unready' : 'Force ready'),
),
if (!info.self && !info.host)
PopupMenuItem( PopupMenuItem(
value: 'kick', value: 'force',
child: Text( child: Text(m.ready ? 'Force unready' : 'Force ready'),
'Kick',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
), ),
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')),
], ],
), ),
); );
+5 -10
View File
@@ -11,7 +11,7 @@ import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
/// What the player's status pill says, most pressing first. /// 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 pill's button: stop reconnecting, retry, or mark yourself ready
/// (the whole pill). /// (the whole pill).
@@ -59,16 +59,10 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
StatusView? playerStatusFor({ StatusView? playerStatusFor({
required HealthStatus health, required HealthStatus health,
bool reconnecting = false, bool reconnecting = false,
String? kickReason,
StateBroadcast? state, StateBroadcast? state,
MemberView? self, MemberView? self,
bool slowConnect = false, 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) { if (reconnecting) {
return const StatusView(StatusKind.reconnecting, 'Reconnecting…', return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
tone: HealthSeverity.warn, action: StatusAction.cancel); tone: HealthSeverity.warn, action: StatusAction.cancel);
@@ -82,6 +76,8 @@ StatusView? playerStatusFor({
} }
final r = state; final r = state;
if (r == null || r.media == null || r.transport == 'playing') return null; 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') { if (r.transport == 'finished') {
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: '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", 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'); 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 = [ final others = [
for (final m in active) for (final m in active)
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m, if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
@@ -117,7 +114,6 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
return playerStatusFor( return playerStatusFor(
health: c.health.value, health: c.health.value,
reconnecting: c.reconnecting.value, reconnecting: c.reconnecting.value,
kickReason: c.kickReason.value,
state: c.serverState.value, state: c.serverState.value,
self: c.selfMember.value, self: c.selfMember.value,
slowConnect: slowConnect, slowConnect: slowConnect,
@@ -258,7 +254,6 @@ class _StatusLine extends StatelessWidget {
Widget? _lead() { Widget? _lead() {
final v = view; final v = view;
switch (v.kind) { switch (v.kind) {
case StatusKind.kicked:
case StatusKind.problem: case StatusKind.problem:
final error = v.tone == HealthSeverity.error; final error = v.tone == HealthSeverity.error;
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded, return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
+14 -2
View File
@@ -81,7 +81,8 @@ class _ProfileCardState extends State<ProfileCard> {
final hosting = online && coord.hostFile != null; final hosting = online && coord.hostFile != null;
final color = _p.color.value; final color = _p.color.value;
final png = _p.picture.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( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -166,7 +167,8 @@ class _ProfileCardState extends State<ProfileCard> {
Pill(online ? 'in a server' : 'offline', Pill(online ? 'in a server' : 'offline',
online ? Colors.greenAccent : Colors.grey), online ? Colors.greenAccent : Colors.grey),
if (online) 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<ProfileCard> {
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(children: [ 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( _link(
Icons.extension_outlined, Icons.extension_outlined,
'Jellyfin & integrations', 'Jellyfin & integrations',
+37 -1
View File
@@ -30,6 +30,7 @@ import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
import 'now_playing_title.dart'; import 'now_playing_title.dart';
import 'player_status.dart'; import 'player_status.dart';
import 'sit_out_card.dart';
import 'start_banner.dart'; import 'start_banner.dart';
/// Where the session's video is shown: nowhere, the floating mini player, /// Where the session's video is shown: nowhere, the floating mini player,
@@ -298,6 +299,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (widget.fullscreen) ...[_pausedParty(), _unreadChip()], if (widget.fullscreen) ...[_pausedParty(), _unreadChip()],
] else ] else
_miniOverlay(), _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<SessionPlayer> {
), ),
); );
/// [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 /// Mini: title, expand and close on hover; ready, time and a seekable
/// bar at the bottom. A thin progress line stays while not hovered. /// bar at the bottom. A thin progress line stays while not hovered.
Widget _miniOverlay() { Widget _miniOverlay() {
@@ -823,7 +859,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
IconButton( IconButton(
focusNode: _noFocus, focusNode: _noFocus,
onPressed: widget.onClose, 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), icon: const Icon(Icons.close, color: Colors.white, size: 18),
), ),
], ],
+24 -5
View File
@@ -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: case SettingsSection.player:
return [ return [
@@ -510,8 +523,9 @@ class NotificationsInfo extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ 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'), '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('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'), line('App notices', 'Shader changes, a frozen video reloaded, the idle warning'),
], ],
@@ -617,10 +631,15 @@ class SoundSettings extends StatelessWidget {
DropdownMenuItem<String?>(value: p.name, child: Text(name)), DropdownMenuItem<String?>(value: p.name, child: Text(name)),
], ],
), ),
Switch( // A nudge always plays (an explicit poke): no switch,
key: ValueKey('sound-on-${kind.name}'), // its place kept so the rows line up.
value: !off.contains(kind.name), Visibility.maintain(
onChanged: volume == 0 ? null : (on) => s.setSoundOn(kind.name, on), 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),
),
), ),
], ],
), ),
@@ -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<SitOutCard> createState() => _SitOutCardState();
}
class _SitOutCardState extends State<SitOutCard> {
// 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,
),
],
);
}),
);
}
}
+7 -5
View File
@@ -34,11 +34,13 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
? Icons.login ? Icons.login
: rest.startsWith('left') : rest.startsWith('left')
? Icons.logout ? Icons.logout
: rest.startsWith('kicked') : rest.startsWith('sat you out')
? Icons.gavel ? Icons.bedtime
: rest.startsWith('changed') : rest.startsWith('nudged')
? Icons.swap_horiz ? Icons.notifications_active_outlined
: Icons.info_outline; : rest.startsWith('changed')
? Icons.swap_horiz
: Icons.info_outline;
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon); return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
} }
+3 -15
View File
@@ -144,7 +144,7 @@ class ServerCrashed implements Exception {
} }
/// A fresh per-hosting key: the app's own client sends it in hello to be /// 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() { String newHostKey() {
final r = Random.secure(); final r = Random.secure();
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]); 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 status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
final SfxService sfx = SynthSfx( final SfxService sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(), 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, presetFor: soundPresetFor,
); );
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx); late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
@@ -183,7 +184,6 @@ class SessionState {
@visibleForTesting @visibleForTesting
ServerIsolate? get hostedServer => _server; ServerIsolate? get hostedServer => _server;
void Function()? _stopKickWatch;
// Bumped by host/join/cancelPending: a cancelled setup notices at its // Bumped by host/join/cancelPending: a cancelled setup notices at its
// next step and unwinds instead of connecting. // next step and unwinds instead of connecting.
int _op = 0; int _op = 0;
@@ -309,7 +309,6 @@ class SessionState {
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash); if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
rethrow; rethrow;
} }
_watchKick();
route.value = AppRoute.session; route.value = AppRoute.session;
} finally { } finally {
await _saveFormQuietly(); await _saveFormQuietly();
@@ -353,15 +352,6 @@ class SessionState {
await stopServer(); 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 /// Saved whatever the outcome: a failed join is exactly when the user
/// wants the form back. /// wants the form back.
Future<void> _saveFormQuietly() async { Future<void> _saveFormQuietly() async {
@@ -387,8 +377,6 @@ class SessionState {
Future<void> stopServer() async { Future<void> stopServer() async {
coordinator.hostFile = null; coordinator.hostFile = null;
_stopKickWatch?.call();
_stopKickWatch = null;
final server = _server; final server = _server;
_server = null; _server = null;
await server?.stop(); await server?.stop();
+10
View File
@@ -25,6 +25,7 @@ const _keySoundsOff = 'soundsOff';
const _keySoundPreset = 'soundPreset'; const _keySoundPreset = 'soundPreset';
const _keySoundPresetFor = 'soundPresetFor'; const _keySoundPresetFor = 'soundPresetFor';
const _keyUnreadyOnSwap = 'unreadyOnSwap'; const _keyUnreadyOnSwap = 'unreadyOnSwap';
const _keyIdleSitsOut = 'idleSitsOut';
enum HwdecMode { auto, off, custom } enum HwdecMode { auto, off, custom }
@@ -39,6 +40,8 @@ class AppSettings {
/// Session: send ready=false when the video changes (a swap keeps the /// Session: send ready=false when the video changes (a swap keeps the
/// server's ready flags). /// server's ready flags).
final unreadyOnSwap = signal(false); 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 /// Player design: the uosc controls (`controls/uosc/`) instead of the
/// standard ones (media_kit's with our bars). Applies live. /// standard ones (media_kit's with our bars). Applies live.
final mpvControls = signal(false); final mpvControls = signal(false);
@@ -95,6 +98,8 @@ class AppSettings {
if (advance is bool) autoAdvance.value = advance; if (advance is bool) autoAdvance.value = advance;
final unready = box.get(_keyUnreadyOnSwap); final unready = box.get(_keyUnreadyOnSwap);
if (unready is bool) unreadyOnSwap.value = unready; if (unready is bool) unreadyOnSwap.value = unready;
final idleOut = box.get(_keyIdleSitsOut);
if (idleOut is bool) idleSitsOut.value = idleOut;
final mpv = box.get(_keyMpvControls); final mpv = box.get(_keyMpvControls);
if (mpv is bool) mpvControls.value = mpv; if (mpv is bool) mpvControls.value = mpv;
final hideRe = box.get(_keyHideReReleases); final hideRe = box.get(_keyHideReReleases);
@@ -156,6 +161,11 @@ class AppSettings {
_put(_keyUnreadyOnSwap, on); _put(_keyUnreadyOnSwap, on);
} }
void setIdleSitsOut(bool on) {
idleSitsOut.value = on;
_put(_keyIdleSitsOut, on);
}
void setMpvControls(bool on) { void setMpvControls(bool on) {
mpvControls.value = on; mpvControls.value = on;
_put(_keyMpvControls, on); _put(_keyMpvControls, on);
+16 -13
View File
@@ -4,7 +4,7 @@ import 'dart:convert';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:web_socket_channel/web_socket_channel.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 { class SyncClient {
/// Local clock; injectable so tests can run a client whose clock is off. /// Local clock; injectable so tests can run a client whose clock is off.
@@ -23,13 +23,14 @@ class SyncClient {
StreamSubscription? _sub; StreamSubscription? _sub;
final StreamController<StateBroadcast> _states = StreamController.broadcast(); final StreamController<StateBroadcast> _states = StreamController.broadcast();
final StreamController<FeedMessage> _feeds = StreamController.broadcast(); final StreamController<FeedMessage> _feeds = StreamController.broadcast();
final StreamController<String> _kicked = StreamController.broadcast(); final StreamController<NudgeNotice> _nudges = StreamController.broadcast();
final StreamController<String> _errors = StreamController.broadcast(); final StreamController<String> _errors = StreamController.broadcast();
final StreamController<SyncStatus> _status = StreamController.broadcast(); final StreamController<SyncStatus> _status = StreamController.broadcast();
Stream<StateBroadcast> get states => _states.stream; Stream<StateBroadcast> get states => _states.stream;
Stream<FeedMessage> get feeds => _feeds.stream; Stream<FeedMessage> get feeds => _feeds.stream;
Stream<String> get kicked => _kicked.stream; /// Someone poked this member (`nudge`).
Stream<NudgeNotice> get nudges => _nudges.stream;
Stream<String> get errors => _errors.stream; Stream<String> get errors => _errors.stream;
Stream<SyncStatus> get status => _status.stream; Stream<SyncStatus> get status => _status.stream;
@@ -153,14 +154,10 @@ class SyncClient {
// one toast, one sfx. // one toast, one sfx.
if (f.action != 'chat') _feeds.add(f); if (f.action != 'chat') _feeds.add(f);
} catch (_) {} } catch (_) {}
case 'kicked': case 'nudge':
try { try {
final msg = KickedMessage.fromJson(decoded); _nudges.add(NudgeNotice.fromJson(decoded));
_kicked.add(msg.reason); } catch (_) {}
_status.add(SyncStatus.kicked);
} catch (_) {
_kicked.add('');
}
case 'pong': case 'pong':
try { try {
final pong = PongMessage.fromJson(decoded); final pong = PongMessage.fromJson(decoded);
@@ -255,8 +252,14 @@ class SyncClient {
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson()); _send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
} }
void sendKick(String target, {String reason = ''}) { /// Sits the member [target] out, or with [out] false brings this
_send(KickRequest(target: target, reason: reason).toJson()); /// 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<void> disconnect() async { Future<void> disconnect() async {
@@ -283,7 +286,7 @@ class SyncClient {
await disconnect(); await disconnect();
await _states.close(); await _states.close();
await _feeds.close(); await _feeds.close();
await _kicked.close(); await _nudges.close();
await _errors.close(); await _errors.close();
await _status.close(); await _status.close();
} }
-22
View File
@@ -1,22 +0,0 @@
import 'package:flutter/material.dart';
Future<bool?> showKickConfirmDialog(
BuildContext context, String memberName) {
return showDialog<bool>(
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'),
),
],
),
);
}
@@ -10,6 +10,8 @@
struct _MyApplication { struct _MyApplication {
GtkApplication parent_instance; GtkApplication parent_instance;
char** dart_entrypoint_arguments; char** dart_entrypoint_arguments;
// "starsync/window" (lib/src/platform/attention.dart).
FlMethodChannel* window_channel;
}; };
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) 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))); 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. // Implements GApplication::activate.
static void my_application_activate(GApplication* application) { static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application); MyApplication* self = MY_APPLICATION(application);
@@ -88,6 +113,15 @@ static void my_application_activate(GApplication* application) {
fl_register_plugins(FL_PLUGIN_REGISTRY(view)); 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)); 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) { static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object); MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
g_clear_object(&self->window_channel);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object); G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
} }
+2 -2
View File
@@ -296,10 +296,10 @@ void main() {
findsOneWidget); findsOneWidget);
expect(find.descendant(of: find.byKey(const ValueKey('member-m2')), matching: find.text('host')), expect(find.descendant(of: find.byKey(const ValueKey('member-m2')), matching: find.text('host')),
findsNothing); 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.tap(find.descendant(of: find.byKey(const ValueKey('member-m1')), matching: find.byTooltip('More')));
await tester.pumpAndSettle(); 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 { testWidgets('hosting: Copy invite address lists this machine\'s addresses', (tester) async {
+10 -15
View File
@@ -26,8 +26,9 @@ final png = Uint8List.fromList(const [
0x42, 0x60, 0x82, 0x42, 0x60, 0x82,
]); ]);
MemberView mv(String id, String name, {bool ready = false, bool? inSet, double buffered = 30, bool stale = false}) => MemberView mv(String id, String name,
MemberView(id: id, name: name, ready: ready, inSet: inSet ?? ready, buffered: buffered, stale: stale); {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<MemberView> members, {String transport = 'held', double position = 0, int mediaId = 1}) => StateBroadcast held(List<MemberView> members, {String transport = 'held', double position = 0, int mediaId = 1}) =>
StateBroadcast( StateBroadcast(
@@ -55,17 +56,8 @@ void main() {
group('status', () { group('status', () {
final me = mv('me', 'Me', ready: true); final me = mv('me', 'Me', ready: true);
test('problems first: removed, reconnecting, health', () { test('problems first: reconnecting, health', () {
var v = playerStatusFor( var v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'),
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…'),
reconnecting: true); reconnecting: true);
expect(v!.text, 'Reconnecting…'); expect(v!.text, 'Reconnecting…');
expect(v.action, StatusAction.cancel); expect(v.action, StatusAction.cancel);
@@ -109,11 +101,14 @@ void main() {
expect(v.self, isTrue); expect(v.self, isTrue);
final w = playerStatusFor( final w = playerStatusFor(
health: ok, 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)!; self: me)!;
expect(w.kind, StatusKind.waiting); 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'); 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']); expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
}); });
+7 -7
View File
@@ -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 { timeout: const Timeout(Duration(minutes: 2)), () async {
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
Hive.init( Hive.init(
@@ -131,16 +131,16 @@ void main() {
final hostId = ((state['members'] as List) final hostId = ((state['members'] as List)
.cast<Map>() .cast<Map>()
.firstWhere((m) => m['name'] == 'host'))['id']; .firstWhere((m) => m['name'] == 'host'))['id'];
friend.ws.add(jsonEncode(KickRequest(target: '$hostId').toJson())); // Anyone may sit the host out; its player unloads, the server serves on.
final err = await friend.next('err'); friend.ws.add(jsonEncode(OutIntent(target: '$hostId', out: true).toJson()));
expect(err['message'], "can't kick the host"); 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) final friendId = ((state['members'] as List)
.cast<Map>() .cast<Map>()
.firstWhere((m) => m['name'] == 'friend'))['id']; .firstWhere((m) => m['name'] == 'friend'))['id'];
session.coordinator.kick('$friendId'); session.coordinator.nudgeMember('$friendId');
await friend.next('kicked'); expect((await friend.next('nudge'))['name'], 'host');
session.hostedServer!.isolate.kill(priority: Isolate.immediate); session.hostedServer!.isolate.kill(priority: Isolate.immediate);
await _poll(() => session.route.peek() == AppRoute.home, 'leave after crash'); await _poll(() => session.route.peek() == AppRoute.home, 'leave after crash');
+5 -5
View File
@@ -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(m.name)), findsOneWidget);
} }
expect(find.descendant(of: card, matching: find.text('reconnecting…')), 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.tap(find.descendant(of: find.byKey(const ValueKey('member-b')), matching: find.byTooltip('More')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Kick')); expect(find.text('Nudge'), findsOneWidget);
await tester.pumpAndSettle(); expect(find.text('Sit out'), findsOneWidget);
expect(find.text('Remove Bob?'), findsOneWidget); expect(find.text('Kick'), findsNothing);
await tester.tap(find.text('Cancel')); await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tapAt(const Offset(40, 700)); await tester.tapAt(const Offset(40, 700));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -2,6 +2,8 @@
#include <optional> #include <optional>
#include <flutter/standard_method_codec.h>
#include "flutter/generated_plugin_registrant.h" #include "flutter/generated_plugin_registrant.h"
FlutterWindow::FlutterWindow(const flutter::DartProject& project) FlutterWindow::FlutterWindow(const flutter::DartProject& project)
@@ -25,6 +27,24 @@ bool FlutterWindow::OnCreate() {
return false; return false;
} }
RegisterPlugins(flutter_controller_->engine()); RegisterPlugins(flutter_controller_->engine());
window_channel_ = std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
flutter_controller_->engine()->messenger(), "starsync/window",
&flutter::StandardMethodCodec::GetInstance());
window_channel_->SetMethodCallHandler(
[this](const flutter::MethodCall<flutter::EncodableValue>& call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> 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()); SetChildContent(flutter_controller_->view()->GetNativeWindow());
flutter_controller_->engine()->SetNextFrameCallback([&]() { flutter_controller_->engine()->SetNextFrameCallback([&]() {
@@ -40,6 +60,7 @@ bool FlutterWindow::OnCreate() {
} }
void FlutterWindow::OnDestroy() { void FlutterWindow::OnDestroy() {
window_channel_ = nullptr;
if (flutter_controller_) { if (flutter_controller_) {
flutter_controller_ = nullptr; flutter_controller_ = nullptr;
} }
@@ -3,6 +3,8 @@
#include <flutter/dart_project.h> #include <flutter/dart_project.h>
#include <flutter/flutter_view_controller.h> #include <flutter/flutter_view_controller.h>
#include <flutter/method_channel.h>
#include <flutter/encodable_value.h>
#include <memory> #include <memory>
@@ -28,6 +30,10 @@ class FlutterWindow : public Win32Window {
// The Flutter instance hosted by this window. // The Flutter instance hosted by this window.
std::unique_ptr<flutter::FlutterViewController> flutter_controller_; std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
// "starsync/window": `attention` flashes the taskbar entry while the
// window is not in front (lib/src/platform/attention.dart).
std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>> window_channel_;
}; };
#endif // RUNNER_FLUTTER_WINDOW_H_ #endif // RUNNER_FLUTTER_WINDOW_H_
+52 -22
View File
@@ -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 uncaught async error is logged and the server keeps going. If the isolate
dies anyway, the session shows "server crashed: ..." and leaves; the dies anyway, the session shows "server crashed: ..." and leaves; the
shell says so in a dialog (during setup the host attempt fails with it). 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. sends `stop`, waits up to 3 s for exit, then kills the isolate.
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe` - **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
and shipped next to the app as `watch-together-server`. It parses flags 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 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 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 key matches is the host (`members[].host`: the crown, art rights). The
comes from plays no part (so a reverse proxy or tunnel on the same machine address a connection comes from plays no part (so a reverse proxy or tunnel
does not make everyone the host). The dedicated server has no key, so it on the same machine does not make everyone the host). The dedicated server
has no host. Kick = revoke token, send `kicked`, close; the person may has no key, so it has no host. Nobody removes anyone: members sit each
rejoin at once with a new token. 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 ## Sync model
@@ -94,8 +95,22 @@ Loosely modeled on Syncplay, with a different readiness design.
setting "Unready when the video changes", `AppSettings.unreadyOnSwap`, setting "Unready when the video changes", `AppSettings.unreadyOnSwap`,
default off, sends `ready=false` itself on a new `mediaId`; not for the 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 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 a swap meanwhile would have dropped). Stale, leave and sitting out
the set. 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 - **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 `need` buffered or stale flips the server to HELD (feed action `pause` by
`server`). Recovery resumes by itself when the gate passes. `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 `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 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 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 dead socket is closed without removing the member. An unknown token joins
token joins as a new member. as a new member.
- **Client policies**: no state broadcast for 5 s means connection lost, clear - **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 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. restored from the server's state. Cancel during setup aborts the join.
Closing the window leaves gracefully (2 s timeout). Closing the window leaves gracefully (2 s timeout).
- **Idle** (`IdleTracker`): measured from the later of the last input and the - **Idle** (`IdleTracker`): measured from the later of the last input and the
moment playback stopped. While HELD and self ready: warning at 8 min, 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. 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 - **Deviations from Syncplay**: clock never follows the slowest watcher; no
pause-on-leave; anyone can force ready/unready (all or one); presence gate; 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). 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 | | `bytes=-n` | 206 last n bytes |
| unsatisfiable | 416, `Content-Range: bytes */size` | | unsatisfiable | 416, `Content-Range: bytes */size` |
| host has no file | 503 + `Retry-After: 2` | | 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 Always `Accept-Ranges: bytes`; `Content-Type` from the extension
(`contentTypeForFile`). Relic hands the Range over as a typed `RangeHeader`; (`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 - 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 `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 - Each request opens `/Videos/{id}/stream?static=true&mediaSourceId=` with
`Authorization: MediaBrowser` built from exactly the configured fields `Authorization: MediaBrowser` built from exactly the configured fields
(Jellyfin rewrites a token's device fields from the header), forwards (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 - **Progress** (`JellyfinProgressReporter`, `OwnPlay` only): Playing once the
file is open and mpv has picked its tracks; Progress every 10 s and at 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 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 = (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 duration, so Jellyfin marks it played); seeking back from the end sends
Playing again. Reports carry `PositionTicks`, `IsPaused` and mpv's 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; 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 "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 (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` address": the dialled address when joined; hosting, `inviteAddresses`
of `NetworkInterface.list` (IPv4, no loopback or link-local, private of `NetworkInterface.list` (IPv4, no loopback or link-local, private
ranges first) with the Host port, and a note that friends outside the 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 - **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
picture with a gap in the background colour (both scale with the size): picture with a gap in the background colour (both scale with the size):
green ready, amber buffering (with a spinner), grey not ready, dimmed 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 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 passing the video on. `memberAvatarImage` is the one picture lookup: yours from the
profile, anyone else's from `coordinator.avatars` by `avatarHash`. 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. Link (online the relay mode is picked there). Images are ignored.
Nothing happens while a dialog or popover is over the shell. Nothing happens while a dialog or popover is over the shell.
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready, - **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
unready, force-ready, join, leave, kick, swap and others' chat (feed unready, force-ready, join, leave, swap and others' chat (feed
events), the idle warning, and connection lost / back (once per drop). 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). Nothing for what the video already says (play, pause, seeks, the end).
The sounds are synthesized, not sampled: `synth.dart` renders voices The sounds are synthesized, not sampled: `synth.dart` renders voices
(plucked strings with bends, glass and star bells, a singing bowl, (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 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 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 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 nor remounted. A tab sends the full player to mini; × on the mini sits
it (muted, still in the server) and the strip brings it back. Fullscreen 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 is `windowManager.setFullScreen` with the strip hidden (party avatars in
a corner while paused); F, Esc and double-click on the picture toggle it. 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 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 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) 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"). 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 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`, 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 transcoding: bandwidth is plentiful and the Jellyfin box should not be loaded.
- No roles, friends trust model: simplest thing that fits the use. - No roles, friends trust model: simplest thing that fits the use.
- Blank password = open server: the owner chooses between open and private. - 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 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 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 its own build of `packages/server`. An isolate keeps the UI responsive and
+4 -2
View File
@@ -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 (stops blocking) and removed after 30 s. The app reconnects by itself and
takes over your old place (same member, ready flag and video). takes over your old place (same member, ready flag and video).
There are no roles: anyone can seek, swap, force-ready others and kick There are no roles: anyone can seek, swap, force-ready others, nudge
anyone except the host. 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 ## Where video comes from
+25 -10
View File
@@ -43,7 +43,8 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true}} "duration":1440.5,"mediaId":2,"jf":{"<serverId>":true}}
{"t":"ping","c":1767225600000} {"t":"ping","c":1767225600000}
{"t":"chat","text":"..."} {"t":"chat","text":"..."}
{"t":"kick","target":"<memberId>","reason":""} {"t":"out","target":"<memberId>","out":true}
{"t":"nudge","target":"<memberId>"}
{"t":"profile","name":"...","avatar":"<base64>"} {"t":"profile","name":"...","avatar":"<base64>"}
{"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"} {"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"}
``` ```
@@ -54,11 +55,11 @@ Field notes:
- `hello.hostKey` (optional, omitted when empty): the hosting app - `hello.hostKey` (optional, omitted when empty): the hosting app
generates a random key per hosting, gives it to its server and sends it 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 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. host. Where the connection comes from does not matter.
- `hello.resume` (optional, omitted when empty): the token from this - `hello.resume` (optional, omitted when empty): the token from this
client's previous `welcome`, sent on reconnect. If the server still has 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 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 there is no `join`/`leave` feed. Otherwise it is ignored and a new member
joins. joins.
@@ -81,6 +82,19 @@ Field notes:
`rejected: avatar too large` (the socket stays open, nothing changes). `rejected: avatar too large` (the socket stays open, nothing changes).
- `art`: pictures of the current media (see "Media art"). - `art`: pictures of the current media (see "Media art").
- `chat.text` is cut to 500 characters. - `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":<memberId>,"name":<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.url` is the link; `name`, `size`, `duration` are informational.
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is `swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
dropped. `swap.media.relay` makes it relayed media (see "Relay"). dropped. `swap.media.relay` makes it relayed media (see "Relay").
@@ -99,12 +113,12 @@ Field notes:
"serverTime":"<ISO-8601 UTC>","mediaId":1, "serverTime":"<ISO-8601 UTC>","mediaId":1,
"media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent, "media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent,
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?, "members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
"host"?}], "host"?,"out"?}],
"seekBy":"m1","seekSeq":3,"relay":true} "seekBy":"m1","seekSeq":3,"relay":true}
{"t":"pong","c":1767225600000,"s":1767225600012} {"t":"pong","c":1767225600000,"s":1767225600012}
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"} {"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
{"t":"chat","text":"...","from":"<name>"} {"t":"chat","text":"...","from":"<name>"}
{"t":"kicked","reason":""} {"t":"nudge","from":"m2","name":"Alice"}
{"t":"err","message":""} {"t":"err","message":""}
``` ```
@@ -130,12 +144,13 @@ Field notes:
`report.jf`, omitted when it never sent one. `members[].avatarHash` `report.jf`, omitted when it never sent one. `members[].avatarHash`
(omitted when the member has no picture) names the picture's bytes (see (omitted when the member has no picture) names the picture's bytes (see
"Avatars"). `members[].host` (omitted when false): the member sent the "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 - `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 (the clock `serverTime` uses). With `t3` the local receive time, the offset
is `s - (c + t3) / 2`, accurate to half the round trip. is `s - (c + t3) / 2`, accurate to half the round trip.
- `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`, - `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` `seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`. `target`
and `reason` are optional; `reason` carries `auto`/`manual` for ready and and `reason` are optional; `reason` carries `auto`/`manual` for ready and
`silent too long` or `no signal` for leave/stale. `rename`: `actor` is the `silent too long` or `no signal` for leave/stale. `rename`: `actor` is the
@@ -154,7 +169,7 @@ Field notes:
- `GET /a/<sid>/<token>/<hash>`: the bytes with their image content type, - `GET /a/<sid>/<token>/<hash>`: the bytes with their image content type,
`cache-control: private, max-age=31536000, immutable` (a hash names fixed `cache-control: private, max-age=31536000, immutable` (a hash names fixed
bytes; clients fetch each hash once, from the host they dialled). 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, picture, 410 unknown session. Pictures go with their member (leave,
change, removal). change, removal).
@@ -204,7 +219,7 @@ state media.art = {"poster":"<hash>","banner":"<hash>"}
file) with its own credential and streams it through. `Range` is forwarded file) with its own credential and streams it through. `Range` is forwarded
as sent; status, `Content-Range`, `Content-Length`, `Content-Type` and as sent; status, `Content-Range`, `Content-Length`, `Content-Type` and
`Accept-Ranges` come back as Jellyfin sent them. Errors: 401 bad token, `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 410 unknown session, 502 Jellyfin unreachable or rejecting the server's
credential. Bytes count toward `bytesServed`. credential. Bytes count toward `bytesServed`.
- Pasted Jellyfin links sent as plain `url` stay plain links. - Pasted Jellyfin links sent as plain `url` stay plain links.
@@ -261,7 +276,7 @@ to the entry.
- `GET /r/<sid>/<token>/<mediaId>/<name>`: the media (one file), Range - `GET /r/<sid>/<token>/<mediaId>/<name>`: the media (one file), Range
table as for `/m`. `GET /r/<sid>/<token>/<mediaId>/<i>/<name>`: track table as for `/m`. `GET /r/<sid>/<token>/<mediaId>/<i>/<name>`: track
`i` (from 0) of a media with `tracks` (track 0 of a one-file media `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 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 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 unknown session, 503 + `retry-after` the source never connected or the
+1 -1
View File
@@ -101,7 +101,7 @@ class AppConfig {
/// client dialled. /// client dialled.
final String advertiseHost; final String advertiseHost;
/// Secret the hosting app's own client sends in hello to be marked host /// 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; final String hostKey;
/// Optional login for relaying Jellyfin media to guests; null = no relay. /// Optional login for relaying Jellyfin media to guests; null = no relay.
final JellyfinCredential? jellyfin; final JellyfinCredential? jellyfin;
+4
View File
@@ -50,4 +50,8 @@ abstract class RelayConstants {
static const int maxBannerBytes = 768 * 1024; static const int maxBannerBytes = 768 * 1024;
static const int maxNameLength = 32; static const int maxNameLength = 32;
static const int maxChatLength = 500; 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;
} }
+37 -17
View File
@@ -288,31 +288,46 @@ class WelcomeMessage {
} }
} }
class KickedMessage { /// Sits [target] (a member id) out, or with [out] false brings oneself
final String reason; /// back. Out: connected, but not in the ready gate and not playing.
const KickedMessage({this.reason = ''}); class OutIntent {
final String target;
final bool out;
const OutIntent({required this.target, required this.out});
Map<String, Object?> toJson() => {'t': 'kicked', 'reason': reason}; Map<String, Object?> toJson() => {'t': 'out', 'target': target, 'out': out};
factory KickedMessage.fromJson(Map<String, Object?> json) { factory OutIntent.fromJson(Map<String, Object?> json) {
if (json['t'] != 'kicked') throw const ProtocolException('not a kicked message'); if (json['t'] != 'out') throw const ProtocolException('not an out intent');
return KickedMessage(reason: (json['reason'] as String?) ?? ''); 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 target;
final String reason; const NudgeIntent({required this.target});
const KickRequest({required this.target, this.reason = ''});
Map<String, Object?> toJson() => {'t': 'kick', 'target': target, 'reason': reason}; Map<String, Object?> toJson() => {'t': 'nudge', 'target': target};
factory KickRequest.fromJson(Map<String, Object?> json) { factory NudgeIntent.fromJson(Map<String, Object?> json) {
if (json['t'] != 'kick') throw const ProtocolException('not a kick request'); if (json['t'] != 'nudge') throw const ProtocolException('not a nudge intent');
return KickRequest( return NudgeIntent(target: (json['target'] as String?) ?? '');
target: (json['target'] as String?) ?? '', }
reason: (json['reason'] 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<String, Object?> toJson() => {'t': 'nudge', 'from': from, 'name': name};
factory NudgeNotice.fromJson(Map<String, Object?> 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<String, bool> jf; final Map<String, bool> jf;
/// The hosting app's own member (sent the server's host key). /// The hosting app's own member (sent the server's host key).
final bool host; final bool host;
/// Sitting out: connected, outside the ready gate, not playing.
final bool out;
const MemberView({ const MemberView({
required this.id, required this.id,
required this.name, required this.name,
@@ -546,6 +563,7 @@ class MemberView {
required this.stale, required this.stale,
this.jf = const {}, this.jf = const {},
this.host = false, this.host = false,
this.out = false,
}); });
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
@@ -558,6 +576,7 @@ class MemberView {
'stale': stale, 'stale': stale,
if (jf.isNotEmpty) 'jf': jf, if (jf.isNotEmpty) 'jf': jf,
if (host) 'host': true, if (host) 'host': true,
if (out) 'out': true,
}; };
factory MemberView.fromJson(Map<String, Object?> json) => MemberView( factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
@@ -570,6 +589,7 @@ class MemberView {
stale: (json['stale'] as bool?) ?? false, stale: (json['stale'] as bool?) ?? false,
jf: jfAccessFromJson(json['jf']) ?? const {}, jf: jfAccessFromJson(json['jf']) ?? const {},
host: json['host'] == true, host: json['host'] == true,
out: json['out'] == true,
); );
} }
+33 -4
View File
@@ -54,6 +54,8 @@ class EngineMember {
Map<String, bool> jf = const {}; Map<String, bool> jf = const {};
/// The hosting app's own member (set by the server from the host key). /// The hosting app's own member (set by the server from the host key).
bool host = false; bool host = false;
/// Sitting out: never in the set, never ready, its buffer ignored.
bool out = false;
DateTime lastSeen; DateTime lastSeen;
EngineMember({ EngineMember({
required this.id, required this.id,
@@ -94,6 +96,7 @@ class EngineMember {
stale: stale, stale: stale,
jf: jf, jf: jf,
host: host, host: host,
out: out,
); );
} }
@@ -193,7 +196,8 @@ class SyncEngine {
void _seen(EngineMember member) { void _seen(EngineMember member) {
final wasStale = isStale(member); final wasStale = isStale(member);
member.lastSeen = now; member.lastSeen = now;
if (!member.inSet && if (!member.out &&
!member.inSet &&
(member.ready || (wasStale && transport != Transport.playing))) { (member.ready || (wasStale && transport != Transport.playing))) {
member.inSet = true; member.inSet = true;
} }
@@ -301,6 +305,8 @@ class SyncEngine {
final member = members[token]; final member = members[token];
if (member == null) return; if (member == null) return;
_seen(member); _seen(member);
// Sitting out: back first ([applyOut]), then ready.
if (member.out) return;
if (member.ready == ready && member.inSet) { if (member.ready == ready && member.inSet) {
evaluate(); evaluate();
return; return;
@@ -320,12 +326,13 @@ class SyncEngine {
final actorName = actor?.name ?? '?'; final actorName = actor?.name ?? '?';
if (target == 'all') { if (target == 'all') {
for (final member in members.values) { for (final member in members.values) {
if (member.out) continue;
member.ready = ready; member.ready = ready;
if (ready) member.inSet = true; if (ready) member.inSet = true;
} }
} else { } else {
final member = byId(target); final member = byId(target);
if (member == null) return; if (member == null || member.out) return;
member.ready = ready; member.ready = ready;
if (ready) member.inSet = true; if (ready) member.inSet = true;
feed.add(FeedMessage( feed.add(FeedMessage(
@@ -343,6 +350,28 @@ class SyncEngine {
evaluate(); 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}) { void applySeek(String token, double seekPosition, {int seq = 0}) {
final member = members[token]; final member = members[token];
if (member == null) return; if (member == null) return;
@@ -379,7 +408,7 @@ class SyncEngine {
// (a stale member rejoins the set with its next frame if still ready). // (a stale member rejoins the set with its next frame if still ready).
final t = now; final t = now;
for (final member in members.values) { for (final member in members.values) {
member.inSet = !isStale(member, t); member.inSet = !member.out && !isStale(member, t);
member.buffered = 0; member.buffered = 0;
} }
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now)); feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now));
@@ -393,7 +422,7 @@ class SyncEngine {
if (!reportPosition.isFinite) return; if (!reportPosition.isFinite) return;
_seen(member); _seen(member);
if (jf != null) member.jf = Map.unmodifiable(jf); 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; final m = media;
// First report of the length wins; only for the media the client has open. // First report of the length wins; only for the media the client has open.
if (m != null && m.duration <= 0 && mediaId == this.mediaId && if (m != null && m.duration <= 0 && mediaId == this.mediaId &&
+49
View File
@@ -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', () { group('membership + gate', () {
test('join while held lands in the set, playback stays held', () { test('join while held lands in the set, playback stays held', () {
join('a', 'alice'); join('a', 'alice');
@@ -53,8 +53,6 @@ void mountJellyfinRelayRoute(
switch (session.gate(token)) { switch (session.gate(token)) {
case MediaGate.ok: case MediaGate.ok:
break; break;
case MediaGate.forbidden:
return _plain(403);
default: default:
return _plain(401); return _plain(401);
} }
@@ -58,8 +58,6 @@ MediaResolution resolveMediaRequest({
return MediaResolution(status: 410, headers: base(contentType), start: 0, end: 0); return MediaResolution(status: 410, headers: base(contentType), start: 0, end: 0);
case MediaGate.unauthorized: case MediaGate.unauthorized:
return MediaResolution(status: 401, headers: base(contentType), start: 0, end: 0); 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: case MediaGate.offline:
return MediaResolution( return MediaResolution(
status: 503, status: 503,
@@ -139,13 +139,6 @@ class ActiveRelay {
if (ch != null) unawaited(_closeNotSource(ch)); 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<int> sizes) { void _attachChannel(RelicWebSocket ws, List<int> sizes) {
final old = _channel; final old = _channel;
if (old != null && !identical(old, ws)) unawaited(old.tryClose()); if (old != null && !identical(old, ws)) unawaited(old.tryClose());
@@ -290,8 +283,6 @@ void mountRelayRoutes(
switch (session.gate(token)) { switch (session.gate(token)) {
case MediaGate.ok: case MediaGate.ok:
break; break;
case MediaGate.forbidden:
return _plain(403);
default: default:
return _plain(401); return _plain(401);
} }
+22 -40
View File
@@ -66,11 +66,9 @@ class ServerHub {
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey; bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
/// The member a reconnecting client may take over with its old token: /// The member a reconnecting client may take over with its old token:
/// still known and not kicked. /// still known.
EngineMember? resumable(String token) { EngineMember? resumable(String token) {
if (token.isEmpty) return null; if (token.isEmpty || !session.tokens.containsKey(token)) return null;
final viewer = session.tokens[token];
if (viewer == null || viewer.kicked) return null;
return engine.members[token]; return engine.members[token];
} }
@@ -150,24 +148,25 @@ class ServerHub {
void forgetSocket(String token) { void forgetSocket(String token) {
engine.applyLeave(token); engine.applyLeave(token);
final viewer = session.tokens[token]; session.tokens.remove(token);
if (viewer != null && !viewer.kicked) session.tokens.remove(token);
sockets.remove(token); sockets.remove(token);
changed(); changed();
} }
Future<void> kickById(String targetId, String reason) async { /// Passes a nudge from [fromToken] to the member [targetId] only;
final member = engine.byId(targetId); /// over the sender's rate cap, to itself or to nobody it is dropped.
if (member == null) throw const AuthException('unknown kick target'); void nudge(String fromToken, String targetId) {
if (session.tokens[member.token]?.isHost ?? false) { final from = engine.members[fromToken];
throw const AuthException("can't kick the host"); final target = engine.byId(targetId);
} final viewer = session.tokens[fromToken];
await session.kick(member.token, reason: reason); 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/<sid>/<token>/<hash>`: a current member's picture by its /// `GET /a/<sid>/<token>/<hash>`: 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. /// hash never change, so clients may cache them for good.
void mountAvatarRoute(RelicApp app, ServerHub hub) => void mountAvatarRoute(RelicApp app, ServerHub hub) =>
_mountPictureRoute(app, hub, 'a', hub.engine.avatarFor); _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)) { switch (session.gate(token)) {
case MediaGate.unauthorized: case MediaGate.unauthorized:
return status(401); return status(401);
case MediaGate.forbidden:
return status(403);
default: default:
break; break;
} }
@@ -415,7 +412,7 @@ void mountSyncRoute(
return; return;
} }
final name = intent.name == null ? null : cleanMemberName(intent.name!); 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; if (name != null) viewer.name = name;
hub.engine.applyProfile(token, name: name, avatar: avatar); hub.engine.applyProfile(token, name: name, avatar: avatar);
hub.changed(); hub.changed();
@@ -441,22 +438,14 @@ void mountSyncRoute(
case 'chat': case 'chat':
final chat = ChatMessage.fromJson(decoded); final chat = ChatMessage.fromJson(decoded);
hub.broadcastChat(viewer?.name ?? '?', chat.text); hub.broadcastChat(viewer?.name ?? '?', chat.text);
case 'kick': case 'out':
final kickReq = KickRequest.fromJson(decoded); if (token == null) return;
final member = hub.engine.byId(kickReq.target); final intent = OutIntent.fromJson(decoded);
if (member == null) { hub.engine.applyOut(token, intent.target, intent.out);
wsErr(ws, 'unknown kick target'); hub.changed();
return; case 'nudge':
} if (token == null) return;
if (session.tokens[member.token]?.isHost ?? false) { hub.nudge(token, NudgeIntent.fromJson(decoded).target);
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));
} }
} on ProtocolException catch (e) { } on ProtocolException catch (e) {
wsErr(ws, 'bad message: ${e.message}'); wsErr(ws, 'bad message: ${e.message}');
@@ -544,13 +533,6 @@ void mountSyncRoute(
hub.engine.members[token]?.host = isHost; hub.engine.members[token]?.host = isHost;
final activeToken = token; final activeToken = token;
hub.sockets[activeToken] = ws; 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 media = session.media;
final relay = hub.relayAvailable; final relay = hub.relayAvailable;
final relayed = hub.relay; final relayed = hub.relay;
+13 -11
View File
@@ -3,21 +3,31 @@ import 'dart:math';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
enum MediaGate { ok, gone, unauthorized, forbidden, offline } enum MediaGate { ok, gone, unauthorized, offline }
class ViewerToken { class ViewerToken {
final String token; final String token;
/// Cleaned ([cleanMemberName]); follows `profile` renames. /// Cleaned ([cleanMemberName]); follows `profile` renames.
String name; String name;
bool kicked = false;
/// Sent the server's host key in hello: the hosting app's own client. /// Sent the server's host key in hello: the hosting app's own client.
bool isHost = false; bool isHost = false;
int bytesServed = 0; int bytesServed = 0;
DateTime lastSeen; DateTime lastSeen;
Future<void> Function(String reason)? onKick; /// When this member's recent nudges went out, for the rate cap.
final List<DateTime> nudges = [];
ViewerToken({required this.token, required this.name}) ViewerToken({required this.token, required this.name})
: lastSeen = DateTime.now().toUtc(); : 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]) => bool isStale(Duration timeout, [DateTime? now]) =>
(now ?? DateTime.now().toUtc()).difference(lastSeen) > timeout; (now ?? DateTime.now().toUtc()).difference(lastSeen) > timeout;
@@ -66,17 +76,9 @@ class Session {
MediaGate gate(String? token) { MediaGate gate(String? token) {
if (token == null || !tokens.containsKey(token)) return MediaGate.unauthorized; if (token == null || !tokens.containsKey(token)) return MediaGate.unauthorized;
if (tokens[token]!.kicked) return MediaGate.forbidden;
return MediaGate.ok; return MediaGate.ok;
} }
Future<void> 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) { void addBytes(String? token, int n) {
if (token != null) tokens[token]?.bytesServed += n; if (token != null) tokens[token]?.bytesServed += n;
} }
+41 -14
View File
@@ -123,29 +123,56 @@ void main() {
await a.ws.close(); 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(); await server.stop();
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk')); server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk'));
await server.start(); await server.start();
final host = final host =
await _Peer.join('127.0.0.1', server.boundPort, 'host', hostKey: 'hk'); 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 friend = await _Peer.join('127.0.0.1', server.boundPort, 'friend');
final guesser = final third = await _Peer.join('127.0.0.1', server.boundPort, 'third');
await _Peer.join('127.0.0.1', server.boundPort, 'guesser', hostKey: 'nope');
String idOf(String name) => String idOf(String name) =>
server.hub.engine.members.values.firstWhere((m) => m.name == name).id; server.hub.engine.members.values.firstWhere((m) => m.name == name).id;
friend.send(jsonEncode(KickRequest(target: idOf('host')).toJson())); EngineMember of(String name) =>
final err = await friend.next('err'); server.hub.engine.members.values.firstWhere((m) => m.name == name);
expect(err['message'], "can't kick the host"); friend.send(jsonEncode(OutIntent(target: idOf('host'), out: true).toJson()));
expect(host.closed, isFalse); var feed = await friend.next('feed');
await expectLater( while (feed['action'] != 'out') {
server.hub.kickById(idOf('host'), 'x'), throwsA(isA<AuthException>())); feed = await friend.next('feed');
// Same machine, no key (or a wrong one): an ordinary member. }
host.send(jsonEncode(KickRequest(target: idOf('guesser')).toJson())); expect([feed['actor'], feed['target']], ['friend', 'host']);
await guesser.next('kicked'); expect(of('host').out, isTrue);
await server.hub.kickById(idOf('friend'), 'x'); expect(of('host').inSet, isFalse);
await friend.next('kicked'); // 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 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 { test('state marks the host member for everyone', () async {
@@ -54,9 +54,6 @@ void main() {
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.unauthorized) resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.unauthorized)
.status, .status,
401); 401);
expect(
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.forbidden).status,
403);
final off = final off =
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.offline); resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.offline);
expect(off.status, 503); expect(off.status, 503);
-188
View File
@@ -133,189 +133,6 @@ void main() {
await resp.drain(); 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<String>();
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 = <String>[];
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<String, Object?>? 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<String, Object?>.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<String, Object?>? 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<String, Object?>.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<String>)> join(String who,
[String host = '127.0.0.1']) async {
final ch = WebSocketChannel.connect(
Uri.parse('ws://$host:${server.boundPort}/sync'));
final messages = <String>[];
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<String, Object?>? 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<String, Object?>.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<String, Object?>? findType(List<String> messages, String type) {
for (final m in messages) {
final decoded = jsonDecode(m) as Map;
if (decoded['t'] == type) return Map<String, Object?>.from(decoded);
}
return null;
}
Future<Map<String, Object?>?> waitType(
List<String> 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 { test('silent socket dropped and member removed', () async {
final ch = WebSocketChannel.connect( final ch = WebSocketChannel.connect(
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync')); Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
@@ -557,11 +374,6 @@ void main() {
ProfileIntent(avatar: 'x' * (RelayConstants.maxAvatarBytes + 1)).toJson())); ProfileIntent(avatar: 'x' * (RelayConstants.maxAvatarBytes + 1)).toJson()));
final err = await waitFor((m) => m['t'] == 'err'); final err = await waitFor((m) => m['t'] == 'err');
expect(err['message'], 'rejected: avatar too large'); 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 { } finally {
await sub.cancel(); await sub.cancel();
try { try {
-28
View File
@@ -21,22 +21,6 @@ void main() {
expect(s.gate(t), MediaGate.ok); 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<AuthException>()));
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', () { test('oversize avatar rejected', () {
final s = Session(id: 'default', secret: 'shh'); final s = Session(id: 'default', secret: 'shh');
final big = 'x' * (RelayConstants.maxAvatarBytes + 1); final big = 'x' * (RelayConstants.maxAvatarBytes + 1);
@@ -122,18 +106,6 @@ void main() {
expect(() => WelcomeMessage.fromJson({'t': 'hello'}), throwsA(isA<ProtocolException>())); expect(() => WelcomeMessage.fromJson({'t': 'hello'}), throwsA(isA<ProtocolException>()));
}); });
test('kicked + kick request round-trip', () {
const k = KickedMessage(reason: 'idle');
final back = KickedMessage.fromJson(Map<String, Object?>.from(k.toJson()));
expect(back.reason, 'idle');
const r = KickRequest(target: 'abc', reason: 'afk?');
final backR = KickRequest.fromJson(Map<String, Object?>.from(r.toJson()));
expect(backR.target, 'abc');
expect(backR.reason, 'afk?');
expect(() => KickedMessage.fromJson({'t': 'err'}), throwsA(isA<ProtocolException>()));
expect(() => KickRequest.fromJson({'t': 'err'}), throwsA(isA<ProtocolException>()));
});
test('lastSeen touch + stale helper', () { test('lastSeen touch + stale helper', () {
final s = Session(id: 'default', secret: 'shh'); final s = Session(id: 'default', secret: 'shh');
final t = s.issueToken('alice', ''); final t = s.issueToken('alice', '');
+1 -8
View File
@@ -127,17 +127,10 @@ void main() {
await second.ws.close(); 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'); final (a, wa) = await _Client.join(server.boundPort, 'a', resume: 'nope');
expect(wa.token, isNot('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 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 { test('ping gets a pong with the client stamp and the server clock', () async {