Files
sync_replay_project/HANDOFF.md
T
foxyz b0d93758f9 CI release pipeline in .gitea, wishlist into backlog
release.yml (tag v* or manual dispatch): beta Flutter per local
convention, Linux build deps, package script tarball, artifact
upload, then release_publish.sh creates the Forgejo release,
uploads the asset, and commits updates/manifest.json back to
main. Workflows moved .github -> .gitea to match the forge setup.
Wishlist.md consumed into roadmap backlog: EOF stall + tiny buffer
bugs prioritized, recovery controls, link conveniences, time UI,
shortcuts, shaders, avatar serving.
2026-09-12 20:38:17 +03:00

322 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# HANDOFF — sync_relay_project (watch-together app)
Date: 2026-09-12 (updated late session). Phases 0–4 + presence hardening
+ UI redesigns done, all green (core 42, server 41, desktop 24).
Phases retired as a tracker 09-12 — `docs/ROADMAP.md` is now a flat
backlog (Built / Backlog / Epics / Deferred); phase text lives in
git history.
Black screen (§8) DIAGNOSED + FIXED (avatar-texture teardown race;
Visibility keep-mounted) + VERIFIED, incl. over internet. Bisect
screens (legacy/minimal/nosheet) REMOVED 09-12 — app.dart routes
session straight to SessionScreen. Internet test PASSED (WAN port
6969, 09-12); user reports 2-client LAN soak + 1.4 GB real-file
test also done 09-12 (outcomes not yet recorded below). Tonight's
fixes: server logging wired into app (`initLogging` + hello logs),
Cancel aborts pending connect, first-join failure no longer
retry-loops. Git repo INITIALIZED (commit 427d851, root
.gitignore). Prior chat may be unreliable; this file plus `docs/`
is the full state (`docs/ROADMAP.md` is now the phase tracker).
## 1. What this is
Private watch-together desktop app (Windows + Linux). Host serves a file
or shared link from their own machine; 4–8 friends watch in sync.
One binary, three modes: Client (Join), Server+Client (Host, opens
ports, self-plays via loopback), `--headless` (server only, no window).
North star: <1.5 s drift, seeks settle ~2 s, cache rides short drops,
kick kills sync+video, no relay memory growth.
## 2. Locked architecture (do not reopen without the user)
- `packages/core`: protocol, sync engine, media abstractions. ZERO Flutter
imports (enforced by test). `packages/server`: Relic-based room + media
server, also zero Flutter imports. `apps/desktop`: the ONE Flutter
binary embedding both. Direct-serve model: host opens ports, clients
open nothing. NO host-upload tunnel (the brief's WSS chunk protocol is
obsolete unless a VPS tier ever needs remote-host upload).
- Server HTTP+WS: **Relic ^1.2.0**. Client WS: **web_socket_channel**.
Server lib also uses **web_socket** for event types.
- PACKAGES ARE NOT LOCKED (user decision 09-11, overrides the old brief
list): pick on merit, no churn where the stack works. Current desktop
additions: media_kit_video + media_kit_libs_video (required),
file_picker (avatar/media picking), hive_ce + hive_ce_flutter
(connect-form persistence), image (avatar shrink). signals + media_kit
+ Relic stay (load-bearing).
- Sync: heavily Syncplay-inspired + original readiness design, NOT 1:1.
Reference clone (scratch, may be gone — re-clone shallow):
`git clone --depth 1 https://github.com/Syncplay/syncplay.git /tmp/opencode/syncplay-ref`
Audit already done; conscious deviations: clock never moves from
reports (theirs follows slowest watcher), no pause-on-leave,
force-for-all instead of operator-only setOthersReadiness, and
QUORUM gate (09-12, user found solo-play meaningless): HELD→PLAYING
needs in-set size ≥ 2 unless ≤1 member is fresh — lone watcher plays
alone, second watcher present ⇒ one Space waits. Late joiners outside
the set never block.
- Single room password (one secret, persisted in Hive on the user's own
box — accepted). No op roles (deferred: op flag + second hello
secret). Kick = transient nudge: revoke + close + `kicked` reason,
instant rejoin with fresh token.
- Deferred, do not build: TLS/Caddy, Jellyfin native, updater
expansion (v1 `--upgrade` SHIPPED 09-12 — see §9.12; in-app
prompt + Windows helper still deferred), playlist (extension shape reserved in PROTOCOL.md), op roles, sounds
(researched, wired, no assets — see §7).
- Standing rules: health reporting (every failure displays, nothing
stalls silently); proto bump + PROTOCOL.md same-commit on ANY
wire change (still v4 — quorum/timeouts are server policy, no bump).
## 3. Repo map (root: Flutter/sync_relay_project/)
- `packages/core/lib/src/`: constants.dart (tuning: seek 300 ms,
drift 5 s, slow 1.5 s @0.95, min_buffer 4 s, heartbeat 1 s, ping 5 s,
avatar 256 KiB (base64 chars; wide headroom over any 128x128 PNG
— raised 09-12 from 64 KiB, which rejected normal PNGs), name 32, chat 500; dropAfter lives as RoomEngine ctor
default, now 30 s), errors.dart, logging.dart, config.dart,
protocol.dart (v4 schemas), media_source.dart, sync_engine.dart
(RoomEngine + quorum in evaluate's HELD branch; PLAYING branch
unchanged).
- `packages/server/lib/src/`: session.dart, media_endpoint.dart,
room_hub.dart (forgetSocket, sweepClosedSockets via `isClosed` poll,
closeAllSockets, onError→forgetSocket), room_server.dart (1 Hz
heartbeat: sweepStale + sweepClosedSockets + dropSilentSockets +
broadcast; stop() closes client sockets first). `bin/server.dart`.
- `apps/desktop/lib/src/`: sync/ (sync_client.dart: hello/intents/
fan-out/match-by-`t`; room_diff.dart: diff + debounce + play gate),
playback/ (media_kit Player, 64 MB cache, pin props, 0.5 s seek
deadband, stop() on leave, SYNC_RELAY_VO/HWDEC env overrides),
session/ (session_coordinator.dart: THE brain — room→player,
reports, scrub/nudge→seek intents incl. HELD, seek-land retry,
idle 8/10 min, reconnect loop, 5 s silence timeout; health.dart),
sfx/ (SfxKind/Cue/Service + NoopSfxService — sounds deferred),
state/ (session_state.dart: route/mode/lastForm/prefs/host/join/
leave; connect_prefs.dart), screens/ (welcome/connect/session),
widgets/ (user_section.dart: ConnectProfileCard; member_avatar.dart:
THE avatar — memoized, no bg on images, dark ring+fallback;
dialogs.dart: kick confirm; media_input.dart: parseMediaInput).
- `apps/desktop/test/`: room_diff_test (9), media_input_test (6),
updater_test (7: version/manifest/layout + stub-server flows),
sync_playback_e2e_test (quorum: solo Space holds, both ready plays;
needs mpv + ~/Videos/chiyo.mp4), coord_presence_test (dead server
clears + reconnects; needs mpv).
- `tool/`: range_logger, shaper (chaos: KB/s cap, latency,
--kill-after), fake_viewer, e2e_check (sim timeline, still ALL PASS
post-quorum), package_linux.sh (release tarball + Discord card,
stamps APP_VERSION).
- `docs/`: PROTOCOL.md (v4 + quorum + 15/30 s timeouts + isClosed note,
normative), ARCHITECTURE.md (+health rule), mpv-http-contract.md.
- `deploy/`: systemd unit. Git repo initialized 09-12 (`git init`,
root `.gitignore`, initial commit 427d851 covering phases 0–4 +
tonight's connect/logging fixes). Bisection is possible again.
## 4. Protocol v4 + policy (see PROTOCOL.md for normative detail)
hello→welcome(token, memberId, mediaUrl?, room-state); intents:
ready{ready,source}/force{target:all|memberId,ready}/seek/seek-swap/
report{position,buffered,eventId}/chat/kick{memberId}. Broadcasts:
room @1 Hz + on change (diff by eventId), feed{actor,action,…}
(toasts/log/sounds), kicked{reason}, err. Media:
GET /m/<sid>/<token>/<name> (200/206×3 shapes/416/401/403/410/503).
Transport PLAYING/HELD; gate = non-empty set AND all in-set ready +
buffered≥4 s + fresh AND quorum (in-set≥2 unless ≤1 fresh).
Stale >15 s ⇒ out of gate; silent >30 s ⇒ closed + removed.
Closed sockets swept every second; socket errors remove immediately.
Client: 5 s room-silence ⇒ dead (clear room, warn, reconnect loop
1.5 s polite infinite + manual cancel/retry, prior-flag restore).
## 5. Phase log (all exits met, all green as stated above)
- P0 scaffold + CI + Flutter-free tests; binary binds/logs/exits.
- P1 Range table byte-exact, real mpv traces, shaper built. 503 tested.
- P2 sessions: secret→128-bit token, 401/403/410 gates, kick kills WS+
media, byte counters, fake_viewer ALL PASS live, proto→3.
- P3 engine + readiness: engine tests (fake clock), e2e 15/15 sim,
Syncplay audit. Sanity: NaN guards, caps, stale/leave feeds,
1 Hz flush, token redaction.
- P4 desktop shell→app: SyncClient framing, RoomDiff (eventId diff,
slowdown latch 0.95×/0.1 reset, seek-land check), media_kit wiring
(pinned buffer props, buffered from demuxer), Space=ready, avatars,
toasts+sheet, health display, reconnect loop. Sounds WIRED ONLY
(NoopSfxService) — see §7.
- P5 presence+sync hardening (user-reported, all reproduced live
first): stop() closes sockets; isClosed sweep (~1 s graceful);
onError forget (~2–4 s kill -9, was 56 s ghost); drop 60→30 s;
client silence-timeout + stuck-reconnect-flag fix + ready-future
leak fix; seek race fixed (2 s same-event debounce both
transports, edge-aware detector 1 s guard, 0.5 s apply deadband);
HELD seeks send intents (paused frame moves for all); arrows
nudge ∓10 s; local play gate (in-set+ready); autoplay off;
leave() stops player (was leaking audio); kick confirm; Cancel
connect; connect-form memory→Hive persistence; file/media
Browse buttons.
- P6 UI redesigns (two subagents, disjoint files — repeat this
pattern): full-bleed video + overlays, controls never autohide
(24 h hover) + Ready/Mark-everyone-ready IN the controls bar
(intents only, zero local transport buttons), center avatars-only
64 px, slide-in toasts, animated right sheet (users/log/chat/swap/
Leave w/ confirm), health pill only-when-unwell, ESC closes/
asks-leave, global focus-proof keys (Space/Tab/arrows/ESC/F,
350 ms throttle, ignored while typing; Tab traversal disabled on
the session screen 09-12 — game-style, Tab toggles the sheet), single media field
(parseMediaInput + tests), avatar pipeline (128 px PNG, keeps
transparency — JPEG did not; self-check measures encoded base64
against RelayConstants, single source of truth), tap to
pick, no path fields), Discord-like corner profile (connect only;
welcome is title+Host/Join; edits apply next join — no rename
intent on the wire), dialogs for terminal states.
- UX decisions locked along the way: Space flips self-ready only;
free seeks any transport (no flag churn); join catch-up never
halts; idle 8-min warn + 10-min auto-unready; reconnect = re-hello
with flag restore; kick → dialog + connect screen; fixed 1.5 s
reconnect + cancel/retry; GMod toasts (social only) + side sheet;
avatar fallback = initial circle; "waiting for X (n/m ready)".
- Open user questions for later: warning-tone timing; bar width at
minimum window; second-ESC on leave dialog (re-asks vs cancel);
cancel-stops-host-server (kept: idle means no bound port).
## 6. Gotchas (learned the hard way)
1. Relic gives Range as TYPED RangeHeader — convert via unit+first.
2. Relic server-side onDone DOES NOT fire for dead peers (proven
live both directions: clean close AND kill -9 both ghosted to
timeout). Presence = isClosed heartbeat sweep + socket-onError
forget + lastSeen timeouts. NEVER depend on onDone. (Docs pattern
confirms CloseReceived is a data event.)
3. Welcome races room broadcasts — match WS messages by `t`, never
by index.
4. web_socket_channel streams are single-subscription — one listener
per socket (test double-listens throw "already been listened to").
5. `sub.asFuture()` can resolve before `onDone` — poll with
deadlines in tests.
6. Comma-form mpv reconnect flags work here; colon-form rejected
(re-verify via set_property if touched).
7. Numeric literals corrupted once by tooling — verify with rg after
writes touching ports/consts.
8. Tool `bash` `&`-backgrounding is unreliable; use nohup + log file
in its OWN call, poll in later calls, kill via `fuser -k
PORT/tcp` (never `pkill -f`). Chain small calls, never long
`&`-chains (they hang the runner past timeout with zero output).
9. Tests must not assume threat model beyond trust-basis.
10. web_socket_channel 3.x: `channel.ready` errors unhandled on
refused connections — eat it (`unawaited(ch.ready.then(...,
onError: ...))`); stream onError is the reporting path.
11. MediaKit has NO public Player.setProperty — runtime props go via
guarded dynamic call on player.platform; cache size via public
PlayerConfiguration(bufferSize:).
12. NEVER `flutter test`-pump the session screen: media_kit Video
hangs the runner (a hung flutter_tester must be kill -9'd by PID;
pgrep carefully). Sync-path coverage lives in room_diff (pure),
lifecycle (server, raw WS), coord_presence + e2e (real mpv,
no widgets). Deleted session_overlays_test.dart for this reason.
13. No `flutter analyze` unless asked (IDE covers it) — except
deliberate per-change checks; kept clean throughout.
14. New `MediaDesktopVideoControlsThemeData` per build is fine
(stock guards subscriptions) but wasteful; leave it.
15. Env overrides for mpv experiments (no rebuild to switch):
SYNC_RELAY_VO (e.g. xv, gpu, gpu-next), SYNC_RELAY_HWDEC (e.g. no).
16. Hidden-but-mounted overlays keep focus: closing the sheet left
focus on an offstage button, so Space fell through (350 ms
throttle hole) into the hidden control. Fix pattern: ExcludeFocus
on the closed subtree + unfocus-on-close effect. Same reason new
overlay buttons reuse the shared no-focus node.
## 7. Sounds (deferred, wiring ready)
Base: Kenney Interface Sounds (CC0, confirmation_* ladder for
ascending-ready). Alt ladder: ObsydianX Pack 1 (CC0, avoid paid
packs). Soft layer: akx/Notifications (CC0 branch). Playback pick:
flutter_soloud (preload WAV, per-play pitch) — NOT added. Swap-in =
replace NoopSfxService; cues already fire with readyIndex. URLs +
mapping in 09-11 chat (unreliable — re-derive from sources above).
## 8. Black window while playing (09-12, FIXED)
Symptom was: room PLAYING ⇒ entire window black (audio + keys
alive, unready restored, zero errors). Cause: the waiting-avatars
center overlay unmounted its GPU textures (avatar Images) exactly
at the HELD→PLAYING flip; teardown racing mpv's frame uploads on
the shared GL context fails silently ⇒ black. Legacy chips never
unmounted and minimal never mounted, which is why they survived.
Fix: center overlay is Visibility(visible + maintainState +
maintainAnimation + maintainSize) — avatars stay mounted, paint
skipped when hidden. Verified by user with full tree, and holds
over internet (WAN 2-client test, port 6969, late 09-12) — CLOSED.
RULE: never unmount GPU-textured widgets on a transport-gated
branch; keep mounted + skip paint. Bisect screens
(legacy/minimal/nosheet + SYNC_RELAY_UI switch in app.dart)
DELETED 09-12 (commit tracks this); no commented bisect lines
remain in session_screen.dart.
## 9. Next (ordered, updated 09-12 late session)
1. §8 black screen — CLOSED (fix verified incl. over internet).
2. Remove bisect flags — DONE 09-12 (files deleted, app.dart
routes session directly; tree analyzes clean).
3. 2-client LAN soak — user reports DONE 09-12 (details pending).
4. 1.4 GB real-file media test — user reports DONE 09-12
(details pending; api_key must never land in files).
5.–8. MIGRATED 09-12 to `docs/ROADMAP.md` backlog (cache-exhaustion,
sounds, file-join wart, /health) — the roadmap is now the single
backlog; this section keeps session history + user-owned pendings
only.
9. FIXED 09-12: auto-unready fired while passively watching —
the 10-min check keyed off `_lastInput`, refreshed only by
explicit actions. Steady playback now counts as activity:
warn + auto-unready are skipped while room transport is
PLAYING (`session_coordinator.dart` idle block). HELD-and-idle
still unreadies after 10 min.
10. FIXED 09-12 (second attempt — first theory was wrong): one chat
send rendered as TWO log rows. Root cause is server dual
delivery, not double-send: `broadcastChat`
(`room_hub.dart:69`) emits both a `ChatMessage` (with text,
converted to a feed entry client-side) and a textless `feed`
entry, and the client logged both. The blank-guard in
`sendChat` was defense against a phantom. Real fix: client
drops textless `chat` feed entries (`sync_client.dart` feed
case) — one send, one row, one toast, one sfx. No wire
change, still v4. Chat field also keeps focus after send
(dedicated FocusNode, re-requested on submit/button).
11. KNOWN (non-breaking, fix later — roadmap backlog #2): seeks feel
jumpy (forward-back-forward) — suspect the seek-land retry
(`_checkSeekLanded`, `session_coordinator.dart:429`)
re-applying SeekAction against the 0.5 s deadband / 2 s
debounce and the 1 Hz room echo. Needs a live repro with a
timeline log before touching.
12. Updater v1 SHIPPED 09-12: `watch-together --upgrade`
(`apps/desktop/lib/src/update/updater.dart`, zero Flutter
imports, 7 tests) — manifest check, download, sha256, apply
over live install (extract-temp + delete-first move-over;
ETXTBSY forbids writing the running exe, delete+rename is
fine). Version stamped via `--dart-define=APP_VERSION`
(wired in `tool/package_linux.sh`; dev runs report
`0.0.0-dev`). Publishing is CI-driven: tag `vX.Y.Z` →
`.gitea/workflows/release.yml` builds, creates the Forgejo
release, uploads the tarball, commits the manifest back
(`tool/release_publish.sh` does the same by hand).
Manual trigger only: bump desktop pubspec in a release commit
first (script enforces tag == pubspec). No silent
proto jumps: updater warns when manifest protocol != local.
Environment: dart 3.14 beta, flutter 3.48 beta, mpv+ffmpeg present,
~/Videos/chiyo.mp4 sample. Never kill the user's Syncplay mpv
instance. Git repo at project root (init 09-12); commit code + docs
together; prefer FEW commits (one per topic/session, not per fix)
over bisect-granularity — user decision 09-12.
## 10. Window chrome — frameless, mpv-style (09-12, VERIFIED by user)
Native title bar gone everywhere (`window_manager ^0.5.2`,
`TitleBarStyle.hidden` in main.dart GUI path only, headless
untouched). New screens MUST bring their own drag + min/max/close
or they ship immovable — reuse `widgets/window_buttons.dart` +
`widgets/window_chrome_bar.dart` (welcome/connect pattern).
Session: drag is `widgets/window_drag_layer.dart`, topmost raw-
pointer observer inside the controls stack (a GestureDetector
below media_kit's overlay LOSES the arena — do not revert to
that); dead zones: bottom 120 px (seek stays exact), open sheet,
leave dialog. `modifyVolumeOnScroll: false` is load-bearing for
drag — side effect: volume is speaker-button-only, no scroll-
volume. Maximize button = media_kit fullscreen toggle in
session (no OS maximize there); NO aspect-ratio lock by user
decision (custom UI lives outside the video frame). Close =
graceful leave + stopServer, then window close. Min size
160x90, no max. Verified on KDE Wayland (Arch): bar-drag,
video-drag, fullscreen, free resize all good.