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
+842 -586

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