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

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