Files
sync_replay_project/docs/DEVELOPMENT.md
T
foxyzandClaude Opus 5.5 69fae18fdc Player: quick chat box, danmaku notifications, the player panel, smooth subtitle lift
- Quick chat (Enter in the full player, whatever plays): a box at the
  bottom centre in the control bar's place, compact and widening with
  the text, sharing its draft with the chat panel; Enter sends, Esc or an
  empty Enter closes, it closes by itself after 8 s without typing (the
  draft stays). Keys go to it; the Enter that opened it no longer reaches
  a focused button. Typing never shows the cursor or the controls: the
  frame wakes only on real mouse movement and holds the controls hidden
  while the box or its echo is up. Your sent line shows as an echo (or
  flies as your own danmaku line); your own chat toast is not repeated.
- Subtitle lift: always for the box, for the control bar as a setting
  (off); 4 eased-out sub-pos steps 42 ms apart instead of one per frame.
- Notifications: Toasts / Danmaku (off by default): chat and short notices
  fly over the full player, problems stay toasts; every knob of the look
  plus boldness, persisted, with a live preview strip in Settings.
- Player panel: ⚙ → Player settings docks a foldable glass panel at the
  right (tracks, subtitle size and lift, picture, volume, notifications,
  controls) in place of the card; Esc closes it.
- Danmaku preview: the mock dialogue follows the lift; the stand-in page
  has the chat box too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:29:13 +03:00

468 lines
30 KiB
Markdown

# Development
Layout and rules: [ARCHITECTURE.md](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](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](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: without it a second start is the same install, so it hands
its arguments to the running app and ends; each profile is its own
instance) 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).
`tool/make_sub_test.sh [out.mkv] [seconds]` (ffmpeg) writes a 720p test
video for subtitle placement and sharpness (default
`~/Videos/starsync-sub-test.mkv`): an .ass track with a top sign
(`\an8`) and bottom dialogue (English and Japanese), and an SRT track.
## Dev tools
Test builds and runs from source (a version with `-dev`) and debug runs
(`devTools`, `screens/home/ui.dart`) get Settings → About → Developer;
releases never show it:
- **Danmaku preview** (`danmaku/danmaku_preview.dart`): the danmaku layer
of "Notifications: Danmaku" (ARCHITECTURE "Danmaku notifications") to
play with, starting from your own look; its knobs change only the
preview. The button opens it over the full player when one is up
(under the controls; the panel top right holds them up), else on a
page with a moving stand-in picture and a mock control bar of the skin
in use. The panel (`DanmakuPanel`) fires samples (chat, own, join,
leave, Up next, starting, a burst), a random stream (Poisson arrivals
at a rate, short to long lines, CJK and emoji; in a session half the
senders are the party's real members), this session's own chat and
notice toasts (mapped as the real feature maps them), every
`DanmakuStyle` knob, and mock subtitles (a top sign and bottom
dialogue). The mock dialogue moves with the subtitle lift's steps as
the real subtitles do: over the player when the quick chat box opens,
and on the stand-in page, which has its own quick chat box (Enter;
what you send flies as your own line). Tests: `danmaku_test`.
## 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, the read-once source (`shared_source_test`: its loopback
range answers, a cut and resume with backoff, seeks of either reader,
the two kept within one window, its memory bound), 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
`starsync://` links, the internet address lookup against fake
services, the join prompt and one app per profile
(`invite_links_test`), 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`), the video view's output size and
subtitle lift steps and the dev-tools gate (`video_view_test`), danmaku
lanes, style, the toasts that fly and an idle layer
(`danmaku_test`), the quick chat box over the player (`quick_chat_test`),
the player panel and the danmaku settings card (`player_settings_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),
`WT_SINGLE_FETCH=1` (server `single_fetch_e2e`: a link passed on read
once against twice, two real mpv against a fake CDN that cuts and locks
out; minutes; `WT_SF_SCENARIOS`, `WT_SF_PLAY` narrow it). 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 (read once: the source's player on
its loopback reader, nothing from mpv at the origin) 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](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](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 it, stays up 10 s from its start and
closes the normal way; the steps go to `STARSYNC_SMOKE_OUT` (`start`,
`opened <ms> render=<mode>`, `closing`, `quitting` just before the
window closes, or `skip` / `fail` with why). It fails when the app ends
by itself before `closing` or never reaches `quitting`, 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 (one cut short at `stop`: `/m`, `/j`; the relay's byte
answers are written on the raw connection and end on their own) 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 can still be
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, refuses when the [changelog](#changelog)'s
Unreleased has no items (warns when it has no summary) and prints it,
renames Unreleased to `X.Y.Z` under a fresh empty Unreleased, 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; `notes` is the section's summary, `changes` its lists), commits
`Release vX.Y.Z` (with `CHANGELOG.md`), makes an annotated tag.
`--dry-run` stops after printing.
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; `tool/setup_flutter_windows.ps1` also adds Visual
Studio's C++ ATL component once, which the notifications plugin needs) 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 (its text: the version's changelog
section) 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` (`changes`:
the changelog's Unreleased, which also becomes the prerelease's text). Dev builds
update from it ([Updater](ARCHITECTURE.md#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`).
### Changelog
`CHANGELOG.md` is what people read: the update band's first items, the
What's new card ([Updater](ARCHITECTURE.md#updater)) and the release page.
A user-facing change adds a line to its `## Unreleased` section in the same
commit, under `### Added`, `### Changed` or `### Fixed` (no other
headings), as a `- ` item in plain words for the people watching, not
commit-speak ("Chat pop-ups stay away while the chat panel is open", not
"Notifier: suppress toasts when panel open"). Internal changes (tests,
refactors, docs, CI) get no line. The paragraph under the version heading
is an optional one-line summary: it becomes the manifest's `notes`, the
only thing 1.2.x/1.3.x apps show (without it, the first items); refresh it
before cutting. Versions carry no date (the tag and release page have it).
`tool/changelog.dart` reads and cuts the file (`cut X.Y.Z`, `json
<version|unreleased>`, `markdown <version|unreleased>`); its tests are
`tool/test/changelog_test.dart` (`cd tool && dart test`).
## 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](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".
A Windows friend without a terminal habit: `tool/windows_report.cmd`
(one file: a cmd header that runs the PowerShell below it). Dropped into
the Starsync folder and double-clicked, it collects the app's logs,
Windows' crash events (1000/1001/1026), that install's file list (and
any other found on Desktop, Downloads, Documents, AppData, Program
Files) and a 20 s test start with its exit code, zips them to the
Desktop and copies the zip (Ctrl+V into Discord).
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](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.