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
+61
-20
@@ -80,14 +80,22 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
server stores `buffered` and liveness, and takes the first reported
|
||||
duration for the current media (swap resets it); a report with `eventId`
|
||||
older than current has its position ignored.
|
||||
- **Ready gate** (HELD to PLAYING): the in-set is non-empty, every in-set
|
||||
member is ready, fresh and has `buffered >= need`, and quorum holds: in-set
|
||||
size >= 2, unless at most one member is fresh. `need` is 4 s
|
||||
(`minBufferSecs`), or near the end what is left minus 2 s (`endSlackSecs`,
|
||||
floor 0), since mpv cannot buffer past the end. So a lone viewer plays
|
||||
alone; with a second viewer present one Space waits for the other. Joining
|
||||
members start with `inSet=false, ready=false` and never block. Disconnect
|
||||
removes from the set; swap clears everyone.
|
||||
- **Ready gate** (HELD to PLAYING): the in-set is non-empty and every
|
||||
in-set member is ready, fresh and has `buffered >= need`. `need` is 4 s
|
||||
(`minBufferSecs`), or near the end what is left minus 2 s
|
||||
(`endSlackSecs`, floor 0), since mpv cannot buffer past the end. So a
|
||||
lone viewer plays alone; with others present everyone must ready.
|
||||
- **In-set** (who the gate waits for): a member joins it on joining, or
|
||||
on coming back from stale, while not PLAYING (any media, the one the
|
||||
server started with included); joining while PLAYING it stays out
|
||||
(`ready=false`) until it readies: a late joiner never pauses anyone. A
|
||||
swap puts every fresh member in the set and keeps everyone's ready flag,
|
||||
so the next video starts once everyone ready has buffered (the client
|
||||
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.
|
||||
- **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.
|
||||
@@ -119,7 +127,8 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
user is not seeking.
|
||||
- **Liveness**: WS ping every 5 s; silent over 15 s means stale (out of the
|
||||
gate, `stale` feed); over 30 s the socket is closed and the member removed.
|
||||
A stale member that was ready goes back into the set with its next frame.
|
||||
A stale member goes back into the set with its next frame if it is ready
|
||||
(also one stale at a swap) or the server is not PLAYING.
|
||||
Closed sockets are polled with `isClosed` every second: Relic's server-side
|
||||
`onDone` does not fire for dead peers, so presence never relies on it.
|
||||
- **Resume**: a reconnecting client sends its previous token as
|
||||
@@ -137,7 +146,7 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
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.
|
||||
- **Deviations from Syncplay**: clock never follows the slowest watcher; no
|
||||
pause-on-leave; anyone can force ready/unready (all or one); quorum gate;
|
||||
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).
|
||||
|
||||
Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
|
||||
@@ -285,8 +294,9 @@ Skip rules (pure, `media_marks.dart`):
|
||||
- `skipOfferAt` (the coordinator's `skipOffer`, null at `finished`): inside
|
||||
the opening (up to its last second) "Skip OP" to its end; inside the
|
||||
ending "Skip ED" to its end only (no guessing about previews: the viewer
|
||||
decides). Without any opening and with media over 20 min, "Skip 1:30"
|
||||
during the first 6 min.
|
||||
decides). Without any opening, without chapters (a chaptered file
|
||||
without an OP chapter has none on purpose) and with media over 20 min,
|
||||
"Skip 1:30" during the first 6 min.
|
||||
- +1:30 (`skipForward`: the pill, the controls' "1:30" button,
|
||||
Shift+Right) is a `nudge`, so repeated presses add up.
|
||||
- Chapter steps (PgUp / PgDn, `chapterStepTarget`): forward to the next
|
||||
@@ -516,7 +526,7 @@ 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),
|
||||
- **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card; 42 px like the party's),
|
||||
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
|
||||
@@ -571,8 +581,10 @@ sideways until an end, then the page).
|
||||
(`library_link.dart`): paste field, a preview (YouTube oEmbed title and
|
||||
thumbnail, else the file name), the three relay modes online, and the
|
||||
last 12 links put up (`recentLinks` in the `connect_form` box). Files
|
||||
is `FilesPage` (`files/`). Ctrl+V outside a field pastes a link into
|
||||
Link. Offline everything browses; the buttons say "Join or host to
|
||||
is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
|
||||
an isolate, videos grouped by series; series start collapsed, a header
|
||||
click opens one and open ones stay open for the app session
|
||||
(`showFilesFolder` opens a folder from outside). Offline everything browses; the buttons say "Join or host to
|
||||
watch together". Schedule (`schedule/`) is a week of day columns built
|
||||
from AniList's airing schedule (`anilist.dart`: pages cached 10 min in
|
||||
memory and 1 h on disk, stale data shown on 429 or failure) matched
|
||||
@@ -589,6 +601,17 @@ sideways until an end, then the page).
|
||||
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
|
||||
`AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds
|
||||
(what pops up; no switches), updates, shortcuts, about.
|
||||
- **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one
|
||||
`DropTarget` (a "Drop to open" overlay while dragging) and a
|
||||
`HardwareKeyboard` handler for Ctrl+V outside a text field (clipboard
|
||||
files first, then text). `classifyHandoff` (pure) turns paths or text
|
||||
into videos (the Files extensions, `kindOf`), a folder, an http(s) link,
|
||||
an image or nothing; videos win over folders, folders over images.
|
||||
`HandoffCard` opens as a popover at the pointer: a video goes up through
|
||||
the shell's put-up (offline: into Files → Recent) or adds its folder to
|
||||
Files; a folder is added to Files and opened; a link fills Library →
|
||||
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).
|
||||
@@ -614,7 +637,13 @@ sideways until an end, then the page).
|
||||
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
|
||||
full and nothing is in front of it; ↑ / ↓ step the volume. Space (ready)
|
||||
and Tab (chat) are app-wide: `AppKeys` (`widgets/app_keys.dart`, around
|
||||
the shell) takes them first while in a server, unless a text field has
|
||||
focus or a dialog or menu is in front. Tab reaches the shell's chat
|
||||
toggle through `ChatKey` (the strip while shown, the player in
|
||||
fullscreen). The app's shortcuts map Tab / Shift+Tab to nothing, so focus
|
||||
never lands on a button Space would press. Something coming up while the player
|
||||
is closed opens it: full right after joining or after putting it up
|
||||
here, else mini. Both control designs show `NowPlayingTitle` top left
|
||||
(`now_playing_title.dart`: 2:3 cover, title, episode line from
|
||||
@@ -644,7 +673,12 @@ sideways until an end, then the page).
|
||||
`WindowButtons` to the top bar while the controls are up (restore
|
||||
leaves fullscreen).
|
||||
- **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.
|
||||
(strip button or Tab): chat with avatars; the "Activity" chip (off by
|
||||
default, `showActivity`, kept for the app's run only) adds the other
|
||||
feed lines between them. Opening focuses the field; Tab or Esc in it
|
||||
close the panel and focus goes back where it was. Unread chat badges
|
||||
the strip's and both player designs' chat buttons; in fullscreen with
|
||||
the controls down a glass chip (`PlayerGlass`) top right shows it.
|
||||
**Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with
|
||||
the avatar of their `ToastItem.actor` (feed and chat toasts; the app's
|
||||
own have none); each
|
||||
@@ -719,8 +753,14 @@ 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 stay up when the controls hide.
|
||||
1:30, Next episode) and cards live outside the auto-hiding controls. The
|
||||
Next episode pill and the cards stay up; the skip pill (`SkipPillGate`)
|
||||
shows for 5 s when an offer of a new kind appears, then only while the
|
||||
controls are up (`ControlsHoverProbe` for this design, `UoscControls.shown`
|
||||
for uosc) or the mouse is on it. Volume (`controls/volume.dart`, both
|
||||
designs): mpv's `volume-max` is pinned to `PlaybackController.maxVolume`
|
||||
(150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the part
|
||||
above in amber; the wheel and ↑ / ↓ step 5 %, clamped.
|
||||
|
||||
The uosc player design (`controls/uosc/`, after mpv's uosc theme;
|
||||
"Player design" = `AppSettings.mpvControls`, default Standard): the `Video`'s
|
||||
@@ -799,7 +839,8 @@ this repo, written by `tool/release.sh`.
|
||||
- Host by key, not by address: loopback detection broke behind a
|
||||
same-machine reverse proxy or tunnel.
|
||||
- Server owns the clock; reports never move it: avoids ping-pong between clients.
|
||||
- Quorum gate: a lone viewer must be able to watch; two or more must start together.
|
||||
- Presence gate: a lone viewer must be able to watch; everyone present
|
||||
while the video is stopped must ready before it starts.
|
||||
- Relic for the server, `web_socket_channel` for the client; packages are
|
||||
chosen on merit, not locked.
|
||||
- Plain `ws://`, no TLS: friends use LAN, VPN or a forwarded port; TLS
|
||||
|
||||
@@ -52,14 +52,14 @@ Friends join either way with Join in the strip at the top of the window.
|
||||
(version, password, name, avatar). Wrong password or version: refused with
|
||||
a reason. Otherwise the server answers with a welcome and the current state.
|
||||
2. **Catch up.** The app opens the current video, jumps to where the server
|
||||
says we are, and buffers. A new arrival is not ready yet and never holds
|
||||
anyone back.
|
||||
says we are, and buffers. Arriving while the video is stopped, you are
|
||||
waited for; arriving while it plays, you never hold anyone back.
|
||||
3. **Ready gate.** Press Space to say you are ready. The server starts
|
||||
playback only when everyone who is ready has at least 4 seconds buffered
|
||||
(or what is left, near the end). At the end everyone stops and the server
|
||||
stays finished until someone seeks back or changes the video.
|
||||
If two or more people are ready, one person alone cannot start it for the
|
||||
others. A lone viewer can play alone.
|
||||
Everyone who was there before it started must be ready, so one person
|
||||
alone cannot start it for the others. A lone viewer can play alone.
|
||||
4. **Play.** The server owns the clock. Each app compares its mpv position
|
||||
to the clock: a small lead slows mpv a little (0.95x), a big gap (over 5 s)
|
||||
jumps. If someone's buffer runs dry the server pauses everyone and resumes
|
||||
@@ -67,7 +67,8 @@ Friends join either way with Join in the strip at the top of the window.
|
||||
5. **Seek.** Anyone can seek, anytime. The server moves the clock and
|
||||
everybody follows. Seeking does not change anyone's ready flag.
|
||||
6. **Swap.** Anyone can change the video by pasting another link. Everybody
|
||||
stops at 0, ready flags clear, and the gate runs again.
|
||||
stops at 0 and the gate runs again for everyone there; ready flags stay
|
||||
as they were (a setting unreadies you instead).
|
||||
7. **Leave.** Closing or leaving drops you from the server; the others keep
|
||||
going. If you vanish without saying goodbye, you are marked stale after 15 s
|
||||
(stops blocking) and removed after 30 s. The app reconnects by itself and
|
||||
|
||||
+8
-4
@@ -1,4 +1,4 @@
|
||||
# Protocol (proto = 9)
|
||||
# Protocol (proto = 10)
|
||||
|
||||
Wire format only. Behavior (gate, clock, timeouts) is in
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
|
||||
@@ -30,7 +30,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||
## Client to server
|
||||
|
||||
```
|
||||
{"t":"hello","proto":9,"secret":"","user":{"name":"","avatar":""},
|
||||
{"t":"hello","proto":10,"secret":"","user":{"name":"","avatar":""},
|
||||
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
|
||||
"resume":"<token>"}
|
||||
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
|
||||
@@ -84,6 +84,9 @@ Field notes:
|
||||
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
|
||||
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
|
||||
dropped. `swap.media.relay` makes it relayed media (see "Relay").
|
||||
A swap keeps every member's `ready` and puts every fresh member in the
|
||||
set (`inSet`); clients that want to be unready for the new media send
|
||||
`ready` themselves (gate: ARCHITECTURE.md, "Ready gate").
|
||||
- Frames before `welcome` (other than `hello`) are ignored; unparsable or
|
||||
unknown frames are ignored silently.
|
||||
|
||||
@@ -258,8 +261,9 @@ server -> source {"t":"pull","id":1,"start":0,"end":1048575} inclusive
|
||||
## Join and reconnect
|
||||
|
||||
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server
|
||||
position and buffers. A joining member is `ready=false, inSet=false` until
|
||||
the first `ready`. A socket the server sees close loses its token; reconnect
|
||||
position and buffers. A joining member is `ready=false`; it is `inSet`
|
||||
at once unless the server is `playing`, then from its first `ready`
|
||||
(members present at a swap are put in the set by it). A socket the server sees close loses its token; reconnect
|
||||
is a fresh `hello` (with `resume` = the old token), after which the client
|
||||
re-sends its prior ready flag. If the old member was still there (unclean
|
||||
drop), `resume` takes it over and the token and media URL stay the same.
|
||||
|
||||
+13
-36
@@ -6,56 +6,33 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
|
||||
## Backlog
|
||||
|
||||
1. **Ready gate and swap (protocol bump).** Bug: three in a server, a file
|
||||
relayed from one; two readied and it played, the third (not ready) was
|
||||
dragged along frame by frame. Members present at a swap must all count
|
||||
for the gate (a joiner after the swap still counts only once ready);
|
||||
a swap keeps everyone's ready flag, with a per-user "Unready when the
|
||||
video changes" setting. Shared auto-skip lives here too (below).
|
||||
1. **Auto-skip OP/ED** (undecided): who decides and how (shared for the
|
||||
server, personal, per show) is open. Constraint: a skip is a seek and
|
||||
seeks are shared, and only real ranges (segments, OP/ED chapters) should
|
||||
ever be skipped.
|
||||
|
||||
2. **Quick UI**
|
||||
- Tab no longer moves focus (Tab toggles chat, then Space pressed a
|
||||
random button); Space is always ready outside text fields.
|
||||
- Opening chat focuses its field; Esc/Tab hands focus back.
|
||||
- Unread badge on uosc's chat button; an unread hint in fullscreen.
|
||||
- Your avatar in the strip the same size as the party's.
|
||||
- Chat panel: people's messages only, a toggle shows activity.
|
||||
- Skip OP/ED: no "Skip 1:30" guess when the file has chapters (a
|
||||
missing OP chapter is on purpose); the pill shows for the first 5 s
|
||||
of the range and again when the mouse moves (like the controls).
|
||||
- Auto-skip OP/ED: a server-wide setting in `state` (anyone toggles it,
|
||||
the feed says who); exactly one client seeks (the media's `swapBy`,
|
||||
else the lowest fresh member id), only for real ranges (segments or
|
||||
OP/ED chapters), as a normal user seek.
|
||||
- Volume above 100% like mpv.
|
||||
2. **Files**: next episode from Files (by parsed episode, with the
|
||||
countdown). Later: tracking for file series.
|
||||
|
||||
3. **Drag & drop and paste** (`fox_utils`): drop or Ctrl+V anywhere; a
|
||||
small card asks how to open it (file: put up / add its folder to Files;
|
||||
link: put up with the link modes; image: the avatar editor when open).
|
||||
|
||||
4. **Files**: detected series groups start collapsed (big libraries);
|
||||
next episode from Files (by parsed episode, with the countdown). Later:
|
||||
tracking for file series.
|
||||
|
||||
5. **yt-dlp** (epic): players that open links themselves use mpv's ytdl
|
||||
3. **yt-dlp** (epic): players that open links themselves use mpv's ytdl
|
||||
hook (bundled on Windows); then relay of single-file formats; then a
|
||||
resolver for the Link preview (title, thumbnail, duration).
|
||||
|
||||
6. **AniList account**: sign in (OAuth via a localhost callback), "My list"
|
||||
4. **AniList account**: sign in (OAuth via a localhost callback), "My list"
|
||||
filter on the Schedule; today's "Following" (AnimeFlow's monitored
|
||||
shows) becomes "On the server".
|
||||
|
||||
7. **Ops**: `watch-together-server --upgrade` (a VPS has no GTK for the
|
||||
5. **Ops**: `watch-together-server --upgrade` (a VPS has no GTK for the
|
||||
app's updater); check shaders on Windows; the relay e2e "source drops
|
||||
uncleanly and resumes" fails about half the time under the full suite.
|
||||
|
||||
8. **Windows: first real use.** CI builds and publishes
|
||||
6. **Windows: first real use.** CI builds and publishes
|
||||
`watch-together-<ver>-windows-x64.zip` (libmpv bundled) and runs the
|
||||
updater tests on NTFS; a friend has joined from it. Still unchecked:
|
||||
fullscreen controls, hosting (firewall prompt), in-app update from one
|
||||
release to the next. The rest of the desktop tests are not in CI.
|
||||
|
||||
9. **Relay follow-ups** (see ARCHITECTURE "Relay"). First real use: owner's
|
||||
7. **Relay follow-ups** (see ARCHITECTURE "Relay"). First real use: owner's
|
||||
PC -> VPS -> friend, Jellyfin and a file. Then:
|
||||
- Source takeover: when the source leaves, another member with an
|
||||
account on that Jellyfin (or the same file: name, size, hash) takes
|
||||
@@ -65,9 +42,9 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
- Show who is relaying and how fast in the member list; warn the source
|
||||
when its upload is below the media's bitrate.
|
||||
|
||||
10. **Jellyfin** (epic, below).
|
||||
8. **Jellyfin** (epic, below).
|
||||
|
||||
11. **Smaller items**
|
||||
9. **Smaller items**
|
||||
- Video output robustness: if media_kit's hardware path regresses to
|
||||
software (see ARCHITECTURE, Media: render mode), vendor a
|
||||
media_kit_video fix that builds the EGL display from GDK's native
|
||||
|
||||
Reference in new issue
Block a user