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:
1 parent
dbfd351ee4
commit
8b81da4069
46 files changed
+855
-599
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in new issue
Block a user