Sync fixes: clean seeks, clock offset, end of file, 2.5 min read-ahead (proto 6)
- User seeks are intercepted at the player (SyncPlayer), previewed locally, sent once 300 ms after the drag ends; own echoes are matched by seekSeq and never re-applied; the landed check can't reapply a stale target. 1 s position poll removed. - ping/pong clock offset (min-RTT ClockSync); positions use server time. - Reports carry duration; buffer gate is min(4 s, remaining - 2 s); new finished transport instead of a HELD stall at the end. - cache/readahead 150 s, 256 MiB demuxer cache, pins read back. - Resumed reports put a stale ready member back in the set; hello.resume takes over the old member after an unclean drop (no ghost). - Docs: roadmap rendering findings (HW path works; shaders verified). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0669ec0fc2
commit
d8d487c189
28 files changed
+1592
-237
No files matched your search
+91
-40
@@ -62,36 +62,65 @@ rejoin at once with a new token.
|
||||
|
||||
Loosely modeled on Syncplay, with a different readiness design.
|
||||
|
||||
- **Transport** is `playing` or `held`. The server owns the clock:
|
||||
- **Transport** is `playing`, `held` or `finished`. The server owns the clock:
|
||||
`positionAt(now) = position + (playing ? now - serverTime : 0)`.
|
||||
Reports from clients never move it. Only seek, swap and gate transitions
|
||||
write `position`/`serverTime` and bump `eventId`.
|
||||
- **Reports**: clients send `position`, `buffered`, `eventId` every 1 s. The
|
||||
server stores `buffered` and liveness; a report with `eventId` older than
|
||||
current has its position ignored.
|
||||
Reports from clients never move it. Only seek, swap, gate transitions and
|
||||
the end of media write `position`/`serverTime` and bump `eventId`.
|
||||
- **Clock offset**: `serverTime` is on the server's clock. Each client sends
|
||||
a `ping` with its clock every second and estimates the offset NTP style
|
||||
(`ClockSync`: `offset = s - (t0 + t3) / 2` of the sample with the smallest
|
||||
round trip among the last 16). All extrapolation uses
|
||||
`SyncClient.serverNow()` (local clock plus offset), so a client whose clock
|
||||
is off still lands on the server position.
|
||||
- **Reports**: clients send `position`, `buffered`, `eventId` every 1 s, plus
|
||||
the media `duration` and its `mediaId` once the player knows it. The
|
||||
server stores `buffered` and liveness, and takes the first reported
|
||||
duration for the current media (swap resets it); a report with `eventId`
|
||||
older than current has its position ignored.
|
||||
- **Ready gate** (HELD to PLAYING): the in-set is non-empty, every in-set
|
||||
member is ready, fresh and has `buffered >= 4 s` (`minBufferSecs`), and
|
||||
quorum holds: in-set size >= 2, unless at most one member is fresh. So a
|
||||
lone viewer plays alone; with a second viewer present one Space waits for the
|
||||
other. Joining members start with `inSet=false, ready=false` and never block.
|
||||
Disconnect removes from the set; swap clears everyone.
|
||||
member is ready, fresh and has `buffered >= need`, and quorum holds: in-set
|
||||
size >= 2, unless at most one member is fresh. `need` is 4 s
|
||||
(`minBufferSecs`), or near the end what is left minus 2 s (`endSlackSecs`,
|
||||
floor 0), since mpv cannot buffer past the end. So a lone viewer plays
|
||||
alone; with a second viewer present one Space waits for the other. Joining
|
||||
members start with `inSet=false, ready=false` and never block. Disconnect
|
||||
removes from the set; swap clears everyone.
|
||||
- **Pause**: while PLAYING, any in-set member who becomes not ready, under
|
||||
4 s buffered or stale flips the server to HELD (feed action `pause` by `server`).
|
||||
Recovery resumes by itself when the gate passes.
|
||||
`need` buffered or stale flips the server to HELD (feed action `pause` by
|
||||
`server`). Recovery resumes by itself when the gate passes.
|
||||
- **End**: while PLAYING with a known duration, reaching it sets FINISHED
|
||||
(feed `finished` by `server`, position = duration). Clients pause where they
|
||||
are; FINISHED never auto-resumes. A seek turns it into HELD at the new
|
||||
position (resuming at once if the gate passes); a swap resets to HELD.
|
||||
- **Seek**: allowed from any member in any transport; the server writes the
|
||||
position and bumps `eventId`; flags are untouched. Clients diff each `state`
|
||||
position, bumps `eventId` and records who sent it (`seekBy`, `seekSeq` in
|
||||
`state` for that event); flags are untouched. Clients diff each `state`
|
||||
broadcast by `eventId` (`StateDiff`): new event means seek to the computed
|
||||
server position, transport change means play/pause. Between events: drift
|
||||
over 5 s for more than 2 s seeks, ahead by more than 1.5 s sets rate 0.95
|
||||
until level, otherwise nothing. Local seeks are detected by the coordinator
|
||||
polling mpv position once per second (`_detectUserSeek`, jump over 2 s and
|
||||
more than 2 s from the server position) and sent as a `seek` intent; a retry
|
||||
(`_checkSeekLanded`) re-applies a server seek that has not landed after 1.5 s.
|
||||
Known roughness: see ROADMAP.
|
||||
until level, otherwise nothing.
|
||||
- **User seeks**: the player is a `SyncPlayer` whose public `seek` is the
|
||||
user's: media_kit's seek bar (one `seek` per pointer move while dragging),
|
||||
the arrow keys and `seekTo` all become explicit events. The player moves at
|
||||
once (scrub preview); `UserSeekGate` sends one `seek` intent with a fresh
|
||||
`seq` after 300 ms without another request (`seekDebounceMs`). While the
|
||||
user is seeking, and until the echo of the last sent seq arrives (3 s at
|
||||
most), server seeks never touch the player; the echo of our own seek is not
|
||||
applied either (the player is already there). A server seek that has not
|
||||
landed after 1.5 s is re-applied once (`_checkSeekLanded`), to the server
|
||||
position recomputed now, and only while its event is still current and the
|
||||
user is not seeking.
|
||||
- **Liveness**: WS ping every 5 s; silent over 15 s means stale (out of the
|
||||
gate, `stale` feed); over 30 s the socket is closed and the member removed.
|
||||
A stale member that was ready goes back into the set with its next frame.
|
||||
Closed sockets are polled with `isClosed` every second: Relic's server-side
|
||||
`onDone` does not fire for dead peers, so presence never relies on it.
|
||||
- **Resume**: a reconnecting client sends its previous token as
|
||||
`hello.resume`. If the server still has that member (an unclean drop it has
|
||||
not noticed), the new socket takes it over: same token, member id, ready
|
||||
flag and media URL (the player keeps streaming), no join/leave feed, and the
|
||||
dead socket is closed without removing the member. An unknown or kicked
|
||||
token joins as a new member.
|
||||
- **Client policies**: no state broadcast for 5 s means connection lost, clear
|
||||
the server state and reconnect every 1.5 s until cancelled (an epoch counter makes
|
||||
a cancelled attempt stale) or kicked. After a reconnect the ready flag is
|
||||
@@ -102,8 +131,7 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
auto-unready at 10 min, and never less than 2 min after the warning.
|
||||
- **Deviations from Syncplay**: clock never follows the slowest watcher; no
|
||||
pause-on-leave; anyone can force ready/unready (all or one); quorum gate;
|
||||
the clock is server time, and remote clock offset is not corrected (clients
|
||||
assume their clock matches the server's).
|
||||
the clock is server time (clients correct for their offset to it).
|
||||
|
||||
Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
|
||||
|
||||
@@ -149,12 +177,21 @@ open-ended), then every seek is a fresh `bytes=N-` connection. A server
|
||||
outage of a few seconds is ridden out from mpv's cache with no error. Longer
|
||||
outages exhaust the cache and rely on reconnect (not chaos-tested yet).
|
||||
|
||||
mpv settings (`PlaybackController`): `Player` with `bufferSize` 64 MiB,
|
||||
`libass` on; pinned via runtime property: `demuxer-readahead-secs=10`,
|
||||
`cache-secs=10`, `stream-lavf-o=reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4`
|
||||
(comma form; this mpv build rejects colon-joined). media_kit has no public
|
||||
`setProperty`; the code calls it through a guarded dynamic call on
|
||||
`player.platform`. Hardware decoding comes from Settings (auto, off, custom)
|
||||
mpv settings (`PlaybackController`): `Player` with `bufferSize` 256 MiB
|
||||
(media_kit's `bufferSize` sets both `demuxer-max-bytes` and
|
||||
`demuxer-max-back-bytes`), `libass` on; pinned via runtime property:
|
||||
`demuxer-readahead-secs=150`, `cache-secs=150` (about two minutes of
|
||||
read-ahead: rides out short drops, intro skips are instant),
|
||||
`demuxer-max-bytes=268435456` (150 s up to ~14 Mbit/s; a 1.4 GB / 24 min
|
||||
file is ~8), `demuxer-max-back-bytes=67108864`,
|
||||
`stream-lavf-o=reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4`
|
||||
(comma form; this mpv build rejects colon-joined). The cache is in RAM:
|
||||
media_kit sets `cache-on-disk` without a `cache-dir`, and mpv falls back. mpv
|
||||
ignores invalid values silently, so every pin is read back and a mismatch is
|
||||
logged and kept in `pinMismatches`. Measured against the `/m` endpoint
|
||||
(`readahead_e2e_test`): a paused player buffers ~147 s ahead (10 s before).
|
||||
media_kit has no public `setProperty`/`getProperty`; the code calls them
|
||||
through a guarded dynamic call on `player.platform`. Hardware decoding comes from Settings (auto, off, custom)
|
||||
and is passed to the `VideoController`; env `SYNC_RELAY_HWDEC` (e.g. `no`)
|
||||
overrides it. It is read per player, so a change applies on the next join or
|
||||
Reload video.
|
||||
@@ -162,8 +199,8 @@ Reload video.
|
||||
Player lifecycle: the `Player` and its `VideoController` live for one
|
||||
session: created on join, disposed on leave, replaced by `recreate()` for
|
||||
"Reload video" (button or Ctrl+R; volume kept). `openUrl` opens with `play: false`; play/pause/seek/rate come only from
|
||||
`StateDiff` actions or the play gate (`localPlayAllowed`: self in-set and
|
||||
ready). A server seek within 0.5 s of the current position is skipped. `leave()`
|
||||
`StateDiff` actions, the play gate (`localPlayAllowed`: self in-set and
|
||||
ready) or the user's own seeks (see Sync model). A server seek within 0.5 s of the current position is skipped. `leave()`
|
||||
stops the player (otherwise audio keeps playing). On media change
|
||||
(`mediaId` bump) the coordinator opens the new URL and `StateDiff` seeks to 0,
|
||||
holds and resets rate.
|
||||
@@ -183,17 +220,30 @@ mounted and skip painting (`Visibility` with `maintainState`).
|
||||
|
||||
## Desktop UI
|
||||
|
||||
One `HomeScreen` (`screens/home/`) with a nav rail: Home (profile chip, Join
|
||||
card, Host card, up to 6 Recent server chips for one-click rejoin), Settings
|
||||
(profile, hardware decoding, updates, shortcuts, shaders "coming soon"),
|
||||
Integrations (Jellyfin "coming soon") and About. The update indicator sits on
|
||||
Home. Settings persist in two Hive boxes: `connect_form` (last form and
|
||||
recents; saved even when a join fails) and `settings`. Link media names are
|
||||
derived from the URL path and never contain a query string.
|
||||
One `HomeScreen` (`screens/home/`) with a single top bar (`top_bar.dart`):
|
||||
profile chip, Join and Host tabs, the update indicator, Settings /
|
||||
Integrations / About buttons, and the window drag area and buttons. Only the
|
||||
selected view is mounted; form state lives in `HomeShell`. Join
|
||||
(`join_tab.dart`): one compact address/port/password row, then up to 6
|
||||
Recent server tiles in a grid that adds columns as the window widens. While
|
||||
the Join tab is visible each tile polls the server's `GET /health` every
|
||||
10 s (`health_probe.dart`) for online, name, viewer count and a "needs
|
||||
update" badge when `proto` differs from ours. Host (`host_tab.dart`): the
|
||||
media picker (`media_picker.dart`: Jellyfin placeholder, Link, File; a
|
||||
reusable widget driven by a `MediaPickerController`) fills the main area;
|
||||
port, password and Host sit in a 340 px column on the right, below the
|
||||
picker under 860 px. Text fields have fixed or capped widths (720 px max) so
|
||||
they stay compact on ultrawide screens. Submit state (busy, cancel,
|
||||
per-tab errors, server crash) is `ConnectFlow`. Settings (profile, hardware
|
||||
decoding, updates, shortcuts, shaders "coming soon"), Integrations
|
||||
(Jellyfin "coming soon") and About open in the same body. Settings persist
|
||||
in two Hive boxes: `connect_form` (last form and recents; saved even when a
|
||||
join fails) and `settings`. Link media names are derived from the URL path
|
||||
and never contain a query string.
|
||||
|
||||
Window chrome is frameless (`window_manager`): every screen brings its own
|
||||
drag area and min/max/close buttons (`widgets/window_chrome_bar.dart`,
|
||||
`window_buttons.dart`); the session drag layer is `window_drag_layer.dart`.
|
||||
drag area and min/max/close buttons (Home's top bar; the session uses
|
||||
`widgets/window_chrome_bar.dart`; both use `window_buttons.dart`); the session drag layer is `window_drag_layer.dart`.
|
||||
|
||||
## Health reporting
|
||||
|
||||
@@ -203,8 +253,9 @@ for connecting, reconnecting, wrong password, version mismatch, kicked,
|
||||
host offline, and starvation ("waiting for X - N.N s buffered").
|
||||
Errors are logged with a stack trace at the boundary, then turned into a
|
||||
displayable state. The server's `GET /health` returns JSON (`ok`, `name`,
|
||||
`proto`, `viewers`, `bytesServed`, `uptimeSecs`); it is unauthenticated and
|
||||
the app does not read it.
|
||||
`proto`, `viewers`, `bytesServed`, `uptimeSecs`); it is unauthenticated, so
|
||||
it carries nothing sensitive. Home's recent-server tiles poll it for live
|
||||
status.
|
||||
|
||||
## Updater
|
||||
|
||||
|
||||
+7
-2
@@ -45,8 +45,13 @@ and `e2e_check` (simulated viewers against a live server).
|
||||
|
||||
- `cd packages/core && dart test`, `cd packages/server && dart test`.
|
||||
- `cd apps/desktop && flutter test test/<file>`.
|
||||
- `sync_playback_e2e_test.dart` and `coord_presence_test.dart` use real mpv;
|
||||
the e2e one also expects `~/Videos/chiyo.mp4`. Run only tests related to
|
||||
- Real-mpv tests: `sync_playback_e2e`, `coord_presence`, `video_reload_e2e`,
|
||||
`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`. 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.
|
||||
- CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus
|
||||
`dart compile exe` of the dedicated server (fails if Flutter creeps into
|
||||
|
||||
@@ -47,7 +47,9 @@ Friends join either way with the Join card on Home.
|
||||
says we are, and buffers. A new arrival is not ready yet and never holds
|
||||
anyone back.
|
||||
3. **Ready gate.** Press Space to say you are ready. The server starts
|
||||
playback only when everyone who is ready has at least 4 seconds buffered.
|
||||
playback only when everyone who is ready has at least 4 seconds buffered
|
||||
(or what is left, near the end). At the end everyone stops and the server
|
||||
stays finished until someone seeks back or changes the video.
|
||||
If two or more people are ready, one person alone cannot start it for the
|
||||
others. A lone viewer can play alone.
|
||||
4. **Play.** The server owns the clock. Each app compares its mpv position
|
||||
@@ -61,7 +63,7 @@ Friends join either way with the Join card on Home.
|
||||
7. **Leave.** Closing or leaving drops you from the server; the others keep
|
||||
going. If you vanish without saying goodbye, you are marked stale after 15 s
|
||||
(stops blocking) and removed after 30 s. The app reconnects by itself and
|
||||
restores your ready flag.
|
||||
takes over your old place (same member, ready flag and video).
|
||||
|
||||
There are no roles: anyone can seek, swap, force-ready others and kick
|
||||
anyone except the host.
|
||||
|
||||
+38
-12
@@ -1,4 +1,4 @@
|
||||
# Protocol (proto = 5)
|
||||
# Protocol (proto = 6)
|
||||
|
||||
Wire format only. Behavior (gate, clock, timeouts) is in
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
|
||||
@@ -25,13 +25,16 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||
## Client to server
|
||||
|
||||
```
|
||||
{"t":"hello","proto":5,"secret":"","user":{"name":"","avatar":""},
|
||||
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"..."}
|
||||
{"t":"hello","proto":6,"secret":"","user":{"name":"","avatar":""},
|
||||
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
|
||||
"resume":"<token>"}
|
||||
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
|
||||
{"t":"force","target":"all"|"<memberId>","ready":true}
|
||||
{"t":"seek","position":123.4}
|
||||
{"t":"seek","position":123.4,"seq":3}
|
||||
{"t":"swap","media":{"name":"","size":0,"duration":0,"url":"..."}}
|
||||
{"t":"report","position":123.1,"buffered":6.2,"eventId":41}
|
||||
{"t":"report","position":123.1,"buffered":6.2,"eventId":41,
|
||||
"duration":1440.5,"mediaId":2}
|
||||
{"t":"ping","c":1767225600000}
|
||||
{"t":"chat","text":"..."}
|
||||
{"t":"kick","target":"<memberId>","reason":""}
|
||||
```
|
||||
@@ -44,6 +47,18 @@ Field notes:
|
||||
from its own client. The member whose key equals the server's is the host
|
||||
and cannot be kicked. A server without a key (the dedicated server) has no
|
||||
host. Where the connection comes from does not matter.
|
||||
- `hello.resume` (optional, omitted when empty): the token from this
|
||||
client's previous `welcome`, sent on reconnect. If the server still has
|
||||
that member and it is not kicked, the connection takes it over: `welcome`
|
||||
returns the same `token` and `memberId`, the member keeps its flags, and
|
||||
there is no `join`/`leave` feed. Otherwise it is ignored and a new member
|
||||
joins.
|
||||
- `seek.seq` (optional, default 0): increasing per client; echoed in
|
||||
`state.seekSeq` so the sender recognises its own seek.
|
||||
- `report.duration` / `report.mediaId` (optional, omitted when 0): the open
|
||||
media's length in seconds and the `mediaId` it belongs to. The server takes
|
||||
the first one whose `mediaId` is current while its media duration is 0.
|
||||
- `ping.c`: the client's clock in epoch ms; answered with `pong`.
|
||||
- `user.avatar`: base64 image, at most 256 KiB of characters. `name` is
|
||||
trimmed, empty becomes `?`, cut to 32 characters.
|
||||
- `chat.text` is cut to 500 characters.
|
||||
@@ -55,10 +70,12 @@ Field notes:
|
||||
|
||||
```
|
||||
{"t":"welcome","token":"...","memberId":"m1","mediaUrl":"...","state":{<state>}}
|
||||
{"t":"state","eventId":7,"transport":"playing"|"held","position":12.3,
|
||||
{"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3,
|
||||
"serverTime":"<ISO-8601 UTC>","mediaId":1,
|
||||
"media":{name,size,duration,url?}|absent,
|
||||
"members":[{"id","name","avatar","ready","inSet","buffered","stale"}]}
|
||||
"members":[{"id","name","avatar","ready","inSet","buffered","stale"}],
|
||||
"seekBy":"m1","seekSeq":3}
|
||||
{"t":"pong","c":1767225600000,"s":1767225600012}
|
||||
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
|
||||
{"t":"chat","text":"...","from":"<name>"}
|
||||
{"t":"kicked","reason":""}
|
||||
@@ -71,12 +88,19 @@ Field notes:
|
||||
the shared link.
|
||||
`welcome.state` has the same shape as `state`.
|
||||
- `state` is sent every second and on every change. `position` is the value at
|
||||
`serverTime`; clients extrapolate while `transport` is `playing`.
|
||||
`serverTime` (server clock); clients extrapolate while `transport` is
|
||||
`playing`, on the server's clock (see `pong`). `finished`: the media
|
||||
reached its end (`position` = duration); it stays until a seek or swap.
|
||||
`eventId` increases on seek, swap and transport changes.
|
||||
`mediaId` increases on swap.
|
||||
`mediaId` increases on swap. `media.duration` is 0 until a client reports
|
||||
it. `seekBy` (member id) and `seekSeq` are present only while the current
|
||||
`eventId` came from a seek.
|
||||
- `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms
|
||||
(the clock `serverTime` uses). With `t3` the local receive time, the offset
|
||||
is `s - (c + t3) / 2`, accurate to half the round trip.
|
||||
- `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`,
|
||||
`force-play`, `force-hold`, `kick`, `join`, `leave`, `stale`, `swap`,
|
||||
`seek`, `pause`, `resume`, `chat`. `target` and `reason` are optional;
|
||||
`seek`, `pause`, `resume`, `finished`, `chat`. `target` and `reason` are optional;
|
||||
`reason` carries `auto`/`manual` for ready and `silent too long` or
|
||||
`no signal` for leave/stale.
|
||||
- Chat is delivered twice: a `chat` frame with the text and a textless
|
||||
@@ -87,8 +111,10 @@ Field notes:
|
||||
|
||||
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server
|
||||
position and buffers. A joining member is `ready=false, inSet=false` until
|
||||
the first `ready`. A socket that dies loses its token; reconnect is a fresh
|
||||
`hello`, after which the client re-sends its prior ready flag.
|
||||
the first `ready`. A socket the server sees close loses its token; reconnect
|
||||
is a fresh `hello` (with `resume` = the old token), after which the client
|
||||
re-sends its prior ready flag. If the old member was still there (unclean
|
||||
drop), `resume` takes it over and the token and media URL stay the same.
|
||||
|
||||
## Reserved, not implemented
|
||||
|
||||
|
||||
+18
-29
@@ -6,31 +6,20 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
|
||||
## Backlog
|
||||
|
||||
1. **Sync fixes.**
|
||||
- Jumpy seek. Root cause: the media_kit seek bar calls `player.seek`
|
||||
continuously while dragging; the coordinator only notices through its
|
||||
1 s position poll (`_detectUserSeek`), so it fights the server echo and
|
||||
the seek-landed retry (`_checkSeekLanded`).
|
||||
- End-of-file stall. The 4 s buffered gate cannot be met in the last 4 s of
|
||||
a file, the server never learns the duration, and there is no finished
|
||||
state. Add duration reporting and a finished state.
|
||||
- Tiny read-ahead. Our own pins (`cache-secs=10`,
|
||||
`demuxer-readahead-secs=10` in `playback_controller.dart`) cap it; want
|
||||
about two minutes for large files.
|
||||
- Remote clock offset is not corrected: clients assume their clock equals
|
||||
the server's.
|
||||
- Stale-ready. A ready member who went stale (no reports for 15 s) should
|
||||
return to the ready set when reports resume; the client re-sends ready as
|
||||
a workaround (remove it then).
|
||||
- Ghost member after an unclean drop until `dropAfter`.
|
||||
1. **Shaders.** Per-viewer local setting using mpv `glsl-shaders` (set after
|
||||
the player is created), shipping Anime4K presets (bloc97, MIT). Shaders only
|
||||
apply on media_kit's hardware path (verified with a test shader); warn
|
||||
when the software fallback is active. The hardware path is fragile:
|
||||
media_kit borrows whatever EGL context GDK left current on the main
|
||||
thread (upstream media-kit#1404), so a `VideoController` created before
|
||||
the first frame, an X11 session, or a Flutter/GDK change can silently
|
||||
fall back to software. Log which path media_kit picked; a vendored
|
||||
media_kit_video fix (build the EGL display from GDK's native display) is
|
||||
the durable answer if it regresses. The video freeze after GPU idle is not
|
||||
explained by this; next suspect is media_kit's context switching on the
|
||||
raster thread with NVIDIA.
|
||||
|
||||
2. **Rendering, then shaders.** media_kit falls back to software rendering
|
||||
on the dev machine. Suspect Impeller on Linux against media_kit's GL
|
||||
texture path; investigate first, since shaders need hardware rendering and
|
||||
it may also cause the video freeze. Then shaders: per-viewer local setting
|
||||
using mpv `glsl-shaders`, shipping Anime4K presets (bloc97, MIT).
|
||||
|
||||
3. **Windows build + CI.** There is no Windows runner. Try
|
||||
2. **Windows build + CI.** There is no Windows runner. Try
|
||||
`ubuntu2610/flutter_build` (LLVM-MinGW + Wine `gen_snapshot`) on the Linux
|
||||
runner, fallback a GitHub Actions mirror. The manifest needs per-platform
|
||||
URLs and `release_publish.sh` must handle a second platform. The updater's
|
||||
@@ -38,12 +27,12 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
`watch-together-server` needs a Windows `dart compile exe` too. Desktop
|
||||
tests are not in CI either.
|
||||
|
||||
4. **Jellyfin** (epic, below).
|
||||
3. **Jellyfin** (epic, below).
|
||||
|
||||
5. **Smaller items**
|
||||
- Link conveniences: Ctrl+V pastes into the media field; a link swap in
|
||||
session updates the remembered media; show the open link with an
|
||||
open-in-browser button.
|
||||
4. **Smaller items**
|
||||
- Link conveniences: a link swap in session updates the remembered media;
|
||||
show the open link with an open-in-browser button; the session's swap
|
||||
panel reuses Home's `MediaPicker`.
|
||||
- Time UI: remaining time, scrubber preview.
|
||||
- mpv / mpv.net shortcut parity.
|
||||
- Sounds: replace `NoopSfxService` (cues already fire). Candidates:
|
||||
|
||||
Reference in new issue
Block a user