Protocol 11: sit out replaces kicking; nudge

Anyone can sit anyone out (host included); only you bring yourself back.
Out members stay in chat and the party, never block or count, and load no
media; a card with the live position and Join back (Space) replaces the
video. The mini player's x sits you out; optionally idle does too. Nudge:
the server forwards it to the target only (3/s, 20/min per sender); the
target hears the nudge sound, gets a toast and a taskbar attention
request. Kick, its messages, errors and dialog are gone.

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

No files matched your search

+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 &&