Restructure step 0: test safety net, AppEvent/Notifier seam, platform ports, protocol 16 planned
- 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:
1 parent
7e98ebd1db
commit
a1b270fd4e
63 files changed
+1231
-434
No files matched your search
+53
-4
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user