1.0 shell: one window with a party strip; protocol 9 profile + avatars by hash
The shell (lib/src/shell/) replaces the Home and session screens:
- Party strip: profile, Library / Schedule / Settings tabs, Join and
Host cards (hosting no longer takes media), status-ring avatar stack
with the member list, chat & activity panel, Leave.
- The real session player is mounted once and shown full, as a
draggable corner-snapping mini player, or fullscreen (window_manager,
party and toasts overlaid); the Video is never mounted twice.
- Profile popover with inline rename, colour and a crop picker (256 px
PNG), pushed live to the session; avatar toasts; waiting pill.
- Motion kit (fastEaseInToSlowEaseOut, fade-through, popovers,
mouse-drag/wheel horizontal scrolling).
- Library = the existing picker for now (restyled next round); Schedule
= the new SchedulePage; Settings = left nav over the existing panels.
Protocol 9 (packages/core, packages/server, sync client):
- {"t":"profile"} renames / changes the picture mid-session ("rename",
"avatar" feed actions); names are cleaned the same way at hello.
- state.members carry avatarHash; pictures are served once from
/a/<sid>/<token>/<hash> (PNG/JPEG/WebP/GIF by magic bytes only) and
cached by hash in the app (AvatarCache).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f790470935
commit
d29c12e4f0
62 files changed
+5777
-3572
No files matched your search
+113
-49
@@ -36,8 +36,8 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
|
||||
`127.0.0.1` like any other client. Server logs go to the app's log with a
|
||||
`[server]` prefix. The isolate runs with `errorsAreFatal: false`: an
|
||||
uncaught async error is logged and the server keeps going. If the isolate
|
||||
dies anyway, the session shows "server crashed: ..." and leaves; Home
|
||||
shows it on the Host card (during setup the host attempt fails with it).
|
||||
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)
|
||||
sends `stop`, waits up to 3 s for exit, then kills the isolate.
|
||||
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
|
||||
@@ -500,37 +500,95 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
||||
matched with `matchTrack` against the source's streams (mapped to mpv ids
|
||||
with `mpvTrackId`) or mpv's track list, and selected locally. Nothing goes
|
||||
on the wire.
|
||||
- **Swap panel**: "Change video" in the side panel opens the shared
|
||||
`MediaPicker` over the video (`screens/session/swap_panel.dart`); Link
|
||||
- **Change video**: the player's "Change video" opens the Library tab
|
||||
(the shared `MediaPicker`, `screens/session/swap_panel.dart`); Link
|
||||
goes through `mediaForLink`, a Jellyfin pick as a ref, File (host only)
|
||||
through `ServerIsolate.setFile` and a matching `swap`.
|
||||
|
||||
## Desktop UI
|
||||
|
||||
One `HomeScreen` (`screens/home/`) with a single top bar (`top_bar.dart`):
|
||||
profile chip, Join and Host tabs, the update indicator, Settings /
|
||||
Integrations / About buttons, and the window drag area and buttons. Only the
|
||||
selected view is mounted; form state lives in `HomeShell`. Join
|
||||
(`join_tab.dart`): one compact address/port/password row, then up to 6
|
||||
Recent server tiles in a grid that adds columns as the window widens. While
|
||||
the Join tab is visible each tile polls the server's `GET /health` every
|
||||
10 s (`health_probe.dart`) for online, name, viewer count and a "needs
|
||||
update" badge when `proto` differs from ours. Host (`host_tab.dart`): the
|
||||
media picker (`media_picker.dart`: Jellyfin, Link, File; a reusable widget
|
||||
driven by a `MediaPickerController` whose `selection` is a sealed
|
||||
`PickedMedia`: `LinkMedia`, `FileMedia` or `JellyfinMedia`, see
|
||||
`picked_media.dart`) fills the main area; port, password and Host sit in a
|
||||
340 px column on the right, below the picker under 860 px. With the
|
||||
Jellyfin source the browser fills the height and can be enlarged, which
|
||||
turns the column into a one-row bar under it. Text fields have fixed or capped widths (720 px max) so
|
||||
they stay compact on ultrawide screens. Submit state (busy, cancel,
|
||||
per-tab errors, server crash) is `ConnectFlow`. Settings (profile, hardware
|
||||
decoding, experimental mpv-style controls, Jellyfin auto-advance, updates,
|
||||
video shaders, shortcuts), Integrations
|
||||
(linked Jellyfin servers) and About open in the same body. Settings persist
|
||||
in two Hive boxes: `connect_form` (last form and recents; saved even when a
|
||||
join fails) and `settings`. Link media names are derived from the URL path
|
||||
and never contain a query string.
|
||||
One window, `AppShell` (`lib/src/shell/`), whatever the connection state
|
||||
(online = `SessionState.route` is `session`). Motion is shared
|
||||
(`motion.dart`): one curve (`fastEaseInToSlowEaseOut`, 320 ms), a fade
|
||||
through for views that replace each other (the old one gone after 35%, the
|
||||
new one fading and scaling in from 0.985), `showPopover` (cards that fade
|
||||
and scale in from where they open; they scroll when the window is short)
|
||||
and `HScroll` (horizontal rows: mouse drag, the vertical wheel scrolls them
|
||||
sideways until an end, then the page).
|
||||
|
||||
- **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card),
|
||||
the Library / Schedule / Settings tabs with a growing underline (an
|
||||
`HScroll` when narrow), the update indicator, then offline Join and Host
|
||||
(connect cards), online the party: `AvatarStack` (host first, then
|
||||
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). Empty strip space is the window
|
||||
drag area; the window buttons sit at its end. Debug builds add a button
|
||||
that opens the layout mock (`screens/demo/`).
|
||||
- **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 (only your own app knows it hosts: the
|
||||
state has no host flag) 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`.
|
||||
Colours are local: yours is picked in the profile, others' come from
|
||||
their name.
|
||||
- **Connect cards** (`connect_cards.dart`): Join lists recent servers as
|
||||
one-click rows that poll `GET /health` every 10 s while the card is open
|
||||
(`health_probe.dart`: online, name, viewer count, "needs update" when
|
||||
`proto` differs from ours), then address, port and password. Host takes
|
||||
a port and a password (hosted ports as chips) and puts nothing up: what
|
||||
to watch is picked in the library afterwards. Submit state (busy,
|
||||
cancel, per-card errors) is `ConnectFlow`, owned by the shell so an
|
||||
attempt and its error outlive the card; a card closes itself once
|
||||
online.
|
||||
- **Profile** (`profile_card.dart`, `screens/home/profile.dart`): a card
|
||||
tinted with your colour: picture (hover "Change" opens `CropPicker`:
|
||||
drag, wheel or slider zoom, previews at 88/42/24 px, exports a 256 px
|
||||
PNG), name edited in place, colour swatches, links to the Settings pages.
|
||||
`Profile` stores the name in the connect form and the PNG and colour in
|
||||
the `connect_form` box (an old picture path is shrunk once and kept as
|
||||
bytes). Online, a name change (once typing settles) or picture change
|
||||
goes out through `Profile.push` (`coordinator.updateProfile`).
|
||||
- **Tabs**: Library (`library_tab.dart`) is the media picker
|
||||
(`media_picker.dart`: Jellyfin, Link, File; a `MediaPickerController`
|
||||
whose `selection` is a sealed `PickedMedia`, see `picked_media.dart`)
|
||||
with "Put it up for everyone" (`screens/session/swap_panel.dart`); it
|
||||
starts on what was last put up. Offline it only browses. Schedule is a
|
||||
placeholder. Settings (`settings_tab.dart`) has a left nav (chips under
|
||||
760 px) over the settings panels (`screens/home/settings_page.dart`):
|
||||
profile, player (hardware decoding, mpv-style controls, shaders),
|
||||
Jellyfin & integrations (linked servers, auto-advance), updates,
|
||||
shortcuts, about.
|
||||
- **Player** (`session_player.dart`): `SessionPlayer` holds the one
|
||||
`Video` of the current player (a new `GlobalKey` per player generation).
|
||||
The shell keeps it in one layer of the body at one place in the element
|
||||
tree: full it fills the body, mini it is 384x216 in a corner (drag it, it
|
||||
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
|
||||
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
|
||||
full and nothing is in front of it. Something coming up while the player
|
||||
is closed opens it: full right after joining or after putting it up
|
||||
here, else mini. While held, a pill under the picture names who
|
||||
everyone waits for, or says "Waiting for you · press Space" (a click
|
||||
marks you ready); the mini shows it in its hover bar.
|
||||
- **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right
|
||||
(strip button or Tab): chat with avatars, other activity as quiet lines.
|
||||
**Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with
|
||||
the actor's avatar when the text starts with a member's name; each
|
||||
slides in, stays 5 s, then slides out and collapses its gap.
|
||||
|
||||
Settings persist in two Hive boxes: `connect_form` (last form, recents and
|
||||
the profile picture and colour; saved even when a join fails) and
|
||||
`settings`. Link media names are derived from the URL path and never
|
||||
contain a query string.
|
||||
|
||||
Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
|
||||
- `accounts.dart`: `JellyfinAccounts` (singleton `instance`), one
|
||||
@@ -555,34 +613,37 @@ Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
|
||||
key changes only when the image does. An inspector pane follows
|
||||
hover/focus from 1400 px browser width.
|
||||
- Picking an item gives a `JellyfinMedia` (serverId, baseUrl, itemId,
|
||||
mediaSourceId?, title, subtitle, runtime; no credentials). The Host tab
|
||||
remembers the last pick in the `jellyfin` box and hosts it as a ref
|
||||
(`hostJellyfinMedia`, `ConnectFlow.submit(media:)`); the hosted server
|
||||
gets the relay credential (`relayCredentialFor`: the account of the
|
||||
mediaSourceId?, title, subtitle, runtime; no credentials). The library
|
||||
remembers the last pick in the `jellyfin` box and puts it up as a ref
|
||||
(`putUpJellyfinMedia`, `swapPicked`); a hosted server gets the relay
|
||||
credential (`relayCredentialFor`: the account of the
|
||||
item's server, else the default one, not expired; its token with
|
||||
exactly `JellyfinAccounts.clientInfo`'s device fields).
|
||||
- Browser location: every navigation saves the route stack per server
|
||||
(`browse:<serverId>` in the `jellyfin` box, `encodeBrowseStack`: ids,
|
||||
names and the fields pages start from), so the Host tab, the swap panel
|
||||
and the next start open where the browser was. A `BrowseStart` overrides
|
||||
it: the swap panel opens on the series of the episode playing, its
|
||||
names and the fields pages start from), so the library and the next
|
||||
start open where the browser was. A `BrowseStart` overrides it: "Change
|
||||
video" opens the library on the series of the episode playing, its
|
||||
season selected and the episode highlighted.
|
||||
|
||||
Session controls: media_kit's `MaterialDesktopVideoControls` with our
|
||||
bars (`screens/session_screen.dart`, pieces in
|
||||
`screens/session/player_controls.dart`). Its seek bar is off; ours
|
||||
bars (`shell/session_player.dart`, pieces in
|
||||
`screens/session/player_controls.dart`); its own double-click and keys are
|
||||
off (its double-click pushes a fullscreen route with a second `Video`), the
|
||||
picture's double-click is a raw pointer observer (`DoubleClickListener`).
|
||||
The top bar holds the title, Change video, chat and back-to-library. Its seek bar is off; ours
|
||||
(`ChapterSeekBar`: chapter diamonds, hover time with the chapter title)
|
||||
sits in the bottom bar, which is taller by the bar's height (the top bar
|
||||
keeps its row at the top). A drag sends no hover events, so the bar keeps
|
||||
the controls up through a guarded dynamic `onHover()` on media_kit's
|
||||
private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED /
|
||||
1:30, Next episode) and cards live outside the auto-hiding controls, so
|
||||
they show in fullscreen (whose route renders only the video and controls).
|
||||
they stay up when the controls hide.
|
||||
|
||||
Experimental mpv-style controls (`controls/uosc/`, after mpv's uosc theme;
|
||||
Settings toggle `AppSettings.mpvControls`, default off): the `Video`'s
|
||||
controls builder wraps both sets in `SessionControlsSwitch`, which follows
|
||||
the setting live, in the window and in fullscreen. `UoscControls` is our
|
||||
the setting live. `UoscControls` is our
|
||||
own frame (no media_kit controls state): mouse movement shows it, 3 s
|
||||
without any hides it and the cursor; `hold` keeps it up during a timeline
|
||||
drag and while a menu route is open, double-click toggles fullscreen.
|
||||
@@ -591,19 +652,20 @@ drag and while a menu route is open, double-click toggles fullscreen.
|
||||
diamonds on the top edge, current time and duration in boxes inside it, a
|
||||
hover tip with the chapter title over the hover time; hidden, it shrinks
|
||||
to a 3 px line with chapter ticks. Above it a row of flat buttons: menu (≡:
|
||||
change video, people, reload, mark everyone ready, fullscreen, leave),
|
||||
change video, chat & activity, reload, mark everyone ready, fullscreen,
|
||||
leave),
|
||||
subtitles and audio with track counts, a shader badge ("A HQ", opening the
|
||||
shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30,
|
||||
chapters, previous / next episode, change video, people, volume,
|
||||
fullscreen. Menus (`showUoscMenu`: a popup route above the button, the
|
||||
chapters, previous / next episode, change video, volume, fullscreen; chat
|
||||
and back-to-library (fullscreen: exit) top right. Menus (`showUoscMenu`: a popup route above the button, the
|
||||
current row highlighted) list tracks and chapters with start times. All
|
||||
actions are the coordinator's (seeks through `player.seek`, the user path;
|
||||
`selectTrack`, `skipForward`, `seekTo`); keys and the floating pills are
|
||||
the same in both sets.
|
||||
|
||||
Window chrome is frameless (`window_manager`): every screen brings its own
|
||||
drag area and min/max/close buttons (Home's top bar; the session uses
|
||||
`widgets/window_chrome_bar.dart`; both use `window_buttons.dart`); the session drag layer is `window_drag_layer.dart`.
|
||||
Window chrome is frameless (`window_manager`): the party strip is the drag
|
||||
area and holds the min/max/close buttons (`widgets/window_buttons.dart`);
|
||||
fullscreen has neither.
|
||||
|
||||
## Health reporting
|
||||
|
||||
@@ -614,7 +676,7 @@ 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`,
|
||||
`proto`, `viewers`, `bytesServed`, `uptimeSecs`); it is unauthenticated, so
|
||||
it carries nothing sensitive. Home's recent-server tiles poll it for live
|
||||
it carries nothing sensitive. The Join card's recent servers poll it for live
|
||||
status.
|
||||
|
||||
## Updater
|
||||
@@ -634,7 +696,7 @@ running exe on NTFS. The manifest's `assets` maps `linux-x64` /
|
||||
`windows-x64` to archive URLs; top-level `url` (Linux) is for builds <= 0.2.1. The version is
|
||||
stamped with `--dart-define=APP_VERSION` (dev runs report `0.0.0-dev` and skip
|
||||
checks). `watch_together --upgrade` (or the shipped `update.sh`) runs it from
|
||||
the CLI; `UpdateController` runs the same code for Home's update indicator,
|
||||
the CLI; `UpdateController` runs the same code for the strip's update indicator,
|
||||
which opens the update card, and restarts the app ("Update and restart").
|
||||
Settings has Check now and an auto-check toggle. The manifest is `updates/manifest.json` in
|
||||
this repo, written by `tool/release.sh`.
|
||||
@@ -667,5 +729,7 @@ this repo, written by `tool/release.sh`.
|
||||
their machine. Prior art (Plex Relay, Tailscale DERP) relays as a
|
||||
bandwidth-capped fallback on someone else's servers; here the server is
|
||||
the owner's, so it carries full quality.
|
||||
- Avatars ride in the 1 Hz `state` broadcast (cap 256 KiB base64 per avatar):
|
||||
simple, costs host uplink; hash-based serving is on the roadmap.
|
||||
- Avatars by content hash: `state` carries only `avatarHash`; the server keeps
|
||||
the bytes of current members in memory and serves them at `/a/...`, which
|
||||
clients fetch once per hash (`AvatarCache`). Uplink per broadcast stays
|
||||
small whatever the picture size (cap 256 KiB base64 per avatar).
|
||||
+17
-11
@@ -64,9 +64,10 @@ and `e2e_check` (simulated viewers against a live server).
|
||||
- Desktop tests that write files use `WT_TEST_TMP` (default: system temp).
|
||||
|
||||
Traps:
|
||||
- Never pump the session screen or media_kit `Video` in `flutter test`: the
|
||||
runner hangs (kill the `flutter_tester` by PID). Test pure logic
|
||||
(`StateDiff`, coordinator without widgets) instead.
|
||||
- Never pump media_kit's `Video` in `flutter test`: the runner hangs (kill
|
||||
the `flutter_tester` by PID). `AppShell(videoBuilder:)` takes a stand-in,
|
||||
so shell tests run the real player layout without it; otherwise test pure
|
||||
logic (`StateDiff`, coordinator without widgets).
|
||||
- Never depend on a server-side WebSocket `onDone`; assert presence with
|
||||
deadline polling.
|
||||
- `sub.asFuture()` can resolve before `onDone`; poll with a deadline.
|
||||
@@ -125,14 +126,19 @@ Host. Without the app: `dart run packages/server/bin/server.dart`.
|
||||
- Overlays that are hidden but mounted keep focus (Space falls into a hidden
|
||||
button). Use `ExcludeFocus` on closed subtrees and unfocus on close; reuse
|
||||
the shared no-focus node for overlay buttons.
|
||||
- `modifyVolumeOnScroll: false` on the session controls is load-bearing for
|
||||
window drag (volume is the speaker button only). A `GestureDetector` below
|
||||
media_kit's overlay loses the gesture arena; the drag layer is a raw pointer
|
||||
observer on top.
|
||||
- New screens must bring their own drag area and window buttons (frameless
|
||||
window).
|
||||
- `toggleFullscreenOnDoublePress: false` on the session controls is
|
||||
load-bearing: media_kit's double-click pushes its own fullscreen route with
|
||||
a second `Video`. A `GestureDetector` below media_kit's overlay never sees
|
||||
the click (its `MouseRegion` takes the hit) and one around it would delay
|
||||
every button click (double-tap holds the arena), so the double-click is a
|
||||
raw pointer observer (`DoubleClickListener`).
|
||||
- The window is frameless: the party strip is the only drag area and holds
|
||||
the window buttons.
|
||||
- Signal-backed singletons (`Profile`, `SessionState`) live across tests;
|
||||
never cache a `Future` in them (each test runs in its own zone, and a
|
||||
Future from an earlier test never completes in the next).
|
||||
- Chat arrives twice from the server (see PROTOCOL.md); the client drops the
|
||||
textless feed copy.
|
||||
- Tab traversal is disabled on the session screen on purpose; Tab toggles the
|
||||
side panel.
|
||||
- Tab traversal is disabled in the session player on purpose; Tab toggles
|
||||
chat & activity.
|
||||
- After editing constants (ports, thresholds), grep to confirm the value landed.
|
||||
@@ -44,7 +44,7 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
|
||||
- **Dedicated server**: `watch-together-server`, a separate program in the
|
||||
release tarball, for an always-on box. See the README.
|
||||
|
||||
Friends join either way with the Join card on Home.
|
||||
Friends join either way with Join in the strip at the top of the window.
|
||||
|
||||
## One session, start to finish
|
||||
|
||||
|
||||
+34
-10
@@ -1,4 +1,4 @@
|
||||
# Protocol (proto = 8)
|
||||
# Protocol (proto = 9)
|
||||
|
||||
Wire format only. Behavior (gate, clock, timeouts) is in
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
|
||||
@@ -23,12 +23,13 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||
serves the host's file (Range table in ARCHITECTURE.md).
|
||||
`GET /j/<sid>/<token>/<itemId>` relays the current Jellyfin media (see
|
||||
"Jellyfin media" below). `/r/<sid>/<token>/...` is the relay of a
|
||||
member's media (see "Relay" below).
|
||||
member's media (see "Relay" below). `GET /a/<sid>/<token>/<hash>` serves
|
||||
a member's picture (see "Avatars" below).
|
||||
|
||||
## Client to server
|
||||
|
||||
```
|
||||
{"t":"hello","proto":7,"secret":"","user":{"name":"","avatar":""},
|
||||
{"t":"hello","proto":9,"secret":"","user":{"name":"","avatar":""},
|
||||
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
|
||||
"resume":"<token>"}
|
||||
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
|
||||
@@ -42,6 +43,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||
{"t":"ping","c":1767225600000}
|
||||
{"t":"chat","text":"..."}
|
||||
{"t":"kick","target":"<memberId>","reason":""}
|
||||
{"t":"profile","name":"...","avatar":"<base64>"}
|
||||
```
|
||||
|
||||
Field notes:
|
||||
@@ -68,8 +70,13 @@ Field notes:
|
||||
Sticky: a report without `jf` keeps the last one. At most 8 entries, keys
|
||||
up to 64 characters; others are dropped.
|
||||
- `ping.c`: the client's clock in epoch ms; answered with `pong`.
|
||||
- `user.avatar`: base64 image, at most 256 KiB of characters. `name` is
|
||||
trimmed, empty becomes `?`, cut to 32 characters.
|
||||
- `user.avatar`: base64 PNG, at most 256 KiB of characters (else
|
||||
`rejected: ...`). `name` is trimmed, empty becomes `?`, cut to 32
|
||||
characters.
|
||||
- `profile`: changes this member's name and/or picture mid-session. A field
|
||||
that is absent (or null) stays as it is; `avatar: ""` removes the picture.
|
||||
Same rules as `hello.user`; a too large avatar gets `err`
|
||||
`rejected: avatar too large` (the socket stays open, nothing changes).
|
||||
- `chat.text` is cut to 500 characters.
|
||||
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
|
||||
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
|
||||
@@ -85,7 +92,7 @@ Field notes:
|
||||
{"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3,
|
||||
"serverTime":"<ISO-8601 UTC>","mediaId":1,
|
||||
"media":{name,size,duration,url?,jellyfin?}|absent,
|
||||
"members":[{"id","name","avatar","ready","inSet","buffered","stale","jf"?}],
|
||||
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?}],
|
||||
"seekBy":"m1","seekSeq":3,"relay":true}
|
||||
{"t":"pong","c":1767225600000,"s":1767225600012}
|
||||
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
|
||||
@@ -111,19 +118,36 @@ Field notes:
|
||||
it. `seekBy` (member id) and `seekSeq` are present only while the current
|
||||
`eventId` came from a seek. `relay` (omitted when false): the server can
|
||||
relay the current Jellyfin media. `members[].jf` is the member's last
|
||||
`report.jf`, omitted when it never sent one.
|
||||
`report.jf`, omitted when it never sent one. `members[].avatarHash`
|
||||
(omitted when the member has no picture) names the picture's bytes (see
|
||||
"Avatars").
|
||||
- `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms
|
||||
(the clock `serverTime` uses). With `t3` the local receive time, the offset
|
||||
is `s - (c + t3) / 2`, accurate to half the round trip.
|
||||
- `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`,
|
||||
`force-play`, `force-hold`, `kick`, `join`, `leave`, `stale`, `swap`,
|
||||
`seek`, `pause`, `resume`, `finished`, `chat`. `target` and `reason` are optional;
|
||||
`reason` carries `auto`/`manual` for ready and `silent too long` or
|
||||
`no signal` for leave/stale.
|
||||
`seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`. `target`
|
||||
and `reason` are optional; `reason` carries `auto`/`manual` for ready and
|
||||
`silent too long` or `no signal` for leave/stale. `rename`: `actor` is the
|
||||
new name, `target` the old one. `avatar`: the actor changed only the
|
||||
picture.
|
||||
- Chat is delivered twice: a `chat` frame with the text and a textless
|
||||
`feed` with `action:"chat"`. Render the `chat` frame only; the reference
|
||||
client drops the textless feed entry.
|
||||
|
||||
## Avatars
|
||||
|
||||
- The picture is sent as base64 in `hello.user.avatar` or `profile.avatar`;
|
||||
`state` carries only its hash: the first 16 hex characters of the sha256
|
||||
of the decoded bytes. An avatar that is not valid base64, or whose bytes
|
||||
are not a PNG, JPEG, WebP or GIF (by magic bytes), counts as none.
|
||||
- `GET /a/<sid>/<token>/<hash>`: the bytes with their image content type,
|
||||
`cache-control: private, max-age=31536000, immutable` (a hash names fixed
|
||||
bytes; clients fetch each hash once, from the host they dialled).
|
||||
Errors: 401 bad token, 403 kicked, 404 no current member has that
|
||||
picture, 410 unknown session. Pictures go with their member (leave,
|
||||
change, removal).
|
||||
|
||||
## Jellyfin media
|
||||
|
||||
```
|
||||
|
||||
@@ -42,8 +42,6 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
outage with cache exhaustion (`tool/shaper.dart --kill-after`); assert no
|
||||
OOM, no stuck session, drift recovers.
|
||||
- Chat history pane (today: activity log only).
|
||||
- Avatar caching: serve avatar bytes once by hash instead of base64 in
|
||||
every `state` broadcast.
|
||||
- The `state` broadcast carries the full media URL, which can include an
|
||||
`api_key` (recognised Jellyfin links become refs; other links do not).
|
||||
- TLS: document a reverse proxy in front of the server.
|
||||
|
||||
Reference in new issue
Block a user