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
+52
-22
@@ -38,7 +38,7 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
|
||||
uncaught async error is logged and the server keeps going. If the isolate
|
||||
dies anyway, the session shows "server crashed: ..." and leaves; the
|
||||
shell says so in a dialog (during setup the host attempt fails with it).
|
||||
Stop (leave, close, cancel during setup, a failed own join, being kicked)
|
||||
Stop (leave, close, cancel during setup, a failed own join)
|
||||
sends `stop`, waits up to 3 s for exit, then kills the isolate.
|
||||
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
|
||||
and shipped next to the app as `watch-together-server`. It parses flags
|
||||
@@ -55,11 +55,12 @@ warning is logged). Otherwise one shared secret, compared at hello.
|
||||
|
||||
Host: when hosting, the app generates a random `hostKey` per hosting, puts
|
||||
it in the server config and sends it in its own `hello`. The member whose
|
||||
key matches is the host and cannot be kicked. The address a connection
|
||||
comes from plays no part (so a reverse proxy or tunnel on the same machine
|
||||
does not make everyone the host). The dedicated server has no key, so it
|
||||
has no host. Kick = revoke token, send `kicked`, close; the person may
|
||||
rejoin at once with a new token.
|
||||
key matches is the host (`members[].host`: the crown, art rights). The
|
||||
address a connection comes from plays no part (so a reverse proxy or tunnel
|
||||
on the same machine does not make everyone the host). The dedicated server
|
||||
has no key, so it has no host. Nobody removes anyone: members sit each
|
||||
other out ("Sitting out" below), the host included, since the hosted
|
||||
server and its `/m/` file serving do not need the host's player.
|
||||
|
||||
## Sync model
|
||||
|
||||
@@ -94,8 +95,22 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
setting "Unready when the video changes", `AppSettings.unreadyOnSwap`,
|
||||
default off, sends `ready=false` itself on a new `mediaId`; not for the
|
||||
first state of a session, and a reconnect does not restore a ready that
|
||||
a swap meanwhile would have dropped). Stale, leave and kick remove from
|
||||
the set.
|
||||
a swap meanwhile would have dropped). Stale, leave and sitting out
|
||||
remove from the set.
|
||||
- **Sitting out** (`EngineMember.out`, `state.members[].out`): a member
|
||||
stays connected (chat, feed, party, nudges) but is never in the set and
|
||||
never ready: no `ready` or `force` reaches it, a swap leaves it out, its
|
||||
reports keep it fresh but their `buffered` counts as 0. Anyone can sit
|
||||
anyone out (`out` with `out:true`, the host included); only the member
|
||||
itself comes back (`out:false`): it is then in the set like a fresh join
|
||||
(unless PLAYING) and still unready. The app: an out member's player has
|
||||
nothing open (no stream or relay pulls; a relay it serves goes on) and
|
||||
shows the join-back card (cover, title, episode, the server position and
|
||||
playing/paused). "Join back" or Space sends `out:false` and then
|
||||
`ready`: at once while HELD (the gate waits for its buffer), while
|
||||
PLAYING once the player has buffered `need` (at most 15 s), so the others
|
||||
do not pause for the load. Putting something up while out comes back
|
||||
first. A reconnect that lands as a new member sits out again.
|
||||
- **Pause**: while PLAYING, any in-set member who becomes not ready, under
|
||||
`need` buffered or stale flips the server to HELD (feed action `pause` by
|
||||
`server`). Recovery resumes by itself when the gate passes.
|
||||
@@ -137,16 +152,26 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
`hello.resume`. If the server still has that member (an unclean drop it has
|
||||
not noticed), the new socket takes it over: same token, member id, ready
|
||||
flag and media URL (the player keeps streaming), no join/leave feed, and the
|
||||
dead socket is closed without removing the member. An unknown or kicked
|
||||
token joins as a new member.
|
||||
dead socket is closed without removing the member. An unknown token joins
|
||||
as a new member.
|
||||
- **Client policies**: no state broadcast for 5 s means connection lost, clear
|
||||
the server state and reconnect every 1.5 s until cancelled (an epoch counter makes
|
||||
a cancelled attempt stale) or kicked. After a reconnect the ready flag is
|
||||
a cancelled attempt stale). After a reconnect the ready flag is
|
||||
restored from the server's state. Cancel during setup aborts the join.
|
||||
Closing the window leaves gracefully (2 s timeout).
|
||||
- **Idle** (`IdleTracker`): measured from the later of the last input and the
|
||||
moment playback stopped. While HELD and self ready: warning at 8 min,
|
||||
auto-unready at 10 min, and never less than 2 min after the warning.
|
||||
With "When idle, sit out instead of just unready"
|
||||
(`AppSettings.idleSitsOut`, default off) the 10 min step sits out.
|
||||
- **Nudge**: `nudge` names a member; the server passes it to that member
|
||||
only (no feed), at most 3 per second and 20 per minute per sender
|
||||
(`nudgesPerSec`, `nudgesPerMin`; the rest is dropped silently). The
|
||||
nudged app plays the nudge sound whatever its per-sound switch (the
|
||||
volume still applies), toasts "Alice nudged you" and asks the desktop
|
||||
for attention when its window is not focused: the runners' `starsync/window`
|
||||
channel (`attention`), the urgency hint on Linux (GTK, cleared on focus)
|
||||
and `FlashWindowEx` until foreground on Windows.
|
||||
- **Deviations from Syncplay**: clock never follows the slowest watcher; no
|
||||
pause-on-leave; anyone can force ready/unready (all or one); presence gate;
|
||||
the clock is server time (clients correct for their offset to it).
|
||||
@@ -192,7 +217,7 @@ Range table (`resolveMediaRequest`, tested byte-exact):
|
||||
| `bytes=-n` | 206 last n bytes |
|
||||
| unsatisfiable | 416, `Content-Range: bytes */size` |
|
||||
| host has no file | 503 + `Retry-After: 2` |
|
||||
| bad / unknown token, kicked, unknown session | 401, 403, 410 |
|
||||
| bad / unknown token, unknown session | 401, 410 |
|
||||
|
||||
Always `Accept-Ranges: bytes`; `Content-Type` from the extension
|
||||
(`contentTypeForFile`). Relic hands the Range over as a typed `RangeHeader`;
|
||||
@@ -365,7 +390,7 @@ errors leave it out).
|
||||
|
||||
- Available (`state.relay`) only while the current media is a ref whose
|
||||
`serverId` equals the credential's. Only that item is relayed, only to a
|
||||
valid, unkicked viewer token: no open proxy.
|
||||
valid viewer token: no open proxy.
|
||||
- Each request opens `/Videos/{id}/stream?static=true&mediaSourceId=` with
|
||||
`Authorization: MediaBrowser` built from exactly the configured fields
|
||||
(Jellyfin rewrites a token's device fields from the header), forwards
|
||||
@@ -573,7 +598,7 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
||||
- **Progress** (`JellyfinProgressReporter`, `OwnPlay` only): Playing once the
|
||||
file is open and mpv has picked its tracks; Progress every 10 s and at
|
||||
once on pause, resume, a seek (position more than 3 s from where playback
|
||||
should be) or a track change; Stopped on swap, leave, kick, window close
|
||||
should be) or a track change; Stopped on swap, leave, sitting out, window close
|
||||
(bounded to 1.5 s inside the graceful leave) and at `finished` (position =
|
||||
duration, so Jellyfin marks it played); seeking back from the end sends
|
||||
Playing again. Reports carry `PositionTicks`, `IsPaused` and mpv's
|
||||
@@ -631,7 +656,8 @@ always scrolls the page). Space never presses a focused button
|
||||
ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and
|
||||
"ready n/m" (hidden under 1100 px), chat with an unread badge, and Leave
|
||||
(asks first). The stack opens the members card (status, host / relay
|
||||
pills, "via host", force ready, kick for all but the host, "Copy invite
|
||||
pills, "via host", "sitting out"; per member force ready, Nudge and Sit
|
||||
out (anyone, the host too), your own row Sit out / Join back; "Copy invite
|
||||
address": the dialled address when joined; hosting, `inviteAddresses`
|
||||
of `NetworkInterface.list` (IPv4, no loopback or link-local, private
|
||||
ranges first) with the Host port, and a note that friends outside the
|
||||
@@ -640,7 +666,7 @@ always scrolls the page). Space never presses a focused button
|
||||
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
|
||||
picture with a gap in the background colour (both scale with the size):
|
||||
green ready, amber buffering (with a spinner), grey not ready, dimmed
|
||||
stale; a crown for the host (`members[].host` in the state; your own
|
||||
stale, dimmed with a moon ("away") sitting out (last in the stack); a crown for the host (`members[].host` in the state; your own
|
||||
app also knows before the first state) and a tower for the member
|
||||
passing the video on. `memberAvatarImage` is the one picture lookup: yours from the
|
||||
profile, anyone else's from `coordinator.avatars` by `avatarHash`.
|
||||
@@ -712,8 +738,9 @@ always scrolls the page). Space never presses a focused button
|
||||
Link (online the relay mode is picked there). Images are ignored.
|
||||
Nothing happens while a dialog or popover is over the shell.
|
||||
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
|
||||
unready, force-ready, join, leave, kick, swap and others' chat (feed
|
||||
events), the idle warning, and connection lost / back (once per drop).
|
||||
unready, force-ready, join, leave, swap and others' chat (feed
|
||||
events), being sat out by someone (the `kick` sound), a nudge (always,
|
||||
whatever its switch), the idle warning, and connection lost / back (once per drop).
|
||||
Nothing for what the video already says (play, pause, seeks, the end).
|
||||
The sounds are synthesized, not sampled: `synth.dart` renders voices
|
||||
(plucked strings with bends, glass and star bells, a singing bowl,
|
||||
@@ -735,8 +762,10 @@ always scrolls the page). Space never presses a focused button
|
||||
snaps to the nearest corner with the shared curve). A mode change fades
|
||||
the layer out, moves it while invisible and fades it in (with the tabs
|
||||
when the full player comes or goes), so the video is never mounted twice
|
||||
nor remounted. A tab sends the full player to mini; × on the mini closes
|
||||
it (muted, still in the server) and the strip brings it back. Fullscreen
|
||||
nor remounted. A tab sends the full player to mini; × on the mini sits
|
||||
out (the player unloads and shows the join-back card, `SitOutCard`, over
|
||||
the still mounted `Video`); × on that card closes the player, the strip
|
||||
brings it back, and joining back with it closed shows the mini. Fullscreen
|
||||
is `windowManager.setFullScreen` with the strip hidden (party avatars in
|
||||
a corner while paused); F, Esc and double-click on the picture toggle it.
|
||||
The player keys (Settings → Keyboard shortcuts) work while the player is
|
||||
@@ -902,7 +931,7 @@ fullscreen has no drag area and shows the buttons in the player's top bar.
|
||||
|
||||
Every failure must show in the UI with a cause and a next step; nothing
|
||||
stalls silently. The app has a `HealthStatus` signal (severity + message)
|
||||
for connecting, reconnecting, wrong password, version mismatch, kicked,
|
||||
for connecting, reconnecting, wrong password, version mismatch,
|
||||
host offline, and starvation ("waiting for X - N.N s buffered").
|
||||
Errors are logged with a stack trace at the boundary, then turned into a
|
||||
displayable state. The server's `GET /health` returns JSON (`ok`, `name`,
|
||||
@@ -950,7 +979,8 @@ this repo, written by `tool/release.sh`.
|
||||
- No transcoding: bandwidth is plentiful and the Jellyfin box should not be loaded.
|
||||
- No roles, friends trust model: simplest thing that fits the use.
|
||||
- Blank password = open server: the owner chooses between open and private.
|
||||
- The host cannot be kicked: the server dies with the host anyway.
|
||||
- Sit out instead of kick: friends never need to throw someone out, only to
|
||||
stop waiting for them; the person stays in chat and can be nudged back.
|
||||
- Server runs inside the app on an isolate, and ships as a separate compiled
|
||||
server: a Flutter binary cannot run headless, so a dedicated server needs
|
||||
its own build of `packages/server`. An isolate keeps the UI responsive and
|
||||
|
||||
Reference in new issue
Block a user