Files
sync_replay_project/docs/DEVELOPMENT.md
T
foxyzandClaude Opus 5.5 741b258ac1
ci / core (push) Successful in 29s
ci / server (push) Successful in 53s
ci / updater-windows (push) Failing after 7m45s
Restructure step 6: what's next and watch tracking
- What's next is the server's: state.advance counts down (10 s) when the
  media finishes with an Up next item first, holds while its performer or
  warm source is gone, and sets queuePlay at its end; cancel is shared, a
  seek or swap ends it; the server skips items nobody can put up. Shared
  rules in core whats_next.dart; the app's janitor, per-screen countdown
  and end-of-episode card are gone.
- The continuation (next Jellyfin episode, next file of a local show
  with its gaps) is an auto item under "Continues with", prepared like
  Up next's first item.
- Watch tracking by time In (D3): only time played while this viewer is
  in counts; away, paused, held, jumps and stalls never do (P7, P9).
  WatchTracker + device WatchHistory (files, links); Jellyfin progress
  reads the same watch; records are the AniList seam.
- A server restart no longer loses what played: apps hold their media
  and one offers it back (swap.restore, first offer wins), Up next is
  re-added, the source re-feeds, and the watch carries on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 02:06:04 +03:00

25 KiB

Development

Layout and rules: ARCHITECTURE.md.

Build

