refactor(player): delete the webview video path; mpv selects its own tracks

DR-235 phase 3. Every video renderer is native now: mpv on Linux and
Windows, ExoPlayer on Android, all drawing behind the transparent
webview. The HTML5 <video> path is gone, not bypassed:

- Frontend: hls.js, Html5PlayerAdapter and its compatibility shim, the
  createAdapter factory, streamTransport, hlsRecovery, timeTracking,
  videoFit, the <video>/<track> markup and every element handler in
  VideoPlayer (3277 -> 2144 lines), the experimentalNativeVideo store
  and its Settings toggle, webviewVideoFallback/supportsNativeVideo, and
  the setHtml5VideoState PiP bridge call. NativePlayerAdapter is the one
  video adapter; webview audio gets its own adapter kind.
- Rust: use_html5 dropped from player_seek_video,
  player_switch_audio_track and player_set_stream_quality with the
  Html5* strategies and ReloadStream responses; use_html5_element and
  VideoBackend dropped from PlayerStatus; player_play_item always loads
  the backend (set_current_item removed); Capabilities::webview removed;
  the WebKitGTK GStreamer/VAAPI setup (and its gst-inspect spawn) removed.
- Android: the HTML5 video state in PictureInPictureManager and
  ScreenWakeManager, and the bridge method feeding it.
- CSP: connect-src loses http:/https: and worker-src loses blob: -
  both existed for hls.js; with it gone they were only an exfiltration
  channel and a blob worker for injected script. A test now keeps them
  out.

mpv takes over what the <video> element did (mpv_tracks, UT-275):
subtitles are the WebVTT list the play request carries, queued on
sub-files and selected by position in that list, starting off; audio
tracks are selected by position in the file; sid/aid are reset before
each load. Without this, Linux video had no subtitle selection and a
direct-play audio switch failed since mpv became its renderer.

