Every app gets the first Up next item ready itself (own Jellyfin plan, direct links, guests via /j when state.relayNext says the server's login can pass it on) and opens it in a hidden, muted, paused second player; the swap promotes that player instead of opening cold, so its buffer counts at once. Reports go out on open and when the buffer reaches the gate's need. SwitchTrace times each phase. Measured in the e2e harness: 1.5-3 s before, 35-103 ms with a preload. Relayed items still open cold for everyone but their source (ROADMAP). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9.4 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. 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 watch-together-server
APP_VERSION defaults to 0.0.0-dev; the packagers stamp it from the
desktop pubspec into both the app and the server. 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
watch-together-server, also with --skip-build.
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).
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
cd packages/core && dart test,cd packages/server && dart test.cd apps/desktop && flutter test test/<file>.- Real-mpv tests:
sync_playback_e2e,coord_presence,video_reload_e2e,seek_drag_e2e,end_of_file_e2e,readahead_e2e,jellyfin_e2e(a local fake Jellyfin over HTTP; tests never talk to a real Jellyfin),queue_switch_e2e(times Up next switches against a delayed, rate-capped fake Jellyfin and link host;WT_SWITCH_PRELOAD=0for the cold path,WT_SWITCH_DELAY_MS/WT_SWITCH_RATE_KBSfor the link). The sync and end-of-file ones expect~/Videos/chiyo.mp4; others generate media with ffmpeg intoWT_TEST_TMP. 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. Run only tests related to what you changed. - CI (
.gitea/workflows/ci.yml): analyze + test for core and server, plusdart compile exeof the dedicated server (fails if Flutter creeps intopackages/server). Desktop tests are not in CI. packages/server/test/serve_isolate_test.dartrunsservein a real isolate;apps/desktop/test/server_isolate_test.dartcovers the app's host path (isolate server, raw WebSocket join, crash, rehost).- Desktop tests that write files use
WT_TEST_TMP(default: system temp).
Traps:
- Never pump media_kit's
Videoinflutter test: the runner hangs (kill theflutter_testerby PID).AppShell(videoBuilder:)takes a stand-in, so shell tests run the real player layout without it; otherwise test pure logic (StateDiff, coordinator without widgets). - Never depend on a server-side WebSocket
onDone; assert presence with deadline polling. sub.asFuture()can resolve beforeonDone; poll with a deadline.- Tests must not assume a threat model beyond the friends trust basis.
Releasing
User pushes tags; CI does the rest.
tool/release.sh [patch|minor|major] [--dry-run]: derives the next version from the latest tag, bumps the three pubspecs in lockstep, writesupdates/manifest.json(per-platformassetsURLs are predictable;urlstays the Linux tarball for builds <= 0.2.1), commitsRelease vX.Y.Z, makes an annotated tag.- Review, then
git push --follow-tags. .gitea/workflows/release.ymlruns one job per platform: Linux builds withtool/package_linux.sh, Windows (runneryuuka-windows, host mode, PowerShell only) withtool/package_windows.ps1. Each then runsdart 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 samedart tool/release_publish.dartline.
Test builds (Linux, no tag, no CI): tool/dev_release.sh [--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 the next patch's
-dev.N (N one past the published one) 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 the hosted server
When hosting, the server runs on its own isolate (ServerIsolate,
apps/desktop/lib/src/state/server_isolate.dart), in the same process.
Breakpoints in packages/server work under flutter run: the debugger
attaches to spawned isolates too (it shows as server). Its log lines are
printed with a [server] prefix. Hot reload reloads the server isolate's
code too, but its state (members, clock, sockets) survives until hosting
restarts; changes to startup code (SyncServer.start, routes) need a new
Host. 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. Without the app:
dart run packages/server/bin/server.dart.
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.SyncServerbinds withshared: trueso a new hosted server in the same process can take it over; a normal stop closes it. - Relic server-side
onDonedoes not fire for dead peers. Presence is theisClosedsweep +onErrorforget +lastSeentimeouts. web_socket_channel3.x:channel.readyerrors are unhandled on refused connections; swallow them and report through the stream'sonError. Streams are single-subscription: one listener per socket.- media_kit has no public
Player.setProperty; the code reaches mpv through a dynamic call onplayer.platform. Cache size goes throughPlayerConfiguration(bufferSize:). - Hot restart with any media_kit
Playeralive (session, thumbnails, sfx) used to SIGSEGV on mpv'scorethread: each mpv handle's wakeup callback is aNativeCallableof the old isolate, freed by the restart, and media_kit then sends the leftover handlesquit, which fires it.fox_utils' Linux plugin (from 0.1.8, debug builds only) clears those callbacks on the engine'son-pre-engine-restartsignal, reading the handles from media_kit's debug-onlyNativeReferenceHolderbuffer (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
setPropertyif touched. - Overlays that are hidden but mounted keep focus (Space falls into a hidden
button). Use
ExcludeFocuson closed subtrees and unfocus on close; reuse the shared no-focus node for overlay buttons. toggleFullscreenOnDoublePress: falseon the session controls is load-bearing: media_kit's double-click pushes its own fullscreen route with a secondVideo. AGestureDetectorbelow media_kit's overlay never sees the click (itsMouseRegiontakes the hit) and one around it would delay every button click (double-tap holds the arena), so the double-click is a raw pointer observer (DoubleClickListener).- The window is frameless: the party strip is the only drag area and holds the window buttons.
- Signal-backed singletons (
Profile,SessionState) live across tests; never cache aFuturein them (each test runs in its own zone, and a Future from an earlier test never completes in the next). - Chat arrives twice from the server (see PROTOCOL.md); the client drops the textless feed copy.
- Tab traversal is disabled in the session player on purpose; Tab toggles chat & activity.
- After editing constants (ports, thresholds), grep to confirm the value landed.