- starsync://host:port/?name=&pw= (PROTOCOL.md "Invite link"); the password only when ticked, never in logs - Linux: launcher entry takes x-scheme-handler/starsync (%u, StartupNotify) and becomes its xdg-mime default; Windows: HKCU\Software\Classes\starsync -> starsync.exe - InstanceLock: lock file + loopback handoff per profile folder; the running app comes up with the second start's activation token (Wayland/X11) or AllowSetForegroundWindow (Windows) - Sharing looks up the internet address (HTTPS services with fallbacks); members card picks Internet / Home network / VPN and says when the port needs forwarding - JoinOffers: link and clipboard prompt with /health, joins through ConnectFlow; dismissed clipboard invites kept as fingerprints Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
31 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: 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. What is there is an experiment to look at, not
a decided feature:
- Danmaku preview (
lib/src/danmaku/): chat lines and short notices (joins, leaves, Up next adds, "starting now") flying right to left across the upper part of the picture.DanmakuLayeris the reusable widget: lanes in a band of the height (DanmakuStyle.bandTop/bandBottom, default 10 to 45 %, under top signs, never down into the dialogue); each line takes the topmost free lane (DanmakuLanes, pure: the last line in a lane must have fully entered with a gap, and the new one, at its own speed, cannot catch it before it leaves); at mostmaxOnScreenfly, the rest wait in order (dropped after 15 s, 60 at most); every line crosses incrossSecs, so long ones move faster; white text with a dark outline and no box, the sender's avatar (StatusAvatar: picture, else initial) and coloured name first, the app's own notices without one, own lines in an accent frame; the layer's own clock, so lines fly on while paused. Each line is a widget moved by aFlow: per frame only paint runs. 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, and everyDanmakuStyleknob live (crossing time, cap, lane band, opacity, size, outline, avatars, own frame), plus mock subtitles (a top sign and bottom dialogue). Tests:danmaku_test. - Lift subtitles above the chat line and controls
(
AppSettings.liftSubtitles, off): Enter in the full player opens a stand-in for the planned chat line (a one-line box at the bottom in place of the control bar; Enter sends to the chat, Esc closes). While it is open, or the control bar is up, bottom subtitles move above it and back down when it goes (SubtitleLifter,video_view.dart): mpvsub-pos, animated over the shell's 320 ms curve in 0.1 steps, only as far as the letterbox under the picture leaves no room (subtitleLiftPos). What each mpv property does, onmake_sub_test.sh's file (mpv 0.41, libmpv as the app uses it):sub-posmoves bottom ASS dialogue and SRT lines up and leaves\an8signs in place (withsub-ass-overrideat its defaultscaleoryes;noignores it for ASS);sub-margin-ymoves SRT only (ASS only withsub-ass-override=force, which restyles every line, signs too);sub-ass-style-overrides=MarginV=...moves top signs down as well;video-margin-ratio-bottomshrinks and moves the picture itself (withsub-ass-force-marginsthe dialogue goes into the new black band).
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), thePresencePolicytable, 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,groupLocalover 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_teston 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, isolateserve, 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.refproducers, shader list, sound engine with a fake backend, invite addresses, thestarsync://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 and the dev-tools gate (video_view_test), danmaku lanes (danmaku_test)),singletons_test(the.instancecount, see ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv*_e2etests pluscoord_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):
| 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 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
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 (one cut short atstop:/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 inrunZonedGuardedand fail on anything else (queue_switch_e2e,relay_e2e): a viewer's read can still be in flight at thestopin 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, orpumpAndSettlenever returns. - 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, refuses when the changelog's Unreleased has no items (warns when it has no summary) and prints it, renames Unreleased toX.Y.Zunder a fresh empty Unreleased, bumps the three pubspecs in lockstep, writesupdates/manifest.json(per-platformassetsURLs are predictable;urlstays the Linux tarball for builds <= 0.2.1;notesis the section's summary,changesits lists), commitsRelease vX.Y.Z(withCHANGELOG.md), makes an annotated tag.--dry-runstops after printing.- 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;tool/setup_flutter_windows.ps1also adds Visual Studio's C++ ATL component once, which the notifications plugin needs) 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 (its text: the version's changelog section) 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 [--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). 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) 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 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).
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.SyncServerbinds withshared: true(both listeners) so a new one 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 player host (MediaKitHost) reaches mpv through a dynamic call onplayer.platform, and nothing outside it imports media_kit except theVideowidget glue. Cache size goes throughPlayerConfiguration(bufferSize:). - Hot restart with any media_kit
Playeralive (session, thumbnails) 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,AppSettings) and a test file'sAppGraphlive 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). - Widgets read the systems from
AppScope: a widget test pumps its tree under one (scoped(graph, ...)fromtest/test_graph.dart; onetestGraph()per file). AStatethat needs a system indisposereads it before (late finaltouched ininitState): 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.