Verified: Rust 948 passing, and the same 948 cross-compiled for Windows
under wine against the shipped DLL (track tests included); frontend
1111 passing; aarch64 debug APK builds. Lint warnings 158 -> 146, CI
ratchet tightened to match. Not yet seen on Windows hardware.
This commit is contained in:
2026-09-24 23:11:17 -04:00
parent bb3ab1edd7
commit 1677f5f299
69 changed files with 4014 additions and 7330 deletions
+14 -40
View File
@@ -791,36 +791,15 @@ by exactly the inset.
Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it
can safely be re-sent on resume.
## Stream Transport
## Stream Selection in the Player
**Location**: `src/lib/player/streamTransport.ts`
**TRACES**: UR-079 | DR-225 | UT-214
`videoLoaderFor(selection, capabilities)` picks the loader for the webview
`<video>` element — `hlsjs`, `nativeHls`, or `direct` — from the backend's tagged
`selection.transport`. `elementSrcFor` is its template companion: the element's
`src` is emptied only when hls.js is driving it.
The split is the point. **The transport is the stream's property and comes from
Rust; whether a given loader exists is the browser's, and is the only thing
decided here.**
> This replaced `currentStreamUrl.includes(".m3u8")`, which appeared twice in
> `VideoPlayer.svelte` — once in the HLS `$effect` and once inline in the
> template's `src`. Rust builds that URL and knows what it is; re-deriving it
> here by substring match was a domain fact reconstructed in the presentation
> layer, and it fails silently in both directions. The two tests that pin it are
> the ones that failed against the old implementation: a `progressive` stream
> whose URL contains `.m3u8` must **not** get an HLS loader, and an `hls` stream
> whose URL contains no `.m3u8` must.
>
> Logic lives in a plain `.ts` module rather than in the component for the usual
> reason — it is testable there. Same pattern as `episodeStrip.ts`.
**TRACES**: UR-079 | DR-225, DR-227
`VideoPlayer` holds a `currentSelection`, not a URL string; `currentStreamUrl` is
derived from it. A reload replaces the selection **wholesale** (the adapter's
bridge takes a `StreamSelection`, not a URL), so transport and URL can never
drift apart. The background-audio handoff states the transport it is moving to —
derived from it. A reload replaces the selection **wholesale**, so transport and
URL can never drift apart — the transport is the stream's property and comes from
Rust, never from a substring match on the URL (which is what `.m3u8` sniffing in
the component once did). The background-audio handoff states the transport it is moving to —
progressive mp3 out, HLS back — via `selectionAt()`, rather than leaving it to be
inferred.
@@ -833,21 +812,16 @@ ceiling above the source bitrate *is* the source.
## Native Video Store
**Location**: `src/lib/stores/nativeVideo.ts`
**TRACES**: UR-003, UR-004 | DR-188
**TRACES**: UR-003, UR-004 | DR-188, DR-235
Two separate concerns live here, deliberately:
`nativeVideoActive` — whether a native surface is on screen *right now*.
Setting it toggles `data-native-video` on `<html>`, which is what the CSS in
`app.css` keys off to clear the app's opaque backgrounds so the picture behind
the webview shows. The backgrounds must come back the moment the player unmounts.
- `experimentalNativeVideo` — the user-facing opt-in flag, **defaulting to on**.
Rust already decides *which backend this platform has* (`useHtml5Element` from
`player_play_item`); this flag only *suppresses* that decision. It never turns
native on where Rust says HTML5. An explicit stored choice wins in both
directions, so someone who opted out is not re-enabled by a default flip —
hence the `null` check rather than a bare `=== "true"`.
- `nativeVideoActive` — whether a native surface is on screen *right now*.
Setting it toggles `data-native-video` on `<html>`, which is what the CSS in
`app.css` keys off to clear the app's opaque backgrounds. It is deliberately
**not** derived from the flag: the backgrounds must come back the moment the
player unmounts.
It used to hold `experimentalNativeVideo`, a stored switch that could force video
back to the webview `<video>` element. That element is gone (DR-235), and the
switch with it.
See [05-platform-backends.md](05-platform-backends.md#native-video-compositing-android)
for what is behind the WebView.
+2 -2
View File
@@ -213,8 +213,8 @@ sequenceDiagram
Online-->>Page: StreamSelection
end
Page->>VP: selection
VP->>VP: videoLoaderFor(selection, caps)
Note over VP: hls.js / native HLS / direct —<br/>from the tag, never from the URL
VP->>VP: player_play_item(selection.url, transport)
Note over VP: the native player opens it —<br/>transport from the tag, never from the URL
```
The selection travels with the stream from then on. A reload — a quality change,
+76 -68
View File
@@ -90,57 +90,75 @@ flowchart LR
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
## HTML5 Video Adapter (webview-rendered video)
## Video is always native (no webview `<video>`)
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
`src-tauri/src/commands/player/timers.rs`
**TRACES**: UR-080 | DR-231, DR-235, DR-237
Video on **Windows** is rendered by an HTML5 `<video>`/HLS element **inside the webview**. Neither
other platform uses this path for video: Android draws in ExoPlayer (see *The webview is not a video
renderer on Android* below), and Linux draws in mpv beneath the webview (DR-235 phase 1 — see
[desktop-native-video.md](../specs/desktop-native-video.md) until it ships on Windows too). Where the
element is used, no native backend renders or observes it. The `<video>` is therefore
the real player, living outside Rust's reach.
Every video renderer is a native player drawing **behind** the transparent
webview, with the Svelte controls composited over it: ExoPlayer on Android, mpv on
Linux and Windows. There is no webview `<video>` element, no hls.js, and no
HTML5 adapter; the frontend has one video adapter, `NativePlayerAdapter`, and the
backend performs every seek, track switch and quality change itself.
To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element
is treated as **a dumb output device that reports back into Rust**, rather than an independent state
authority:
Why the webview path was deleted rather than kept as a fallback:
```mermaid
flowchart LR
subgraph Webview["Webview"]
Video["HTML5 <video> / HLS.js"]
Adapter["html5Adapter.ts<br/>(reports DOM events)"]
end
subgraph Backend["Rust"]
Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
Controller["PlayerController"]
Emitter["TauriEventEmitter"]
end
subgraph Frontend["Frontend"]
Events["playerEvents.ts"]
Store["player store"]
end
- **The transcode was a decoder constraint.** The `<video>` element decodes
little beyond h264, so the desktop profile could only claim h264 and the server
re-encoded almost everything (7% direct play on a real library, against 85% for
the same library through ExoPlayer). The machine was never the limit — mpv was
already running for audio. (The Linux/Windows profile still claims h264 until
DR-234 widens it; see [desktop-native-video.md](../specs/desktop-native-video.md).)
- **Each renderer is another place for every bug.** Three video renderers meant
every seek strategy, track switch and lifecycle fix had three places to be got
right; the webview path was also where the renderer choice itself went wrong
(silent Linux video, a Windows soundtrack decoded twice — DR-237).
- **A fallback that decodes less is not a fallback.** Android showed it first
(DR-293): an original-file download plays silent in the webview. With no
webview path there is no silent downgrade to fall into; a failed mpv init
emits `backend-init-failed` instead.
Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store
```
What survives of the webview reporting pipeline is audio-only:
`WebviewAudioBackend` plays through a hidden `<audio>` element and reports back
through the `player_report_*` commands (`rustReportHost.ts`), for a desktop with
no mpv. No shipped platform uses it.
**Key points:**
- The adapter re-emits the *same* `PlayerStatusEvent`s (`StateChanged`, `PositionUpdate`, `MediaLoaded`)
the native backends emit, so `playerEvents.ts` needs **no** HTML5-specific branch — HTML5 is just
another event source feeding the existing pipeline.
- Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the
60fps RAF loop.
- **Boundary rule**: UI components never touch the report commands or `videoElement` state directly.
Playback *control* goes through the unified facade `src/lib/player/index.ts` (`playerController`);
HTML5 *state reporting* goes through `html5Adapter.ts`. This restores the documented invariant
("frontend only displays state and invokes commands") for the video path.
## Native video on the desktop (mpv)
**TRACES**: UR-080 | DR-231 … DR-237, DR-298, DR-299
The mpv half is shared; only the surface differs per platform
(`mpv_backend::video_output`):
| Platform | Output | Where the picture goes |
|---|---|---|
| Linux | `vo=libmpv` (render API) | An FBO drawn in the main window's own `GtkBox` `draw` handler, which GTK paints *before* its children — so beneath the webview, with no widget reparenting (`video_surface.rs`; a `GtkOverlay` aborts the process on the first click, see that module) |
| Windows | `vo=gpu-next,gpu`, `wid=<HWND>` | mpv renders as a child of the app window, beneath the transparent WebView2 — the arrangement tauri-plugin-libmpv ships. `wid` only takes effect before initialisation, so `MpvBackend::new` takes the handle; with no handle it draws nothing rather than open a window of its own. mpv's controller, bindings and cursor handling are off |
In both, the page clears its opaque backgrounds while a video is on screen
(`data-native-video`, the same CSS Android uses).
**Tracks** (`mpv_tracks.rs`): subtitles are the WebVTT list the play request
carries, queued on `sub-files` before the load and selected **by position in
that list** — the same meaning ExoPlayer gives `player_set_subtitle_track`.
Selection starts off (the menu opens on "Off") and `sid`/`aid` are reset before
every load, so a choice made for one item cannot leak into the next. Audio tracks
are selected by position in the file; a transcode carries one track and is
re-opened instead.
**Two rules for every libmpv handle** (`mpv_command.rs`):
- Commands go through `mpv_command::command`, an argv built for `mpv_command`,
never the pinned crate's `Mpv::command`, which joins its arguments into a
string that mpv parses — `;` chains a second command, so a track title in a
downloaded file's path could run `run …` (DR-298).
- Every handle is hardened before its first load: `tls-verify=yes` (mpv's
default is *no*, and its URLs carry the `ApiKey`) and `ytdl=no` (DR-299).
## MpvBackend (Linux)
**Location**: `src-tauri/src/player/mpv/`
The MPV backend uses libmpv for audio playback on Linux. Since MPV handles are not `Send`, all operations occur on a dedicated thread.
The MPV backend uses libmpv for audio **and video** playback on Linux and Windows (video: see *Native video on the desktop* above). Since MPV handles are not `Send`, all operations occur on a dedicated thread.
```mermaid
flowchart TB
@@ -320,18 +338,12 @@ remux). It costs minutes of CPU and twice the disk per film, needs a pipeline
state of its own, and does nothing for streaming. Decoding at playback fixes both
paths with no extra step.
### The webview is not a video renderer on Android
### Why the webview could not stay a fallback on Android
ExoPlayer is Android's only video renderer. The HTML5 path used to be reachable
through the `experimentalNativeVideo` setting (a *suppressor* of Rust's native
choice), but the webview decodes none of the codecs above — so with the original
file now downloaded as-is (DR-293), turning native video off would play every such
download as a silent film. Rust reports `webview_video_fallback` in
`PlaybackCapabilities`: **false on Android**, true only beside mpv native video on
Linux, where the webview is still the tested fallback. The frontend offers the
switch and honours a stored "off" only when it is true (`nativeVideoWanted` in
`stores/nativeVideo.ts`), so a user who once switched it off on Android is not
stranded on the silent path.
The webview decodes none of the codecs above — so with the original file now
downloaded as-is (DR-293), the old `experimentalNativeVideo` switch would have
played every such download as a silent film. That is what first removed the
webview video path on Android; DR-235 then removed it everywhere.
### The equalizer, and where its vocabulary lives
@@ -360,8 +372,8 @@ chain of unbounded length.
Keeping a video's **audio** alive when the app is backgrounded or the screen
locks, while video decode stops. Two verified facts drive the whole design:
1. An Android WebView `<video>` **does not** keep playing audio once the app is
backgrounded — the system throttles the WebView and media pauses.
1. Nothing in the WebView keeps playing once the app is backgrounded — the
system throttles it.
2. Keeping audio alive in the background requires a **native foreground media
service**, which already exists for music (`JellyTauPlaybackService` +
`JellyTauPlayer` + `MediaSessionCompat`).
@@ -391,18 +403,14 @@ sequenceDiagram
Details that were each a shipped defect:
- **Position is absolute.** Transcoded HLS tracks time as
`videoElement.currentTime + seekOffset` (the element resets to 0 after each
transcode reload). `computeHandoffPosition` sums both terms; using the element
time alone rewinds by the offset.
- **Position is absolute** — the position on the item's timeline the player
reports, never an offset within a re-opened transcode (`computeHandoffPosition`).
- **A downloaded episode takes no base URL and an ordinary seek** (DR-180); a
stream takes the base and no seek; a handoff at 0:00 takes neither.
- **The return must restart the renderer that is actually on screen** (DR-196).
The two paths resume by different means — the webview `<video>` reloads off its
stream URL, watched by an `$effect`; ExoPlayer owns no element and nothing
watches the URL for it, so it needs an explicit re-issue. Doing only the URL
assignment restarted nothing on the native path and left a black screen with a
play button that did nothing.
- **The return must re-issue the load** (DR-196). The native player owns no
element and nothing watches the stream URL for it, so reassigning the URL — how
the deleted webview path came back — restarted nothing and left a black screen
with a play button that did nothing.
- **`wasPlaying` is captured on the way out** so play/pause survives the round
trip, and the handoff does not silently rewind (DR-203).
- **Mutually exclusive with PiP.** Toggle on → `setAutoEnterEnabled(false)`;
@@ -423,10 +431,10 @@ non-Android platform.
**TRACES**: UR-003, UR-004 | DR-150 … DR-152, DR-182 … DR-196
Android can render video on the **native ExoPlayer surface behind a transparent
Tauri WebView**, with the Svelte controls drawn over it. This is on by default;
the HTML5 `<video>` path remains the fallback and is not being removed. The
default has been flipped and reverted twice and each revert has a named cause —
the per-defect record is in `requirements.md` (DR-150 … DR-196).
Tauri WebView**, with the Svelte controls drawn over it. It is the only video
path (DR-235). Before that it was an opt-in whose default was flipped and
reverted twice, each revert with a named cause — the per-defect record is in
`requirements.md` (DR-150 … DR-196).
```mermaid
flowchart TB
@@ -456,7 +464,7 @@ Load-bearing details, each of which was a shipped defect:
- **The app shell stops painting over the surface** (DR-185). `app.css` clears
its opaque backgrounds off `[data-native-video]`; before that, a CSS rule
targeted an attribute nothing ever set, so the fix looked applied and was not.
- **The poster card can lift on a path with no `<video>` element** (DR-182) — the
- **The poster card lifts without a `<video>` element** (DR-182) — the
native reveal fires on a `playing` state or a position tick carrying a position
or duration, and on nothing else.
- **Letterbox bars are painted**, not left holding whatever was last in the
+6 -6
View File
@@ -68,8 +68,8 @@ style-src 'self' 'unsafe-inline';
font-src 'self' data:;
img-src 'self' data: blob: asset: http://asset.localhost http: https:;
media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:;
connect-src 'self' ipc: http://ipc.localhost http: https:;
worker-src 'self' blob:;
connect-src 'self' ipc: http://ipc.localhost;
worker-src 'self';
object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
```
@@ -79,13 +79,13 @@ object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-
| `script-src 'self'` | The genuinely restrictive half. Bundled JS only; Tauri's build-time nonce covers the one inline `<script>` in `index.html`. Adding `'unsafe-inline'` here would silently do nothing anyway — a nonce in a directive voids it. |
| `style-src 'self' 'unsafe-inline'` | Svelte compiles `style="…"` attributes into markup, including `app.html`'s `display: contents` wrapper, and CSP treats a style *attribute* as inline. Safe only while no `<style>` **element** survives into `index.html`: Tauri would nonce it, and the nonce would then void `'unsafe-inline'`. The production build extracts all CSS to files, so it currently has none. |
| `img-src` | Thumbnails come from two places: the asset protocol (`asset://localhost/…` on Linux/macOS, `http://asset.localhost/…` on Windows/Android — the same protocol, named differently by `convertFileSrc`) and, on a cache miss, straight from the Jellyfin server. `data:`/`blob:` cover inline and generated images. |
| `media-src` | `<video>`/`<audio>` sources: HLS transcodes and progressive streams from the server, the token-guarded loopback media server on `http://127.0.0.1:<random port>` (DR-137), and `blob:` for the MSE object URL hls.js attaches. |
| `connect-src` | `ipc:` / `http://ipc.localhost` is Tauri's `invoke` transport (custom scheme on Linux/macOS, `http` host on Windows/Android) — without it every command is blocked. `http:`/`https:` is hls.js fetching manifests and segments; ordinary API traffic goes through Rust and is not subject to CSP. |
| `worker-src 'self' blob:` | hls.js runs its demuxer in a worker built from a blob (`enableWorker: true`). Without `blob:` it falls back to main-thread demuxing — playback survives but costs more CPU. |
| `media-src` | `<audio>` sources for the webview audio backend (a desktop without mpv; no shipped platform): streams from the server and the token-guarded loopback media server on `http://127.0.0.1:<random port>` (DR-137). Video never plays in the webview (DR-235). |
| `connect-src` | `ipc:` / `http://ipc.localhost` is Tauri's `invoke` transport (custom scheme on Linux/macOS, `http` host on Windows/Android) — without it every command is blocked. Nothing else: all network traffic goes through Rust. It allowed any `http:`/`https:` host while hls.js fetched manifests and segments in the page; with hls.js gone (DR-235) that grant was only an exfiltration channel for injected script, so it went too. |
| `worker-src 'self'` | No blob workers since hls.js (whose demuxer ran in one) was removed (DR-235). |
| `object-src`, `frame-src` = `'none'` | No plugins, no iframes; both are classic injection sinks. |
| `base-uri 'self'`, `form-action 'self'`, `frame-ancestors 'none'` | Block `<base>` hijacking, form exfiltration and framing. `frame-ancestors` is only honoured when the policy is delivered as a header, which is platform-dependent; it is harmless where it is not. |
**`img-src`/`media-src`/`connect-src` are deliberately permissive.** The Jellyfin
**`img-src`/`media-src` are deliberately permissive.** The Jellyfin
origin is typed in by the user at run time and is routinely plain `http` on a
LAN, so it cannot be enumerated at build time. `http: https:` is a wide grant for
*data* — but it still bars `file:`, `filesystem:` and scripting schemes, and it
+1 -2
View File
@@ -99,7 +99,7 @@ Each major subsystem is documented in its own file in this directory:
| [02 - Svelte Frontend](02-svelte-frontend.md) | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging |
| [03 - Data Flow](03-data-flow.md) | Repository query flow (cache-first), locally-indexed search, playback initiation, playback mode transfer, queue navigation, volume control |
| [04 - Type Sync & Threading](04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns |
| [05 - Platform Backends](05-platform-backends.md) | Player events system, HTML5 video adapter, MpvBackend (Linux), ExoPlayerBackend (Android) incl. audio settings parity, **native video compositing**, MediaSession & remote volume, album art caching, backend initialization |
| [05 - Platform Backends](05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android) incl. audio settings parity, **native video compositing**, MediaSession & remote volume, album art caching, backend initialization |
| [06 - Downloads & Offline](06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, **one storage model (cache entries are downloads)**, offline catalog visibility, download/offline commands, player integration, frontend store, UI components |
| [07 - Connectivity](07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture |
| [08 - Database Design](08-database-design.md) | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates |
@@ -176,7 +176,6 @@ src/lib/
│ └── sessions.ts # SessionsApi (remote session control)
├── player/ # Unified player boundary (frontend)
│ ├── index.ts # playerController facade — the only write-side entry point for playback
│ └── html5Adapter.ts # Reports webview <video> DOM events back into Rust (player_report_*)
├── services/
│ ├── playerEvents.ts # Tauri event listener for player events
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
+8 -7
View File
@@ -384,7 +384,7 @@ Internal architecture, components, and application logic.
| DR-188 | Native Android video is **ready to be the default except for the background-audio handoff**, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no `<video>` element), DR-183 (the JS bridges raced the page load, so `setTransparent(true)` could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries `WebView transparent = true` and `Marking media ready` with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a *different* unverified sub-path: the background-audio handoff could only *return* through the HTML5 element, so coming back from the lockscreen left playback dead, and the flip waited for that rather than shipping a verified sub-path over an unverified one as DR-161 had. **The default is now on.** The two defects holding it back are fixed and device-verified — DR-196 (the handoff return restarts the renderer that is actually on screen) and DR-194 (the letterbox bars are painted rather than retaining stale framebuffer content) — with the evidence this default has been held to since DR-161: an audio handoff at 69:54 returning to video playing at 70:18, and clean bars across playback, the control bar and a rotation round-trip. An explicit stored choice still wins in both directions, so an opt-out survives the flip (the stored value is null-checked rather than compared to "true", which would have silently re-enabled it for everyone who turned it off) | Android | UR-003, UR-004 | Done |
| DR-189 | The control bar comes down on a touchscreen. Its hide timer was armed from exactly one place — the player container's `onmousemove` — and a touchscreen never fires `mousemove`, so on Android the bar was never scheduled to hide and sat over the video for the whole film. It went unnoticed for as long as the native video surface was itself invisible (DR-172/DR-185): with nothing behind it to obscure, a permanent control bar reads as the UI rather than as a defect. Two changes, because there were two faults. `revealControls()` replaces `handleMouseMove` and is called on entry and on every touch interaction as well as on mouse movement, so touch arms the countdown. And the countdown became an `$effect` over the state rather than a one-shot timer armed by the input event: the first attempt armed a timer on entry, three seconds later playback had not started, `shouldHideControls` correctly declined, and nothing ever re-armed it — the timer has to follow the conditions that *permit* hiding, which arrive on their own schedule. The decision itself is `shouldHideControls` in `controlsVisibility.ts`, pure and separated from the clock and the DOM, because what was wrong here was the conditions and not the `setTimeout`: the bar stays up while paused (a user who paused by tapping the surface has no other way back), mid-seek (the position readout is the point of the bar then), and while any track/subtitle/quality menu is open (the menus are anchored to the bar, so hiding it would take the open menu with it) | UI | UR-003, UR-066 | Done |
| DR-191 | Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer **SurfaceView** beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only **structural** changes got through, which is why the play overlay always appeared to work (an `{#if}` block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. **Superseded by DR-192**: this drove `postInvalidateOnAnimation` in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identified | Android | UR-003, UR-004 | Superseded by DR-192 |
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Done |
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Superseded by DR-235 |
| DR-196 | Returning from background audio brings the picture back on the **native** path, because the return now restarts the renderer that is actually on screen. The two paths resume by different means: the webview `<video>` reloads off its stream URL, watched by an `$effect` that reinitialises HLS and lets `canplay` drive the seek — while ExoPlayer owns no element and nothing watches the URL on its behalf, so its playback is only ever started by an explicit `player_play_item` + adapter load, issued once from `onMount`. `exitBackgroundAudioHandoff` did only the URL assignment, for both paths, so on the native path it restarted nothing: `player_exit_background_audio` had already stopped the handoff's audio player, leaving the backend holding no item at all. The symptom is a black screen with a play overlay pinned at 0:00, a seek bar at zero, and a play button that does nothing — the process alive and the frontend still logging, since nothing crashed; the transition was simply dropped. The branch is decided by `planHandoffReturn` (pure, in `backgroundAudioHandoff.ts`), which also folds in `shouldResumeOnForeground` so a lockscreen pause during the handoff still wins over the snapshot taken on the way out. Subtitle configurations are reused from the ones resolved at mount, since ExoPlayer sideloads them as `MediaItem.SubtitleConfiguration`s and cannot accept one after `prepare()`. Verified on device: handoff to audio at 69:54, return restored video playing at 70:18 | Playback | UR-040, UR-003 | Done |
| DR-197 | Continue Watching and Next Up stop showing the same episode. Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns a partially-watched episode as its own series' next up — precisely the episode `/Items/Resume` already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. `build_next_up_endpoint` sends `EnableResumable=false`, and because servers predating that parameter ignore it, `filterInProgressNextUpItems` also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the *unfiltered* Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finished | Repository | UR-059 | Done |
| DR-200 | The lockscreen notification is exempt from `POST_NOTIFICATIONS`, because of the **session token**, not because it belongs to a foreground service — and the difference is what the code now records. `POST_NOTIFICATIONS` was declared in the manifest and requested nowhere, so on Android 13+ it sat permanently denied; an audit read that as a threat to UR-006, since the media notification is what carries the lockscreen transport controls. It is not. Android's own wording is that the permission covers "non-exempt (including Foreground Services (FGS)) notifications", with denied users seeing FGS notices "in the Task Manager but [not] in the notification drawer" — so an FGS notification is explicitly *not* exempt — while separately "Notifications related to media sessions are exempt from this behavior change". The platform predicate is `Notification.isMediaNotification()`, which requires `MediaStyle` **and** a non-null `EXTRA_MEDIA_SESSION`, and it is byte-identical across API 33–36. `NotificationManagerService` uses it to decide whether to drop the post, and SystemUI's media carousel (`MediaDataProcessor.onNotificationAdded`) is gated on the *same* predicate — so a token-less notification is not merely absent from the shade, it never reaches the notification listener and the lockscreen/Quick-Settings controls do not exist at all. Confirmed on device (HONOR ROD2-W09, Android 16 / SDK 36): appops `POST_NOTIFICATION: ignore`, `granted=false`, and the service simultaneously `isForeground=true` with `foregroundNoti=Notification(category=transport actions=3 vis=PUBLIC)`. So **no runtime permission request is added** — a prompt the app does not need is a prompt that can be permanently denied for nothing — and no `checkSelfPermission` gate is placed on `startForeground`, which would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard that matches the real precondition: `mediaSessionCompat?.sessionToken` is a null-safe call, and the exemption hangs entirely on it, so both builders now bind the token once and log an error if it is ever null while the permission is denied — converting a failure that is invisible unless the tester happened to deny the permission (most grant it reflexively) into a logcat line. The manifest declaration is *kept*, unrequested, and documented: media3 does not need it (media3-session declares no permissions and the `MediaSessionService` guide asks only for the two `FOREGROUND_SERVICE` ones), but the exemption covers media and self-managed-call notifications only, so a download-completion notice (UR-011) would be an ordinary notification and silently dropped — keeping the declaration is what makes adding one a one-file change | Android | UR-006 | Done |
@@ -437,7 +437,7 @@ Internal architecture, components, and application logic.
| DR-232 | The mpv render context's lifetime is bound to the GL context it draws into: created on `realize`, freed on `unrealize`, on the same thread, with the update callback unregistered *before* the free so a callback cannot land on a freed context. This is DR-184 on Android restated — a surface outliving its player — and it is a requirement in its own right rather than a fix for a specific crash. The spike observed one SIGSEGV in a decoder thread that three targeted soaks failed to reproduce; what is not in doubt is that the spike never called `mpv_render_context_free` and never tore down on `unrealize`, so nothing defended against the GL context being recreated underneath. Removing the likeliest cause is worth doing whether or not it was the cause | Playback | UR-080 | Proposed |
| DR-233 | Frame pacing goes through mpv's update callback, with `mpv_render_context_report_swap` after each render. Recorded as a requirement because the failure mode misleads: driving the widget's frame clock every tick without reporting the swap leaves mpv with nothing to time against, which looks fine in a window and **judders at fullscreen** — reading as a compositing or GPU limit and being neither | Playback | UR-080 | Proposed |
| DR-234 | The device profile is derived from the **renderer that will decode the stream**, not from a compile-time platform constant. `video_codecs` was `#[cfg(target_os)]`, which is correct only while a build has one video renderer; once mpv and the webview element coexist it must be runtime state. This is the change that converts the measured 7% desktop direct-play rate toward the 85% the Android profile achieves on the same library, because the two differ by nothing except which component decodes. It looks like configuration and is not — it is the input that decides whether the server re-encodes, and getting it wrong fails silently, a claimed codec the renderer cannot decode being a black picture or silence (DR-148, and DR-227's audio override). The webview's narrower *audio* set stops applying to the video path once mpv decodes it, while the multichannel bound still does, since a 5.1 track direct-played into a two-channel sink is silence or inaudible dialogue | Repository | UR-080, UR-070 | In Progress |
| DR-235 | The webview video path is deleted, not merely bypassed. Staged, because a path cannot be removed while a shipped platform still needs it: Linux moves to mpv first, Windows follows, and only then do `hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the `<video>` element go. The staging is the point — a Linux-only version would leave the fork alive permanently, taking video from three renderers to four and giving every seek strategy, track switch and lifecycle bug one more place to be got right. Android keeps ExoPlayer and keeps the webview as its documented opt-out; the background-audio `<audio>` path is untouched. With no HTML5 fallback left, a failed mpv init emits `backend-init-failed` and surfaces a real error rather than silently degrading to the transcode this work exists to stop paying for | Playback | UR-080 | In Progress |
| DR-235 | The webview video path is deleted, not merely bypassed. Staged, because a path cannot be removed while a shipped platform still needs it: Linux moves to mpv first, Windows follows, and only then do `hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the `<video>` element go. The staging is the point — a Linux-only version would leave the fork alive permanently, taking video from three renderers to four and giving every seek strategy, track switch and lifecycle bug one more place to be got right. Android keeps ExoPlayer and keeps the webview as its documented opt-out; the background-audio `<audio>` path is untouched. With no HTML5 fallback left, a failed mpv init emits `backend-init-failed` and surfaces a real error rather than silently degrading to the transcode this work exists to stop paying for | Playback | UR-080 | Done |
| DR-236 | Hardware-decode policy is decided from what mpv reports it **selected** (`hwdec-current`), never from what it was asked for. The spike established that hardware decode works through the render API at all — the load-bearing result, since it means direct play is not bought with software decoding — but also that `auto` reached for the discrete GPU in copy-back mode on a hybrid Intel+NVIDIA laptop, the least efficient hardware path, and that `vaapi` fell back to software silently because the libva driver was absent. So zero-copy VA-API on the integrated GPU is preferred where the driver is present, `auto` is a fallback rather than the default, and a missing driver is detected and logged rather than mistaken for a compositing limit | Playback | UR-080 | Proposed |
| DR-237 | Windows reaches the same mpv path, reusing everything except the surface. The surface is genuinely different code — a native child window beneath a transparent WebView2, not GTK — but the render context, lifetime discipline, frame pacing, device profile and hwdec policy are shared, which is why none of them may be guarded on `cfg!(target_os = "linux")`. The cost is mostly build, not video: `libmpv` is currently a Linux-only dependency while Windows is cross-compiled from Linux via `x86_64-pc-windows-msvc` + `cargo-xwin`, so a Windows libmpv must reach that cross-build and its DLL must ship in the NSIS bundle, carrying the LGPL obligations DR-216 already records — dynamic linkage, licence text shipped alongside. Windows gains a native audio decoder as a side effect, which is what the long-blocked Windows audio work wants and cannot otherwise have | Playback | UR-080 | In Progress |
| DR-238 | A transcoded seek re-negotiates the stream on every renderer, not just the webview. Jellyfin produces a transcode *from* `StartTimeTicks`, so where a seek lands is a property of the request rather than of the stream in hand. `determine_video_seek_strategy` treated `is_hls` as a proxy for "seekable in place", which held only because hls.js was always the HLS renderer — it seeks within the VOD playlist it is handed and lets the server catch up. mpv's HLS demuxer cannot make the server transcode from a new offset, so with native video on, every transcoded seek became a backend seek that silently did nothing and presented as "resume does not work". The rule is now written on `needs_transcoding` with hls.js as the stated exception; all four webview cells are unchanged | Player | UR-040 | Done |
@@ -739,7 +739,7 @@ Internal architecture, components, and application logic.
| UT-140 | `useOfflineFilterReload` skips the value a page already loaded under and reloads on each later change | DR-143 | Done |
| UT-141 | The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word | DR-141 | Done |
| UT-148 | Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed | DR-149 | Done |
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Done |
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Superseded by DR-235 |
| UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done |
| UT-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | Done |
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
@@ -773,7 +773,7 @@ Internal architecture, components, and application logic.
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Superseded by DR-235 |
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
@@ -781,7 +781,7 @@ Internal architecture, components, and application logic.
| UT-183 | A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seek | DR-181 | Done |
| UT-184 | The native reveal rule fires on `state === "playing"` and on a position tick carrying a position or a duration, and on nothing else — not `buffering`, `paused`, `stopped`, `ended` or `error`, not an empty tick, and not a negative position | DR-182 | Done |
| UT-188 | The control-bar auto-hide rule permits hiding only during uninterrupted playback: it declines while paused, while a seek is in flight, and while a track/subtitle/quality menu is open — asserted against the pure `shouldHideControls` rule rather than a clock or a DOM | DR-189 | Done |
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Done |
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Superseded by DR-235 |
| UT-187 | On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen button | DR-186, DR-187 | Done |
| UT-186 | Every attribute the native-video compositing block in app.css targets is set somewhere in the app — `[data-app-shell]` in particular — so a selector aimed at nothing fails the suite instead of failing silently on a device | DR-185 | Done |
| UT-185 | Mounted on the native path (backend reports native, opt-in flag on, no `<video>` element rendered and the backend not stopped), VideoPlayer keeps the poster card up until the backend reports something, drops it on a playing state or a position tick with a duration, and keeps it up through `error` and `stopped` | DR-182 | Done |
@@ -809,7 +809,7 @@ Internal architecture, components, and application logic.
| UT-211 | The background decision: a video with the toggle off pauses (the reported defect, where the media service kept playing regardless), a video with it on hands off to audio, music keeps playing whatever the toggle says because it has no picture to lose, picture-in-picture keeps playing in every combination since the window is still visible, and the answer does not vary by renderer | DR-224 | Done |
| UT-212 | The stream-selection contract. `Transport` and `PlaybackKind` each serialise to exactly the tag the frontend matches (`{"type":"hls"}`, `{"type":"directPlay"}`, …) and round-trip; nested `StreamSelection` fields are camelCase on the wire including `playbackKind`, `mediaSourceId` and `maxBitrate`; only `Transcode` counts as transcoding, so a direct stream does not; a local file is a direct play over a local transport with no ladder. The ladder: every rung at or above a 1.12 Mbps source is marked redundant while the three that constrain it are not, `Original` is never marked for any bitrate including zero and unknown, an unreported source bitrate keeps all eight rungs offered, a 40 Mbps source marks none, and each option carries the ladder's own label and detail | DR-224, DR-226 | Done |
| UT-213 | The direct-play negotiation, one test per branch, against `PlaybackInfo` fixtures whose shapes were all observed on a live server: a supported source direct-plays; a remuxable one direct-streams and reports itself as *not* transcoding; an unsupported codec transcodes; undecodable audio overrides the server's direct-play offer (silent picture is worse than a transcode); a pinned audio track forces a transcode; a ceiling below the source bitrate transcodes even though the codec is fine, and the ladder agrees that rung constrains it; direct play wins over direct stream when both are offered. Plus the ceiling: a per-playback override governs the stream being opened without disturbing the durable default the Settings screen shows, and dropping it returns to that default | DR-225, DR-227 | Done |
| UT-214 | The loader comes from the transport, never the URL. hls.js is attached for `hls` when available and the element's own loader when not; progressive and local files load directly; the element's `src` is emptied only when hls.js drives it. The two cases that fail against a substring check, and the reason the field exists: a `progressive` stream whose URL contains `.m3u8` is *not* given an HLS loader, and an `hls` stream whose URL contains no `.m3u8` *is*. Both failed against the pre-DR-225 implementation before the fix landed | DR-224 | Done |
| UT-214 | The loader comes from the transport, never the URL. hls.js is attached for `hls` when available and the element's own loader when not; progressive and local files load directly; the element's `src` is emptied only when hls.js drives it. The two cases that fail against a substring check, and the reason the field exists: a `progressive` stream whose URL contains `.m3u8` is *not* given an HLS loader, and an `hls` stream whose URL contains no `.m3u8` *is*. Both failed against the pre-DR-225 implementation before the fix landed | DR-224 | Superseded by DR-235 |
| UT-215 | Waiting for the repository rather than racing it: it resolves immediately when the session is already restored, resolves when the session arrives later (the race the player page lost on mount), still rejects when there genuinely is no session, unsubscribes once settled so a later store change cannot re-settle it, and leaves no armed timer to reject an already-resolved promise | DR-013 | Done |
| UT-216 | The native-video opt-in is read from one place and only explicit truthy values enable it: absent, empty, `0`, `no`, `false` and anything unrecognised all mean off, because a half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangle | DR-231 | Superseded by UT-271 |
| UT-217 | A transcoded HLS stream on the native backend re-negotiates rather than seeking in place, while the same stream under hls.js still seeks in place — the cell that native video made reachable for the first time | DR-238 | Done |
@@ -857,7 +857,7 @@ Internal architecture, components, and application logic.
| UT-259 | The user may send video to the webview only beside mpv native video on Linux: never on Android, where ExoPlayer is the only video renderer, and not where the webview is the only renderer | DR-293 | Superseded by UT-272 |
| UT-260 | A downloaded item gets playback info with the server unreachable — immediately, from its download row (local path, direct play, item id as media source) — while an unfinished download, another user's, or an item never downloaded is left to the server | DR-294 | Done |
| UT-261 | Next Up answers from the cache, rather than failing, when the server is unreachable | DR-294 | Done |
| UT-262 | The Android webview fallback is neither offered in Settings nor honoured by the player unless Rust reports it, so a stored "native video off" cannot route video to a renderer that plays the original file silent | DR-293 | Done |
| UT-262 | The Android webview fallback is neither offered in Settings nor honoured by the player unless Rust reports it, so a stored "native video off" cannot route video to a renderer that plays the original file silent | DR-293 | Superseded by UT-272 |
| UT-263 | With the database held past the 100 ms fast path and the server unreachable, `get_items`, the library list, a cache-only search and cache-only favourites all answer from the cache instead of failing | DR-294 | Done |
| UT-264 | Ten seasons whose listings each take 100 ms are gathered in well under the 1 s a sequential walk takes, and a season that fails to load leaves the other nine seasons' episodes in the result | DR-295 | Done |
| UT-265 | `planHandoffReturn` switches to the item the backend advanced to while backgrounded, and reloads in place when the backend is still on the mounted item or reports none | DR-296 | Done |
@@ -870,6 +870,7 @@ Internal architecture, components, and application logic.
| UT-272 | No platform reports a webview video fallback, Linux reports native video, and the player status on Linux never sends video to the `<video>` element | DR-235 | Done |
| UT-273 | `player_play_item`, `get_player_status` and `player_get_capabilities` answer "does a native renderer draw video here" from one function, so the backend is loaded with video exactly where the frontend is told not to use a `<video>` element — on Windows, where mpv now plays audio, the film's soundtrack is not decoded twice | DR-237 | Done |
| UT-274 | mpv's video output is decided per platform: Windows renders into the app window's HWND (`wid`, set before initialisation) with `vo=gpu-next,gpu` and mpv's own controller, bindings and cursor handling off; Windows with no handle draws nothing rather than opening a window of its own; Linux uses the render API; no native video means `video=no`. Every option is accepted by the real libmpv, on Linux and by the shipped Windows DLL | DR-237 | Done |
| UT-275 | mpv selects sideloaded subtitles and audio tracks by position, as ExoPlayer does: against a real file, a sideloaded WebVTT arrives, starts hidden, and is shown and hidden by its position in the sent list; position 1 plays the file's second audio track; and preparing the next load forgets the last item's subtitle files, subtitle choice and audio track. Positions resolve to mpv ids per kind, embedded before external | DR-023, DR-024, DR-235 | Done |
### Integration Tests
| Test ID | Test Description | Traces To | Status |
+12 -12
View File
@@ -1,17 +1,17 @@
# Spec: Desktop native video — mpv renders the picture, everywhere
**Status:** Partially implemented — phase 1 routing shipped: mpv is the only
Linux video renderer (`native_video::enabled()` is unconditional on Linux, no
webview fallback is offered). **Left:** Linux's device profile still claims only
`h264` (DR-234), so video still arrives as a server transcode — mpv plays it, but
the direct-play gain is not yet taken; `hwdec` is unset, so mpv decodes in
software (DR-236); a failed surface attach only logs, it does not surface an
error; the phase 1 soak and X11/Wayland criteria are unrecorded; phases 2 and 3.
Phase 2 has started from its build half: Windows now links and ships libmpv
(for audio, see windows-native-audio-backend.md); its video surface is not
written. Phase 3 also deletes the now-inert frontend switch (`experimentalNativeVideo`,
`nativeVideoWanted`, the Settings toggle and the adapter's suppressor flag),
which no platform reaches since `webview_video_fallback` became false everywhere.
**Status:** Partially implemented — all three phases' *code* has shipped: mpv
is the only video renderer on Linux (render API into the GTK vbox) and Windows
(`wid` into the app window, `vo=gpu-next,gpu`), and the webview video path is
deleted — hls.js, the HTML5 adapter, the `<video>` element, the frontend switch,
the `use_html5` command parameters and the Android HTML5 PiP/screen-wake state
(DR-235). mpv selects subtitles and audio tracks itself (`mpv_tracks`).
**Left:** Linux/Windows still claim only `h264` (DR-234), so video is still a
server transcode — mpv plays it, but the direct-play gain is not taken; `hwdec`
is unset, so mpv decodes in software (DR-236); a failed surface attach only
logs; and every hardware criterion is unrecorded — the X11/Wayland check and
soak on Linux, and **any** run of Windows video, which has only been verified by
unit tests under wine (options accepted by the shipped DLL), never on screen.
**Requirements:** UR-080 (new) → DR-231 … DR-237 (new); IR-033 (new)
**UX spec:** n/a — nothing about the player's appearance changes. What changes is
what is behind the controls.
+3014 -2923
View File
File diff suppressed because it is too large Load Diff