The server keeps one small relay (32 MiB ahead, 8 behind) for Up next's first relayed item, keyed by its queue id at a URL that stays the same after the swap: fetched by the server for server-fetch links, fed by the adder's app for through-me links and files, by a member with an account for member-passed Jellyfin items. Every app preloads from it; the swap promotes it with its buffer. Dropped when the item stops being first or its adder leaves. Measured switch: ~1.6 s cold, 20-40 ms preloaded. ROADMAP gains the research item on server-side fetching of member-resolved URLs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9.8 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: an episode, a direct link, a link passed on and a server-fetched one;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). - A
SyncServerstarted 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 inrunZonedGuardedand fail on anything else (queue_switch_e2e). - 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.