Needs the Flutter SDK (beta channel, Dart 3.13+) and, for the Linux desktop build, the usual GTK toolchain (clang, cmake, ninja, pkg-config, libgtk-3-dev). mpv libraries come from media_kit_libs_video. media_kit_video is the patched copy in third_party/media_kit_video (see its starsync/README.md); test video output changes under X11 (GDK_BACKEND=x11, ideally a real X11 session) and Wayland. flutter_soloud (the app's sounds) compiles SoLoud and miniaudio from source in its build hook with that C++ toolchain (MSVC on Windows); it needs no audio dev headers (miniaudio loads ALSA / PulseAudio / JACK at run time) and, with no_xiph_libs set in the desktop pubspec, downloads nothing. fox_utils (drag & drop, clipboard files and images; native Linux/Windows plugin) is the owner's shared package, a git dependency pinned to a tag of git.senkochan.moe/Foxyz/fox_utils: bump it by tagging there and changing ref in the desktop pubspec.

cd apps/desktop && flutter pub get && flutter run -d linux
cd apps/desktop && flutter build linux --release --dart-define=APP_VERSION=<x.y.z>
tool/package_linux.sh [--skip-build] [--noupload]    # dist/*.tar.gz
powershell -File tool/package_windows.ps1 [-SkipBuild]  # dist/*.zip (on Windows)
cd packages/server && dart compile exe --define=APP_VERSION=<x.y.z> bin/server.dart -o starsync-server

APP_VERSION defaults to 0.0.0-dev; the packagers stamp it from the desktop pubspec into both the app and the server. tool/package_linux.sh also passes GIT_COMMIT (what the app shows: "Updater" in ARCHITECTURE.md). Setting DISCORD_WEBHOOK_URL makes the packager post the tarball to Discord (never commit it). The packager always compiles the dedicated server (with the Flutter SDK's dart) into the tarball as starsync-server, also with --skip-build. The app binary is starsync (BINARY_NAME; Linux application id dev.foxyz.starsync), the archive dist/starsync-<version>-linux-x64.tar.gz / -windows-x64.zip with one top folder starsync-<version>/; it also carries the names up to 1.2.x (watch_together, watch-together-server: launchers on Linux, copies on Windows; "Updater" in ARCHITECTURE.md). The Dart package of the app is starsync (package:starsync/...).

Dev loop

Run a server without the app, then point apps at it:

dart run packages/server/bin/server.dart --port 8080 --secret x [--media FILE]

This is the same serve() the app runs when hosting and the same code as the dedicated binary. Prints READY port=... name=... and stops on SIGINT or SIGTERM. Then Join from one or more apps at 127.0.0.1:8080. Or just Host from the app. mpv env override: SYNC_RELAY_HWDEC (wins over the Settings choice). SYNC_RELAY_VO has no effect with embedded video: media_kit forces vo=libmpv. SYNC_RELAY_PRELOAD=0 turns Up next's preload player off; SYNC_RELAY_TRACE_SWITCH=1 prints the timings of each media change (SwitchTrace).

Two instances on one machine: STARSYNC_PROFILE=<name> (letters, digits, -, _; at most 32) gives an instance its own Hive boxes (settings, profile, connect form, window bounds, folders, Jellyfin sign-ins, the client id: two instances without it are one install, and the second takes the first's member over) and logs under <app support>/profiles/<name>/, and [<name>] in the window title; yt-dlp, shaders and caches stay shared (AppDirs, ARCHITECTURE "Desktop UI"). Without it nothing changes. Run one plain instance and one with STARSYNC_PROFILE=b (flutter run -d linux passes the environment on, or start the built binary twice); one hosts, the other joins 127.0.0.1:<port>, or both join a dart run server. An unusable name is ignored with a warning in the log.

Harnesses in tool/ (own pubspec.yaml, dart run tool/<name>.dart): range_logger (serve a file and log every Range request), shaper (bandwidth cap, latency, --kill-after for outage tests), fake_viewer and e2e_check (simulated viewers against a live server).

Tests

Three suites, run from each package's folder:

  • cd packages/core && dart test (a few seconds): protocol round-trips, what a player opens for a page (YtDlpPlay) and the cookie notes of a failed resolve against a stand-in yt-dlp (cookies_test, D11), the sync engine (clock, gate, presence and grace, correction, end of file), the PresencePolicy table, queue model and what's next (who puts an item up, the countdown on a fake clock), the library's L1 (release and folder names, groupLocal over a real anime folder's names: shows across folders, holes, extras, a movie), relay pieces, updater, logging redaction, the watched rule (time In), yt-dlp and Jellyfin client parsing against captured fixtures.
  • cd packages/server && dart test (well under a minute): live servers on loopback: auth and version refusal, identity, takeover and grace (presence_test on a fake clock), media and relay byte serving with source drops, Jellyfin relay against a fake, queue (and the shared countdown on a fake clock), session log, isolate serve, sharing (share_test: a listener attached and detached under the same members and Up next, the host key standing in for the password).
  • cd apps/desktop && flutter test test/<file>: widgets (shell, library, controls, settings, schedule), pure app logic (StateDiff, the library's L2 matching against a fake AniList (show_matcher_test), media.ref producers, shader list, sound engine with a fake backend, invite addresses, the version gate, the media plan's table with the Jellyfin relay rule and the "Waiting for" status (media_plan_test), watch tracking and the device history (watch_tracker_test), what continues a file of a local show (queue_logic_test)), singletons_test (the .instance count, see ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv *_e2e tests plus coord_presence. The whole suite takes a couple of minutes; run it only before a restructure step lands, otherwise only the files related to what you changed.

Opt-in, skipped unless set: JELLYFIN_TEST_* (core jellyfin_live and server jellyfin_relay_live against a real Jellyfin; read their headers), YTDLP_LIVE=1 (downloads yt-dlp and resolves a real tweet). Everything else is offline: Jellyfin and link hosts are local fakes.

Real-mpv tests: sync_playback_e2e, coord_presence (drops through a FlakyProxy: a half-open link and return, 10 flaps, replaced, bye, a dead or restarted server; a restarted server that came back empty gets what played back at its position, a link everyone opens and a file passed on, nothing reopened, the watch and Up next carrying on), video_reload_e2e (a fresh player from the host, the old one retired, leave releases them all), seek_drag_e2e, end_of_file_e2e, readahead_e2e, chapters_mpv_e2e, ytdl_headers_e2e (a page through the app's own resolve against a stand-in yt-dlp and a Referer-checking CDN: the EDL, the headers and cookies, subtitles without danmaku, a 403, the D11 error), media_switch_e2e (commands during a switch wait and apply once, no mpv errors), relay_e2e (a dedicated server with no file of its own: a guest's file, links passed on or fetched by the server, a late source, a source that drops and resumes; a seek past what a slow source has sent, timed; a source that drops while a viewer seeks past the buffer and another joins: no seek loop, "Waiting for", all resume (D6)), jellyfin_e2e (account holders report what they watched, one who sat out is not marked watched, the next episode continues for everyone), whats_next_e2e (a file of a show continued by its next episode with the gap said, the server's countdown on both screens, a shared cancel, the watch history), queue_switch_e2e (times Up next switches through the media pipeline against a delayed, rate-capped fake Jellyfin and link host: an episode, a direct link, a link passed on and a server-fetched one, each prepared and promoted; no mpv "error running command" or "file cache" lines; WT_SWITCH_PRELOAD=0 for the cold path, WT_SWITCH_DELAY_MS / WT_SWITCH_RATE_KBS for the link). The sync, reload and end-of-file ones expect ~/Videos/chiyo.mp4; the others generate media with ffmpeg into WT_TEST_TMP (default: system temp; all desktop tests that write files use it). Headless mpv (no video output attached) won't open video-only files, so test media needs an audio track. Tests always log media_kit "S/W rendering" because they have no display; that says nothing about the app. A headless player has no video track selected (vid=no), and an mkv's cues list only the video, so seek tests over a relay or HTTP use an mp4 with its index first (+faststart); setting vid=auto on a headless player crashes mpv when it is disposed. sound_device_e2e opens the real audio device (flutter_soloud) and plays every sound of every preset quietly (WT_SFX_VOLUME, default 0.1).

Bad links: FlakyProxy (package:sync_relay_core/testing.dart, so core, server and desktop tests share it; not part of core.dart) sits between a client and a server on loopback: FlakyProxy.start(serverPort), the client dials proxy.port, and each accepted connection is a FlakyLink the test steers at any time: pass(), blackhole() (both sockets stay open, every byte and a closing end are dropped: a half-open link), delay (added latency each way, order kept), holdNext(d, flow:) (the next chunk, and what queues behind it, arrives d late), cutAfter(n, flow:) (n more bytes, then a reset) and reset() (RST via SO_LINGER 0). onConnect steers a link before its first byte (blackhole or refuse the next dial). It counts bytes each way and the dropped ones. It is TCP only: a "frame" is one chunk as read (on loopback usually one small WebSocket frame). Its own tests: core flaky_proxy_test; against the server: server flaky_link_test. For manual runs with real apps use tool/shaper instead.

Long durations on a fake clock: SyncServer(clock:, liveness:) and SyncEngine(clock:, liveness:) take the engine clock and the server's limits (Liveness: silence, grace, gone notice, joiner hold, joining timeout; ARCHITECTURE "Sync model"), so a test can pass short limits or step a fake clock through minutes: move the clock, then call ServerHub.heartbeat() (or SyncEngine.tick()) yourself; the real 1 s heartbeat still ticks and reads the same clock. On the app side Connection(now:, unstableAfter:, silenceLimit:, lostSoundAfter:, reconnectDelay:, identity:) (connection_test drives one against a scripted fake server), and AutoSitOut takes the time on every call (session_logic_test steps it through minutes).

Safety-net scenarios: the core flows every restructure step must keep working. What covers them today and which step adds the rest (steps as in ROADMAP.md):

Scenario Covered today Missing, added in
Drop and reconnect while sharing a relay server flaky_link_test (blackhole: gone with ready, queue item and relay kept, the same member back; reset), sync_wave_test (takeover by client id, replaced, stale), presence_test (grace on a fake clock, restart), relay_test "the source drops (D6)" (sourceGone, buffer served, same info continues, another 4002, a second channel 4003, leave ends it); desktop relay_e2e "the source drops uncleanly and comes back" (gone past the server's silence, the relay kept, the source re-dials and continues), coord_presence (a half-open drop while playing: same member, no reopen, the state kept; 10 flaps: at most one lost/back sound; replaced is terminal; a restarted server gets the ready again, and what played if it came back empty), server queue_test (the first restore offer wins), connection_test (the hello's id, seq, caps, feedAfter)
Join mid-playback core sync_engine_test "D2: joiners" (never changes the transport while playing, at most 15 s while held, joining ends on error or timeout), presence_test (the table); server relay_test "a late joiner gets its own /r/ URL"; desktop coord_presence (the returning member plays on in sync)
Sit out / Away and back core sync_engine_test "away" (only oneself comes back, through joining), "return" (away survives a drop), presence_test (isAlone); server hardening_test (anyone sets anyone away, forbidden); desktop server_isolate_test (the host sat out through presence on your shared server), notice_table_test (the feed actions one-to-one, matched by id; sitting out gets only chat, nudges, "starting now", Up next and the chosen joins and leaves; alone no social sounds), session_logic_test (auto sit-out on a fake clock: never alone, a manual away stays), player_status_test / shell_test (the ready counter leaves away and gone out); core watch_session_test, desktop watch_tracker_test, jellyfin_session_test "sat out", jellyfin_e2e (sat out: not tracked as watched, P7)
Swap, also while someone is Away or gone core sync_engine_test "swap" (a gone member stays out of the gate, comes back joining), "away" (a swap leaves it away), queue_test (a swap takes its item, drops the auto one); server queue_test (a gone adder's item waits), relay_test (a swap drops the buffer); desktop sync_playback_e2e, media_switch_e2e (no command reaches a player mid-switch)
Up next advance core queue_test (skip, play request timeout, auto and cancel; the countdown: start, follow the first item, seek, cancel, hold for a gone performer or source, skip what nobody can put up), whats_next_test; server queue_test (the countdown and cancel shared, a skip by the server); relay_test "warm relay"; desktop jellyfin_session_test (end card), jellyfin_e2e and whats_next_e2e (the continuation and the countdown end to end), queue_switch_e2e (prepared and promoted through the media pipeline)
Close the app cleanly, incl. bye server lifecycle_test (a close keeps the member gone, bye leaves at once, server stop closes sockets); desktop server_isolate_test (stop frees the port; Stop sharing tells guests with 4016 and keeps you and Up next; a friend's server and back keeps yours), coord_presence (Stop sharing and your server's stop release their ports; leave sends bye and others see "left" at once, leaving for a restart keeps the member gone), video_reload_e2e (leave releases every player); the close smoke run on the built app (tool/smoke_windows.ps1 in CI, tool/smoke_linux.sh by hand)

A test file whose first line is // restructure: rewrite in step N pins behaviour that step of the restructure (ROADMAP.md) changes; the comment names the tests. Rewrite or delete them in that step, not before.

CI (.gitea/workflows/ci.yml): analyze + test for core and server, plus dart compile exe of the dedicated server (fails if Flutter creeps into packages/server). Desktop tests are not in CI. The Windows job runs the updater tests, the release build and the close smoke run on that build (tool/smoke_windows.ps1, P32): the app starts with STARSYNC_SMOKE=<clip> (a 5 s clip from ffmpeg, else a WAV the script writes) under its own profile (STARSYNC_PROFILE=smoke), opens the clip in a player with a video output through the media pipeline (platform/smoke.dart), plays a second and closes the normal way; the steps go to STARSYNC_SMOKE_OUT (start, opened <ms> render=<mode>, closing, or skip / fail with why). It fails when the app does not exit within 45 s or leaves a starsync process, skips with a warning when the runner cannot bring the app or its video output up, and prints the smoke:, close: and mpv: render lines of the app log. tool/smoke_linux.sh is the same on Linux (needs a display; after flutter build linux --release).

Traps:

  • 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).
  • A SyncServer started inside a test: relic reports an error writing a response (a relay response cut short when its relay is dropped at a swap or stop) to the zone the server started in, which fails the test; outside tests it is only logged. Start it in runZonedGuarded and fail on anything else (queue_switch_e2e, relay_e2e): a viewer's read is still in flight at the stop in tearDown.
  • The Link page probes a filled-in link (network, yt-dlp), which never answers on the widget-test clock: pass AppShell(linkProbe:) / LibraryTab(probe:) a fake, or pumpAndSettle never returns.
  • Never depend on a server-side WebSocket onDone; assert presence with deadline polling.
  • sub.asFuture() can resolve before onDone; poll with a deadline.
  • Tests must not assume a threat model beyond the friends trust basis.

Releasing

User pushes tags; CI does the rest.

  1. tool/release.sh [patch|minor|major] [--dry-run]: derives the next version from the latest tag, bumps the three pubspecs in lockstep, writes updates/manifest.json (per-platform assets URLs are predictable; url stays the Linux tarball for builds <= 0.2.1), commits Release vX.Y.Z, makes an annotated tag.
  2. Review, then git push --follow-tags.
  3. .gitea/workflows/release.yml runs one job per platform: Linux builds with tool/package_linux.sh, Windows (runner yuuka-windows, host mode, PowerShell only) with tool/package_windows.ps1. Each then runs dart tool/release_publish.dart vX.Y.Z <archive>, which creates the release if the other job hasn't yet and uploads (or replaces) its asset. By hand: package, then the same dart tool/release_publish.dart line.

Test builds (Linux, no tag, no CI): tool/dev_release.sh [--minor|--major] [--no-publish] (token: RELEASE_TOKEN or ~/.config/watch-together-release-token, a Gitea access token with repository read/write) builds the working tree here as <next>-dev.N (N one past the published one; <next> the pubspec's next patch, or next minor / major when asked, and once such a line is published later runs follow it until a release moves the pubspec) and replaces the assets of the dev prerelease with the tarball and its manifest.json. Dev builds update from it (Updater). A stable install moves onto it once with the command the script prints (run with the app open: it finds the install from the running process and copies the new files over it).

Tokens come from the environment (RELEASE_TOKEN, or Actions-provided GITHUB_TOKEN/GITEA_TOKEN). The manifest has no sha256 by default; the updater skips the check when absent. Workflows live in .gitea/workflows (the forge reads .gitea before .github).

Debugging your server

The app's own server runs on its own isolate (ServerIsolate, apps/desktop/lib/src/state/server_isolate.dart, owned by YourServer) in the same process, from start to close. Breakpoints in packages/server work under flutter run: the debugger attaches to spawned isolates too (it shows as server). Its log lines carry server.<logger> (e.g. server.relay). Hot reload reloads the server isolate's code too, but its state (members, clock, sockets) survives; changes to startup code (SyncServer.start, share, routes) need a restart of the app. Hot restart kills the server isolate (flutter_tools kills every non-UI isolate first), so a [server] server crashed: the server stopped unexpectedly line just before it is expected. Its loopback port changes every run (the log's listening on line has it); STARSYNC_PROFILE gives a second window its own server. Without the app: dart run packages/server/bin/server.dart.

Logs

After a session, read the log files rather than reproducing ("Logging" in ARCHITECTURE.md has what goes in them): the app's are ~/.local/share/dev.foxyz.starsync/logs/app-*.log on Linux (on Windows %APPDATA%\foxyz\Starsync\logs; Settings → About → "Open logs folder" finds it anywhere), with your server's lines in the same file; a dedicated server prints its folder at start (--log-dir). Users send theirs with Settings → About → "Copy diagnostics".

Start with the session log when there is one: session-YYYY-MM-DD.log in the server's folder (the hosting app's logs folder, or the dedicated server's --log-dir), every member's lines and the server's merged in server-clock order, each tagged [name (id)] or [server]. Any member gets it with "Copy diagnostics" in a session. Find === PROBLEM marked by first (a member pressed Ctrl+Shift+B), then read the lines around it from every side; grep '\[Bob (m2)\]' follows one member. [server] diag: lines say who shares (with their app version) and who doesn't, so a missing side is explained. Times are the server's clock; a member's local log (app-*.log) is on its own clock.

For a "can't play" report, grep both sides around the time: swap by (what went up and how), relay channel (when the source dialled in), media: (each viewer's requests: status and time to headers), mpv: (cannot open the media and ffmpeg's lines), yt-dlp: (the resolve and its picked formats) and upstream (CDN refusals with the headers sent). A viewer that got no bytes while the source was slow to come up shows mpv's error with "no HTTP error logged".

For "the video is slow / shaders do nothing", grep mpv: render (what each line holds: "Render mode" in ARCHITECTURE.md). render: gpu with a real GL_RENDERER (not llvmpipe / softpipe) is the good case; render: softwareGl is OpenGL on the CPU (Mesa's software rasterizer: missing or blocked GPU drivers, LIBGL_ALWAYS_SOFTWARE=1); render: software with no GL lines is media_kit's own fallback (it found no usable EGL context: check render env: for an X11 session or a GDK_BACKEND / FLUTTER_LINUX_RENDERER override). Its reason is only on stderr: have the user start the app from a terminal (./starsync 2>&1 | grep VideoOutput). hwdec-current=no on the first-frame line means software decoding (no hwdec for that codec, or it is off), not software rendering.

Gotchas

  • Relic gives Range as a typed RangeHeader; convert from unit + first range.
  • A killed isolate (Isolate.kill) does not close its listening socket: the port stays bound in the process until exit. SyncServer binds with shared: true (both listeners) so a new one in the same process can take it over; a normal stop closes it.
  • Relic server-side onDone does not fire for dead peers. Presence is the isClosed sweep + onError forget + lastSeen timeouts.
  • web_socket_channel 3.x: channel.ready errors are unhandled on refused connections; swallow them and report through the stream's onError. Streams are single-subscription: one listener per socket.
  • media_kit has no public Player.setProperty; the player host (MediaKitHost) reaches mpv through a dynamic call on player.platform, and nothing outside it imports media_kit except the Video widget glue. Cache size goes through PlayerConfiguration(bufferSize:).
  • Hot restart with any media_kit Player alive (session, thumbnails) used to SIGSEGV on mpv's core thread: each mpv handle's wakeup callback is a NativeCallable of the old isolate, freed by the restart, and media_kit then sends the leftover handles quit, which fires it. fox_utils' Linux plugin (from 0.1.8, debug builds only) clears those callbacks on the engine's on-pre-engine-restart signal, reading the handles from media_kit's debug-only NativeReferenceHolder buffer (address in $TMPDIR/com.alexmercerind.media_kit.NativeReferenceHolder.<pid>). Old players are leaked, not destroyed, until the process exits. Re-check this after a media_kit upgrade.
  • mpv reconnect flags: comma form works, colon form is rejected. Re-verify through setProperty if touched.
  • 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.
  • 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, AppSettings) and a test file's AppGraph 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).
  • Widgets read the systems from AppScope: a widget test pumps its tree under one (scoped(graph, ...) from test/test_graph.dart; one testGraph() per file). A State that needs a system in dispose reads it before (late final touched in initState): the scope cannot be looked up from a deactivated element.
  • Chat arrives twice from the server (see PROTOCOL.md); the client drops the textless feed copy.
  • 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.