Protocol 10 presence gate, drop/paste anywhere, quick UI fixes

Ready gate: whoever is in the server while it is not playing counts (joining
while playing does not pause anyone); a swap counts everyone present and
keeps ready flags; the quorum rule is gone. Optional 'Unready when the
video changes'. No 'Skip 1:30' guess when the media has chapters.

Drag & drop and Ctrl+V anywhere (fox_utils): files, folders and links get a
small card asking how to open them. Files series start collapsed.

UI: Tab never moves focus and Space is always ready outside text fields;
chat focuses its field; unread badge on uosc and a chip in fullscreen;
people-only chat with an Activity toggle; skip pills show 5 s then with
the controls; volume up to 150%; own avatar same size as the party.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 07:06:48 +03:00
1 parent 5f22be6c20
commit 8effa573a0
38 files changed
+1529 -303

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
abstract class RelayConstants {
static const int protoVersion = 9;
static const int protoVersion = 10;
// Relay (`/r/...`): a member's bytes through the server's buffer.
static const int relaySliceBytes = 1 << 20;
+18 -11
View File
@@ -186,12 +186,17 @@ class SyncEngine {
return left.clamp(0.0, full).toDouble();
}
/// Any frame from a member: fresh again, and a ready member the stale
/// sweep took out of the set goes back in (ready && !inSet only arises
/// from that sweep).
/// Any frame from a member: fresh again. Back in the set: a ready member
/// the stale sweep (or a swap while it was stale) took out (ready &&
/// !inSet only arises from those), and any member back from stale while
/// not playing (like a join then).
void _seen(EngineMember member) {
final wasStale = isStale(member);
member.lastSeen = now;
if (member.ready && !member.inSet) member.inSet = true;
if (!member.inSet &&
(member.ready || (wasStale && transport != Transport.playing))) {
member.inSet = true;
}
}
/// Heartbeat: stale sweep, end of media.
@@ -202,8 +207,11 @@ class SyncEngine {
String applyJoin(String token, String name, String avatar) {
final id = 'm${_nextId++}';
// Joining while not playing: in the set, so the start waits for them;
// while playing, out until they ready (a late joiner never pauses).
members[token] =
EngineMember(id: id, token: token, name: name, avatar: avatar, now: now);
EngineMember(id: id, token: token, name: name, avatar: avatar, now: now)
..inSet = transport != Transport.playing;
feed.add(FeedMessage(actor: name, action: 'join', at: now));
return id;
}
@@ -367,9 +375,11 @@ class SyncEngine {
serverTime = now;
eventId++;
transport = Transport.held;
// Ready flags stay; everyone here now waits for everyone else here now
// (a stale member rejoins the set with its next frame if still ready).
final t = now;
for (final member in members.values) {
member.ready = false;
member.inSet = false;
member.inSet = !isStale(member, t);
member.buffered = 0;
}
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now));
@@ -448,12 +458,9 @@ class SyncEngine {
}
final inSet = members.values.where((m) => m.inSet).toList();
if (inSet.isEmpty) return;
final freshCount =
members.values.where((m) => !isStale(m, t)).length;
final quorum = inSet.length >= 2 || freshCount <= 1;
final allGood = inSet.every(
(m) => m.ready && m.buffered >= need && !isStale(m, t));
if (allGood && quorum) {
if (allGood) {
serverTime = t;
eventId++;
transport = Transport.playing;
+78 -12
View File
@@ -37,12 +37,12 @@ void main() {
});
group('membership + gate', () {
test('join lands out-of-set, playback stays held', () {
test('join while held lands in the set, playback stays held', () {
join('a', 'alice');
expect(engine.transport, Transport.held);
expect(engine.members['a']!.inSet, isFalse);
expect(engine.members['a']!.inSet, isTrue);
final state = engine.broadcast();
expect(state.members.single.inSet, isFalse);
expect(state.members.single.inSet, isTrue);
});
test('single ready + buffered auto-plays', () {
@@ -126,7 +126,22 @@ void main() {
});
});
group('quorum gate', () {
group('presence gate', () {
test('initial media: three join while held, two ready stays held', () {
engine.media = const HelloMedia(name: 'f', size: 1, duration: 0);
join('a', 'alice');
join('b', 'bob');
join('c', 'cara');
engine.applyReady('a', true);
engine.applyReady('b', true);
buffer('a', 6.0);
buffer('b', 6.0);
buffer('c', 6.0);
expect(engine.transport, Transport.held);
engine.applyReady('c', true);
expect(engine.transport, Transport.playing);
});
test('one ready of two fresh stays held', () {
join('a', 'alice');
join('b', 'bob');
@@ -153,6 +168,7 @@ void main() {
t = t.add(const Duration(seconds: 16));
engine.applyReady('a', true);
buffer('a', 6.0);
engine.tick();
expect(engine.transport, Transport.playing);
});
@@ -257,19 +273,66 @@ void main() {
});
});
group('swap resets everything', () {
test('swap bumps mediaId, clears sets, holds', () {
group('swap', () {
const ep2 = HelloMedia(name: 'Ep02.mkv', size: 9, duration: 90.0);
test('swap bumps mediaId, holds, keeps ready, fresh members in set', () {
join('a', 'alice');
engine.applyReady('a', true);
buffer('a', 4.0);
expect(engine.transport, Transport.playing);
engine.applySwap('a',
const HelloMedia(name: 'Ep02.mkv', size: 9, duration: 9.0));
engine.applySwap('a', ep2);
expect(engine.mediaId, 2);
expect(engine.transport, Transport.held);
expect(engine.positionAt(), 0);
expect(engine.members['a']!.inSet, isFalse);
expect(engine.members['a']!.ready, isFalse);
expect(engine.members['a']!.inSet, isTrue);
expect(engine.members['a']!.ready, isTrue);
expect(engine.members['a']!.buffered, 0);
buffer('a', 4.0);
expect(engine.transport, Transport.playing);
});
test('a member present at the swap blocks until ready', () {
join('a', 'alice');
join('b', 'bob');
join('c', 'cara');
engine.applySwap('a', ep2);
engine.applyReady('a', true);
engine.applyReady('b', true);
buffer('a', 6.0);
buffer('b', 6.0);
buffer('c', 6.0);
expect(engine.transport, Transport.held);
expect(engine.members['c']!.inSet, isTrue);
engine.applyReady('c', true);
expect(engine.transport, Transport.playing);
});
test('a joiner while playing never holds anyone back', () {
join('a', 'alice');
engine.applySwap('a', ep2);
engine.applyReady('a', true);
buffer('a', 6.0);
expect(engine.transport, Transport.playing);
join('b', 'bob');
buffer('b', 0);
expect(engine.members['b']!.inSet, isFalse);
expect(engine.transport, Transport.playing);
});
test('a stale member stays out, rejoins with its next frame if ready', () {
join('a', 'alice');
join('b', 'bob');
engine.applyReady('b', true);
t = t.add(const Duration(seconds: 16));
engine.applyReady('a', true);
engine.applySwap('a', ep2);
expect(engine.members['b']!.inSet, isFalse);
buffer('a', 6.0);
expect(engine.transport, Transport.playing);
buffer('b', 0);
expect(engine.members['b']!.inSet, isTrue);
expect(engine.transport, Transport.held);
});
});
@@ -427,10 +490,13 @@ void main() {
isTrue);
});
test('unready member does not rejoin on reports', () {
test('unready late joiner does not join on reports', () {
join('a', 'alice');
engine.applyReady('a', true);
buffer('a', 6.0);
expect(engine.members['a']!.inSet, isFalse);
join('b', 'bob');
buffer('b', 6.0);
expect(engine.members['b']!.inSet, isFalse);
});
});
+2 -2
View File
@@ -80,8 +80,8 @@ void main() {
});
group('core welcome + proto', () {
test('proto 9: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 9);
test('proto 10: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 10);
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
expect(v.toJson().containsKey('avatarHash'), isFalse);
expect(v.toJson().containsKey('avatar'), isFalse);