Restructure step 0: test safety net, AppEvent/Notifier seam, platform ports, protocol 16 planned
ci / core (push) Successful in 29s
ci / server (push) Successful in 1m9s
ci / updater-windows (push) Successful in 5m58s

- Tests: the four known failures fixed (test-side causes), one duplicate
  file and four detail-pinning cases removed, every test that pins
  behaviour the restructure changes tagged with its step.
- Every toast, sound and activity-log line goes through Notifier.notify
  (AppEvent), mapped one-to-one to today's behaviour.
- Platform ports (shell, URLs, folders, link resolver, system
  notifications) with desktop implementations; window_manager, Process
  and file_picker now live only in platform/desktop/.
- ROADMAP: protocol 16 planned; app id dev.foxyz.starsync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 19:24:11 +03:00
1 parent 7e98ebd1db
commit a1b270fd4e
63 files changed
+1231 -434

No files matched your search

+53 -4
View File
@@ -1081,11 +1081,60 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
in order, a link in the Link page's mode). All go through
`QueueController.add`. None of it offline.
## App events and platform ports
**Events.** Everything that may become a toast, a sound, a window
attention request or an activity-log line is an `AppEvent`
(`session/app_events.dart`, a sealed type, pure Dart): the server feed as
typed `FeedEvent`s (`FeedEvent.of`: joins, leaves, stale, sat out, back,
ready/unready, force ready/play/hold, swap, seek, pause, resume,
finished, chat, rename, avatar, queue add/remove/skip, fetch failed),
the session's own (nudged, a member can't play, slow source, relay
started/failed, idle warning, connection lost/back, Jellyfin progress
not saved) and the app's (shader message, added to Up next, a put-up or
queue add that failed, Schedule results, problem marked, copied / copy
failed, update available). Code with news calls
`SessionCoordinator.notify` (= `Notifier.notify`, `session/notifier.dart`);
nothing else writes toasts or plays sounds (Settings' sound preview
aside). `noticeFor` is the whole policy, one `Notice` per event: the
toast text and its actor, the `SfxCue`, attention, the feed line for the
activity log. Today: every feed line is logged (`Notifier.feedLog`, the
last 100); joins, leaves, swaps, chat, being sat out by someone else and
Up next skips toast; the sounds are listed under "Sounds"; a nudge also
asks for attention; an available update does nothing (the update band
shows it). `Notifier.toasts` holds the toasts, dropped 4 s after they came
(`expire`, the session's 1 s tick), cleared on connect. The Library
tab's card errors are still its own snack bars.
**Ports.** UI and session code reach the platform through interfaces
(`platform/ports/`), made once in the composition root
(`SessionState.ports`, `desktopPorts()` in `platform/desktop/`) and
handed on from there (the coordinator gets its `Notifier` and
`LinkResolver` as constructor arguments):
- `ShellPort` (`DesktopShell`): window start (remembered bounds, the
close request routed to `shutDownApp`), fullscreen, minimize/maximize
and their changes, dragging (`WindowDragArea`), close, the way out
(save, hide, quit), attention, the clipboard paste (files, else text)
and drop targets (fox_utils).
- `UrlOpener` (`DesktopUrls`): `xdg-open`, `open`, Windows' `url.dll`.
- `FolderAccess` (`DesktopFolders`): folder and file dialogs
(file_picker), the Files scan, folder exists, file stat, showing a
folder in the file manager. Paths for now.
- `LinkResolver` (`AppYtDlp`): the app's yt-dlp, link probes, mpv's raw
options, warm-up.
- `SystemNotifications`: an interface only; desktop shows none
(`NoSystemNotifications`).
Still direct: the player (`PlaybackController`, media_kit, and its
`AppYtDlp.instance.mpvRawOptions`), the updater's restart
(`Process.start`), the session's own file reads for what it plays or
relays (`QueueController`, thumbnails, cookies import), app-private
caches.
## Desktop UI
One window, `AppShell` (`lib/src/shell/`), whatever the connection state
(online = `SessionState.route` is `session`). The window comes back as it was
(`platform/window_memory.dart`, settings key `windowBounds`, saved on
(`platform/desktop/window_memory.dart`, settings key `windowBounds`, saved on
resize/move/maximize and close): its normal size and maximized state
everywhere, its position where an app may place itself (Windows, X11;
not Wayland, where the compositor decides) and only if it still lands
@@ -1269,7 +1318,7 @@ panel) and chat messages (Copy message, and a link in it).
Library → Link (with others the relay mode is picked there). In a
server, videos and links also get Add to queue / Play next. Images are ignored.
Nothing happens while a dialog or popover is over the shell.
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
- **Sounds** (`sfx/`): the `Notifier` plays an `SfxCue` for ready,
unready, force-ready, join, leave, swap and others' chat (feed
events), being sat out by someone (the `kick` sound), a nudge (always,
whatever its switch), the idle warning, and connection lost / back (once per drop).
@@ -1390,7 +1439,7 @@ panel) and chat messages (Copy message, and a link in it).
the controls down and the video not playing a glass chip
(`PlayerGlass`) top right shows it (while it plays nothing sits on the
picture).
**Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with
**Toasts** (`toasts.dart`): the `Notifier`'s toasts bottom left, with
the avatar of their `ToastItem.actor` (feed and chat toasts; the app's
own have none); each
slides in, stays 5 s, then slides out and collapses its gap. A click on
@@ -1551,7 +1600,7 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
`StartBanner` under them); media_kit's own controls are not used, so
its keys, double-click route and seek bar are out of the picture.
Window chrome is frameless (`window_manager`): the party strip is the drag
Window chrome is frameless (`window_manager`, behind `ShellPort`): the party strip is the drag
area and holds the min/max/close buttons (`widgets/window_buttons.dart`);
fullscreen has no drag area and shows the buttons in the player's top bar.
+52 -26
View File
@@ -56,31 +56,53 @@ 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=0` for the cold path,
`WT_SWITCH_DELAY_MS` / `WT_SWITCH_RATE_KBS` for the link). The sync and
end-of-file ones expect `~/Videos/chiyo.mp4`; others generate media with
ffmpeg into `WT_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.
- `sound_device_e2e` opens the real audio device (flutter_soloud) and plays
every sound of every preset quietly (`WT_SFX_VOLUME`, default 0.1);
`sound_engine_test` covers the engine with a fake backend.
- 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.
- `packages/server/test/serve_isolate_test.dart` runs `serve` in a real
isolate; `apps/desktop/test/server_isolate_test.dart` covers the app's
host path (isolate server, raw WebSocket join, crash, rehost).
- Desktop tests that write files use `WT_TEST_TMP` (default: system temp).
Three suites, run from each package's folder:
- `cd packages/core && dart test` (a few seconds): protocol round-trips,
the sync engine (clock, gate, correction, end of file), queue model,
relay pieces, updater, logging redaction, watch rule, yt-dlp and
Jellyfin client parsing against captured fixtures.
- `cd packages/server && dart test` (well under a minute): live servers
on loopback: auth, media and relay byte serving, Jellyfin relay against
a fake, queue, session log, isolate `serve`.
- `cd apps/desktop && flutter test test/<file>`: widgets (shell, library,
controls, settings, schedule), pure app logic (`StateDiff`, name parser,
shader list, sound engine with a fake backend) 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`, `video_reload_e2e`,
`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `chapters_mpv_e2e`,
`ytdl_headers_e2e`, `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), `jellyfin_e2e`,
`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=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. `sound_device_e2e` opens the
real audio device (flutter_soloud) and plays every sound of every preset
quietly (`WT_SFX_VOLUME`, default 0.1).
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.
Traps:
- Never pump media_kit's `Video` in `flutter test`: the runner hangs (kill
@@ -91,7 +113,11 @@ Traps:
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`).
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.
+98 -7
View File
@@ -59,8 +59,6 @@ Problems are numbered; delete a line when its fix ships.
- P23 Solo mode you enter and leave; going hosted loses the queue (D9).
- P24 The invite address list is unhelpful (shows the proxy's 198.18.x).
- P25 ssh to Gitea blocked through the proxy (pushes go over https).
- P27 Failing tests: shell "Ctrl+V outside a field", relay e2e "a guest
puts up a file", "links: through me".
- P28 Log noise: "Failed to create file cache"; an idle relay channel for
Jellyfin items everyone has an account for.
- P29 Relay start delay after a seek into an unbuffered part.
@@ -87,10 +85,9 @@ the automatic continuation and countdown, relay state), the device
(client id, library and folders, match corrections, watch history,
settings, notification prefs, what this app is sourcing) and the
integrations (Jellyfin played/resume, AniList list).
0. Safety net and seams, no visible change: fix P27; an `AppEvent` type;
platform ports wrapping today's calls (window/shell, open URL,
notifications, yt-dlp process, folder access, player host);
Connection extracted from the coordinator; the proto 16 spec written.
0. Safety net and seams, no visible change. Left: Connection extracted
from the coordinator. (Done: tests audited and tagged per step, P27
fixed, AppEvent + Notifier seam, platform ports, protocol 16 planned.)
1. Connection + Identity + Presence, proto 16 in full (one bump, with a
min/max version range and a stable refusal frame so later changes are
additive): a lasting client id; a socket close no longer removes the
@@ -133,7 +130,8 @@ No Windows dev builds: restructure testing happens on Linux; Windows
friends stay on 1.2.x until the 1.3.0 release (Windows-only fixes like
P30 reach them then).
Rename to Starsync everywhere (repo, app id, data folder, updater URL
migration) is its own step; proposed app id `ru.foxyz.starsync`. Auto
migration) is its own step; app id `dev.foxyz.starsync`, Windows
company name `foxyz` (replacing Flutter's default `com.example`). Auto
sit-out: idle or minimised for a while sets you Away, activity sets you
In; it replaces the 10-minute auto-unready.
@@ -148,6 +146,99 @@ In; it replaces the 10-minute auto-unready.
- D10 was dropped on purpose (preload/warm relay/fresh player are folded
into step 6, not a separate decision).
### Protocol 16 (planned)
Lands in step 1, written into PROTOCOL.md then. Tags against proto 15:
**new**, **changed**, **removed**. Rooms are out: one server, no room id.
Versions and refusal:
- `hello.proto` **changed**: the client's version; the server accepts
`[protoMin, protoMax]` (both 16 at 1.3.0; later additions raise
`protoMax` only, so they stay additive). `welcome.proto` **new**: the
version used on this connection.
- Refusal **changed**, stable from 16 on: `{"t":"err","code":"version",
"min":16,"max":16,"server":"1.3.0","message":"proto mismatch: got 15,
want 16..16"}`, then close (a proto 15 app shows `message`). Every
`err` gets `code` **new**: `version`, `secret`, `rejected`, `bad`,
`replaced`.
- `/health`: `protoMin`, `protoMax`, `tls` **new** (`proto` = `protoMax`).
Handshake:
- `hello.client` **new**: a random id per install (22 characters
base64url), private: never broadcast.
- `hello.app` (`"1.3.0"`), `hello.platform` (`linux`, `windows`,
`android`), `hello.caps` **new**: what this app can do (`source`: serve
a relay; `resolve`: run yt-dlp; `jellyfin`: play with its own
account); unknown entries ignored.
- `hello.resume` **removed** (the client id replaces it); `hello.media`
**removed** (unused).
- `members[].app` **new**: the member's `hello.app`.
Lasting member:
- The server maps `client` to one member. A new socket with a known
client id takes that member over (`memberId` and `token` unchanged, so
`/m` `/r` `/j` URLs keep working); the older socket gets `err`
`replaced` and closes. `welcome.resumed` **new**: true when it did.
- A socket close no longer removes the member **changed**: it goes
`gone` and is kept for the grace period (~10 min, `welcome.grace`
**new**, seconds) with its ready flag, presence, queue items and
relay. After the grace it leaves (feed `leave`).
- `{"t":"bye"}` **new**: leave now (Leave button, app close).
Presence:
- `members[].presence` **new**: `joining` | `in` | `away` | `gone`;
`members[].goneSince` **new** (ISO, only while `gone`).
`members[].out` and `members[].stale` **removed** (`away`; a silent
socket is closed and its member is `gone`).
- `{"t":"presence","target":"<memberId>","state":"away"|"in"}` **new**,
replaces `out` **removed**: anyone sets someone `away`; only the member
itself goes back `in`.
- Feed **changed**: `away` (was `out`), `in` (was `back`), `gone` (was
`stale`), `return` **new** (back within the grace: no `join`), `leave`
(after the grace or `bye`), `join` (new members only).
- Gate (one `PresencePolicy` in core, used by server and app): only `in`
counts for the gate, the ready counter and watched tracking; `away` and
`gone` never. A joiner is `joining` until its report holds the current
media with `bufferNeed` at the current position, then `in`. While
`playing` a joiner never holds (D2): it loads in the background and
comes in already in sync. While held it is in the set like a fresh
member today, for at most ~15 s, then out of the set until `in`. A
returning member (`gone` → back within grace) is `joining` again with
its ready flag kept, treated like a joiner; an `away` one stays
`away`.
Relay (D6):
- `media.relay.sourceGone` **new**: true while the source member is
`gone`. The relay and its buffer stay; viewers play what is buffered.
- Source resume **changed**: the source's close no longer ends the relay;
only its leave (grace over, `bye`) or a swap does. Back in the grace,
it dials `/r/<sid>/<token>/source` again; an `info` equal to the first
continues (pulls restart from the first byte or piece not buffered, new
pull ids), a different one ends the relay (close 4002 **new**).
Queue and what's next:
- `queue[].by` **changed**: lasting (the member id survives reconnects);
a `through`/`file` item of a `gone` adder waits instead of being
skipped, until its adder leaves.
- `queue[].auto` **new**: the continuation (next episode): `add` with
`auto:true`; at most one, after the others, replaced by a newer one,
dropped by a swap to something else.
- `state.advance` **new**: `{"id":"q3","at":"<server time>","secs":10}`,
the shared countdown when the media finishes and an item is first; at
`at` + `secs` the server sets `queuePlay` to it.
`{"t":"queue","op":"cancel"}` **new** clears it for everyone (feed
`advance-cancel` **new**).
Media identity:
- `media.ref` **new**, in `swap.media`, `state.media` and
`queue[].media`: `{"kind":"episode"|"movie"|"extra","ids":{"anilist":1,
"tvdb":2},"show":"...","season":1,"episode":6}` (MediaRef); passed
through as is, at most 2 KiB.
Transport: paths unchanged. A server with TLS serves the same endpoints
over `wss://`/`https://` (`/health.tls`); apps use `wss://` when offered,
`ws://` stays allowed.
## Backlog
1. **Auto-skip OP/ED** (undecided): who decides and how (shared for the