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:
1 parent
5f22be6c20
commit
8effa573a0
38 files changed
+1529
-303
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user