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
+1 -1
View File
@@ -109,7 +109,7 @@ jobs:
# at "warn" until its class is cleared and it can be promoted to "error". # at "warn" until its class is cleared and it can be promoted to "error".
# Lower this as you clear them. Never raise it to make a build pass. # Lower this as you clear them. Never raise it to make a build pass.
- name: Lint - name: Lint
run: bun run lint -- --max-warnings=158 run: bun run lint -- --max-warnings=146
- name: Check TypeScript - name: Check TypeScript
run: | run: |
+15 -13
View File
@@ -2,8 +2,9 @@
A cross-platform Jellyfin client. Business logic lives in a Rust backend A cross-platform Jellyfin client. Business logic lives in a Rust backend
(`src-tauri/`); a SvelteKit + TypeScript frontend (`src/`) handles presentation (`src-tauri/`); a SvelteKit + TypeScript frontend (`src/`) handles presentation
and talks to it over Tauri v2 IPC. Targets **Linux** (libmpv for audio and video) and and talks to it over Tauri v2 IPC. Targets **Linux** and **Windows** (libmpv for audio and video) and **Android**
**Android** (ExoPlayer); Windows still renders video in the webview `<video>`. (ExoPlayer). There is no webview `<video>`: every video renderer is native,
drawing behind the transparent webview.
Package manager is **bun**. Package manager is **bun**.
@@ -151,10 +152,11 @@ output as a reviewed draft, not a final changelog.
- **Svelte frontend** (`src/`) — presentation only. Stores in - **Svelte frontend** (`src/`) — presentation only. Stores in
`src/lib/stores/`, API wrappers in `src/lib/api/`, components in `src/lib/stores/`, API wrappers in `src/lib/api/`, components in
`src/lib/components/`. `src/lib/components/`.
- **Playback layers** — Linux uses libmpv for audio and video (mpv draws video - **Playback layers** — Linux and Windows use libmpv for audio and video (mpv
beneath the transparent webview); Windows still uses the webview HTML5 draws video beneath the transparent webview: the render API into a GTK surface
`<video>` element for video; Android uses ExoPlayer with a foreground media on Linux, `wid` into the app window on Windows); Android uses ExoPlayer with a
service + `MediaSessionCompat`. Every mpv command goes through foreground media service + `MediaSessionCompat`. The webview `<video>`/hls.js
path was deleted (DR-235). Every mpv command goes through
`player/mpv_command.rs` (argv, never a command string) and every handle is `player/mpv_command.rs` (argv, never a command string) and every handle is
hardened (`tls-verify=yes`, `ytdl=no`) — see DR-298/DR-299. hardened (`tls-verify=yes`, `ytdl=no`) — see DR-298/DR-299.
- **tauri-specta** generates TypeScript bindings and typed events from the Rust - **tauri-specta** generates TypeScript bindings and typed events from the Rust
@@ -170,7 +172,7 @@ canonical, maintained source; this file only summarizes. See
| [02-svelte-frontend.md](docs/architecture/02-svelte-frontend.md) | Stores, repository architecture, MiniPlayer, autoplay, nav guard | | [02-svelte-frontend.md](docs/architecture/02-svelte-frontend.md) | Stores, repository architecture, MiniPlayer, autoplay, nav guard |
| [03-data-flow.md](docs/architecture/03-data-flow.md) | Cache-first query flow, playback initiation, mode transfer | | [03-data-flow.md](docs/architecture/03-data-flow.md) | Cache-first query flow, playback initiation, mode transfer |
| [04-type-sync-and-threading.md](docs/architecture/04-type-sync-and-threading.md) | **Rust↔TS type sync, the IPC camelCase convention + param table, locking** | | [04-type-sync-and-threading.md](docs/architecture/04-type-sync-and-threading.md) | **Rust↔TS type sync, the IPC camelCase convention + param table, locking** |
| [05-platform-backends.md](docs/architecture/05-platform-backends.md) | MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession, HTML5 adapter | | [05-platform-backends.md](docs/architecture/05-platform-backends.md) | MpvBackend (Linux/Windows) incl. desktop native video, ExoPlayerBackend (Android), MediaSession |
| [06-downloads-and-offline.md](docs/architecture/06-downloads-and-offline.md) | Download manager/worker, smart cache, offline commands | | [06-downloads-and-offline.md](docs/architecture/06-downloads-and-offline.md) | Download manager/worker, smart cache, offline commands |
| [07-connectivity.md](docs/architecture/07-connectivity.md) | HTTP retry, ConnectivityMonitor, reachability model | | [07-connectivity.md](docs/architecture/07-connectivity.md) | HTTP retry, ConnectivityMonitor, reachability model |
| [08-database-design.md](docs/architecture/08-database-design.md) | Tables, relationships, key queries | | [08-database-design.md](docs/architecture/08-database-design.md) | Tables, relationships, key queries |
@@ -188,10 +190,9 @@ and [docs/build/build-release.md](docs/build/build-release.md).
player reports and never determine it. player reports and never determine it.
- **Unified player boundary.** UI controls playback *only* through the frontend - **Unified player boundary.** UI controls playback *only* through the frontend
facade `src/lib/player/index.ts` (`playerController`) — never by calling facade `src/lib/player/index.ts` (`playerController`) — never by calling
`commands.player*` directly. Webview HTML5 `<video>` reports its state back `commands.player*` directly. Video has one adapter, `NativePlayerAdapter`,
into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*` which only forwards intents; the backend performs every seek, track switch
commands, so the controller stays the single source of truth in both native and quality change itself.
and HTML5 modes.
- **Reachability from real traffic.** Server online/offline is derived from the - **Reachability from real traffic.** Server online/offline is derived from the
outcome of actual repository requests (reported to `ConnectivityMonitor`), not outcome of actual repository requests (reported to `ConnectivityMonitor`), not
a side-channel poller. The `/System/Info/Public` probe runs *only while a side-channel poller. The `/System/Info/Public` probe runs *only while
@@ -316,8 +317,9 @@ tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
player or hold a lock — it deadlocks. On Android, bind a locked player or hold a lock — it deadlocks. On Android, bind a locked
`AutoplayDecision` to a `let` *before* matching; a tokio `MutexGuard` held in `AutoplayDecision` to a `let` *before* matching; a tokio `MutexGuard` held in
the `match` scrutinee deadlocks the `AdvanceToNext` arm. the `match` scrutinee deadlocks the `AdvanceToNext` arm.
- **VideoPlayer native mode**: no lifecycle calls after an `await` in `onMount` - **VideoPlayer `onMount`**: no lifecycle calls after an `await` — they throw
(it flips to HTML5 mode and breaks Android seek). `lifecycle_outside_component` (this once silently switched seeks to a renderer
that was not playing).
- **Transcoded resume/seek**: `get_video_stream_url` must return the HLS - **Transcoded resume/seek**: `get_video_stream_url` must return the HLS
`master.m3u8`, not `stream.mp4`, or transcoded playback never starts. `master.m3u8`, not `stream.mp4`, or transcoded playback never starts.
- **Downloads** cap at 3 concurrent; the backend pump auto-starts pending rows. - **Downloads** cap at 3 concurrent; the backend pump auto-starts pending rows.
-3
View File
@@ -11,7 +11,6 @@
"@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "2.10.1", "@tauri-apps/plugin-updater": "2.10.1",
"hls.js": "^1.6.15",
"svelte-dnd-action": "^0.9.69", "svelte-dnd-action": "^0.9.69",
}, },
"devDependencies": { "devDependencies": {
@@ -485,8 +484,6 @@
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
+14 -40
View File
@@ -791,36 +791,15 @@ by exactly the inset.
Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it
can safely be re-sent on resume. 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, DR-227
**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`.
`VideoPlayer` holds a `currentSelection`, not a URL string; `currentStreamUrl` is `VideoPlayer` holds a `currentSelection`, not a URL string; `currentStreamUrl` is
derived from it. A reload replaces the selection **wholesale** (the adapter's derived from it. A reload replaces the selection **wholesale**, so transport and
bridge takes a `StreamSelection`, not a URL), so transport and URL can never URL can never drift apart — the transport is the stream's property and comes from
drift apart. The background-audio handoff states the transport it is moving to — 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 progressive mp3 out, HLS back — via `selectionAt()`, rather than leaving it to be
inferred. inferred.
@@ -833,21 +812,16 @@ ceiling above the source bitrate *is* the source.
## Native Video Store ## Native Video Store
**Location**: `src/lib/stores/nativeVideo.ts` **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**. It used to hold `experimentalNativeVideo`, a stored switch that could force video
Rust already decides *which backend this platform has* (`useHtml5Element` from back to the webview `<video>` element. That element is gone (DR-235), and the
`player_play_item`); this flag only *suppresses* that decision. It never turns switch with it.
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.
See [05-platform-backends.md](05-platform-backends.md#native-video-compositing-android) See [05-platform-backends.md](05-platform-backends.md#native-video-compositing-android)
for what is behind the WebView. for what is behind the WebView.
+2 -2
View File
@@ -213,8 +213,8 @@ sequenceDiagram
Online-->>Page: StreamSelection Online-->>Page: StreamSelection
end end
Page->>VP: selection Page->>VP: selection
VP->>VP: videoLoaderFor(selection, caps) VP->>VP: player_play_item(selection.url, transport)
Note over VP: hls.js / native HLS / direct —<br/>from the tag, never from the URL 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, 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. **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 **TRACES**: UR-080 | DR-231, DR-235, DR-237
`src-tauri/src/commands/player/timers.rs`
Video on **Windows** is rendered by an HTML5 `<video>`/HLS element **inside the webview**. Neither Every video renderer is a native player drawing **behind** the transparent
other platform uses this path for video: Android draws in ExoPlayer (see *The webview is not a video webview, with the Svelte controls composited over it: ExoPlayer on Android, mpv on
renderer on Android* below), and Linux draws in mpv beneath the webview (DR-235 phase 1 — see Linux and Windows. There is no webview `<video>` element, no hls.js, and no
[desktop-native-video.md](../specs/desktop-native-video.md) until it ships on Windows too). Where the HTML5 adapter; the frontend has one video adapter, `NativePlayerAdapter`, and the
element is used, no native backend renders or observes it. The `<video>` is therefore backend performs every seek, track switch and quality change itself.
the real player, living outside Rust's reach.
To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element Why the webview path was deleted rather than kept as a fallback:
is treated as **a dumb output device that reports back into Rust**, rather than an independent state
authority:
```mermaid - **The transcode was a decoder constraint.** The `<video>` element decodes
flowchart LR little beyond h264, so the desktop profile could only claim h264 and the server
subgraph Webview["Webview"] re-encoded almost everything (7% direct play on a real library, against 85% for
Video["HTML5 <video> / HLS.js"] the same library through ExoPlayer). The machine was never the limit — mpv was
Adapter["html5Adapter.ts<br/>(reports DOM events)"] already running for audio. (The Linux/Windows profile still claims h264 until
end DR-234 widens it; see [desktop-native-video.md](../specs/desktop-native-video.md).)
subgraph Backend["Rust"] - **Each renderer is another place for every bug.** Three video renderers meant
Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"] every seek strategy, track switch and lifecycle fix had three places to be got
Controller["PlayerController"] right; the webview path was also where the renderer choice itself went wrong
Emitter["TauriEventEmitter"] (silent Linux video, a Windows soundtrack decoded twice — DR-237).
end - **A fallback that decodes less is not a fallback.** Android showed it first
subgraph Frontend["Frontend"] (DR-293): an original-file download plays silent in the webview. With no
Events["playerEvents.ts"] webview path there is no silent downgrade to fall into; a failed mpv init
Store["player store"] emits `backend-init-failed` instead.
end
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:** ## Native video on the desktop (mpv)
- 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 **TRACES**: UR-080 | DR-231 … DR-237, DR-298, DR-299
another event source feeding the existing pipeline.
- Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the The mpv half is shared; only the surface differs per platform
60fps RAF loop. (`mpv_backend::video_output`):
- **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`); | Platform | Output | Where the picture goes |
HTML5 *state reporting* goes through `html5Adapter.ts`. This restores the documented invariant |---|---|---|
("frontend only displays state and invokes commands") for the video path. | 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) ## MpvBackend (Linux)
**Location**: `src-tauri/src/player/mpv/` **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 ```mermaid
flowchart TB 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 state of its own, and does nothing for streaming. Decoding at playback fixes both
paths with no extra step. 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 The webview decodes none of the codecs above — so with the original file now
through the `experimentalNativeVideo` setting (a *suppressor* of Rust's native downloaded as-is (DR-293), the old `experimentalNativeVideo` switch would have
choice), but the webview decodes none of the codecs above — so with the original played every such download as a silent film. That is what first removed the
file now downloaded as-is (DR-293), turning native video off would play every such webview video path on Android; DR-235 then removed it everywhere.
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 equalizer, and where its vocabulary lives ### 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 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: 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 1. Nothing in the WebView keeps playing once the app is backgrounded — the
backgrounded — the system throttles the WebView and media pauses. system throttles it.
2. Keeping audio alive in the background requires a **native foreground media 2. Keeping audio alive in the background requires a **native foreground media
service**, which already exists for music (`JellyTauPlaybackService` + service**, which already exists for music (`JellyTauPlaybackService` +
`JellyTauPlayer` + `MediaSessionCompat`). `JellyTauPlayer` + `MediaSessionCompat`).
@@ -391,18 +403,14 @@ sequenceDiagram
Details that were each a shipped defect: Details that were each a shipped defect:
- **Position is absolute.** Transcoded HLS tracks time as - **Position is absolute** — the position on the item's timeline the player
`videoElement.currentTime + seekOffset` (the element resets to 0 after each reports, never an offset within a re-opened transcode (`computeHandoffPosition`).
transcode reload). `computeHandoffPosition` sums both terms; using the element
time alone rewinds by the offset.
- **A downloaded episode takes no base URL and an ordinary seek** (DR-180); a - **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. 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 return must re-issue the load** (DR-196). The native player owns no
The two paths resume by different means — the webview `<video>` reloads off its element and nothing watches the stream URL for it, so reassigning the URL — how
stream URL, watched by an `$effect`; ExoPlayer owns no element and nothing the deleted webview path came back — restarted nothing and left a black screen
watches the URL for it, so it needs an explicit re-issue. Doing only the URL with a play button that did nothing.
assignment restarted nothing on the native path 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 - **`wasPlaying` is captured on the way out** so play/pause survives the round
trip, and the handoff does not silently rewind (DR-203). trip, and the handoff does not silently rewind (DR-203).
- **Mutually exclusive with PiP.** Toggle on → `setAutoEnterEnabled(false)`; - **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 **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 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; Tauri WebView**, with the Svelte controls drawn over it. It is the only video
the HTML5 `<video>` path remains the fallback and is not being removed. The path (DR-235). Before that it was an opt-in whose default was flipped and
default has been flipped and reverted twice and each revert has a named cause — reverted twice, each revert with a named cause — the per-defect record is in
the per-defect record is in `requirements.md` (DR-150 … DR-196). `requirements.md` (DR-150 … DR-196).
```mermaid ```mermaid
flowchart TB 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 - **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 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. 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 native reveal fires on a `playing` state or a position tick carrying a position
or duration, and on nothing else. or duration, and on nothing else.
- **Letterbox bars are painted**, not left holding whatever was last in the - **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:; font-src 'self' data:;
img-src 'self' data: blob: asset: http://asset.localhost http: https:; 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:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:;
connect-src 'self' ipc: http://ipc.localhost http: https:; connect-src 'self' ipc: http://ipc.localhost;
worker-src 'self' blob:; worker-src 'self';
object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none' 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. | | `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. | | `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. | | `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. | | `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. `http:`/`https:` is hls.js fetching manifests and segments; ordinary API traffic goes through Rust and is not subject to CSP. | | `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' 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. | | `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. | | `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. | | `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 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 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 *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 | | [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 | | [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 | | [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 | | [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 | | [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 | | [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) │ └── sessions.ts # SessionsApi (remote session control)
├── player/ # Unified player boundary (frontend) ├── player/ # Unified player boundary (frontend)
│ ├── index.ts # playerController facade — the only write-side entry point for playback │ ├── 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/ ├── services/
│ ├── playerEvents.ts # Tauri event listener for player events │ ├── playerEvents.ts # Tauri event listener for player events
│ └── playbackReporting.ts # Thin wrapper (~50 lines) │ └── 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-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-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-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-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-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 | | 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-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-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-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-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-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 | | 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-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-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-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-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-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 | | 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-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-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-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-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-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 | | 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-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-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-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-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-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 | | 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-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-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-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-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-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 | | 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-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-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-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-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-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 | | 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-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-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-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 ### Integration Tests
| Test ID | Test Description | Traces To | Status | | Test ID | Test Description | Traces To | Status |
+12 -12
View File
@@ -1,17 +1,17 @@
# Spec: Desktop native video — mpv renders the picture, everywhere # Spec: Desktop native video — mpv renders the picture, everywhere
**Status:** Partially implemented — phase 1 routing shipped: mpv is the only **Status:** Partially implemented — all three phases' *code* has shipped: mpv
Linux video renderer (`native_video::enabled()` is unconditional on Linux, no is the only video renderer on Linux (render API into the GTK vbox) and Windows
webview fallback is offered). **Left:** Linux's device profile still claims only (`wid` into the app window, `vo=gpu-next,gpu`), and the webview video path is
`h264` (DR-234), so video still arrives as a server transcode — mpv plays it, but deleted — hls.js, the HTML5 adapter, the `<video>` element, the frontend switch,
the direct-play gain is not yet taken; `hwdec` is unset, so mpv decodes in the `use_html5` command parameters and the Android HTML5 PiP/screen-wake state
software (DR-236); a failed surface attach only logs, it does not surface an (DR-235). mpv selects subtitles and audio tracks itself (`mpv_tracks`).
error; the phase 1 soak and X11/Wayland criteria are unrecorded; phases 2 and 3. **Left:** Linux/Windows still claim only `h264` (DR-234), so video is still a
Phase 2 has started from its build half: Windows now links and ships libmpv server transcode — mpv plays it, but the direct-play gain is not taken; `hwdec`
(for audio, see windows-native-audio-backend.md); its video surface is not is unset, so mpv decodes in software (DR-236); a failed surface attach only
written. Phase 3 also deletes the now-inert frontend switch (`experimentalNativeVideo`, logs; and every hardware criterion is unrecorded — the X11/Wayland check and
`nativeVideoWanted`, the Settings toggle and the adapter's suppressor flag), soak on Linux, and **any** run of Windows video, which has only been verified by
which no platform reaches since `webview_video_fallback` became false everywhere. 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) **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 **UX spec:** n/a — nothing about the player's appearance changes. What changes is
what is behind the controls. what is behind the controls.
+3014 -2923
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -64,7 +64,6 @@
"@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "2.10.1", "@tauri-apps/plugin-updater": "2.10.1",
"hls.js": "^1.6.15",
"svelte-dnd-action": "^0.9.69" "svelte-dnd-action": "^0.9.69"
}, },
"devDependencies": { "devDependencies": {
+12 -4
View File
@@ -54,21 +54,29 @@ describe("tauri.conf.json CSP", () => {
expect(csp["img-src"]).toContain("asset:"); expect(csp["img-src"]).toContain("asset:");
expect(csp["img-src"]).toContain("http://asset.localhost"); expect(csp["img-src"]).toContain("http://asset.localhost");
expect(csp["media-src"]).toContain("asset:"); expect(csp["media-src"]).toContain("asset:");
// hls.js: MSE object URLs, and its demuxer worker built from a blob.
expect(csp["media-src"]).toContain("blob:");
expect(csp["worker-src"]).toContain("blob:");
// The token-guarded loopback media server (DR-137). // The token-guarded loopback media server (DR-137).
expect(csp["media-src"]).toContain("http://127.0.0.1:*"); expect(csp["media-src"]).toContain("http://127.0.0.1:*");
// Tauri's invoke transport. // Tauri's invoke transport.
expect(csp["connect-src"]).toContain("ipc:"); expect(csp["connect-src"]).toContain("ipc:");
expect(csp["connect-src"]).toContain("http://ipc.localhost"); expect(csp["connect-src"]).toContain("http://ipc.localhost");
// The user's Jellyfin server: an arbitrary run-time origin, http on a LAN. // The user's Jellyfin server: an arbitrary run-time origin, http on a LAN.
for (const directive of ["img-src", "media-src", "connect-src"]) { for (const directive of ["img-src", "media-src"]) {
expect(csp[directive]).toContain("http:"); expect(csp[directive]).toContain("http:");
expect(csp[directive]).toContain("https:"); expect(csp[directive]).toContain("https:");
} }
}); });
// The page makes no network requests of its own — all traffic goes through
// Rust — so `connect-src` is IPC only. It allowed any http(s) host while
// hls.js fetched segments in the page; with hls.js deleted (DR-235) that
// grant was only an exfiltration channel for injected script. Likewise the
// blob worker was hls.js' demuxer.
it("gives injected script no network egress and no blob workers", () => {
expect(csp["connect-src"]).not.toContain("http:");
expect(csp["connect-src"]).not.toContain("https:");
expect(csp["worker-src"]).not.toContain("blob:");
});
it("never widens a data directive into script execution", () => { it("never widens a data directive into script execution", () => {
for (const [name, sources] of Object.entries(csp)) { for (const [name, sources] of Object.entries(csp)) {
if (name === "script-src" || name === "worker-src") { if (name === "script-src" || name === "worker-src") {
@@ -85,10 +85,9 @@ class MainActivity : TauriActivity() {
super.onWebViewCreate(webView) super.onWebViewCreate(webView)
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load") android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
mediaWebView = webView mediaWebView = webView
// A new WebView means a new page, which reports no video yet. Anything the // A new WebView means a new page, which plays no video yet. Anything held
// previous one left held would otherwise pin the screen on for the life of // for the previous one would otherwise pin the screen on for the life of
// the process, since a page that goes away never sends its final // the process. (DR-202)
// setHtml5VideoState(false, …). (DR-202)
ScreenWakeManager.releaseAll() ScreenWakeManager.releaseAll()
installJavascriptBridges(webView) installJavascriptBridges(webView)
configureWebViewSettings(webView) configureWebViewSettings(webView)
@@ -327,22 +326,6 @@ class MainActivity : TauriActivity() {
autoEnterPipEnabled = enabled autoEnterPipEnabled = enabled
} }
/**
* Report the WebView `<video>` state.
*
* Without this PiP only ever knew about the native ExoPlayer surface,
* which is behind an experimental flag that defaults to off — so in the
* shipping configuration nothing ever satisfied canEnterPip and the
* button did nothing. (DR-160)
*/
@JavascriptInterface
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
// The same report is what keeps the display awake on the webview
// rendering path — the WebView takes no display wake lock of its own
// for `<video>`. (DR-202)
ScreenWakeManager.onHtml5VideoState(active, playing)
}
}, "AndroidPictureInPicture") }, "AndroidPictureInPicture")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added") android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
@@ -46,52 +46,7 @@ object PictureInPictureManager {
private var receiver: BroadcastReceiver? = null private var receiver: BroadcastReceiver? = null
private var hiddenWebView: WebView? = null private var hiddenWebView: WebView? = null
/** /** True when a native video surface is attached and playing. */
* State of an HTML5 `<video>` playing inside the WebView, reported by the
* frontend.
*
* PiP was written for the native ExoPlayer surface only — [canEnterPip]
* required a SurfaceView to be attached and rendering. But native video is
* behind `experimentalNativeVideo`, which defaults to **off**, so in the
* shipping configuration video plays in the WebView's `<video>` element and
* every one of those conditions is false. `enterPip` therefore always bailed
* with "no local video playing": PiP could not work at all, however the
* button was pressed.
*
* On this path the WebView *is* the video, which inverts two things: the
* WebView must stay visible in PiP rather than be hidden, and play/pause has
* to reach the element rather than ExoPlayer. Both are handled below.
*
* TRACES: UR-041 | DR-160
*/
@Volatile
private var html5VideoActive = false
@Volatile
private var html5VideoPlaying = false
@Volatile
private var html5AspectRatio: Rational? = null
/**
* Report the WebView `<video>` state from the frontend.
*
* @param active whether a video element is currently the playback surface
* @param width intrinsic video width, for the PiP window's aspect ratio
* @param height intrinsic video height
* @param playing whether it is playing right now, for the PiP play/pause action
*/
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
html5VideoActive = active
html5VideoPlaying = playing
html5AspectRatio = if (active && width > 0 && height > 0) {
clampedRatio(width.toDouble() / height.toDouble())
} else {
null
}
}
/** True when PiP would be showing the native surface rather than the WebView. */
private fun isNativeVideoPath(): Boolean = try { private fun isNativeVideoPath(): Boolean = try {
val player = JellyTauPlayer.getInstance() val player = JellyTauPlayer.getInstance()
player.isPlayingVideo() && player.isPlayingVideo() &&
@@ -120,10 +75,9 @@ object PictureInPictureManager {
*/ */
fun canEnterPip(activity: Activity): Boolean { fun canEnterPip(activity: Activity): Boolean {
if (!isPipSupported(activity)) return false if (!isPipSupported(activity)) return false
// Either surface will do: the native one, or the WebView's `<video>`, // Video only ever renders on the native surface: the WebView `<video>`
// which is what actually plays while experimentalNativeVideo is off. // path is gone (DR-235). (DR-160)
// (DR-160) return isNativeVideoPath()
return isNativeVideoPath() || html5VideoActive
} }
/** /**
@@ -186,9 +140,7 @@ object PictureInPictureManager {
return clampedRatio(surface.width.toDouble() / surface.height.toDouble()) return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
} }
// No native surface: the WebView is the video, so use the intrinsic size return null
// the frontend reported. (DR-160)
return html5AspectRatio
} }
/** /**
@@ -207,17 +159,11 @@ object PictureInPictureManager {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun buildPlayPauseAction(activity: Activity): RemoteAction { private fun buildPlayPauseAction(activity: Activity): RemoteAction {
// On the HTML5 path ExoPlayer is idle, so its `isPlaying` is always false val isPlaying = try {
// and the button would be stuck showing "Play" mid-playback. (DR-160)
val isPlaying = if (isNativeVideoPath()) {
try {
JellyTauPlayer.getInstance().getExoPlayer().isPlaying JellyTauPlayer.getInstance().getExoPlayer().isPlaying
} catch (e: Exception) { } catch (e: Exception) {
false false
} }
} else {
html5VideoPlaying
}
val (iconRes, title, controlType, requestCode) = if (isPlaying) { val (iconRes, title, controlType, requestCode) = if (isPlaying) {
Quad( Quad(
@@ -288,11 +234,8 @@ object PictureInPictureManager {
*/ */
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) { fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
if (isInPipMode) { if (isInPipMode) {
// Hiding the WebView is correct only when the video is *behind* it on // The video is *behind* the WebView on the native surface, so hiding
// the native surface. On the HTML5 path the WebView is the video, so // the WebView leaves only the picture. (DR-160)
// hiding it would leave an empty black PiP window — the frontend
// instead strips its own chrome when it hears the event below.
// (DR-160)
if (isNativeVideoPath()) { if (isNativeVideoPath()) {
hideWebView(activity) hideWebView(activity)
} }
@@ -315,9 +258,8 @@ object PictureInPictureManager {
/** /**
* Fire a DOM event into the WebView. * Fire a DOM event into the WebView.
* *
* The HTML5 PiP path is a conversation with the frontend rather than * Tells the frontend the window shrank or grew, so it can strip or restore
* something native can do alone: it has to be told to strip its chrome when * its chrome. (DR-160)
* the window shrinks, and to play/pause the element. (DR-160)
*/ */
private fun dispatchWebEvent(activity: Activity, name: String) { private fun dispatchWebEvent(activity: Activity, name: String) {
val webView = findWebView(activity.window.decorView) ?: return val webView = findWebView(activity.window.decorView) ?: return
@@ -358,7 +300,6 @@ object PictureInPictureManager {
if (intent?.action != ACTION_MEDIA_CONTROL) return if (intent?.action != ACTION_MEDIA_CONTROL) return
val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0) val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
if (isNativeVideoPath()) {
val player = try { val player = try {
JellyTauPlayer.getInstance() JellyTauPlayer.getInstance()
} catch (e: Exception) { } catch (e: Exception) {
@@ -368,19 +309,6 @@ object PictureInPictureManager {
CONTROL_PLAY -> player.play() CONTROL_PLAY -> player.play()
CONTROL_PAUSE -> player.pause() CONTROL_PAUSE -> player.pause()
} }
} else {
// The WebView owns playback here, so the command has to reach
// the `<video>` element. Driving ExoPlayer instead would do
// nothing at all, which is what a PiP button on the HTML5 path
// used to do. (DR-160)
val name = when (control) {
CONTROL_PLAY -> "jellytau-pip-play"
CONTROL_PAUSE -> "jellytau-pip-pause"
else -> return
}
dispatchWebEvent(activity, name)
html5VideoPlaying = control == CONTROL_PLAY
}
// Swap the button to reflect the new state. // Swap the button to reflect the new state.
updatePipActions(activity) updatePipActions(activity)
} }
@@ -7,14 +7,12 @@ import android.view.WindowManager
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
/** /**
* Which playback paths currently want the screen kept awake. * Whether playback currently wants the screen kept awake.
* *
* Pure state, deliberately free of any Android type so it can be unit-tested — * Pure state, deliberately free of any Android type so it can be unit-tested —
* see ScreenWakeStateTest. Two independent holders, because video can be * see ScreenWakeStateTest. The one holder is ExoPlayer drawing video into the
* rendered by either renderer and only one of them is active at a time: * TextureView (DR-192); the WebView `<video>` that was a second holder is gone
* * (DR-235).
* - **native** — ExoPlayer drawing into the TextureView (DR-192)
* - **html5** — a `<video>` inside the WebView, reported by the frontend
* *
* Audio is deliberately *not* a holder. Playing music with the screen off is the * Audio is deliberately *not* a holder. Playing music with the screen off is the
* point of the audio path; only video needs the display alive. * point of the audio path; only video needs the display alive.
@@ -23,11 +21,10 @@ import java.lang.ref.WeakReference
*/ */
class ScreenWakeState { class ScreenWakeState {
private var nativeVideoPlaying = false private var nativeVideoPlaying = false
private var html5VideoPlaying = false
/** True while any video renderer is actively playing. */ /** True while video is actively playing. */
val keepScreenOn: Boolean val keepScreenOn: Boolean
get() = nativeVideoPlaying || html5VideoPlaying get() = nativeVideoPlaying
/** /**
* @param playing whether ExoPlayer is playing right now * @param playing whether ExoPlayer is playing right now
@@ -37,18 +34,9 @@ class ScreenWakeState {
nativeVideoPlaying = playing && isVideo nativeVideoPlaying = playing && isVideo
} }
/**
* @param active whether a webview `<video>` is the current playback surface
* @param playing whether that element is playing right now
*/
fun updateHtml5(active: Boolean, playing: Boolean) {
html5VideoPlaying = active && playing
}
/** Drop every hold (teardown, or a page that can no longer be trusted). */ /** Drop every hold (teardown, or a page that can no longer be trusted). */
fun reset() { fun reset() {
nativeVideoPlaying = false nativeVideoPlaying = false
html5VideoPlaying = false
} }
} }
@@ -66,9 +54,7 @@ class ScreenWakeState {
* appeared nowhere, and neither renderer supplies one for free — ExoPlayer's * appeared nowhere, and neither renderer supplies one for free — ExoPlayer's
* `setWakeMode` is a *CPU/wifi* wake lock and says nothing about the display, * `setWakeMode` is a *CPU/wifi* wake lock and says nothing about the display,
* and it draws into a `TextureView` we own rather than a `PlayerView`, which is * and it draws into a `TextureView` we own rather than a `PlayerView`, which is
* the media3 widget that would otherwise set `keepScreenOn` itself. The WebView * the media3 widget that would otherwise set `keepScreenOn` itself.
* `<video>` path does not either: the display wake lock Chrome takes for video
* lives in the browser layer, not in an embedded WebView.
* *
* ## Approach * ## Approach
* *
@@ -78,15 +64,9 @@ class ScreenWakeState {
* missed release the way an explicitly acquired wake lock can. It needs no * missed release the way an explicitly acquired wake lock can. It needs no
* permission. (The manifest's `WAKE_LOCK` is the media service's, unrelated.) * permission. (The manifest's `WAKE_LOCK` is the media service's, unrelated.)
* *
* The two renderers report independently and are OR-ed together in * `JellyTauPlayer.onIsPlayingChanged` and its surface teardown drive
* [ScreenWakeState]: * [ScreenWakeState] — ExoPlayer is the authoritative source of playback state,
* * so the hold follows what it reports rather than what the UI intends.
* - `JellyTauPlayer.onIsPlayingChanged` and its surface teardown drive the
* native path — ExoPlayer is the authoritative source of playback state, so
* the hold follows what it reports rather than what the UI intends.
* - `MainActivity`'s `AndroidPictureInPicture.setHtml5VideoState` bridge drives
* the webview path. The frontend already reports that state on every
* play/pause and on player teardown for PiP, so no new bridge is needed.
* *
* The Activity reference is weak and re-set on every `onCreate`, so a * The Activity reference is weak and re-set on every `onCreate`, so a
* recreation (rotation) re-applies the current hold to the new window. * recreation (rotation) re-applies the current hold to the new window.
@@ -126,20 +106,9 @@ object ScreenWakeManager {
} }
/** /**
* The frontend reported the webview `<video>` state. Arrives on a WebView * Drop every hold. Used when a new WebView/page load starts from scratch, so
* binder thread, hence the synchronization and the post to the main thread. * nothing held for the previous page can pin the screen on for the life of
*/ * the process.
@Synchronized
fun onHtml5VideoState(active: Boolean, playing: Boolean) {
state.updateHtml5(active, playing)
apply()
}
/**
* Drop every hold. Used when a new WebView/page load invalidates whatever the
* previous page last reported — a page that goes away without a final
* `setHtml5VideoState(false, …)` would otherwise leave the screen pinned on
* for the life of the process.
*/ */
@Synchronized @Synchronized
fun releaseAll() { fun releaseAll() {
@@ -40,46 +40,9 @@ class ScreenWakeStateTest {
} }
@Test @Test
fun `webview video playing holds the screen on`() { fun `teardown releases the hold`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
assertTrue(state.keepScreenOn)
}
@Test
fun `webview video paused releases the screen`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = true, playing = false)
assertFalse(state.keepScreenOn)
}
/** The element going away must release even if it never reported a pause. */
@Test
fun `webview video going inactive while playing releases the screen`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = false, playing = true)
assertFalse(state.keepScreenOn)
}
/** The two rendering paths are independent holders; either one is enough. */
@Test
fun `one path releasing does not release while the other still plays`() {
val state = ScreenWakeState() val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true) state.updateNative(playing = true, isVideo = true)
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = false, playing = false)
assertTrue(state.keepScreenOn)
state.updateNative(playing = false, isVideo = true)
assertFalse(state.keepScreenOn)
}
@Test
fun `teardown releases both paths`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true)
state.updateHtml5(active = true, playing = true)
state.reset() state.reset()
assertFalse(state.keepScreenOn) assertFalse(state.keepScreenOn)
} }
+72 -253
View File
@@ -69,10 +69,6 @@ pub struct PlayerStatus {
pub muted: bool, pub muted: bool,
pub shuffle: bool, pub shuffle: bool,
pub repeat: RepeatMode, pub repeat: RepeatMode,
/// Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
pub backend: VideoBackend,
/// Whether frontend should render HTML5 video element
pub use_html5_element: bool,
// Merged fields (prefer remote session when available) // Merged fields (prefer remote session when available)
/// Media item from either local queue or remote session /// Media item from either local queue or remote session
@@ -156,16 +152,6 @@ pub struct QueueStatus {
pub has_previous: bool, pub has_previous: bool,
} }
/// Backend type for video playback
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum VideoBackend {
/// Native backend (ExoPlayer on Android, libmpv on Linux)
Native,
/// HTML5 video element fallback
Html5,
}
/// Request to play a single video item /// Request to play a single video item
/// ///
/// Simplified to video playback only. Audio playback uses player_play_tracks /// Simplified to video playback only. Audio playback uses player_play_tracks
@@ -217,9 +203,8 @@ pub struct PlayItemRequest {
pub series_id: Option<String>, pub series_id: Option<String>,
/// Subtitle tracks to sideload, with URLs the frontend has already resolved. /// Subtitle tracks to sideload, with URLs the frontend has already resolved.
/// ///
/// Only the native backends use these: on Android they become the /// On Android they become the `MediaItem.SubtitleConfiguration`s ExoPlayer
/// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path /// renders; mpv loads them as external subtitle files (`mpv_tracks`).
/// builds its own `<track>` children instead and ignores this list.
/// ///
/// **Order is the contract.** `player_set_subtitle_track(n)` reaches /// **Order is the contract.** `player_set_subtitle_track(n)` reaches
/// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's /// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
@@ -328,19 +313,6 @@ pub enum VideoSeekResponse {
/// Confirmed position after seek /// Confirmed position after seek
position: f64, position: f64,
}, },
/// Reload stream from new position (transcoded non-HLS)
ReloadStream {
/// What to open, and how — transport included, so the frontend picks
/// its loader from a tagged enum rather than by searching the URL for
/// `.m3u8`. TRACES: UR-079 | DR-225
selection: StreamSelection,
/// `seek_offset` carries the position to RESUME AT, not a base to add to
/// the element's clock. The reloaded stream starts at the item's zero —
/// a position on an HLS playlist makes the server 400 every segment
/// behind it (DR-181) — so the adapter reaches the position by seeking
/// the element and leaves the transcode offset at zero.
seek_offset: f64,
},
} }
/// Response for audio track switching operations /// Response for audio track switching operations
@@ -352,13 +324,6 @@ pub enum AudioTrackSwitchResponse {
/// Confirmation message /// Confirmation message
success: bool, success: bool,
}, },
/// HTML5 needs to reload stream with new audio track
ReloadStream {
/// What to open, and how. TRACES: UR-079 | DR-225
selection: StreamSelection,
/// Current position to resume from
position: f64,
},
} }
/// Response for a mid-playback streaming-quality change. /// Response for a mid-playback streaming-quality change.
@@ -388,16 +353,6 @@ pub enum StreamQualityResponse {
/// Position playback resumed at. /// Position playback resumed at.
position: f64, position: f64,
}, },
/// HTML5 must reload its element with this selection.
ReloadStream {
/// What to open, and how — already negotiated against the requested
/// ceiling. Carries `available` too, so a picker opened after a quality
/// change still describes the source correctly.
/// TRACES: UR-070, UR-079 | DR-225, DR-227
selection: StreamSelection,
/// Position to resume from.
position: f64,
},
} }
/// Helper function to create MediaItem from video request /// Helper function to create MediaItem from video request
@@ -726,36 +681,18 @@ pub async fn player_play_item(
} }
let controller = player.0.lock().await; let controller = player.0.lock().await;
// Who gets the stream depends on who is going to *render* it, which is a // The backend always gets the stream: every video renderer is native (mpv,
// runtime question, not a platform constant. // ExoPlayer) since the webview path was deleted (DR-235). This used to ask
// // who would render — the webview's `<video>` played it itself, so the
// Historically Linux video was always the webview's (`use_html5_element`), // backend was only told about it (`set_current_item`) — and got the answer
// so handing the file to MPV as well would only have started a redundant // wrong twice: the Linux guard silenced mpv video entirely once mpv drew the
// decode with no window to show it in — hence a `#[cfg(not(linux))]` guard // picture, and on Windows it loaded video into the backend while the status
// and a queue-only path here. With mpv drawing the picture that inverts: // sent it to the element too.
// the webview is no longer loading anything, so if this does not load the
// file, *nothing does*. The symptom is total silence — no picture and no
// audio — which reads like a broken stream rather than a stream nobody was
// given.
//
// This is the fifth place in this cycle where a renderer's capability was
// written as a compile-time platform fact. Same fix as the others: ask.
// (It said `cfg!(not(linux))`, which also loaded Windows video into the
// backend while the status sent it to the `<video>` element.)
// //
// TRACES: UR-080 | DR-231, DR-235, DR-237 // TRACES: UR-080 | DR-231, DR-235, DR-237
let renders_natively = video_renders_natively();
if renders_natively {
controller controller
.play_item(media_item) .play_item(media_item)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} else {
// The webview will play it; keep the queue in sync for the UI and for a
// remote transfer without starting a second decode.
controller
.set_current_item(media_item)
.map_err(|e| e.to_string())?;
}
// Emit queue changed event // Emit queue changed event
controller.emit_queue_changed(); controller.emit_queue_changed();
@@ -778,8 +715,8 @@ pub async fn player_play_item(
/// `stream_url` MUST be an audio-only URL (see /// `stream_url` MUST be an audio-only URL (see
/// `get_audio_only_stream_url_for_video`). The item is created as /// `get_audio_only_stream_url_for_video`). The item is created as
/// `MediaType::Audio` so it starts an audio session and loads into the native /// `MediaType::Audio` so it starts an audio session and loads into the native
/// backend with `mediaType="audio"` — the WebView `<video>` is torn down on the /// backend with `mediaType="audio"`, replacing the video, so exactly one audio
/// frontend side, so exactly one audio source is ever active. /// source is ever active.
/// ///
/// This deliberately goes through the queue-based `play_item` path (NOT a /// This deliberately goes through the queue-based `play_item` path (NOT a
/// side-channel) so end-of-track lands in `on_playback_ended`, which already /// side-channel) so end-of-track lands in `on_playback_ended`, which already
@@ -895,7 +832,7 @@ pub async fn player_enter_background_audio(
} }
/// Exit background-audio mode: stop the native audio player and return its final /// Exit background-audio mode: stop the native audio player and return its final
/// position so the frontend can reload the WebView `<video>` there (UR-040). /// position so the frontend can reload the video there (UR-040).
/// ///
/// Returns the position in seconds. The sleep timer is intentionally left /// Returns the position in seconds. The sleep timer is intentionally left
/// untouched — if it fired while backgrounded, playback is already stopped and /// untouched — if it fired while backgrounded, playback is already stopped and
@@ -1190,8 +1127,9 @@ pub async fn player_stop(
let mode = playback_mode.0.get_mode(); let mode = playback_mode.0.get_mode();
// Stopping is a state transition worth seeing in a log. Native video is // Stopping is a state transition worth seeing in a log. Native video is
// what made its absence matter: the webview <video> stopped implicitly when // what made its absence matter: the (since deleted) webview <video> stopped
// the component unmounted, so nothing ever had to call this — and "never // implicitly when the component unmounted, so nothing ever had to call
// this — and "never
// called" and "called but the backend kept playing" look identical from // called" and "called but the backend kept playing" look identical from
// outside without it. // outside without it.
info!("[player_stop] called (mode: {:?})", mode); info!("[player_stop] called (mode: {:?})", mode);
@@ -1432,8 +1370,8 @@ pub async fn player_seek(
/// - Direct play streams: Use native seeking /// - Direct play streams: Use native seeking
/// - Transcoded non-HLS: Request new stream URL from server starting at seek position /// - Transcoded non-HLS: Request new stream URL from server starting at seek position
/// ///
/// For native (non-HTML5) backends, this command handles the entire stream reload /// The backend always handles the seek itself, including re-opening a stream,
/// internally. For HTML5 backends, it returns the new URL for the frontend to handle. /// since every video renderer is native (DR-235).
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn player_seek_video( pub async fn player_seek_video(
@@ -1443,12 +1381,8 @@ pub async fn player_seek_video(
position: f64, position: f64,
media_source_id: Option<String>, media_source_id: Option<String>,
audio_stream_index: Option<i32>, audio_stream_index: Option<i32>,
use_html5: bool,
) -> Result<VideoSeekResponse, String> { ) -> Result<VideoSeekResponse, String> {
info!( info!("[player_seek_video] Seeking to {} seconds", position);
"[player_seek_video] Seeking to {} seconds (use_html5: {})",
position, use_html5
);
// Get repository // Get repository
let repository = repository_manager let repository = repository_manager
@@ -1490,17 +1424,13 @@ pub async fn player_seek_video(
let controller = player.0.lock().await; let controller = player.0.lock().await;
controller.capabilities().seeks_transcoded_in_place controller.capabilities().seeks_transcoded_in_place
}; };
let strategy = determine_video_seek_strategy( let strategy =
is_local, determine_video_seek_strategy(is_local, seeks_transcoded_in_place, needs_transcoding);
seeks_transcoded_in_place,
needs_transcoding,
use_html5,
);
info!( info!(
"[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \ "[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
needs_transcoding={}, use_html5={}, strategy={:?}", needs_transcoding={}, strategy={:?}",
is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy is_local, seeks_transcoded_in_place, needs_transcoding, strategy
); );
match strategy { match strategy {
@@ -1511,35 +1441,6 @@ pub async fn player_seek_video(
controller.seek(position).map_err(|e| e.to_string())?; controller.seek(position).map_err(|e| e.to_string())?;
Ok(VideoSeekResponse::Native { position }) Ok(VideoSeekResponse::Native { position })
} }
VideoSeekStrategy::Html5NativeSeek => {
// HTML5 backend with HLS or direct play - frontend handles seeking
// We don't call backend.seek() because video is in HTML5 element, not in MPV
info!("[player_seek_video] HTML5 native seek - returning position for frontend");
Ok(VideoSeekResponse::Native { position })
}
VideoSeekStrategy::Html5ReloadStream => {
// Transcoded non-HLS with HTML5 - frontend handles stream reload
info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
audio_stream_index,
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
info!(
"[player_seek_video] Selected {:?} over {:?} for position {}",
selection.playback_kind, selection.transport, position
);
Ok(VideoSeekResponse::ReloadStream {
selection,
seek_offset: position,
})
}
VideoSeekStrategy::BackendReloadStream => { VideoSeekStrategy::BackendReloadStream => {
// Transcoded non-HLS with native backend - backend handles stream reload // Transcoded non-HLS with native backend - backend handles stream reload
info!("[player_seek_video] Backend reload stream - requesting new stream URL"); info!("[player_seek_video] Backend reload stream - requesting new stream URL");
@@ -1610,9 +1511,6 @@ pub async fn player_seek_video(
/// carries the requested track at all** — see /// carries the requested track at all** — see
/// [`determine_audio_track_switch_strategy`]: /// [`determine_audio_track_switch_strategy`]:
/// ///
/// - An HTML5 `<video>` element has no track-selection API, so the stream is
/// always re-opened at the chosen `AudioStreamIndex` and the frontend seeks
/// the reloaded element back to `position`.
/// - A native backend playing a **direct play** holds the source file with /// - A native backend playing a **direct play** holds the source file with
/// every track in it, so ExoPlayer selects in place by track-group index. /// every track in it, so ExoPlayer selects in place by track-group index.
/// - A native backend playing a **transcode** does not. Jellyfin builds a /// - A native backend playing a **transcode** does not. Jellyfin builds a
@@ -1628,10 +1526,8 @@ pub async fn player_seek_video(
/// audio track index` and dropped the request — the default track just kept /// audio track index` and dropped the request — the default track just kept
/// playing, with nothing in the UI saying so. /// playing, with nothing in the UI saying so.
/// ///
/// libmpv implements neither selection nor reload here — it is the audio-only /// mpv selects in place the same way (`mpv_tracks::select_audio`, by position in
/// backend and leaves `PlayerBackend::set_audio_track` at its /// the file's audio tracks), and re-opens a transcode through the same path.
/// `not_implemented()` default, which is why IR-019 is met by these paths
/// rather than by MPV.
/// ///
/// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258 /// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
#[tauri::command] #[tauri::command]
@@ -1646,12 +1542,13 @@ pub async fn player_switch_audio_track(
repository_handle: String, repository_handle: String,
stream_index: i32, stream_index: i32,
array_index: i32, array_index: i32,
use_html5: bool,
current_position: Option<f64>, current_position: Option<f64>,
media_source_id: Option<String>, media_source_id: Option<String>,
) -> Result<AudioTrackSwitchResponse, String> { ) -> Result<AudioTrackSwitchResponse, String> {
info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}", info!(
stream_index, array_index, use_html5); "[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}",
stream_index, array_index
);
// Read what the engine is playing before deciding anything — including // Read what the engine is playing before deciding anything — including
// where it is, which has to be captured before the stop below wipes it. // where it is, which has to be captured before the stop below wipes it.
@@ -1675,11 +1572,11 @@ pub async fn player_switch_audio_track(
) )
}; };
let strategy = determine_audio_track_switch_strategy(needs_transcoding, use_html5); let strategy = determine_audio_track_switch_strategy(needs_transcoding);
info!( info!(
"[player_switch_audio_track] needs_transcoding={}, use_html5={}, strategy={:?}", "[player_switch_audio_track] needs_transcoding={}, strategy={:?}",
needs_transcoding, use_html5, strategy needs_transcoding, strategy
); );
if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace { if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace {
@@ -1700,7 +1597,7 @@ pub async fn player_switch_audio_track(
// Select a stream carrying the chosen audio track. It starts at zero — // Select a stream carrying the chosen audio track. It starts at zero —
// an HLS playlist cannot carry a position (DR-181) — so the position is // an HLS playlist cannot carry a position (DR-181) — so the position is
// restored by seeking afterwards, here or in the frontend. // restored by seeking afterwards.
// //
// Pinning a track is itself a reason the source cannot be direct-played: // Pinning a track is itself a reason the source cannot be direct-played:
// the file has one default track and the viewer asked for another, so // the file has one default track and the viewer asked for another, so
@@ -1721,10 +1618,6 @@ pub async fn player_switch_audio_track(
let position = crate::player::track_switch::resume_position(current_position, engine_position); let position = crate::player::track_switch::resume_position(current_position, engine_position);
match strategy { match strategy {
AudioTrackSwitchStrategy::Html5ReloadStream => Ok(AudioTrackSwitchResponse::ReloadStream {
selection,
position,
}),
AudioTrackSwitchStrategy::BackendReloadStream => { AudioTrackSwitchStrategy::BackendReloadStream => {
// The native backend re-opens its own stream, the same sequence the // The native backend re-opens its own stream, the same sequence the
// transcoded seek and quality change use: stop, repoint the queue // transcoded seek and quality change use: stop, repoint the queue
@@ -1783,9 +1676,7 @@ pub async fn player_switch_audio_track(
/// A cap is a property of the stream the server is producing, so unlike a volume /// A cap is a property of the stream the server is producing, so unlike a volume
/// change it cannot be applied to a stream already in flight — the stream has to /// change it cannot be applied to a stream already in flight — the stream has to
/// be re-opened at the new quality and resumed at the current position. That is /// be re-opened at the new quality and resumed at the current position. That is
/// the same reload the transcoded-seek and audio-track paths use, and the same /// the same reload the transcoded-seek and audio-track paths use, done here.
/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
/// native backend is reloaded here.
/// ///
/// The change applies to **this playback only**. The in-player picker is a /// The change applies to **this playback only**. The in-player picker is a
/// "this film, this connection" control and its doc has always said so, but it /// "this film, this connection" control and its doc has always said so, but it
@@ -1809,15 +1700,13 @@ pub async fn player_set_stream_quality(
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
repository_handle: String, repository_handle: String,
quality: crate::settings::StreamingQuality, quality: crate::settings::StreamingQuality,
use_html5: bool,
current_position: Option<f64>, current_position: Option<f64>,
media_source_id: Option<String>, media_source_id: Option<String>,
audio_stream_index: Option<i32>, audio_stream_index: Option<i32>,
) -> Result<StreamQualityResponse, String> { ) -> Result<StreamQualityResponse, String> {
info!( info!(
"[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})", "[player_set_stream_quality] Switching to {} (position: {:?})",
quality.label(), quality.label(),
use_html5,
current_position current_position
); );
@@ -1882,14 +1771,7 @@ pub async fn player_set_stream_quality(
.map_err(|e| format!("Failed to select a stream: {:?}", e))?; .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
let new_url = selection.url.clone(); let new_url = selection.url.clone();
if use_html5 { // The native backend (mpv, ExoPlayer): stop, repoint the queue entry at the
return Ok(StreamQualityResponse::ReloadStream {
selection,
position,
});
}
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`. // new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
// The re-opened stream begins at zero (an HLS playlist cannot carry a start // The re-opened stream begins at zero (an HLS playlist cannot carry a start
// position without 400ing every segment — DR-181), so it is seeked back to // position without 400ing every segment — DR-181), so it is seeked back to
@@ -1942,8 +1824,8 @@ pub async fn player_set_audio_track(
/// ///
/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position /// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream /// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
/// index. The HTML5 path never reaches here; it toggles its own `<track>` /// index. mpv gives it the same meaning: the position in the sideloaded WebVTT
/// children. libmpv implements neither, leaving the trait default in place. /// list, loaded as external subtitle files (`mpv_tracks`).
/// ///
/// TRACES: UR-020 | IR-018, DR-023 /// TRACES: UR-020 | IR-018, DR-023
#[tauri::command] #[tauri::command]
@@ -2186,17 +2068,13 @@ pub async fn player_get_queue(
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PlaybackCapabilities { pub struct PlaybackCapabilities {
/// True when audio is rendered by a webview `<audio>` element rather than a /// True when audio is rendered by a webview `<audio>` element rather than a
/// native backend. Native audio exists on Linux (mpv) and Android /// native backend. Native audio exists on Linux and Windows (mpv) and
/// (ExoPlayer); everything else (Windows, future desktops) uses the webview. /// Android (ExoPlayer); only an unported desktop uses the webview.
///
/// Video has no counterpart: it is always drawn by the native backend, behind
/// the transparent webview (DR-235) — there is no webview video renderer
/// left to report.
pub uses_webview_audio: bool, pub uses_webview_audio: bool,
/// True when video is rendered by a native surface composited *behind* a
/// transparent webview: ExoPlayer's SurfaceView on Android, mpv's GL area on
/// Linux.
pub supports_native_video: bool,
/// True when the user may send video to the webview element instead of the
/// native renderer — the frontend offers the switch only then, and honours
/// the stored preference only then. False on every platform since DR-235.
pub webview_video_fallback: bool,
} }
/// Report this platform's playback capabilities to the frontend. /// Report this platform's playback capabilities to the frontend.
@@ -2215,51 +2093,14 @@ pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
Ok(PlaybackCapabilities { Ok(PlaybackCapabilities {
uses_webview_audio: !native_audio, uses_webview_audio: !native_audio,
// TRACES: UR-080 | DR-235
supports_native_video: video_renders_natively(),
// No platform offers one: Android since DR-293, Linux since DR-235,
// and on Windows the webview is the only video renderer, so there is
// nothing to fall back *from*. Kept on the wire until phase 3 deletes
// the frontend switch with the rest of the webview video path.
// TRACES: UR-080, UR-003 | DR-235, DR-293
webview_video_fallback: false,
}) })
} }
/// Whether a native renderer draws video on this platform, so the backend
/// must be handed the stream and the webview must not load it.
///
/// ExoPlayer on Android, mpv on Linux; the webview `<video>` element on
/// Windows until DR-237 gives it mpv video. Asked by `player_play_item`,
/// `get_player_status` and `player_get_capabilities` — the answer drifted when
/// each spelled it out for itself.
///
/// TRACES: UR-003, UR-080 | DR-235, DR-237
pub(crate) fn video_renders_natively() -> bool {
cfg!(target_os = "android") || crate::player::native_video::enabled()
}
pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus { pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
// Determine backend at compile time based on platform
let (backend, use_html5_element) = if cfg!(target_os = "android") {
// Android uses ExoPlayer native backend
(VideoBackend::Native, false)
} else if video_renders_natively() {
// mpv draws the picture on this desktop; the frontend must not also
// load it into a <video> element or the stream decodes twice and the
// two fight over the audio. TRACES: UR-080 | DR-235
(VideoBackend::Native, false)
} else {
// Windows: the webview <video> element is its only video renderer
// until mpv reaches it (DR-237).
(VideoBackend::Html5, true)
};
PlayerStatus { PlayerStatus {
state: controller.state(), state: controller.state(),
// The position on the item's timeline, whichever of the three paths is // The position on the item's timeline, whichever path is rendering it —
// rendering it — the native backend answers for only one of them, and // the native backend reads 0 for a handoff that has not ticked yet.
// reads 0 for webview video and for a handoff that has not ticked yet.
// TRACES: UR-005 | DR-178 // TRACES: UR-005 | DR-178
position: controller.absolute_position(), position: controller.absolute_position(),
duration: controller.duration(), duration: controller.duration(),
@@ -2267,8 +2108,6 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
muted: controller.muted(), muted: controller.muted(),
shuffle: controller.is_shuffle(), shuffle: controller.is_shuffle(),
repeat: controller.repeat_mode(), repeat: controller.repeat_mode(),
backend,
use_html5_element,
// Merged fields initialized to defaults (will be set by player_get_status) // Merged fields initialized to defaults (will be set by player_get_status)
merged_media: None, merged_media: None,
@@ -3092,61 +2931,41 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
mod tests { mod tests {
use crate::utils::lock::MutexSafe; use crate::utils::lock::MutexSafe;
/// The webview is not a video renderer anywhere the app ships a native one: /// Video always goes to the backend. `player_play_item` once decided per
/// Android since DR-293, Linux since DR-235 made mpv its only video path. /// platform whether the backend or the webview's `<video>` would render,
/// So the frontend is never offered the switch, and a stored "native video /// and each wrong answer was silence (Linux, once mpv drew the picture) or a
/// off" from before cannot send Linux video back to the `<video>` element. /// soundtrack decoded twice (Windows). With the webview video path deleted
/// /// there is no second renderer to route to, and the queue-only branch is
/// TRACES: UR-080, UR-003 | DR-235, DR-293 | UT-272 /// gone with it.
#[tokio::test]
async fn test_no_platform_offers_a_webview_video_fallback() {
let caps = super::player_get_capabilities().await.unwrap();
assert!(!caps.webview_video_fallback);
if cfg!(target_os = "linux") {
assert!(caps.supports_native_video, "mpv draws video on Linux");
assert!(!caps.uses_webview_audio);
}
}
/// The three places that answer "who draws video here" give one answer:
/// `play_item` loads the backend exactly where the status tells the
/// frontend *not* to use a `<video>` element. They disagreed on Windows —
/// `play_item` loaded video into the backend while the status sent it to
/// the element — which was invisible while that backend was the webview's
/// own `<audio>`, and would play every film's soundtrack twice once mpv
/// plays Windows audio.
/// ///
/// TRACES: UR-003, UR-080 | DR-235, DR-237 | UT-273 /// TRACES: UR-003, UR-080 | DR-235, DR-237 | UT-273
#[tokio::test] #[test]
async fn test_video_routing_has_one_answer() { fn test_video_always_goes_to_the_backend() {
let src = include_str!("mod.rs"); let src = include_str!("mod.rs");
let routing = src let play_item = src
.split("let renders_natively =") .split("pub async fn player_play_item(")
.nth(1) .nth(1)
.and_then(|rest| rest.split(';').next()) .and_then(|rest| rest.split("\n}\n").next())
.expect("player_play_item decides renders_natively"); .expect("player_play_item exists");
assert_eq!( assert!(play_item.contains(".play_item(media_item)"));
routing.trim(), assert!(
"video_renders_natively()", !play_item.contains(".set_current_item("),
"player_play_item must ask the same question as get_player_status" "player_play_item must not keep video from the backend"
); );
let status = super::get_player_status(&crate::player::PlayerController::default());
assert_eq!(status.use_html5_element, !super::video_renders_natively());
let caps = super::player_get_capabilities().await.unwrap();
assert_eq!(caps.supports_native_video, super::video_renders_natively());
} }
/// And the status the video page reads agrees: on Linux the frontend is told /// Only audio can still be the webview's, and only on a desktop with no mpv.
/// the native backend renders, never to load a `<video>` element.
/// ///
/// TRACES: UR-080 | DR-235 | UT-272 /// TRACES: UR-003, UR-080 | DR-235, DR-237 | UT-272
#[test] #[tokio::test]
fn test_linux_video_is_not_sent_to_the_webview() { async fn test_every_shipped_platform_plays_audio_natively() {
let controller = crate::player::PlayerController::default(); let caps = super::player_get_capabilities().await.unwrap();
let status = super::get_player_status(&controller); if cfg!(any(
if cfg!(target_os = "linux") { target_os = "linux",
assert!(!status.use_html5_element); target_os = "windows",
target_os = "android"
)) {
assert!(!caps.uses_webview_audio);
} }
} }
+10 -9
View File
@@ -138,7 +138,7 @@ pub async fn player_play_next_episode(
/// Handle playback ended event - triggers autoplay decision logic /// Handle playback ended event - triggers autoplay decision logic
/// This is called from: /// This is called from:
/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video /// - Frontend when a video ends - passes itemId + repositoryHandle for the video
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed /// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
/// - Android JNI callback also triggers this logic directly /// - Android JNI callback also triggers this logic directly
/// ///
@@ -158,7 +158,7 @@ pub async fn player_on_playback_ended(
let controller_arc = player.0.clone(); let controller_arc = player.0.clone();
// Run autoplay decision logic // Run autoplay decision logic
// If item_id is provided (HTML5 video case), use the video-specific path // If item_id is provided (a video), use the video-specific path
// that bypasses the backend queue and stale end_reason // that bypasses the backend queue and stale end_reason
let decision = { let decision = {
let controller = controller_arc.lock().await; let controller = controller_arc.lock().await;
@@ -326,16 +326,17 @@ pub async fn player_recover_stream(player: State<'_, PlayerStateWrapper>) -> Res
} }
} }
// ===== HTML5 video state-report commands ===== // ===== Webview media state-report commands =====
// //
// On platforms where video renders in the webview (Linux WebKitGTK HTML5 // Where media renders in the webview — the `<audio>` element of the webview
// <video>), the real player lives outside the native backend, so the frontend // audio backend, on a desktop with no mpv; video never does since DR-235 — the
// HTML5 adapter reports DOM events back through these commands. The controller // real player lives outside the native backend, so the frontend adapter reports
// DOM events back through these commands. The controller
// re-emits them through the same PlayerStatusEvent pipeline the native backends // re-emits them through the same PlayerStatusEvent pipeline the native backends
// use, keeping the Rust controller the single source of truth and the frontend // use, keeping the Rust controller the single source of truth and the frontend
// player store fed from one place (playerEvents.ts) in both modes. // player store fed from one place (playerEvents.ts) in both modes.
/// Report an HTML5 <video> state change (playing/paused/loading/stopped/idle). /// Report a webview media element's state change (playing/paused/loading/stopped/idle).
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn player_report_state( pub async fn player_report_state(
@@ -348,7 +349,7 @@ pub async fn player_report_state(
Ok(()) Ok(())
} }
/// Report an HTML5 <video> position tick (seconds). The adapter should throttle /// Report a webview media element's position tick (seconds). The adapter should throttle
/// these to roughly match the native backends' ~250ms cadence. /// these to roughly match the native backends' ~250ms cadence.
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -362,7 +363,7 @@ pub async fn player_report_position(
Ok(()) Ok(())
} }
/// Report that the HTML5 <video> finished loading and knows its duration. /// Report that a webview media element finished loading and knows its duration.
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn player_report_media_loaded( pub async fn player_report_media_loaded(
-87
View File
@@ -1102,86 +1102,6 @@ fn specta_builder() -> Builder<tauri::Wry> {
]) ])
} }
/// Configure GStreamer (the media backend behind WebKitGTK's HTML5 `<video>`
/// element on Linux) to prefer hardware-accelerated VAAPI decoding when the
/// host provides it, falling back to software decoding otherwise.
///
/// All variables are only set if the user has not already exported them, so an
/// explicit override (e.g. forcing software decode for debugging) is respected.
/// They must be applied before WebKitGTK builds its GStreamer pipeline, hence the
/// call at the very top of `run()`.
#[cfg(target_os = "linux")]
fn enable_linux_hardware_video_decoding() {
// Boost the rank of the modern stateless VAAPI decoders (gst-plugins-bad
// `va` plugin) so GStreamer selects them ahead of the software decoders. The
// `MAX` rank wins decoder autoplugging when the hardware/driver supports the
// codec; unsupported codecs simply fall through to software.
let rank_overrides = "vah264dec:MAX,vah265dec:MAX,vavp9dec:MAX,vaav1dec:MAX,\
vampeg2dec:MAX,vavp8dec:MAX";
set_env_if_unset("GST_PLUGIN_FEATURE_RANK", rank_overrides);
// Ensure WebKit keeps GStreamer's hardware/DMABUF video path enabled. Setting
// this to "0" would force software decoding, so only default it to "1".
set_env_if_unset("WEBKIT_GST_ENABLE_HW_VIDEO_DECODER", "1");
info!("[INIT] Linux hardware video decoding (VAAPI) enabled where supported");
log_available_vaapi_decoders();
}
/// Probe (via `gst-inspect-1.0`, which ships with GStreamer) which VAAPI hardware
/// video decoders GStreamer can actually load on this host, and log the result so
/// it is clear at startup whether hardware decoding is genuinely available or
/// whether playback will fall back to software.
#[cfg(target_os = "linux")]
fn log_available_vaapi_decoders() {
const HW_DECODERS: &[&str] = &[
"vah264dec",
"vah265dec",
"vavp9dec",
"vaav1dec",
"vampeg2dec",
"vavp8dec",
];
let available: Vec<&str> = HW_DECODERS
.iter()
.copied()
.filter(|name| {
std::process::Command::new("gst-inspect-1.0")
.arg(name)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
})
.collect();
if available.is_empty() {
log::warn!(
"[INIT] No VAAPI hardware video decoders found via gst-inspect-1.0; \
video will use software decoding. Install the GStreamer 'va' plugin \
(gst-plugins-bad) and a VAAPI driver to enable hardware decoding."
);
} else {
info!(
"[INIT] VAAPI hardware video decoders available to GStreamer: {}",
available.join(", ")
);
}
}
#[cfg(target_os = "linux")]
fn set_env_if_unset(key: &str, value: &str) {
if std::env::var_os(key).is_none() {
// SAFETY: called once at startup before any threads that read the
// environment (WebKitGTK/GStreamer) are spawned.
std::env::set_var(key, value);
}
}
/// Cached thumbnails are handed to the webview as asset-protocol URLs by /// Cached thumbnails are handed to the webview as asset-protocol URLs by
/// `convertFileSrc` (`asset://localhost/…` on Linux/macOS, /// `convertFileSrc` (`asset://localhost/…` on Linux/macOS,
/// `http://asset.localhost/…` on Windows/Android). Tauri only answers that /// `http://asset.localhost/…` on Windows/Android). Tauri only answers that
@@ -1261,13 +1181,6 @@ pub fn run() {
// TRACES: UR-078 | DR-218 // TRACES: UR-078 | DR-218
crate::utils::diagnostics::install_panic_hook(); crate::utils::diagnostics::install_panic_hook();
// On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
// GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
// when available so video transcoding/decoding does not fall back to the CPU.
// These must be set before WebKitGTK initializes its GStreamer pipeline.
#[cfg(target_os = "linux")]
enable_linux_hardware_video_decoding();
// NOTE: TypeScript bindings are generated by the `export_typescript_bindings` // NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling // test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app // `.export()` here would try to write `../src/lib/api/bindings.ts` at app
+6 -8
View File
@@ -98,10 +98,9 @@ pub trait PlayerBackend: Send + Sync {
/// Set the active audio track by stream index /// Set the active audio track by stream index
/// ///
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately /// Overridden by both video backends, ExoPlayer and `MpvBackend`; the
/// does **not** override it — MPV is the audio-only backend here, so it keeps /// argument is a position among the file's audio tracks. A transcode is
/// this `not_implemented()` default and the Linux video path switches track by /// re-opened instead (`player_switch_audio_track`).
/// re-opening the stream instead (`player_switch_audio_track`).
/// ///
/// TRACES: UR-021 | IR-019, DR-024 /// TRACES: UR-021 | IR-019, DR-024
fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> { fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> {
@@ -111,10 +110,9 @@ pub trait PlayerBackend: Send + Sync {
/// Set the active subtitle track by stream index (None to disable subtitles) /// Set the active subtitle track by stream index (None to disable subtitles)
/// ///
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately /// Overridden by both video backends, ExoPlayer and `MpvBackend`; the
/// does **not** override it, so it keeps this `not_implemented()` default; /// argument is a position in the sideloaded subtitle list the play request
/// the Linux video path renders subtitles as `<track>` children of the /// carried.
/// WebKitGTK HTML5 `<video>` element and never calls this.
/// ///
/// TRACES: UR-020 | IR-018, DR-023 /// TRACES: UR-020 | IR-018, DR-023
fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> { fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> {
+9 -10
View File
@@ -85,9 +85,8 @@ pub enum PlayerStatusEvent {
remaining_seconds: u32, remaining_seconds: u32,
}, },
/// Time-based sleep timer expired: playback must stop. The backend stops /// Time-based sleep timer expired: playback must stop. The backend stops
/// its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the /// its own (MPV/ExoPlayer) playback; the frontend pauses the active adapter
/// webview outside the backend's control — the frontend pauses it on this /// on this event, which reaches a webview `<audio>` element where one plays.
/// event.
SleepTimerExpired, SleepTimerExpired,
/// Show next episode popup with countdown /// Show next episode popup with countdown
ShowNextEpisodePopup { ShowNextEpisodePopup {
@@ -152,9 +151,9 @@ pub enum PlayerStatusEvent {
/// media item locally), so the native side only signals intent here. /// media item locally), so the native side only signals intent here.
RemoteDisconnectRequested, RemoteDisconnectRequested,
/// Backend-originated control command targeting the active frontend player /// Backend-originated control command targeting the active frontend player
/// adapter (the HTML5 <video> that lives in the webview, which Rust cannot /// adapter — the webview `<audio>` element, which Rust cannot drive
/// drive directly). Emitted by control paths like the sleep timer, lockscreen, /// directly. Emitted by control paths like the sleep timer, lockscreen, or
/// or remote so they can pause/play/seek/stop the webview element. /// remote so they can pause/play/seek/stop it.
/// `playerEvents.ts` routes this to the active PlayerAdapter via the facade. /// `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
ControlCommand { ControlCommand {
/// One of: "play", "pause", "stop", "seek". /// One of: "play", "pause", "stop", "seek".
@@ -164,10 +163,10 @@ pub enum PlayerStatusEvent {
}, },
/// Ask the frontend webview `<audio>` element to load and play a stream. /// Ask the frontend webview `<audio>` element to load and play a stream.
/// ///
/// Emitted by `WebviewAudioBackend` on platforms with no native audio /// Emitted by `WebviewAudioBackend` on a desktop with no native audio
/// backend (e.g. Windows): audio-only playback is rendered by an `<audio>` /// backend (none that ships: Linux and Windows have mpv): audio-only
/// element in the webview, mirroring how all video already renders through /// playback is rendered by an `<audio>` element in the webview. The element
/// the webview `<video>`. The element then reports its state/position back /// then reports its state/position back
/// through the `player_report_*` commands, so the Rust controller stays the /// through the `player_report_*` commands, so the Rust controller stays the
/// single source of truth. Subsequent play/pause/seek/stop reach the element /// single source of truth. Subsequent play/pause/seek/stop reach the element
/// via `ControlCommand`. /// via `ControlCommand`.
+4
View File
@@ -0,0 +1,4 @@
WEBVTT
00:00.000 --> 00:02.000
fixture subtitle
Binary file not shown.
+5 -18
View File
@@ -140,7 +140,7 @@ pub struct Capabilities {
pub audio_track_switching: bool, pub audio_track_switching: bool,
/// A *server-side transcode* can be seeked without re-opening the stream. /// A *server-side transcode* can be seeked without re-opening the stream.
/// ///
/// True for hls.js, which seeks within the VOD playlist it is handed and /// True for ExoPlayer, which seeks within the VOD playlist it is handed and
/// lets the server catch up. False for mpv, whose HLS demuxer cannot make /// lets the server catch up. False for mpv, whose HLS demuxer cannot make
/// the server transcode from a new offset. /// the server transcode from a new offset.
/// ///
@@ -173,9 +173,8 @@ impl Capabilities {
/// ExoPlayer. /// ExoPlayer.
/// ///
/// **Can** seek a transcode in place. It is a full HLS client, so like /// **Can** seek a transcode in place. It is a full HLS client, so it seeks
/// hls.js it seeks within the VOD playlist it was handed and lets the /// within the VOD playlist it was handed and lets the server catch up. Grouping it with mpv as "a native engine" gets this
/// server catch up. Grouping it with mpv as "a native engine" gets this
/// exactly backwards — being native is not the property that matters here, /// exactly backwards — being native is not the property that matters here,
/// speaking HLS is, and that is the whole reason this is declared per /// speaking HLS is, and that is the whole reason this is declared per
/// engine rather than inferred from a category. /// engine rather than inferred from a category.
@@ -188,18 +187,6 @@ impl Capabilities {
seeks_transcoded_in_place: true, seeks_transcoded_in_place: true,
} }
} }
/// An engine that renders through the webview element, where hls.js seeks
/// within the playlist it was handed.
pub fn webview() -> Self {
Self {
video: true,
audio_settings: false,
subtitle_switching: true,
audio_track_switching: false,
seeks_transcoded_in_place: true,
}
}
} }
/// A request to present an item. /// A request to present an item.
@@ -242,7 +229,7 @@ impl OpenRequest {
/// Anything that can present media. /// Anything that can present media.
/// ///
/// Implementations: `MpvPlayer` (Linux/Windows), `ExoPlayerPlayer` (Android), /// Implementations: `MpvPlayer` (Linux/Windows), `ExoPlayerPlayer` (Android),
/// `WebviewPlayer` (HTML5 element), and `FakePlayer` for tests. Every one of /// and `FakePlayer` for tests. Every one of
/// them must pass [`super::conformance`]. /// them must pass [`super::conformance`].
pub trait MediaPlayer: Send { pub trait MediaPlayer: Send {
/// Present `req.selection`, beginning at `req.start`. /// Present `req.selection`, beginning at `req.start`.
@@ -267,7 +254,7 @@ pub trait MediaPlayer: Send {
/// Seek to an absolute position on the item's own timeline. /// Seek to an absolute position on the item's own timeline.
/// ///
/// Whether that is an in-place seek or a re-open of the stream is the /// Whether that is an in-place seek or a re-open of the stream is the
/// engine's business: hls.js seeks within a VOD playlist, mpv's HLS demuxer /// engine's business: ExoPlayer seeks within a VOD playlist, mpv's HLS demuxer
/// cannot make a server transcode from a new offset. Callers state the /// cannot make a server transcode from a new offset. Callers state the
/// destination and nothing else. /// destination and nothing else.
fn seek(&mut self, to: Duration) -> Result<(), PlayerError>; fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
+12 -39
View File
@@ -19,6 +19,8 @@ pub mod media_player;
pub mod mpv_command; pub mod mpv_command;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod mpv_player; pub mod mpv_player;
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub mod mpv_tracks;
pub mod queue; pub mod queue;
pub mod seek; pub mod seek;
pub mod session; pub mod session;
@@ -322,9 +324,10 @@ pub struct PlayerController {
background_audio_base: Arc<Mutex<f64>>, background_audio_base: Arc<Mutex<f64>>,
// True while a background-audio handoff owns playback: the native audio // True while a background-audio handoff owns playback: the native audio
// player is the real player and the webview <video> has been torn down. // player is the real player and the video has been replaced.
// //
// The teardown is what makes this necessary. It fires a DOM `pause` that the // The teardown is what made this necessary (when a webview <video> was
// torn down). It fires a DOM `pause` that the
// frontend reports like any other, which would otherwise leave the controller // frontend reports like any other, which would otherwise leave the controller
// believing webview media is still active — aiming lockscreen transport at an // believing webview media is still active — aiming lockscreen transport at an
// element that no longer exists (see `is_html5_active`). // element that no longer exists (see `is_html5_active`).
@@ -341,7 +344,8 @@ pub struct PlayerController {
// TRACES: UR-040 | DR-129 // TRACES: UR-040 | DR-129
stream_resume: Arc<Mutex<stream_end::ResumeTracker>>, stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
// Last state reported by a webview-rendered HTML5 <video>/<audio> element. // Last state reported by a webview-rendered `<audio>` element (the webview
// audio backend; video never renders in the webview since DR-235).
// //
// Webview-rendered media is played by an element the native backend cannot // Webview-rendered media is played by an element the native backend cannot
// reach, so the backend's own state() says nothing about it. Tracking the // reach, so the backend's own state() says nothing about it. Tracking the
@@ -421,7 +425,7 @@ impl PlayerController {
/// Configure the media repository used for next-episode lookups. /// Configure the media repository used for next-episode lookups.
/// ///
/// The Android ExoPlayer ended-callback calls `on_playback_ended` with no /// The Android ExoPlayer ended-callback calls `on_playback_ended` with no
/// repository handle (unlike the Linux HTML5 path, which passes one per /// repository handle (unlike a frontend-reported end, which passes one per
/// call), so the controller needs a repository of its own or episode /// call), so the controller needs a repository of its own or episode
/// autoplay silently decides Stop. /// autoplay silently decides Stop.
pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) { pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
@@ -541,37 +545,6 @@ impl PlayerController {
Ok(()) Ok(())
} }
/// Set the current queue item without loading it into the playback backend.
///
/// Used on platforms where video is rendered outside the native backend
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
/// item, but MPV must not start a redundant decode for it.
///
/// Not gated to Linux. Its caller stopped being a `#[cfg]` branch and became
/// a runtime question — "does this renderer draw the picture?" — so the
/// `else` arm is compiled on every platform even where it never runs. The
/// gate outliving its caller broke the Android build outright, which went
/// unnoticed because nothing built for Android afterwards.
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
debug!(
"[PlayerController] set_current_item (no backend load): {}",
item.title
);
self.reset_autoplay_count();
// A different item is current; the last one's reported position must not
// be reported against it. This path is how webview-rendered video is
// queued (no backend load at all), so it is exactly where a stale
// reading would otherwise survive.
// TRACES: UR-005 | DR-178
self.clear_reported_time();
let mut queue = self.queue.lock_safe();
queue.set_queue(vec![item], 0);
Ok(())
}
/// Load and play an item without modifying the queue /// Load and play an item without modifying the queue
/// Use this when the queue is already set up and you just want to play a specific item from it /// Use this when the queue is already set up and you just want to play a specific item from it
pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> { pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
@@ -596,7 +569,7 @@ impl PlayerController {
// intermittent. // intermittent.
// //
// The webview re-establishes its own authority the moment an element // The webview re-establishes its own authority the moment an element
// reports again, so nothing is lost on the HTML5 path: this is the same // reports again, so nothing is lost on the webview path: this is the same
// "element is gone" semantics as the "stopped"/"idle" report, applied at // "element is gone" semantics as the "stopped"/"idle" report, applied at
// the point where we can know it directly. // the point where we can know it directly.
// //
@@ -720,7 +693,7 @@ impl PlayerController {
Ok(()) Ok(())
} }
/// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real /// True while webview-rendered media (a webview `<audio>`) is the real
/// player, so transport must be routed to it rather than the native backend. /// player, so transport must be routed to it rather than the native backend.
/// ///
/// TRACES: UR-005 | DR-097 /// TRACES: UR-005 | DR-097
@@ -772,7 +745,7 @@ impl PlayerController {
/// Toggle play/pause. /// Toggle play/pause.
/// ///
/// The decision is made HERE, from authoritative state — the reported webview /// The decision is made HERE, from authoritative state — the reported webview
/// state for HTML5-rendered media, or the native backend's state otherwise. /// state for webview-rendered audio, or the native backend's state otherwise.
/// The frontend must never decide this from the DOM (see DR-097). /// The frontend must never decide this from the DOM (see DR-097).
/// ///
/// TRACES: UR-005 | DR-097 /// TRACES: UR-005 | DR-097
@@ -1049,7 +1022,7 @@ impl PlayerController {
/// truncation comparison. `position()` alone answers for exactly one of the /// truncation comparison. `position()` alone answers for exactly one of the
/// three ways this app plays media, and reads 0 for the other two: /// three ways this app plays media, and reads 0 for the other two:
/// ///
/// - **Webview `<video>`/`<audio>`**: nothing is loaded into the native /// - **Webview `<audio>`**: nothing is loaded into the native
/// backend, so its position is a permanent 0. The element's own reports are /// backend, so its position is a permanent 0. The element's own reports are
/// the only reading there is. /// the only reading there is.
/// - **Background-audio handoff**: the audio-only stream's zero is the /// - **Background-audio handoff**: the audio-only stream's zero is the
+41 -2
View File
@@ -384,8 +384,8 @@ impl MpvBackend {
// StateChanged rather than tracking playback itself, per the // StateChanged rather than tracking playback itself, per the
// one-directional state rule. Unobserved, the event never came and // one-directional state rule. Unobserved, the event never came and
// the button never moved. Invisible until native video shipped, // the button never moved. Invisible until native video shipped,
// because the webview <video> element's own DOM events drove that // because the (since deleted) webview <video> element's own DOM
// control on Linux. // events drove that control on Linux.
// //
// TRACES: UR-005 | DR-239 // TRACES: UR-005 | DR-239
ev_ctx ev_ctx
@@ -696,6 +696,15 @@ impl PlayerBackend for MpvBackend {
// TRACES: UR-040, UR-005 | DR-253 // TRACES: UR-040, UR-005 | DR-253
*self.pending_seek.lock_safe() = None; *self.pending_seek.lock_safe() = None;
// The item's own sideloaded subtitles, none shown, and its default audio
// track — whatever the previous item had chosen. Only video carries
// subtitles; for audio this just clears the last item's.
// TRACES: UR-020, UR-021 | DR-023, DR-024, DR-235
let subtitle_urls: Vec<&str> = media.subtitles.iter().map(|t| t.url.as_str()).collect();
super::mpv_tracks::prepare_load(&self.mpv, &subtitle_urls).map_err(|e| PlayerError {
message: format!("Failed to prepare tracks: {e}"),
})?;
// Load the media file. Through `mpv_command::command`, never // Load the media file. Through `mpv_command::command`, never
// `Mpv::command`: the URL carries server-controlled text. // `Mpv::command`: the URL carries server-controlled text.
// TRACES: UR-003, UR-004 | DR-298 // TRACES: UR-003, UR-004 | DR-298
@@ -858,6 +867,36 @@ impl PlayerBackend for MpvBackend {
state.volume state.volume
} }
/// `stream_index` is a *position*: the n-th audio track of the file, the
/// same meaning ExoPlayer gives it (`player_switch_audio_track` passes the
/// array index). Only reached for a direct play/stream — a transcode carries
/// one track and is re-opened instead.
///
/// TRACES: UR-021 | IR-019, DR-024, DR-235
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
let position = usize::try_from(stream_index).map_err(|_| PlayerError {
message: format!("Invalid audio track position {stream_index}"),
})?;
super::mpv_tracks::select_audio(&self.mpv, position)
.map_err(|message| PlayerError { message })
}
/// `stream_index` is the position in the sideloaded subtitle list the play
/// request carried (`nativeSubtitleArrayIndex`), `None` to hide subtitles.
///
/// TRACES: UR-020 | IR-018, DR-023, DR-235
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
let position = stream_index
.map(|i| {
usize::try_from(i).map_err(|_| PlayerError {
message: format!("Invalid subtitle position {i}"),
})
})
.transpose()?;
super::mpv_tracks::select_subtitle(&self.mpv, position)
.map_err(|message| PlayerError { message })
}
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> { fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
info!("[MpvBackend] Applying audio settings"); info!("[MpvBackend] Applying audio settings");
self.audio_settings = settings.clone(); self.audio_settings = settings.clone();
+257
View File
@@ -0,0 +1,257 @@
//! Subtitle and audio-track selection on mpv, by *position*, the way the
//! frontend asks for it.
//!
//! Until DR-235 mpv never drew video, so it never had to: subtitles were
//! `<track>` children of the webview `<video>` and an audio track change
//! re-opened the stream. With mpv the only desktop video renderer, it has to
//! answer the same two calls ExoPlayer does, with the same meaning:
//!
//! - **Subtitles** are the sideloaded WebVTT list the play request carries
//! (`MediaItem::subtitles`), and `set_subtitle_track(n)` selects the *n-th of
//! those* — the position the frontend computes with `nativeSubtitleArrayIndex`.
//! They reach mpv as external files queued on `sub-files` before the load, and
//! selection starts off, because the menu opens on "Off".
//! - **Audio** `set_audio_track(n)` selects the n-th audio track of the file —
//! the position in the item's audio streams, which is file order. Only a direct
//! play/stream carries every track; a transcode is re-opened instead
//! (`AudioTrackSwitchStrategy`).
//!
//! mpv's own track ids are not positions: they count every track of a type,
//! embedded before external, from 1. So a position is always resolved against
//! the live `track-list`.
//!
//! TRACES: UR-020, UR-021 | DR-023, DR-024, DR-235
use libmpv::Mpv;
use super::mpv_command;
/// One entry of mpv's `track-list`, reduced to what selection needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackInfo {
pub id: i64,
/// "video", "audio" or "sub".
pub kind: String,
pub external: bool,
}
/// The mpv id of the `position`-th track of `kind` (optionally only external or
/// only embedded ones), in `track-list` order.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
pub fn nth_track_id(
tracks: &[TrackInfo],
kind: &str,
external: Option<bool>,
position: usize,
) -> Option<i64> {
tracks
.iter()
.filter(|t| t.kind == kind && external.is_none_or(|e| t.external == e))
.nth(position)
.map(|t| t.id)
}
/// Read the current `track-list`.
pub fn track_list(mpv: &Mpv) -> Vec<TrackInfo> {
let count: i64 = mpv.get_property("track-list/count").unwrap_or(0);
(0..count)
.filter_map(|i| {
let id: i64 = mpv.get_property(&format!("track-list/{i}/id")).ok()?;
let kind: String = mpv.get_property(&format!("track-list/{i}/type")).ok()?;
let external: bool = mpv
.get_property(&format!("track-list/{i}/external"))
.unwrap_or(false);
Some(TrackInfo { id, kind, external })
})
.collect()
}
/// Prepare the next `loadfile`: these subtitle files will be loaded with it, no
/// subtitle is shown, and the file's default audio track plays.
///
/// `sid`/`aid` set while idle become the options the next file opens with, so
/// a track chosen for the previous item cannot leak into this one.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
pub fn prepare_load(mpv: &Mpv, subtitle_urls: &[&str]) -> Result<(), String> {
mpv_command::command(mpv, &["change-list", "sub-files", "clr", ""])?;
for url in subtitle_urls {
// `append` adds one item without splitting on the list separator, which
// a URL's `:` would otherwise trip. Through the argv form (DR-298), so the
// URL is never parsed as command text.
mpv_command::command(mpv, &["change-list", "sub-files", "append", url])?;
}
mpv.set_property("sid", "no")
.map_err(|e| format!("could not set sid=no: {e:?}"))?;
mpv.set_property("aid", "auto")
.map_err(|e| format!("could not set aid=auto: {e:?}"))?;
Ok(())
}
/// Show the `position`-th sideloaded subtitle, or none.
///
/// TRACES: UR-020 | DR-023 | UT-275
pub fn select_subtitle(mpv: &Mpv, position: Option<usize>) -> Result<(), String> {
let Some(position) = position else {
return mpv
.set_property("sid", "no")
.map_err(|e| format!("could not set sid=no: {e:?}"));
};
let id = nth_track_id(&track_list(mpv), "sub", Some(true), position)
.ok_or_else(|| format!("no sideloaded subtitle at position {position}"))?;
mpv.set_property("sid", id)
.map_err(|e| format!("could not set sid={id}: {e:?}"))
}
/// Play the `position`-th audio track of the file.
///
/// TRACES: UR-021 | DR-024 | UT-275
pub fn select_audio(mpv: &Mpv, position: usize) -> Result<(), String> {
let id = nth_track_id(&track_list(mpv), "audio", Some(false), position)
.ok_or_else(|| format!("no audio track at position {position}"))?;
mpv.set_property("aid", id)
.map_err(|e| format!("could not set aid={id}: {e:?}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
fn fixture(name: &str) -> String {
format!("{}/src/player/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
}
fn track(id: i64, kind: &str, external: bool) -> TrackInfo {
TrackInfo {
id,
kind: kind.to_string(),
external,
}
}
/// mpv numbers each type from 1, embedded before external, so a position in
/// the sideloaded list is not an id. The file here has two embedded
/// subtitles; the first sideloaded one is mpv's sub 3.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
#[test]
fn positions_resolve_to_mpv_ids_per_kind() {
let tracks = [
track(1, "video", false),
track(1, "audio", false),
track(2, "audio", false),
track(1, "sub", false),
track(2, "sub", false),
track(3, "sub", true),
track(4, "sub", true),
];
assert_eq!(nth_track_id(&tracks, "sub", Some(true), 0), Some(3));
assert_eq!(nth_track_id(&tracks, "sub", Some(true), 1), Some(4));
assert_eq!(nth_track_id(&tracks, "sub", Some(true), 2), None);
assert_eq!(nth_track_id(&tracks, "audio", Some(false), 1), Some(2));
assert_eq!(nth_track_id(&tracks, "audio", None, 0), Some(1));
}
fn loaded_mpv(subs: &[&str]) -> Mpv {
let mpv = Mpv::new().expect("libmpv must be available to run the player tests");
mpv.set_property("ao", "null").unwrap();
mpv.set_property("vo", "null").unwrap();
mpv.set_property("pause", true).unwrap();
prepare_load(&mpv, subs).unwrap();
mpv_command::command(&mpv, &["loadfile", &fixture("two-audio-tracks.mkv")]).unwrap();
// Video, two audio tracks, and one track per sideloaded subtitle.
let expected = 3 + subs.len() as i64;
let deadline = Instant::now() + Duration::from_secs(10);
while mpv.get_property::<i64>("track-list/count").unwrap_or(0) < expected {
assert!(
Instant::now() < deadline,
"the fixture never finished loading"
);
std::thread::sleep(Duration::from_millis(20));
}
mpv
}
/// Against a real file: the sideloaded subtitle arrives, starts hidden, and
/// is shown and hidden by position.
///
/// TRACES: UR-020 | DR-023 | UT-275
#[test]
fn a_sideloaded_subtitle_starts_off_and_is_selected_by_position() {
let vtt = fixture("sub.vtt");
let mpv = loaded_mpv(&[&vtt]);
assert_eq!(mpv.get_property::<String>("sid").unwrap(), "no");
select_subtitle(&mpv, Some(0)).unwrap();
let expected = nth_track_id(&track_list(&mpv), "sub", Some(true), 0).unwrap();
assert_eq!(mpv.get_property::<i64>("sid").unwrap(), expected);
select_subtitle(&mpv, None).unwrap();
assert_eq!(mpv.get_property::<String>("sid").unwrap(), "no");
assert!(select_subtitle(&mpv, Some(5)).is_err());
}
/// Against a real file with two audio tracks: position 1 is the second one.
///
/// TRACES: UR-021 | DR-024 | UT-275
#[test]
fn an_audio_track_is_selected_by_position_in_the_file() {
let mpv = loaded_mpv(&[]);
select_audio(&mpv, 1).unwrap();
assert_eq!(mpv.get_property::<i64>("aid").unwrap(), 2);
select_audio(&mpv, 0).unwrap();
assert_eq!(mpv.get_property::<i64>("aid").unwrap(), 1);
assert!(select_audio(&mpv, 2).is_err());
}
/// The next item opens with no subtitle and its own default audio, whatever
/// the last one had chosen, and with only its own subtitle files.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
#[test]
fn preparing_a_load_forgets_the_previous_items_choices() {
let vtt = fixture("sub.vtt");
let mpv = loaded_mpv(&[&vtt]);
select_subtitle(&mpv, Some(0)).unwrap();
select_audio(&mpv, 1).unwrap();
// The next item: no subtitles of its own this time.
prepare_load(&mpv, &[]).unwrap();
mpv_command::command(
&mpv,
&["loadfile", &fixture("two-audio-tracks.mkv"), "replace"],
)
.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let tracks = track_list(&mpv);
let settled = tracks.len() == 3 && mpv.get_property::<i64>("aid").is_ok();
if settled {
break;
}
assert!(
Instant::now() < deadline,
"the second load never settled: {tracks:?}"
);
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(mpv.get_property::<String>("sid").unwrap(), "no");
assert_eq!(
mpv.get_property::<i64>("aid").unwrap(),
1,
"default audio again"
);
assert_eq!(
nth_track_id(&track_list(&mpv), "sub", None, 0),
None,
"the previous item's subtitle file came along"
);
}
}
+1 -2
View File
@@ -2,8 +2,7 @@
//! //!
//! Three things need this and must agree: the mpv backend (which has to be //! Three things need this and must agree: the mpv backend (which has to be
//! configured for video *at construction*, before anything plays), the video //! configured for video *at construction*, before anything plays), the video
//! surface (which has nothing to draw otherwise), and `get_player_status` //! surface (which has nothing to draw otherwise), and the device profile.
//! (which tells the frontend whether to use a webview `<video>` element).
//! //!
//! It is a function rather than three `env::var` checks for the reason this //! It is a function rather than three `env::var` checks for the reason this
//! codebase keeps rediscovering: a capability answered in several places is a //! codebase keeps rediscovering: a capability answered in several places is a
+33 -122
View File
@@ -5,17 +5,18 @@
//! [`VideoSeekStrategy`] into a concrete backend/frontend action. //! [`VideoSeekStrategy`] into a concrete backend/frontend action.
/// Seek strategy for video playback, derived from a stream's characteristics. /// Seek strategy for video playback, derived from a stream's characteristics.
///
/// Every video renderer is a native backend (mpv, ExoPlayer) since the webview
/// `<video>` path was deleted (DR-235), so the backend performs every seek; what
/// remains to decide is whether it can move the stream in place.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoSeekStrategy { pub enum VideoSeekStrategy {
/// Local file - always use native seek on backend /// Local file - always use native seek on backend
LocalNativeSeek, LocalNativeSeek,
/// HLS or direct stream with HTML5 - frontend handles seek, skip backend /// Seekable where it sits (direct play/stream, or a transcode the engine
Html5NativeSeek, /// can move) - backend seeks
/// HLS or direct stream with native backend - backend handles seek
BackendNativeSeek, BackendNativeSeek,
/// Transcoded non-HLS with HTML5 - reload stream, frontend handles /// A transcode the engine cannot move - re-open the stream at the target
Html5ReloadStream,
/// Transcoded non-HLS with native backend - reload stream, backend handles
BackendReloadStream, BackendReloadStream,
} }
@@ -29,12 +30,10 @@ pub enum VideoSeekStrategy {
/// can seek a server-side transcode without re-opening it. Declared by the /// can seek a server-side transcode without re-opening it. Declared by the
/// engine via `Capabilities`, never inferred from the URL or the renderer. /// engine via `Capabilities`, never inferred from the URL or the renderer.
/// * `needs_transcoding` - Whether the content needs transcoding /// * `needs_transcoding` - Whether the content needs transcoding
/// * `use_html5` - Whether frontend is using HTML5 video element
pub fn determine_video_seek_strategy( pub fn determine_video_seek_strategy(
is_local: bool, is_local: bool,
seeks_transcoded_in_place: bool, seeks_transcoded_in_place: bool,
needs_transcoding: bool, needs_transcoding: bool,
use_html5: bool,
) -> VideoSeekStrategy { ) -> VideoSeekStrategy {
// Local files always support native seeking via backend // Local files always support native seeking via backend
if is_local { if is_local {
@@ -42,39 +41,20 @@ pub fn determine_video_seek_strategy(
} }
// A server-side transcode is produced *from* `StartTimeTicks`, so where the // A server-side transcode is produced *from* `StartTimeTicks`, so where the
// seek lands is a property of the request, not of the stream in hand. // seek lands is a property of the request, not of the stream in hand. mpv's
// // HLS demuxer cannot make Jellyfin transcode from a new offset, so for it a
// hls.js is the exception: handed a VOD playlist it seeks within it and lets
// the server catch up segment by segment. mpv's HLS demuxer cannot make
// Jellyfin transcode from a new offset, so for the native backend a
// transcoded seek must re-negotiate the stream regardless of container. // transcoded seek must re-negotiate the stream regardless of container.
// //
// Before native video shipped, `use_html5` was always true for HLS and the // Whether a transcode can be seeked in place is a property of the engine,
// native+HLS+transcode cell was unreachable, which is why `is_hls` alone // and the engine states it. This used to be inferred from `is_hls`, which
// used to be a safe proxy for "seekable in place". It no longer is: turning // held only while hls.js was the sole HLS renderer — and stopped holding the
// native video on routed every transcoded seek into a backend seek that // moment mpv became one (DR-238).
// silently does nothing, and presents as "resume does not work". if needs_transcoding && !seeks_transcoded_in_place {
if needs_transcoding { return VideoSeekStrategy::BackendReloadStream;
// Whether a transcode can be seeked in place is a property of the
// engine, and the engine states it. This used to be inferred from
// `is_hls`, which held only while hls.js was the sole HLS renderer —
// and stopped holding the moment mpv became one (DR-238).
return match (seeks_transcoded_in_place, use_html5) {
(true, true) => VideoSeekStrategy::Html5NativeSeek,
(true, false) => VideoSeekStrategy::BackendNativeSeek,
(false, true) => VideoSeekStrategy::Html5ReloadStream,
(false, false) => VideoSeekStrategy::BackendReloadStream,
};
} }
// Direct play and direct stream are seekable where they sit. // Direct play, direct stream, or a transcode the engine can move.
if use_html5 {
// The frontend seeks via videoElement.currentTime; calling backend.seek()
// would move a player that is not the one rendering.
VideoSeekStrategy::Html5NativeSeek
} else {
VideoSeekStrategy::BackendNativeSeek VideoSeekStrategy::BackendNativeSeek
}
} }
// The four items below are consumed by the Android MediaSessionHandler; on other // The four items below are consumed by the Android MediaSessionHandler; on other
@@ -224,114 +204,45 @@ mod tests {
#[test] #[test]
fn test_seek_strategy_local_file() { fn test_seek_strategy_local_file() {
// Local files always use native backend seek regardless of other flags // Local files always use native backend seek regardless of other flags
for (in_place, transcode) in [(false, false), (true, true), (false, true)] {
assert_eq!( assert_eq!(
determine_video_seek_strategy(true, false, false, false), determine_video_seek_strategy(true, in_place, transcode),
VideoSeekStrategy::LocalNativeSeek
);
assert_eq!(
determine_video_seek_strategy(true, false, false, true),
VideoSeekStrategy::LocalNativeSeek
);
assert_eq!(
determine_video_seek_strategy(true, true, true, true),
VideoSeekStrategy::LocalNativeSeek VideoSeekStrategy::LocalNativeSeek
); );
} }
}
/// Non-transcoded streams seek in place regardless of the engine's /// Direct play and direct stream seek in place, whatever the engine's
/// transcode ability, which only applies to transcodes. /// transcode ability — that only applies to transcodes.
#[test] #[test]
fn test_seek_strategy_direct_stream() { fn test_seek_strategy_direct_stream() {
// HTML5 renders, so the frontend seeks the element for in_place in [false, true] {
assert_eq!( assert_eq!(
determine_video_seek_strategy(false, true, false, true), determine_video_seek_strategy(false, in_place, false),
VideoSeekStrategy::Html5NativeSeek
);
// The native engine renders, so it seeks
assert_eq!(
determine_video_seek_strategy(false, true, false, false),
VideoSeekStrategy::BackendNativeSeek VideoSeekStrategy::BackendNativeSeek
); );
// A transcode an engine says it can move: seek in place }
assert_eq!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
);
} }
/// A server-side transcode cannot be seeked by the native backend. /// A server-side transcode is re-opened by an engine that cannot move it,
/// and seeked in place by one that says it can.
/// ///
/// Jellyfin produces a transcode from `StartTimeTicks`; hls.js can seek /// Jellyfin produces a transcode from `StartTimeTicks`; mpv's HLS demuxer
/// within the VOD playlist it is handed, but mpv's HLS demuxer cannot make /// cannot make the server transcode from a new offset, so the stream has to
/// the server transcode from a new offset, so the stream has to be /// be re-negotiated. Inferring this from the container once routed every
/// re-negotiated. Before native video existed, `use_html5` was always true /// transcoded seek into a native seek that silently does nothing, which
/// for HLS and this case was unreachable — turning native video on routed /// presents as "resume does not work".
/// every transcoded seek into a native seek that silently does nothing,
/// which presents as "resume does not work".
/// ///
/// TRACES: UR-040 | DR-238, DR-246 | UT-217 /// TRACES: UR-040 | DR-238, DR-246 | UT-217
#[test] #[test]
fn test_transcoded_seek_follows_the_engines_declared_ability() { fn test_transcoded_seek_follows_the_engines_declared_ability() {
// An engine that cannot move a server-side transcode re-opens it,
// whichever side is rendering.
assert_eq!( assert_eq!(
determine_video_seek_strategy(false, false, true, false), determine_video_seek_strategy(false, false, true),
VideoSeekStrategy::BackendReloadStream VideoSeekStrategy::BackendReloadStream
); );
assert_eq!( assert_eq!(
determine_video_seek_strategy(false, false, true, true), determine_video_seek_strategy(false, true, true),
VideoSeekStrategy::Html5ReloadStream
);
// hls.js can, and says so, so it seeks in place.
assert_eq!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
);
// The container the stream arrives in no longer decides anything: the
// same declared ability gives the same answer on the native side.
assert_eq!(
determine_video_seek_strategy(false, true, true, false),
VideoSeekStrategy::BackendNativeSeek VideoSeekStrategy::BackendNativeSeek
); );
} }
/// Test video seek strategy for direct play (non-transcoded) streams
#[test]
fn test_seek_strategy_direct_play() {
// Direct play with HTML5 - frontend handles seek
assert_eq!(
determine_video_seek_strategy(false, false, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// Direct play with native backend - backend handles seek
assert_eq!(
determine_video_seek_strategy(false, false, false, false),
VideoSeekStrategy::BackendNativeSeek
);
}
/// Test video seek strategy for transcoded non-HLS streams
#[test]
fn test_seek_strategy_transcoded_non_hls() {
// Transcoded non-HLS with HTML5 - need to reload stream, frontend handles
assert_eq!(
determine_video_seek_strategy(false, false, true, true),
VideoSeekStrategy::Html5ReloadStream
);
// Transcoded non-HLS with native backend - need to reload stream, backend handles
assert_eq!(
determine_video_seek_strategy(false, false, true, false),
VideoSeekStrategy::BackendReloadStream
);
}
/// Test the specific bug fix: HLS + HTML5 should NOT call backend seek
/// This was the bug causing "Raw(-10)" errors
#[test]
fn test_hls_html5_does_not_use_backend_seek() {
let strategy = determine_video_seek_strategy(false, true, false, true);
// Should be Html5NativeSeek, NOT BackendNativeSeek
assert_eq!(strategy, VideoSeekStrategy::Html5NativeSeek);
assert_ne!(strategy, VideoSeekStrategy::BackendNativeSeek);
}
} }
+4 -32
View File
@@ -13,10 +13,6 @@
/// How a request to change audio track has to be carried out. /// How a request to change audio track has to be carried out.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioTrackSwitchStrategy { pub enum AudioTrackSwitchStrategy {
/// Re-open the stream pinned to the chosen track; the frontend reloads its
/// `<video>` element. An HTML5 element cannot select an audio track at all,
/// so this holds whether or not the current stream is a transcode.
Html5ReloadStream,
/// Re-open the stream pinned to the chosen track; the backend reloads /// Re-open the stream pinned to the chosen track; the backend reloads
/// itself and restores the position. /// itself and restores the position.
BackendReloadStream, BackendReloadStream,
@@ -29,17 +25,9 @@ pub enum AudioTrackSwitchStrategy {
/// # Arguments /// # Arguments
/// * `needs_transcoding` - Whether the stream now playing is a server-side /// * `needs_transcoding` - Whether the stream now playing is a server-side
/// transcode, which carries exactly the one audio track it was built around. /// transcode, which carries exactly the one audio track it was built around.
/// * `use_html5` - Whether the frontend `<video>` element is rendering.
/// ///
/// TRACES: UR-021 | IR-019, DR-024, DR-258 | UT-232 /// TRACES: UR-021 | IR-019, DR-024, DR-258 | UT-232
pub fn determine_audio_track_switch_strategy( pub fn determine_audio_track_switch_strategy(needs_transcoding: bool) -> AudioTrackSwitchStrategy {
needs_transcoding: bool,
use_html5: bool,
) -> AudioTrackSwitchStrategy {
if use_html5 {
return AudioTrackSwitchStrategy::Html5ReloadStream;
}
if needs_transcoding { if needs_transcoding {
AudioTrackSwitchStrategy::BackendReloadStream AudioTrackSwitchStrategy::BackendReloadStream
} else { } else {
@@ -86,8 +74,7 @@ mod tests {
assert_eq!(resume_position(None, 1337.5), 1337.5); assert_eq!(resume_position(None, 1337.5), 1337.5);
} }
/// The HTML5 path does have an element and its clock is the honest answer /// A caller that does know the position is believed.
/// there, so what the caller supplies wins.
#[test] #[test]
fn a_caller_that_knows_its_position_is_believed() { fn a_caller_that_knows_its_position_is_believed() {
assert_eq!(resume_position(Some(42.0), 1337.5), 42.0); assert_eq!(resume_position(Some(42.0), 1337.5), 42.0);
@@ -123,7 +110,7 @@ mod tests {
#[test] #[test]
fn a_transcode_is_re_opened_because_it_carries_only_one_track() { fn a_transcode_is_re_opened_because_it_carries_only_one_track() {
assert_eq!( assert_eq!(
determine_audio_track_switch_strategy(true, false), determine_audio_track_switch_strategy(true),
AudioTrackSwitchStrategy::BackendReloadStream AudioTrackSwitchStrategy::BackendReloadStream
); );
} }
@@ -133,23 +120,8 @@ mod tests {
#[test] #[test]
fn a_direct_play_switches_in_place() { fn a_direct_play_switches_in_place() {
assert_eq!( assert_eq!(
determine_audio_track_switch_strategy(false, false), determine_audio_track_switch_strategy(false),
AudioTrackSwitchStrategy::BackendSelectInPlace AudioTrackSwitchStrategy::BackendSelectInPlace
); );
} }
/// An HTML5 `<video>` element has no track-selection API, so it reloads
/// either way. This is the path that already worked, and it must keep
/// working: the fix is about the native side only.
#[test]
fn html5_always_reloads_because_the_element_cannot_select() {
assert_eq!(
determine_audio_track_switch_strategy(true, true),
AudioTrackSwitchStrategy::Html5ReloadStream
);
assert_eq!(
determine_audio_track_switch_strategy(false, true),
AudioTrackSwitchStrategy::Html5ReloadStream
);
}
} }
+11 -18
View File
@@ -1,25 +1,18 @@
//! Webview audio backend — audio-only playback for platforms without a native //! Webview audio backend — audio-only playback for a desktop without a native
//! audio backend (currently Windows). //! audio backend. No shipped platform uses it: Linux and Windows play through
//! mpv, Android through ExoPlayer.
//! //!
//! ## Why this exists //! Instead of decoding audio itself, it hands the stream URL to a frontend
//! All *video* already renders through the webview HTML5 `<video>` element on //! `<audio>` element via a `WebviewAudioLoad` event and then drives
//! every platform (see `VideoPlayer.svelte`); libmpv/ExoPlayer only ever drive //! play/pause/seek/stop through `ControlCommand` events. The `<audio>` element
//! *audio-only* (music) playback. On Windows there is no native audio backend, //! reports its real state/position back through the `player_report_*` commands,
//! so `create_player_backend()` used to fall back to `NullBackend` and music was //! so the Rust `PlayerController` remains the single source of truth (the
//! silent. //! controller's `report_html5_*` methods fold those reports into the normal
//! //! event pipeline).
//! This backend fills that gap without any C dependency (so it still
//! cross-compiles from Linux): instead of decoding audio itself, it hands the
//! stream URL to a frontend `<audio>` element via a `WebviewAudioLoad` event and
//! then drives play/pause/seek/stop through `ControlCommand` events — exactly the
//! round-trip the HTML5 video path already uses. The `<audio>` element reports
//! its real state/position back through the `player_report_*` commands, so the
//! Rust `PlayerController` remains the single source of truth (the controller's
//! `report_html5_*` methods fold those reports into the normal event pipeline).
//! //!
//! Because the reported state flows through the event pipeline (not through this //! Because the reported state flows through the event pipeline (not through this
//! backend's `position()`/`state()` pollers — the timer loop does not poll the //! backend's `position()`/`state()` pollers — the timer loop does not poll the
//! backend for HTML5-rendered media), this backend only needs to keep a //! backend for webview-rendered media), this backend only needs to keep a
//! best-effort local mirror for direct `player_get_state` queries. //! best-effort local mirror for direct `player_get_state` queries.
//! //!
//! TRACES: UR-003, UR-004, UR-005 | DR-004 //! TRACES: UR-003, UR-004, UR-005 | DR-004
+2 -2
View File
@@ -22,8 +22,8 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self'; script-src 'self'; 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:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'", "csp": "default-src 'self'; script-src 'self'; 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; worker-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; 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: ws: wss:; worker-src 'self' blob:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'", "devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; 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 ws: wss:; worker-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'",
"assetProtocol": { "assetProtocol": {
"enable": true, "enable": true,
"scope": [ "scope": [
+41 -90
View File
@@ -21,7 +21,7 @@ async playerPlayItem(item: PlayItemRequest) : Promise<PlayerStatus> {
}, },
/** /**
* Exit background-audio mode: stop the native audio player and return its final * Exit background-audio mode: stop the native audio player and return its final
* position so the frontend can reload the WebView `<video>` there (UR-040). * position so the frontend can reload the video there (UR-040).
* *
* Returns the position in seconds. The sleep timer is intentionally left * Returns the position in seconds. The sleep timer is intentionally left
* untouched — if it fired while backgrounded, playback is already stopped and * untouched — if it fired while backgrounded, playback is already stopped and
@@ -52,8 +52,8 @@ async playerBackgroundAction(backgroundAudioArmed: boolean, inPictureInPicture:
* `stream_url` MUST be an audio-only URL (see * `stream_url` MUST be an audio-only URL (see
* `get_audio_only_stream_url_for_video`). The item is created as * `get_audio_only_stream_url_for_video`). The item is created as
* `MediaType::Audio` so it starts an audio session and loads into the native * `MediaType::Audio` so it starts an audio session and loads into the native
* backend with `mediaType="audio"` — the WebView `<video>` is torn down on the * backend with `mediaType="audio"`, replacing the video, so exactly one audio
* frontend side, so exactly one audio source is ever active. * source is ever active.
* *
* This deliberately goes through the queue-based `play_item` path (NOT a * This deliberately goes through the queue-based `play_item` path (NOT a
* side-channel) so end-of-track lands in `on_playback_ended`, which already * side-channel) so end-of-track lands in `on_playback_ended`, which already
@@ -129,11 +129,11 @@ async playerSeek(position: number) : Promise<PlayerStatus> {
* - Direct play streams: Use native seeking * - Direct play streams: Use native seeking
* - Transcoded non-HLS: Request new stream URL from server starting at seek position * - Transcoded non-HLS: Request new stream URL from server starting at seek position
* *
* For native (non-HTML5) backends, this command handles the entire stream reload * The backend always handles the seek itself, including re-opening a stream,
* internally. For HTML5 backends, it returns the new URL for the frontend to handle. * since every video renderer is native (DR-235).
*/ */
async playerSeekVideo(repositoryHandle: string, position: number, mediaSourceId: string | null, audioStreamIndex: number | null, useHtml5: boolean) : Promise<VideoSeekResponse> { async playerSeekVideo(repositoryHandle: string, position: number, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<VideoSeekResponse> {
return await TAURI_INVOKE("player_seek_video", { repositoryHandle, position, mediaSourceId, audioStreamIndex, useHtml5 }); return await TAURI_INVOKE("player_seek_video", { repositoryHandle, position, mediaSourceId, audioStreamIndex });
}, },
async playerSetVolume(volume: number) : Promise<PlayerStatus> { async playerSetVolume(volume: number) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_set_volume", { volume }); return await TAURI_INVOKE("player_set_volume", { volume });
@@ -157,9 +157,6 @@ async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
* carries the requested track at all** — see * carries the requested track at all** — see
* [`determine_audio_track_switch_strategy`]: * [`determine_audio_track_switch_strategy`]:
* *
* - An HTML5 `<video>` element has no track-selection API, so the stream is
* always re-opened at the chosen `AudioStreamIndex` and the frontend seeks
* the reloaded element back to `position`.
* - A native backend playing a **direct play** holds the source file with * - A native backend playing a **direct play** holds the source file with
* every track in it, so ExoPlayer selects in place by track-group index. * every track in it, so ExoPlayer selects in place by track-group index.
* - A native backend playing a **transcode** does not. Jellyfin builds a * - A native backend playing a **transcode** does not. Jellyfin builds a
@@ -175,23 +172,21 @@ async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
* audio track index` and dropped the request — the default track just kept * audio track index` and dropped the request — the default track just kept
* playing, with nothing in the UI saying so. * playing, with nothing in the UI saying so.
* *
* libmpv implements neither selection nor reload here — it is the audio-only * mpv selects in place the same way (`mpv_tracks::select_audio`, by position in
* backend and leaves `PlayerBackend::set_audio_track` at its * the file's audio tracks), and re-opens a transcode through the same path.
* `not_implemented()` default, which is why IR-019 is met by these paths
* rather than by MPV.
* *
* TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258 * TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
*/ */
async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> { async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> {
return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, useHtml5, currentPosition, mediaSourceId }); return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, currentPosition, mediaSourceId });
}, },
/** /**
* Set (or clear, with `None`) the active subtitle track on a native backend. * Set (or clear, with `None`) the active subtitle track on a native backend.
* *
* On Android this indexes ExoPlayer's *text track groups* — i.e. the position * On Android this indexes ExoPlayer's *text track groups* — i.e. the position
* of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream * of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
* index. The HTML5 path never reaches here; it toggles its own `<track>` * index. mpv gives it the same meaning: the position in the sideloaded WebVTT
* children. libmpv implements neither, leaving the trait default in place. * list, loaded as external subtitle files (`mpv_tracks`).
* *
* TRACES: UR-020 | IR-018, DR-023 * TRACES: UR-020 | IR-018, DR-023
*/ */
@@ -283,9 +278,7 @@ async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string
* A cap is a property of the stream the server is producing, so unlike a volume * A cap is a property of the stream the server is producing, so unlike a volume
* change it cannot be applied to a stream already in flight — the stream has to * change it cannot be applied to a stream already in flight — the stream has to
* be re-opened at the new quality and resumed at the current position. That is * be re-opened at the new quality and resumed at the current position. That is
* the same reload the transcoded-seek and audio-track paths use, and the same * the same reload the transcoded-seek and audio-track paths use, done here.
* two-sided split: HTML5 gets the URL back and reloads its own element, while a
* native backend is reloaded here.
* *
* The change applies to **this playback only**. The in-player picker is a * The change applies to **this playback only**. The in-player picker is a
* "this film, this connection" control and its doc has always said so, but it * "this film, this connection" control and its doc has always said so, but it
@@ -299,8 +292,8 @@ async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string
* *
* TRACES: UR-074, UR-079 | DR-162, DR-226 * TRACES: UR-074, UR-079 | DR-162, DR-226
*/ */
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> { async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex }); return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, currentPosition, mediaSourceId, audioStreamIndex });
}, },
/** /**
* Set sleep timer mode * Set sleep timer mode
@@ -347,7 +340,7 @@ async playerPlayNextEpisode(item: PlayItemRequest) : Promise<PlayerStatus> {
/** /**
* Handle playback ended event - triggers autoplay decision logic * Handle playback ended event - triggers autoplay decision logic
* This is called from: * This is called from:
* - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video * - Frontend when a video ends - passes itemId + repositoryHandle for the video
* - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed * - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
* - Android JNI callback also triggers this logic directly * - Android JNI callback also triggers this logic directly
* *
@@ -380,20 +373,20 @@ async playerRecoverStream() : Promise<boolean> {
return await TAURI_INVOKE("player_recover_stream"); return await TAURI_INVOKE("player_recover_stream");
}, },
/** /**
* Report an HTML5 <video> state change (playing/paused/loading/stopped/idle). * Report a webview media element's state change (playing/paused/loading/stopped/idle).
*/ */
async playerReportState(state: string, mediaId: string | null) : Promise<null> { async playerReportState(state: string, mediaId: string | null) : Promise<null> {
return await TAURI_INVOKE("player_report_state", { state, mediaId }); return await TAURI_INVOKE("player_report_state", { state, mediaId });
}, },
/** /**
* Report an HTML5 <video> position tick (seconds). The adapter should throttle * Report a webview media element's position tick (seconds). The adapter should throttle
* these to roughly match the native backends' ~250ms cadence. * these to roughly match the native backends' ~250ms cadence.
*/ */
async playerReportPosition(position: number, duration: number) : Promise<null> { async playerReportPosition(position: number, duration: number) : Promise<null> {
return await TAURI_INVOKE("player_report_position", { position, duration }); return await TAURI_INVOKE("player_report_position", { position, duration });
}, },
/** /**
* Report that the HTML5 <video> finished loading and knows its duration. * Report that a webview media element finished loading and knows its duration.
*/ */
async playerReportMediaLoaded(duration: number) : Promise<null> { async playerReportMediaLoaded(duration: number) : Promise<null> {
return await TAURI_INVOKE("player_report_media_loaded", { duration }); return await TAURI_INVOKE("player_report_media_loaded", { duration });
@@ -2134,11 +2127,7 @@ export type AudioTrackSwitchResponse =
/** /**
* Native backend handled it (Android ExoPlayer) * Native backend handled it (Android ExoPlayer)
*/ */
{ strategy: "native"; success: boolean } | { strategy: "native"; success: boolean }
/**
* HTML5 needs to reload stream with new audio track
*/
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
/** /**
* Authentication result * Authentication result
*/ */
@@ -2836,9 +2825,8 @@ seriesId?: string | null;
/** /**
* Subtitle tracks to sideload, with URLs the frontend has already resolved. * Subtitle tracks to sideload, with URLs the frontend has already resolved.
* *
* Only the native backends use these: on Android they become the * On Android they become the `MediaItem.SubtitleConfiguration`s ExoPlayer
* `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path * renders; mpv loads them as external subtitle files (`mpv_tracks`).
* builds its own `<track>` children instead and ignores this list.
* *
* **Order is the contract.** `player_set_subtitle_track(n)` reaches * **Order is the contract.** `player_set_subtitle_track(n)` reaches
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's * `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
@@ -2907,22 +2895,14 @@ startPosition?: number | null }
export type PlaybackCapabilities = { export type PlaybackCapabilities = {
/** /**
* True when audio is rendered by a webview `<audio>` element rather than a * True when audio is rendered by a webview `<audio>` element rather than a
* native backend. Native audio exists on Linux (mpv) and Android * native backend. Native audio exists on Linux and Windows (mpv) and
* (ExoPlayer); everything else (Windows, future desktops) uses the webview. * Android (ExoPlayer); only an unported desktop uses the webview.
*
* Video has no counterpart: it is always drawn by the native backend, behind
* the transparent webview (DR-235) — there is no webview video renderer
* left to report.
*/ */
usesWebviewAudio: boolean; usesWebviewAudio: boolean }
/**
* True when video is rendered by a native surface composited *behind* a
* transparent webview: ExoPlayer's SurfaceView on Android, mpv's GL area on
* Linux.
*/
supportsNativeVideo: boolean;
/**
* True when the user may send video to the webview element instead of the
* native renderer — the frontend offers the switch only then, and honours
* the stored preference only then. False on every platform since DR-235.
*/
webviewVideoFallback: boolean }
/** /**
* Playback information * Playback information
*/ */
@@ -3134,14 +3114,6 @@ export type PlayerState =
* Response for player state queries * Response for player state queries
*/ */
export type PlayerStatus = { state: PlayerState; position: number; duration: number | null; volume: number; muted: boolean; shuffle: boolean; repeat: RepeatMode; export type PlayerStatus = { state: PlayerState; position: number; duration: number | null; volume: number; muted: boolean; shuffle: boolean; repeat: RepeatMode;
/**
* Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
*/
backend: VideoBackend;
/**
* Whether frontend should render HTML5 video element
*/
useHtml5Element: boolean;
/** /**
* Media item from either local queue or remote session * Media item from either local queue or remote session
*/ */
@@ -3197,9 +3169,8 @@ export type PlayerStatusEvent =
{ type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } | { type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } |
/** /**
* Time-based sleep timer expired: playback must stop. The backend stops * Time-based sleep timer expired: playback must stop. The backend stops
* its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the * its own (MPV/ExoPlayer) playback; the frontend pauses the active adapter
* webview outside the backend's control — the frontend pauses it on this * on this event, which reaches a webview `<audio>` element where one plays.
* event.
*/ */
{ type: "sleep_timer_expired" } | { type: "sleep_timer_expired" } |
/** /**
@@ -3244,19 +3215,19 @@ export type PlayerStatusEvent =
{ type: "remote_disconnect_requested" } | { type: "remote_disconnect_requested" } |
/** /**
* Backend-originated control command targeting the active frontend player * Backend-originated control command targeting the active frontend player
* adapter (the HTML5 <video> that lives in the webview, which Rust cannot * adapter — the webview `<audio>` element, which Rust cannot drive
* drive directly). Emitted by control paths like the sleep timer, lockscreen, * directly. Emitted by control paths like the sleep timer, lockscreen, or
* or remote so they can pause/play/seek/stop the webview element. * remote so they can pause/play/seek/stop it.
* `playerEvents.ts` routes this to the active PlayerAdapter via the facade. * `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
*/ */
{ type: "control_command"; action: string; position: number | null } | { type: "control_command"; action: string; position: number | null } |
/** /**
* Ask the frontend webview `<audio>` element to load and play a stream. * Ask the frontend webview `<audio>` element to load and play a stream.
* *
* Emitted by `WebviewAudioBackend` on platforms with no native audio * Emitted by `WebviewAudioBackend` on a desktop with no native audio
* backend (e.g. Windows): audio-only playback is rendered by an `<audio>` * backend (none that ships: Linux and Windows have mpv): audio-only
* element in the webview, mirroring how all video already renders through * playback is rendered by an `<audio>` element in the webview. The element
* the webview `<video>`. The element then reports its state/position back * then reports its state/position back
* through the `player_report_*` commands, so the Rust controller stays the * through the `player_report_*` commands, so the Rust controller stays the
* single source of truth. Subsequent play/pause/seek/stop reach the element * single source of truth. Subsequent play/pause/seek/stop reach the element
* via `ControlCommand`. * via `ControlCommand`.
@@ -3611,11 +3582,7 @@ export type StreamQualityResponse =
* *
* TRACES: UR-074, UR-079 | DR-226, DR-227 * TRACES: UR-074, UR-079 | DR-226, DR-227
*/ */
{ strategy: "native"; selection: StreamSelection; position: number } | { strategy: "native"; selection: StreamSelection; position: number }
/**
* HTML5 must reload its element with this selection.
*/
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
/** /**
* Everything a player backend needs to open a stream, and everything the UI * Everything a player backend needs to open a stream, and everything the UI
* needs to describe it. * needs to describe it.
@@ -3844,18 +3811,6 @@ playbackPositionMs?: number | null; isPlayed?: boolean | null; isFavorite?: bool
* User info returned to frontend * User info returned to frontend
*/ */
export type UserInfo = { id: string; serverId: string; username: string; isActive: boolean } export type UserInfo = { id: string; serverId: string; username: string; isActive: boolean }
/**
* Backend type for video playback
*/
export type VideoBackend =
/**
* Native backend (ExoPlayer on Android, libmpv on Linux)
*/
"native" |
/**
* HTML5 video element fallback
*/
"html5"
/** /**
* Response for video seek operations * Response for video seek operations
*/ */
@@ -3863,11 +3818,7 @@ export type VideoSeekResponse =
/** /**
* Use native seeking (HLS or direct stream) * Use native seeking (HLS or direct stream)
*/ */
{ strategy: "native"; position: number } | { strategy: "native"; position: number }
/**
* Reload stream from new position (transcoded non-HLS)
*/
{ strategy: "reloadStream"; selection: StreamSelection; seek_offset: number }
/** /**
* Video playback settings * Video playback settings
*/ */
@@ -1,78 +1,27 @@
/** /**
* VideoPlayer scrub regression tests (Android backend path) * VideoPlayer scrub regression tests.
* *
* Reproduces the reported bug: with a sleep timer active, scrubbing the * Reproduces the reported bug: with a sleep timer active, scrubbing the
* video seek bar "seeks, then jumps back to the old position". * video seek bar "seeks, then jumps back to the old position".
* *
* Root cause history: * Root cause history: native init called onDestroy() after an await ->
* - Native init called onDestroy() after an await -> lifecycle_outside_component * lifecycle_outside_component -> the catch treated init as failed and silently
* -> the catch treated init as failed and silently flipped useHtml5Element to * switched seeks to the (since deleted) webview `<video>` path while the native
* true, so seeks went down the HTML5 path while ExoPlayer kept playing. * player kept playing.
* - The native SurfaceView has never been visible through the webview, so the
* INTERIM behavior (until the video-player API refactor) is: when the backend
* reports native mode, VideoPlayer deliberately overrides to HTML5 rendering
* and stops the native backend (single audio source, webview owns playback).
* *
* These tests pin the interim behavior: Android's native response is * These tests pin that scrubbing reaches the backend and holds its position
* overridden, the backend is stopped exactly once, and scrubbing keeps * with a sleep timer active. Every video renderer is native now (DR-235).
* working (and holds its position) with a sleep timer active.
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
// ---- Mocks (must precede component import) -------------------------------- // ---- Mocks (must precede component import) --------------------------------
const channelHandlers: Record<string, (event: any) => void> = {};
// These tests pin the **flag-off** behaviour: when `experimentalNativeVideo` is
// off, VideoPlayer overrides Android's native backend response to HTML5
// rendering and stops the native backend. That is the default again (DR-172,
// after native video shipped as audio with no picture), so this mock now agrees
// with the default rather than opposing it — kept explicit so the tests state
// which path they guard instead of inheriting whatever the default happens to be.
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
return {
...actual,
experimentalNativeVideo: {
subscribe: (run: (v: boolean) => void) => {
run(false);
return () => {};
},
set: () => {},
current: () => false,
},
};
});
// The webview path only exists where Rust offers a fallback from the native
// renderer — beside mpv native video on Linux; never on Android since DR-293,
// where a stored "off" is ignored. These tests guard that path's scrubbing, so
// they declare a platform that has it.
vi.mock("$lib/services/playbackCapabilities", () => ({
getPlaybackCapabilities: async () => ({
usesWebviewAudio: false,
supportsNativeVideo: true,
webviewVideoFallback: true,
}),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (channel: string, handler: any) => {
channelHandlers[channel] = handler;
return () => {
delete channelHandlers[channel];
};
}),
}));
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(), invoke: vi.fn(),
})); }));
const playerPlayItem = vi.fn(async () => ({ const playerPlayItem = vi.fn(async () => ({
// What Android reports: native ExoPlayer backend
useHtml5Element: false,
backend: "exoplayer",
state: { kind: "playing" }, state: { kind: "playing" },
})); }));
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({ const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
@@ -164,7 +113,7 @@ function sleepTimerTick(remaining = 2) {
}); });
} }
async function mountAndroidPlayer() { async function mountPlayer() {
const utils = render(VideoPlayer, { const utils = render(VideoPlayer, {
props: { props: {
media: makeEpisode(), media: makeEpisode(),
@@ -175,63 +124,41 @@ async function mountAndroidPlayer() {
}, },
}); });
// Init: backend reports native, component overrides to HTML5 and stops it.
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled()); await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
await waitFor(() => expect(playerStop).toHaveBeenCalled());
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement; const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
const video = utils.container.querySelector("video") as HTMLVideoElement;
expect(slider).not.toBeNull(); expect(slider).not.toBeNull();
expect(video).not.toBeNull(); return { ...utils, slider };
return { ...utils, slider, video };
} }
/** Scrub the seek bar to `target` seconds like a user drag. */ /** Scrub the seek bar to `target` seconds like a user drag. */
async function scrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) { async function scrubTo(slider: HTMLInputElement, target: number) {
await fireEvent.mouseDown(slider); await fireEvent.mouseDown(slider);
slider.value = String(target); slider.value = String(target);
await fireEvent.input(slider); await fireEvent.input(slider);
await fireEvent.change(slider); await fireEvent.change(slider);
await fireEvent.mouseUp(slider); await fireEvent.mouseUp(slider);
// Resolve the "wait for seeked" step of the HTML5 native-seek path.
await fireEvent(video, new Event("seeked"));
await tick(); await tick();
} }
describe("VideoPlayer scrubbing with active sleep timer (Android)", () => { describe("VideoPlayer scrubbing with active sleep timer", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
sleepTimer.set({ mode: { kind: "off" }, remainingSeconds: 0 }); sleepTimer.set({ mode: { kind: "off" }, remainingSeconds: 0 });
sleepTimerExpiredSignal.set(0); sleepTimerExpiredSignal.set(0);
}); });
it("overrides the native backend response to HTML5 rendering and stops the backend once", async () => { it("scrubbing without a timer seeks through the backend and keeps the new position", async () => {
await mountAndroidPlayer(); const { slider } = await mountPlayer();
// The native backend must be stopped so it doesn't play audio behind the
// webview (frozen picture + double audio source).
expect(playerStop).toHaveBeenCalledTimes(1);
});
it("scrubbing without a timer seeks via the HTML5 path and keeps the new position", async () => { await scrubTo(slider, 600);
const { slider, video } = await mountAndroidPlayer();
await scrubTo(slider, video, 600); await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null));
await waitFor(() =>
expect(playerSeekVideo).toHaveBeenCalledWith(
"repo-1",
600,
"src-1",
null,
true, // HTML5 path: the webview owns playback after the override
),
);
expect(parseFloat(slider.value)).toBeCloseTo(600); expect(parseFloat(slider.value)).toBeCloseTo(600);
}); });
it("scrubbing still works (and holds position) after enabling an episodes sleep timer", async () => { it("scrubbing still works (and holds position) after enabling an episodes sleep timer", async () => {
const { slider, video } = await mountAndroidPlayer(); const { slider } = await mountPlayer();
// Enable "2 more episodes" timer; backend then ticks every second. // Enable "2 more episodes" timer; backend then ticks every second.
sleepTimerTick(2); sleepTimerTick(2);
@@ -239,7 +166,7 @@ describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
sleepTimerTick(2); sleepTimerTick(2);
await tick(); await tick();
await scrubTo(slider, video, 600); await scrubTo(slider, 600);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(1)); await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(1));
expect(parseFloat(slider.value)).toBeCloseTo(600); expect(parseFloat(slider.value)).toBeCloseTo(600);
@@ -249,13 +176,13 @@ describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
expect(parseFloat(slider.value)).toBeCloseTo(600); expect(parseFloat(slider.value)).toBeCloseTo(600);
// A second scrub must also work. // A second scrub must also work.
await scrubTo(slider, video, 900); await scrubTo(slider, 900);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(2)); await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(2));
expect(parseFloat(slider.value)).toBeCloseTo(900); expect(parseFloat(slider.value)).toBeCloseTo(900);
}); });
it("sleep-timer ticks alone never move the seek bar", async () => { it("sleep-timer ticks alone never move the seek bar", async () => {
const { slider } = await mountAndroidPlayer(); const { slider } = await mountPlayer();
const before = slider.value; const before = slider.value;
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
File diff suppressed because it is too large Load Diff
@@ -21,8 +21,9 @@
* rather than internals. * rather than internals.
* *
* The specific traps encoded here, each a bug that shipped: * The specific traps encoded here, each a bug that shipped:
* - pausing renders a full-screen <button> play overlay OVER the video, so the * - pausing renders a full-screen <button> play overlay OVER the video
* second tap of a double tap lands on a button, not the video; * surface, so the second tap of a double tap lands on a button, not the
* surface;
* - the browser synthesizes a `click` after a touch tap, which must not toggle * - the browser synthesizes a `click` after a touch tap, which must not toggle
* a second time, on ANY layered target; * a second time, on ANY layered target;
* - the bottom controls bar must drive its own buttons and NOT the container's * - the bottom controls bar must drive its own buttons and NOT the container's
@@ -35,6 +36,7 @@ import { tick } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import VideoPlayer from "./VideoPlayer.svelte"; import VideoPlayer from "./VideoPlayer.svelte";
import { SEEK_FORWARD_SECONDS } from "./tapGestures"; import { SEEK_FORWARD_SECONDS } from "./tapGestures";
import { player } from "$lib/stores/player";
/** /**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is * A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
@@ -94,18 +96,10 @@ vi.mock("$lib/player/adapters/rustReportHost", () => ({
}), }),
})); }));
vi.mock("$lib/player/html5Adapter", () => ({
reportState: vi.fn(),
reportPosition: vi.fn(),
reportMediaLoaded: vi.fn(),
resetReporting: vi.fn(),
}));
vi.mock("$lib/utils/pictureInPicture", () => ({ vi.mock("$lib/utils/pictureInPicture", () => ({
isPipSupported: () => false, isPipSupported: () => false,
enterPip: vi.fn(), enterPip: vi.fn(),
setAutoEnterEnabled: vi.fn(), setAutoEnterEnabled: vi.fn(),
setHtml5VideoState: vi.fn(),
})); }));
vi.mock("$lib/stores/auth", () => ({ vi.mock("$lib/stores/auth", () => ({
@@ -143,9 +137,22 @@ function renderPlayer() {
}); });
} }
/**
* The transparent area the native picture shows through — the video surface.
* The first `[data-player-surface]`; the play overlay, when raised, is another.
*/
function videoSurface(container: HTMLElement): Element {
const surface = container.querySelector("[data-player-surface]");
expect(surface).toBeTruthy();
return surface!;
}
describe("VideoPlayer tap surface (real component)", () => { describe("VideoPlayer tap surface (real component)", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Playing, as the backend would report it, so no play overlay covers the
// surface to begin with.
player.setPlaying(MEDIA, 0, 600);
// This file deliberately does NOT mock `$lib/api/bindings` — it renders the // This file deliberately does NOT mock `$lib/api/bindings` — it renders the
// real component against the real bindings, which bottom out in the globally // real component against the real bindings, which bottom out in the globally
// mocked `invoke`. That mock resolves `undefined` for every command, so the // mocked `invoke`. That mock resolves `undefined` for every command, so the
@@ -167,17 +174,17 @@ describe("VideoPlayer tap surface (real component)", () => {
it("a single tap on the video toggles play/pause exactly once", async () => { it("a single tap on the video toggles play/pause exactly once", async () => {
const { container } = renderPlayer(); const { container } = renderPlayer();
const video = container.querySelector("video"); await tick();
expect(video).toBeTruthy();
touchAt(video!, 900); touchAt(videoSurface(container), 900);
expect(toggleSpy).toHaveBeenCalledTimes(1); expect(toggleSpy).toHaveBeenCalledTimes(1);
}); });
it("the synthesized click after a tap does not toggle a second time", async () => { it("the synthesized click after a tap does not toggle a second time", async () => {
const { container } = renderPlayer(); const { container } = renderPlayer();
const video = container.querySelector("video")!; await tick();
const video = videoSurface(container);
touchAt(video, 900); touchAt(video, 900);
// The compatibility click the browser fires after a touch tap. detail=0 is // The compatibility click the browser fires after a touch tap. detail=0 is
@@ -195,21 +202,21 @@ describe("VideoPlayer tap surface (real component)", () => {
// guard that does not know about that overlay discards it and seeking dies. // guard that does not know about that overlay discards it and seeking dies.
// //
// Reproducing it requires the overlay to actually render, which means // Reproducing it requires the overlay to actually render, which means
// driving `isPlaying` the way the real element does: via its `pause` event. // driving `isPlaying` the way production does: the player reports paused.
vi.useFakeTimers(); vi.useFakeTimers();
try { try {
const { container } = renderPlayer(); const { container } = renderPlayer();
const video = container.querySelector("video")!; await tick();
// Tap 1 on the video. // Tap 1 on the video surface.
touchAt(video, 900); touchAt(videoSurface(container), 900);
// The element reports it paused → isPlaying=false → overlay renders. // The player reports it paused → isPlaying=false → overlay renders.
video.dispatchEvent(new Event("pause")); player.setPaused(MEDIA, 0, 600);
await Promise.resolve(); await Promise.resolve();
await tick(); await tick();
const overlay = container.querySelector("[data-player-surface]"); const overlay = container.querySelector('[data-testid="play-overlay"]');
expect(overlay, "the play overlay should be covering the video").toBeTruthy(); expect(overlay, "the play overlay should be covering the video").toBeTruthy();
vi.advanceTimersByTime(120); // inside DOUBLE_TAP_WINDOW_MS vi.advanceTimersByTime(120); // inside DOUBLE_TAP_WINDOW_MS
@@ -25,56 +25,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// ---- Mocks (must precede component import) -------------------------------- // ---- Mocks (must precede component import) --------------------------------
const channelHandlers: Record<string, (event: any) => void> = {};
// These tests pin the **flag-off** behaviour: when `experimentalNativeVideo` is
// off, VideoPlayer overrides Android's native backend response to HTML5
// rendering and stops the native backend. That is the default again (DR-172,
// after native video shipped as audio with no picture), so this mock now agrees
// with the default rather than opposing it — kept explicit so the tests state
// which path they guard instead of inheriting whatever the default happens to be.
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
return {
...actual,
experimentalNativeVideo: {
subscribe: (run: (v: boolean) => void) => {
run(false);
return () => {};
},
set: () => {},
current: () => false,
},
};
});
// The webview path only exists where Rust offers a fallback from the native
// renderer — beside mpv native video on Linux; never on Android since DR-293,
// where a stored "off" is ignored. These tests guard that path's scrubbing, so
// they declare a platform that has it.
vi.mock("$lib/services/playbackCapabilities", () => ({
getPlaybackCapabilities: async () => ({
usesWebviewAudio: false,
supportsNativeVideo: true,
webviewVideoFallback: true,
}),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (channel: string, handler: any) => {
channelHandlers[channel] = handler;
return () => {
delete channelHandlers[channel];
};
}),
}));
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(), invoke: vi.fn(),
})); }));
const playerPlayItem = vi.fn(async () => ({ const playerPlayItem = vi.fn(async () => ({
useHtml5Element: false,
backend: "exoplayer",
state: { kind: "playing" }, state: { kind: "playing" },
})); }));
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({ const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
@@ -166,12 +121,10 @@ async function mountAndroidPlayer() {
}); });
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled()); await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
await waitFor(() => expect(playerStop).toHaveBeenCalled());
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement; const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
const video = utils.container.querySelector("video") as HTMLVideoElement;
expect(slider).not.toBeNull(); expect(slider).not.toBeNull();
return { ...utils, slider, video }; return { ...utils, slider };
} }
function touch(x: number, y: number) { function touch(x: number, y: number) {
@@ -184,7 +137,7 @@ function touch(x: number, y: number) {
* A real drag along the bar moves the finger far enough that the container's * A real drag along the bar moves the finger far enough that the container's
* swipe detector (50px) would trigger if it were still listening. * swipe detector (50px) would trigger if it were still listening.
*/ */
async function touchScrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) { async function touchScrubTo(slider: HTMLInputElement, target: number) {
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] }); await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
// Finger travels across the bar. Small vertical wander is normal for a thumb // Finger travels across the bar. Small vertical wander is normal for a thumb
// drag; the horizontal travel is what matters. // drag; the horizontal travel is what matters.
@@ -194,31 +147,27 @@ async function touchScrubTo(slider: HTMLInputElement, video: HTMLVideoElement, t
await fireEvent.touchMove(slider, { touches: [touch(700, 705)] }); await fireEvent.touchMove(slider, { touches: [touch(700, 705)] });
await fireEvent.change(slider); await fireEvent.change(slider);
await fireEvent.touchEnd(slider, { touches: [] }); await fireEvent.touchEnd(slider, { touches: [] });
if (video) await fireEvent(video, new Event("seeked"));
await tick(); await tick();
} }
describe("VideoPlayer seek bar — touch drag (Android)", () => { describe("VideoPlayer seek bar — touch drag (Android)", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
}); });
it("a touch drag on the seek bar seeks to the dragged position", async () => { it("a touch drag on the seek bar seeks to the dragged position", async () => {
const { slider, video } = await mountAndroidPlayer(); const { slider } = await mountAndroidPlayer();
await touchScrubTo(slider, video, 600); await touchScrubTo(slider, 600);
await waitFor(() => await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null));
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
);
expect(parseFloat(slider.value)).toBeCloseTo(600); expect(parseFloat(slider.value)).toBeCloseTo(600);
}); });
it("a touch drag on the seek bar never toggles play/pause", async () => { it("a touch drag on the seek bar never toggles play/pause", async () => {
const { slider, video } = await mountAndroidPlayer(); const { slider } = await mountAndroidPlayer();
await touchScrubTo(slider, video, 600); await touchScrubTo(slider, 600);
// The container gesture layer must stay out of a control drag entirely: // The container gesture layer must stay out of a control drag entirely:
// no swipe mis-read, so no play/pause correction. // no swipe mis-read, so no play/pause correction.
@@ -226,7 +175,7 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
}); });
it("commits the seek on touchend even when the engine never fires `change`", async () => { it("commits the seek on touchend even when the engine never fires `change`", async () => {
const { slider, video } = await mountAndroidPlayer(); const { slider } = await mountAndroidPlayer();
// Android's WebView does not reliably fire `change` for a touch interaction // Android's WebView does not reliably fire `change` for a touch interaction
// on a range input. A tap on the track still moves the thumb and fires // on a range input. A tap on the track still moves the thumb and fires
@@ -235,32 +184,29 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
slider.value = "600"; slider.value = "600";
await fireEvent.input(slider); await fireEvent.input(slider);
await fireEvent.touchEnd(slider, { touches: [] }); await fireEvent.touchEnd(slider, { touches: [] });
if (video) await fireEvent(video, new Event("seeked"));
await tick(); await tick();
await waitFor(() => await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null));
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
);
}); });
it("commits the seek exactly once when both touchend and change fire", async () => { it("commits the seek exactly once when both touchend and change fire", async () => {
const { slider, video } = await mountAndroidPlayer(); const { slider } = await mountAndroidPlayer();
await touchScrubTo(slider, video, 600); await touchScrubTo(slider, 600);
expect(playerSeekVideo).toHaveBeenCalledTimes(1); expect(playerSeekVideo).toHaveBeenCalledTimes(1);
}); });
it("a touch drag on the seek bar does not hijack into brightness control", async () => { it("a touch drag on the seek bar does not hijack into brightness control", async () => {
const { slider, video, container } = await mountAndroidPlayer(); const { slider, container } = await mountAndroidPlayer();
await touchScrubTo(slider, video, 600); // Mid-drag, with the finger still down: a mis-read swipe raises the
// brightness indicator for as long as the swipe lasts.
// Brightness is applied as a CSS filter on the <video>; a control drag must await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
// leave it untouched. await fireEvent.touchMove(slider, { touches: [touch(400, 600)] });
const el = container.querySelector("video") as HTMLVideoElement | null; await fireEvent.touchMove(slider, { touches: [touch(700, 500)] });
if (el) { await tick();
expect(el.style.filter).toBe("brightness(1)"); expect(container.textContent).not.toContain("Brightness");
} await fireEvent.touchEnd(slider, { touches: [] });
}); });
}); });
@@ -97,9 +97,8 @@ describe("backgroundAudioHandoff", () => {
// //
// TRACES: UR-040, UR-003 | DR-196 // TRACES: UR-040, UR-003 | DR-196
describe("planHandoffReturn", () => { describe("planHandoffReturn", () => {
it("restarts the native backend when the native path is rendering", () => { it("restarts the native backend", () => {
const plan = planHandoffReturn({ const plan = planHandoffReturn({
useHtml5Element: false,
position: 4214, position: 4214,
wasPlaying: true, wasPlaying: true,
nativeStateKind: "playing", nativeStateKind: "playing",
@@ -109,19 +108,8 @@ describe("backgroundAudioHandoff", () => {
expect(plan.shouldPlay).toBe(true); expect(plan.shouldPlay).toBe(true);
}); });
it("reloads the webview element when HTML5 is rendering", () => {
const plan = planHandoffReturn({
useHtml5Element: true,
position: 120,
wasPlaying: true,
nativeStateKind: "playing",
});
expect(plan.target).toBe("html5-element");
});
it("honours a lockscreen pause over the handoff snapshot", () => { it("honours a lockscreen pause over the handoff snapshot", () => {
const plan = planHandoffReturn({ const plan = planHandoffReturn({
useHtml5Element: false,
position: 300, position: 300,
wasPlaying: true, wasPlaying: true,
nativeStateKind: "paused", nativeStateKind: "paused",
@@ -131,7 +119,6 @@ describe("backgroundAudioHandoff", () => {
it("never returns a negative resume position", () => { it("never returns a negative resume position", () => {
const plan = planHandoffReturn({ const plan = planHandoffReturn({
useHtml5Element: false,
position: -3, position: -3,
wasPlaying: false, wasPlaying: false,
nativeStateKind: undefined, nativeStateKind: undefined,
@@ -146,7 +133,6 @@ describe("backgroundAudioHandoff", () => {
// TRACES: UR-040, UR-023 | DR-296 | UT-265 // TRACES: UR-040, UR-023 | DR-296 | UT-265
it("switches to the item the backend advanced to", () => { it("switches to the item the backend advanced to", () => {
const plan = planHandoffReturn({ const plan = planHandoffReturn({
useHtml5Element: false,
position: 95, position: 95,
wasPlaying: true, wasPlaying: true,
nativeStateKind: "playing", nativeStateKind: "playing",
@@ -160,21 +146,19 @@ describe("backgroundAudioHandoff", () => {
it("reloads in place when the backend is still on the mounted item", () => { it("reloads in place when the backend is still on the mounted item", () => {
const plan = planHandoffReturn({ const plan = planHandoffReturn({
useHtml5Element: true,
position: 95, position: 95,
wasPlaying: true, wasPlaying: true,
nativeStateKind: "playing", nativeStateKind: "playing",
mountedItemId: "ep1", mountedItemId: "ep1",
resumeItemId: "ep1", resumeItemId: "ep1",
}); });
expect(plan.target).toBe("html5-element"); expect(plan.target).toBe("native-backend");
}); });
it("reloads in place when the backend reports no item", () => { it("reloads in place when the backend reports no item", () => {
// Queue emptied while backgrounded (e.g. the sleep timer): there is no // Queue emptied while backgrounded (e.g. the sleep timer): there is no
// other item to go to, so the mounted one is the best we have. // other item to go to, so the mounted one is the best we have.
const plan = planHandoffReturn({ const plan = planHandoffReturn({
useHtml5Element: false,
position: 95, position: 95,
wasPlaying: false, wasPlaying: false,
nativeStateKind: undefined, nativeStateKind: undefined,
@@ -82,7 +82,7 @@ export interface HandoffReturn {
* no longer on the item this player was mounted with, so the player must * no longer on the item this player was mounted with, so the player must
* switch to `itemId` instead of reloading itself. * switch to `itemId` instead of reloading itself.
*/ */
target: "html5-element" | "native-backend" | "other-item"; target: "native-backend" | "other-item";
/** The item to switch to; set only for `other-item`. */ /** The item to switch to; set only for `other-item`. */
itemId?: string; itemId?: string;
/** Absolute position the background audio reached. */ /** Absolute position the background audio reached. */
@@ -94,19 +94,14 @@ export interface HandoffReturn {
/** /**
* How to come back when the app returns to the foreground. * How to come back when the app returns to the foreground.
* *
* The two render paths resume by completely different means, and conflating * The native backend owns no element, and nothing reacts to the stream URL on
* them is what broke the native one: * its behalf. Native playback is only ever started by an explicit backend load,
* which the component issues once, from `onMount`. So the return has to
* re-issue it; reassigning the URL restarts nothing.
* *
* - **html5-element** — assigning the stream URL is enough. An `$effect` in the * The component once did only the URL assignment — which is how the deleted
* component watches it, (re)initialises HLS or sets `videoElement.src`, and * webview `<video>` path came back. On the native path that left the backend
* `canplay` then drives the seek and play. * holding no item at all: a black screen with
* - **native-backend** — ExoPlayer owns no element, and nothing reacts to the
* stream URL on its behalf. Native playback is only ever started by an
* explicit backend load, which the component issues once, from `onMount`. So
* the return has to re-issue it; reassigning the URL restarts nothing.
*
* The component previously did only the URL assignment, for both paths. On the
* native path that left the backend holding no item at all: a black screen with
* a play overlay, a play button that did nothing, and the position pinned at * a play overlay, a play button that did nothing, and the position pinned at
* 0:00 — the handoff's own audio player having been stopped on the way out. * 0:00 — the handoff's own audio player having been stopped on the way out.
* *
@@ -122,7 +117,6 @@ export interface HandoffReturn {
* TRACES: UR-040, UR-003, UR-023 | DR-196, DR-296 | UT-060, UT-265 * TRACES: UR-040, UR-003, UR-023 | DR-196, DR-296 | UT-060, UT-265
*/ */
export function planHandoffReturn(opts: { export function planHandoffReturn(opts: {
useHtml5Element: boolean;
position: number; position: number;
wasPlaying: boolean; wasPlaying: boolean;
nativeStateKind: string | undefined; nativeStateKind: string | undefined;
@@ -134,11 +128,7 @@ export function planHandoffReturn(opts: {
if (opts.resumeItemId && opts.resumeItemId !== opts.mountedItemId) { if (opts.resumeItemId && opts.resumeItemId !== opts.mountedItemId) {
return { target: "other-item", itemId: opts.resumeItemId, position, shouldPlay }; return { target: "other-item", itemId: opts.resumeItemId, position, shouldPlay };
} }
return { return { target: "native-backend", position, shouldPlay };
target: opts.useHtml5Element ? "html5-element" : "native-backend",
position,
shouldPlay,
};
} }
/** /**
@@ -2,14 +2,9 @@ import { describe, it, expect } from "vitest";
import { planFullscreen } from "./fullscreenTarget"; import { planFullscreen } from "./fullscreenTarget";
describe("planFullscreen", () => { describe("planFullscreen", () => {
it("fullscreens only the document when an in-document <video> renders", () => { it("fullscreens the OS window as well as the document", () => {
// Unchanged behaviour: WebKit scales the element, the window need not move.
expect(planFullscreen(false)).toEqual({ document: true, osWindow: false });
});
it("also fullscreens the OS window when a native surface renders", () => {
// The picture is drawn behind the webview at window size, so a // The picture is drawn behind the webview at window size, so a
// document-only fullscreen leaves it at the old size. // document-only fullscreen leaves it at the old size.
expect(planFullscreen(true)).toEqual({ document: true, osWindow: true }); expect(planFullscreen()).toEqual({ document: true, osWindow: true });
}); });
}); });
@@ -27,9 +27,9 @@ export interface FullscreenPlan {
} }
/** /**
* @param rendersNatively true when a native surface (mpv/ExoPlayer) draws the * Every video renderer is a native surface since the webview `<video>` path was
* picture rather than an in-document `<video>` element. * deleted (DR-235), so the OS window always has to move with the document.
*/ */
export function planFullscreen(rendersNatively: boolean): FullscreenPlan { export function planFullscreen(): FullscreenPlan {
return { document: true, osWindow: rendersNatively }; return { document: true, osWindow: true };
} }
@@ -1,64 +0,0 @@
import { describe, it, expect } from "vitest";
import { fatalNetworkErrorAction } from "./hlsRecovery";
/**
* A fatal hls.js network error mid-film must be retried, not reported as the
* end of the stream — reporting "ended" hands control to autoplay and skips to
* the next item while the user is still watching this one.
*
* The position the player displays is *already absolute*: the RAF loop sets
* `currentTime = seekOffset + element.currentTime`. Anything that adds the
* offset a second time doubles the apparent position, and after a quality
* switch or a transcoded seek the offset is the whole resume position — so past
* roughly the halfway mark the doubled value clears the near-end threshold and
* every transient error is misread as the end.
*
* TRACES: UR-004, UR-074 | DR-177 | UT-174
*/
describe("fatalNetworkErrorAction", () => {
it("retries a mid-film failure after a quality switch instead of ending playback", () => {
// 90-minute film, quality switched at the 50-minute mark: the reloaded
// stream's timeline starts at 0, so seekOffset carries the 50 minutes and
// the displayed position — already absolute — is 3000s of 5400s, 56%
// through and nowhere near the end.
const action = fatalNetworkErrorAction({
positionSeconds: 3000,
knownDurationSeconds: 5400,
attempts: 1,
});
expect(action).toBe("retry");
});
it("treats a failure in the last tenth of the stream as the end", () => {
// Jellyfin's transcoded HLS does not always emit #EXT-X-ENDLIST, so a
// genuine end-of-stream arrives as a fatal network error.
const action = fatalNetworkErrorAction({
positionSeconds: 5300,
knownDurationSeconds: 5400,
attempts: 1,
});
expect(action).toBe("ended");
});
it("stops retrying once the recovery budget is spent", () => {
const action = fatalNetworkErrorAction({
positionSeconds: 60,
knownDurationSeconds: 5400,
attempts: 4,
});
expect(action).toBe("giveUp");
});
it("retries when the runtime is not known yet", () => {
const action = fatalNetworkErrorAction({
positionSeconds: 120,
knownDurationSeconds: 0,
attempts: 1,
});
expect(action).toBe("retry");
});
});
-52
View File
@@ -1,52 +0,0 @@
/**
* What to do about a *fatal* hls.js network error.
*
* Jellyfin's transcoded HLS streams do not always terminate with an
* `#EXT-X-ENDLIST`, so a stream that has simply run out looks identical to one
* that broke: both arrive as a fatal network error. The only thing separating
* them is how far playback had got, which is why this decision is worth
* isolating from the player component — read the position wrong and a
* recoverable stall turns into a skip to the next item.
*
* TRACES: UR-004, UR-074 | DR-177 | UT-174
*/
/** Fraction of the runtime past which a fatal error reads as "the stream ended". */
const NEAR_END_FRACTION = 0.9;
/** How many times to ask hls.js to resume before giving up on the stream. */
export const MAX_FATAL_NETWORK_RECOVERIES = 3;
export type FatalNetworkErrorAction = "ended" | "retry" | "giveUp";
export interface FatalNetworkErrorInput {
/**
* Absolute position in the media, in seconds — the value the player displays.
*
* It is already absolute (`seekOffset + element.currentTime`): do NOT add the
* transcode seek offset again. After a quality switch or a transcoded seek the
* offset *is* the resume position, so double-counting it puts an apparent
* position past the near-end threshold from roughly halfway through, and every
* transient error then ends playback.
*/
positionSeconds: number;
/** Known runtime in seconds; 0 or negative when the runtime isn't known yet. */
knownDurationSeconds: number;
/** Recovery attempts already made against this hls.js instance. */
attempts: number;
}
/** Whether a failure at this position should be read as the stream ending. */
export function isNearEndOfStream(positionSeconds: number, knownDurationSeconds: number): boolean {
if (knownDurationSeconds <= 0 || positionSeconds <= 0) return false;
return positionSeconds / knownDurationSeconds > NEAR_END_FRACTION;
}
export function fatalNetworkErrorAction({
positionSeconds,
knownDurationSeconds,
attempts,
}: FatalNetworkErrorInput): FatalNetworkErrorAction {
if (isNearEndOfStream(positionSeconds, knownDurationSeconds)) return "ended";
return attempts <= MAX_FATAL_NETWORK_RECOVERIES ? "retry" : "giveUp";
}
@@ -5,30 +5,19 @@ import {
subtitleStreamsOf, subtitleStreamsOf,
subtitleTrackLabel, subtitleTrackLabel,
resolveSubtitleTracks, resolveSubtitleTracks,
reconcileSelectedSubtitle,
videoCrossOriginMode,
nativeSubtitleTracks, nativeSubtitleTracks,
nativeSubtitleArrayIndex, nativeSubtitleArrayIndex,
type SubtitleStreamLike, type SubtitleStreamLike,
} from "./subtitleTracks"; } from "./subtitleTracks";
/** /**
* Subtitles on the Linux / WebKitGTK HTML5 `<video>` path. * Subtitle resolution — the list the play request carries.
* *
* TRACES: UR-020 | DR-023 | UT-143, UT-144 * TRACES: UR-020 | DR-023 | UT-143, UT-144
* *
* The bug this guards: VideoPlayer rendered no `<track>` children at all (the * URLs are resolved into plain strings before they reach the player: the
* block was commented out "to debug playback issues"), so * original markup bound the *Promise* returned by an async function to a
* `Html5PlayerAdapter.selectSubtitle()` walked an empty `textTracks` list and * `<track src>`, so every track's src stringified to "[object Promise]".
* the subtitle menu was inert on Linux. The reason it had to be disabled is
* visible in the original markup — `src={getSubtitleUrl(track.index)}` bound the
* *Promise* returned by an async function to the attribute, so every track's src
* stringified to "[object Promise]", an unloadable resource hanging off the
* media element.
*
* So the fix has two halves and both are tested here: URLs must be resolved into
* plain strings *before* they reach the markup, and the markup must actually
* render the tracks (with the `data-stream-index` the adapter matches on).
*/ */
const SUBS: SubtitleStreamLike[] = [ const SUBS: SubtitleStreamLike[] = [
@@ -177,56 +166,6 @@ describe("resolveSubtitleTracks", () => {
}); });
}); });
describe("reconcileSelectedSubtitle", () => {
it("starts off (null) and keeps 'off' selectable", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
});
it("keeps a selection that is still renderable", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, 3)).toBe(3);
});
it("falls back to off when the selected track is gone (new item / failed URL)", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, 9)).toBeNull();
expect(reconcileSelectedSubtitle([], 3)).toBeNull();
});
it("never auto-selects the server's default track", async () => {
// The menu opens on "Off" and a <track default> would auto-show, so the UI
// would claim subtitles are off while they are burned over the picture.
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(tracks[0].isDefault).toBe(true);
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
});
});
describe("videoCrossOriginMode", () => {
it("opts into CORS for a server stream that has subtitles", () => {
expect(videoCrossOriginMode("http://jelly.example/Videos/x/master.m3u8", 2)).toBe("anonymous");
expect(videoCrossOriginMode("https://jelly.example/Videos/x/stream.mp4", 1)).toBe("anonymous");
});
it("leaves a local/offline source alone so playback cannot regress", () => {
expect(videoCrossOriginMode("asset://localhost/movie.mkv", 2)).toBeUndefined();
expect(videoCrossOriginMode("file:///home/u/movie.mkv", 2)).toBeUndefined();
});
it("stays out of the way when there is nothing to load", () => {
expect(videoCrossOriginMode("http://jelly.example/x.m3u8", 0)).toBeUndefined();
expect(videoCrossOriginMode("", 0)).toBeUndefined();
});
it("is decided by inputs known at first render, so it cannot flip mid-load", () => {
// Same answer before and after the async URL resolution completes.
const before = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
const after = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
expect(before).toBe(after);
});
});
/** /**
* Subtitles on the Android / ExoPlayer native path. * Subtitles on the Android / ExoPlayer native path.
* *
@@ -320,31 +259,6 @@ describe("nativeSubtitleArrayIndex", () => {
}); });
}); });
describe("VideoPlayer markup (the regression that made the menu inert)", () => {
const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8");
it("renders <track> elements instead of leaving them commented out", () => {
expect(source).not.toContain("Temporarily disabled to debug playback issues");
expect(source).toMatch(/<track\b/);
expect(source).toContain('kind="subtitles"');
});
/** The rendered element, not a `<track>` mentioned in prose. */
const trackElement = source.slice(source.search(/<track\s/), source.search(/<track\s/) + 400);
it("keeps data-stream-index — Html5PlayerAdapter.selectSubtitle matches on it", () => {
expect(trackElement).toContain("data-stream-index");
});
it("never binds the async getSubtitleUrl() Promise to src", () => {
expect(source).not.toMatch(/src=\{\s*getSubtitleUrl\(/);
});
it("does not mark any track default (a default track auto-shows)", () => {
expect(trackElement).not.toMatch(/\bdefault=/);
});
});
/** /**
* The half of the Android fix that lives in the component: the resolved list has * The half of the Android fix that lives in the component: the resolved list has
* to actually be handed to `playerPlayItem`, and the index sent to the backend * to actually be handed to `playerPlayItem`, and the index sent to the backend
+6 -65
View File
@@ -125,72 +125,13 @@ export async function resolveSubtitleTracks(
return resolved.filter((t): t is RenderableSubtitleTrack => t !== null); return resolved.filter((t): t is RenderableSubtitleTrack => t !== null);
} }
/** // ===== The native player =====================================================
* The selection to keep once the rendered track list changes.
*
* Subtitles are OFF unless the user turns them on: `null` in, `null` out. The
* server's `isDefault` flag is deliberately NOT promoted to a selection (and the
* markup deliberately omits the `default` attribute, which would auto-show the
* track) — the menu opens on "Off", so auto-enabling would make the UI lie about
* what is on screen, and it would change behaviour for every user who has never
* asked for subtitles.
*
* A selection that is no longer renderable (new item, or a URL that failed to
* resolve) collapses to off, so the menu's checkmark can never point at a track
* that does not exist on the element.
*/
export function reconcileSelectedSubtitle(
tracks: readonly RenderableSubtitleTrack[],
selected: number | null,
): number | null {
if (selected === null) return null;
return tracks.some((t) => t.streamIndex === selected) ? selected : null;
}
function originOf(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
return parsed.origin;
} catch {
return null;
}
}
/**
* The `crossorigin` value for the `<video>` element, or undefined for none.
*
* Text-track fetches are CORS-enabled per the HTML spec and use the *media
* element's* CORS setting, so a cross-origin `<track>` never loads unless the
* element opts in. The webview page's origin is `tauri://localhost`, so every
* subtitle served by Jellyfin is cross-origin.
*
* Opting in is only safe when the media itself comes from an http(s) server —
* the same Jellyfin that already answers hls.js' cross-origin XHRs, so we know
* it sends the headers. For a local/offline source (`file:`/`asset:`) we leave
* the attribute off: subtitles staying dark there is the status quo, whereas
* forcing CORS onto the video fetch could break playback outright.
*
* Deliberately keyed on the *count of subtitle streams* rather than on the
* resolved tracks: both inputs are known at first render, so the attribute is
* decided before the element starts loading and never flips underneath an
* in-flight media fetch.
*/
export function videoCrossOriginMode(
streamUrl: string,
subtitleStreamCount: number,
): "anonymous" | undefined {
if (subtitleStreamCount <= 0) return undefined;
return originOf(streamUrl) ? "anonymous" : undefined;
}
// ===== Native (Android / ExoPlayer) path ====================================
// //
// The HTML5 element gets `<track>` children; the native backend instead gets the // The native backend gets the list *up front*, as part of the play request:
// list *up front*, as part of the play request, because ExoPlayer sideloads // ExoPlayer sideloads subtitles as `MediaItem.SubtitleConfiguration`s that must
// subtitles as `MediaItem.SubtitleConfiguration`s that must exist before // exist before `prepare()`, and mpv queues them as external files for the load.
// `prepare()`. There is no "add a subtitle later" — a track absent from the // There is no "add a subtitle later" — a track absent from the request simply
// MediaItem simply does not exist as far as the player is concerned. // does not exist as far as the player is concerned.
/** /**
* Map resolved tracks onto the wire shape `PlayItemRequest.subtitles` carries. * Map resolved tracks onto the wire shape `PlayItemRequest.subtitles` carries.
@@ -1,45 +0,0 @@
import { describe, it, expect } from "vitest";
import { shouldApplyTimeUpdate } from "./timeTracking";
/**
* TRACES: UT-245 | DR-265
*/
describe("shouldApplyTimeUpdate", () => {
const base = { isPlaying: false, isSeeking: false, isDraggingSeekBar: false, readyState: 4 };
it("applies the update while the video is PLAYING", () => {
// THE REPORTED BUG. `timeupdate` was the only position source that still
// fires once requestAnimationFrame stops -- which is exactly what happens
// when the activity is paused behind a picture-in-picture window. Gating it
// on `!isPlaying` disabled it precisely when it was the only thing left,
// so the component's `currentTime` froze at the moment PiP was entered
// while the element played on. The background-audio handoff then resumed
// the audio-only stream at that frozen position.
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true })).toBe(true);
});
it("still applies the update while paused", () => {
// The case it always handled: RAF is stopped, timeupdate carries the seek.
expect(shouldApplyTimeUpdate(base)).toBe(true);
});
it("yields to an in-flight seek", () => {
// A seek owns the position until it settles; a stale element read landing
// mid-seek is what makes a scrubbed video snap back.
expect(shouldApplyTimeUpdate({ ...base, isSeeking: true })).toBe(false);
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isSeeking: true })).toBe(false);
});
it("yields while the user is dragging the seek bar", () => {
expect(shouldApplyTimeUpdate({ ...base, isDraggingSeekBar: true })).toBe(false);
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isDraggingSeekBar: true })).toBe(
false,
);
});
it("ignores an element with no usable data yet", () => {
// readyState < HAVE_CURRENT_DATA reads 0, which would rewind the position.
expect(shouldApplyTimeUpdate({ ...base, readyState: 1 })).toBe(false);
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, readyState: 0 })).toBe(false);
});
});
-45
View File
@@ -1,45 +0,0 @@
/**
* Pure helpers for keeping the player's position variable honest.
*
* TRACES: UR-004, UR-041 | DR-265 | UT-245
*
* `VideoPlayer.svelte` tracks the absolute playback position in its own
* `currentTime` variable rather than reading `videoElement.currentTime` at the
* point of use — transcoded HLS resets the element to 0 on every segment
* rebuild, so only the component's running total is meaningful. Everything
* downstream reads that variable: the seek bar, the progress reports, the
* position mirrored into Rust, and the background-audio handoff.
*
* Which makes "who is allowed to write it" a correctness question, not a
* rendering detail — hence a pure module with tests rather than a condition
* buried in an event handler.
*/
export interface TimeUpdateGate {
/**
* Deliberately does NOT gate the update, and is accepted only to say so.
*
* `timeupdate` was written as a fallback "for when RAF isn't running" and so
* excluded itself whenever `isPlaying` was true. But RAF is driven by the
* document being rendered, and an Android activity behind a picture-in-picture
* window is paused: the loop stops while the element plays on, and the one
* remaining position source had switched itself off. Both writing the same
* derived value costs nothing — the element is the authority either way.
*/
isPlaying?: boolean;
isSeeking: boolean;
isDraggingSeekBar: boolean;
readyState: number;
}
/**
* Whether a `timeupdate` event may write the component's position.
*
* Kept free of Svelte/DOM so the rule is unit-testable without mounting the
* player.
*/
export function shouldApplyTimeUpdate(opts: TimeUpdateGate): boolean {
// An in-flight seek or a drag owns the position until it settles, and an
// element with no current data reads 0, which would rewind it.
return !opts.isSeeking && !opts.isDraggingSeekBar && opts.readyState >= 2;
}
@@ -1,21 +0,0 @@
import { describe, it, expect } from "vitest";
import { videoFitClass } from "./videoFit";
describe("videoFitClass", () => {
it("fills the container instead of capping at the source's intrinsic size", () => {
const cls = videoFitClass();
// max-w/max-h only shrink oversized media; a 480p source would stay a small
// box in the middle of a large window.
expect(cls).not.toContain("max-w-full");
expect(cls).not.toContain("max-h-full");
expect(cls).toContain("w-full");
expect(cls).toContain("h-full");
});
it("preserves aspect ratio while fitting (letterbox, never crop)", () => {
const cls = videoFitClass();
expect(cls).toContain("object-contain");
expect(cls).not.toContain("object-cover");
expect(cls).not.toContain("object-fill");
});
});
-17
View File
@@ -1,17 +0,0 @@
// Sizing rules for the HTML5 <video> element in the full-screen player.
// Extracted from VideoPlayer.svelte so the fit behaviour is unit-testable.
/**
* Classes applied to the <video> element so it fits the player viewport.
*
* TRACES: UR-005
*
* `max-w-full max-h-full` only ever *shrinks* oversized media, so a source
* smaller than the window (e.g. 480p on a 1080p display) rendered at its
* intrinsic size - a small box in the middle of a black screen. Filling the
* container and letting `object-contain` do the scaling fits the picture to
* whichever axis constrains it, in both directions, preserving aspect ratio.
*/
export function videoFitClass(): string {
return "w-full h-full object-contain";
}
@@ -1,95 +0,0 @@
/**
* Adapter-selection regression guards.
*
* TRACES: UR-003, UR-004 | DR-150 | UT-149
*
* The selection rule has two inputs and one hard safety property:
*
* - Rust says which backend the platform has (`backendKind`).
* - The user opts in with `experimentalNativeVideo`.
* - **The flag off must force HTML5 even when Rust says native.** That is the
* regression guard: a broken spike must not be able to ship as the default.
*
* These are pure functions, so the whole matrix is testable without a device.
*/
import { describe, expect, it } from "vitest";
import { createAdapter } from "./index";
import { Html5PlayerAdapter } from "./html5Adapter";
import { NativePlayerAdapter } from "./nativeAdapter";
import type { AdapterHost } from "./types";
const host: AdapterHost = {
reportState: () => {},
reportPosition: () => {},
reportEnded: () => {},
} as unknown as AdapterHost;
const bridge = {
getElement: () => null,
} as any;
describe("createAdapter", () => {
it("returns the native adapter when Rust says native and the flag is on", () => {
const adapter = createAdapter({
backendKind: "native",
host,
bridge,
experimentalNativeVideo: true,
});
expect(adapter).toBeInstanceOf(NativePlayerAdapter);
expect(adapter.kind).toBe("native");
});
// The regression guard: the flag is a suppressor, so off must beat Rust.
it("forces HTML5 when the flag is off even though Rust says native", () => {
const adapter = createAdapter({
backendKind: "native",
host,
bridge,
experimentalNativeVideo: false,
});
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
expect(adapter.kind).toBe("html5");
});
it("returns the HTML5 adapter when Rust says html5 and the flag is off", () => {
const adapter = createAdapter({
backendKind: "html5",
host,
bridge,
experimentalNativeVideo: false,
});
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
});
// The flag must never *promote* a platform Rust said has no native backend
// (e.g. Linux, where WebKitGTK cannot composite a surface behind the webview).
it("stays on HTML5 when Rust says html5 even with the flag on", () => {
const adapter = createAdapter({
backendKind: "html5",
host,
bridge,
experimentalNativeVideo: true,
});
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
});
it("defaults to HTML5 when the flag is omitted entirely", () => {
const adapter = createAdapter({ backendKind: "native", host, bridge });
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
});
it("requires a bridge for the HTML5 adapter", () => {
expect(() =>
createAdapter({ backendKind: "html5", host, experimentalNativeVideo: false }),
).toThrow(/bridge/i);
});
// The native adapter owns no DOM element, so it must not demand a bridge.
it("does not require a bridge for the native adapter", () => {
expect(() =>
createAdapter({ backendKind: "native", host, experimentalNativeVideo: true }),
).not.toThrow();
});
});
@@ -1,340 +0,0 @@
/**
* Unit tests for Html5PlayerAdapter.
*
* The Option-1 primitive design makes the adapter pure, decision-free mechanics
* — it takes a mock <video> element + bridge + host, so we can assert each
* primitive drives the element correctly without any real DOM or backend.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
import type { AdapterHost } from "./types";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
* what these paths exercised before the contract carried a transport.
*/
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
return {
url,
transport: { type: transport },
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
rendition: null,
available: [],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: transport === "hls",
} as import("$lib/api/bindings").StreamSelection;
}
/** A minimal fake <video> element that records mutations and fires events. */
function makeFakeVideo() {
const listeners: Record<string, Array<() => void>> = {};
const el: any = {
paused: true,
currentTime: 0,
volume: 1,
muted: false,
src: "blob:existing",
play: vi.fn(async () => {
el.paused = false;
}),
pause: vi.fn(() => {
el.paused = true;
}),
load: vi.fn(),
removeAttribute: vi.fn((attr: string) => {
if (attr === "src") el.src = "";
}),
addEventListener: (event: string, cb: () => void) => {
(listeners[event] ??= []).push(cb);
},
removeEventListener: (event: string, cb: () => void) => {
listeners[event] = (listeners[event] ?? []).filter((f) => f !== cb);
},
// Test helper: fire an event so waitForEvent resolves immediately.
_fire: (event: string) => {
(listeners[event] ?? []).slice().forEach((f) => f());
},
querySelectorAll: () => [] as any,
textTracks: [] as any,
};
return el;
}
type FakeVideo = ReturnType<typeof makeFakeVideo>;
function makeBridge(overrides: Partial<Html5ElementBridge> = {}): Html5ElementBridge {
let offset = 0;
return {
getElement: () => null,
getSeekOffset: () => offset,
setSeekOffset: vi.fn((o: number) => {
offset = o;
}),
setStreamSelection: vi.fn(),
destroyHls: vi.fn(),
getMediaSourceId: () => "msid-1",
...overrides,
};
}
function makeHost(): AdapterHost {
return {
onState: vi.fn(),
onPosition: vi.fn(),
onMediaLoaded: vi.fn(),
onEnded: vi.fn(),
onError: vi.fn(),
onStreamUrlChanged: vi.fn(),
onBuffering: vi.fn(),
onReady: vi.fn(),
};
}
describe("Html5PlayerAdapter", () => {
let host: AdapterHost;
let bridge: Html5ElementBridge;
let adapter: Html5PlayerAdapter;
let video: ReturnType<typeof makeFakeVideo>;
beforeEach(() => {
host = makeHost();
bridge = makeBridge();
adapter = new Html5PlayerAdapter(host, bridge);
video = makeFakeVideo();
adapter.attach(video);
});
it("is an html5-kind adapter", () => {
expect(adapter.kind).toBe("html5");
});
it("play() calls element.play()", async () => {
await adapter.play();
expect(video.play).toHaveBeenCalledTimes(1);
});
// A stalling HLS stream makes hls.js' gap-controller nudge the element, which
// aborts an in-flight play(). That AbortError is transient — the element is
// still trying to play — so it must not be surfaced as a player error, or the
// UI reports failure ~once a second for the whole stall.
it("play() does not report an interrupted-by-pause AbortError as an error", async () => {
const abort = new DOMException(
"The play() request was interrupted by a call to pause().",
"AbortError",
);
video.play = vi.fn(async () => {
throw abort;
});
await adapter.play();
expect(host.onError).not.toHaveBeenCalled();
});
it("play() still reports a genuine failure", async () => {
video.play = vi.fn(async () => {
throw new DOMException("no supported source", "NotSupportedError");
});
await adapter.play();
expect(host.onError).toHaveBeenCalledTimes(1);
expect(String((host.onError as any).mock.calls[0][0])).toContain("play() failed");
});
it("play() coalesces concurrent attempts into one element.play() call", async () => {
// During a stall the UI and recovery paths can both ask to play. Stacking
// element.play() calls is what generates the AbortError storm.
let resolvePlay: () => void = () => {};
video.play = vi.fn(
() =>
new Promise<void>((r) => {
resolvePlay = () => {
video.paused = false;
r();
};
}),
);
const first = adapter.play();
const second = adapter.play();
resolvePlay();
await Promise.all([first, second]);
expect(video.play).toHaveBeenCalledTimes(1);
});
it("play() works again after a previous attempt settled", async () => {
await adapter.play();
await adapter.play();
expect(video.play).toHaveBeenCalledTimes(2);
});
it("pause() calls element.pause()", async () => {
video.paused = false;
await adapter.pause();
expect(video.pause).toHaveBeenCalledTimes(1);
});
it("toggle() plays when paused and reports the resulting state", async () => {
video.paused = true;
const playing = await adapter.toggle();
expect(video.play).toHaveBeenCalled();
expect(playing).toBe(true);
});
it("toggle() pauses when playing", async () => {
video.paused = false;
const playing = await adapter.toggle();
expect(video.pause).toHaveBeenCalled();
expect(playing).toBe(false);
});
it("seekElement() sets currentTime, offset, and waits for 'seeked'", async () => {
const p = adapter.seekElement(42, 0);
expect(video.currentTime).toBe(42);
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
video._fire("seeked"); // resolve the wait
await p;
});
it("reloadSource() runs the invariant teardown->swap->resume sequence", async () => {
video.paused = false; // was playing → should resume
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
// Teardown happened synchronously before the awaited canplay wait.
expect(video.pause).toHaveBeenCalled();
expect(bridge.destroyHls).toHaveBeenCalledTimes(1);
expect(video.removeAttribute).toHaveBeenCalledWith("src");
expect(video.load).toHaveBeenCalled();
// Allow the internal 100ms settle delay, then fire canplay to resume.
await new Promise((r) => setTimeout(r, 110));
expect(bridge.setStreamSelection).toHaveBeenCalledWith(
expect.objectContaining({ url: "http://new/master.m3u8", transport: { type: "hls" } }),
);
video._fire("canplay");
video._fire("seeked");
await p;
expect(video.play).toHaveBeenCalled(); // resumed because it was playing
});
/**
* The reload lands the viewer at the position they asked for — by *seeking*,
* with no transcode offset left over.
*
* This used to be inverted: the offset was set to the position and nothing
* seeked, which was right only while the reloaded URL itself began there via
* `StartTimeTicks`. DR-181 removes that parameter, because on an HLS playlist
* the server copies it onto every segment URI and then rejects each one with
* `400`. With the URL starting at the item's zero, the old arithmetic leaves
* `currentTime = offset + 0` — the scrubber reading 20:00 over the opening
* titles, and the seek silently never happening.
*
* TRACES: UR-004, UR-005 | DR-181 | UT-183
*/
it("reloadSource() seeks to the position and clears the transcode offset", async () => {
video.paused = false;
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 1200);
await new Promise((r) => setTimeout(r, 110));
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
expect(bridge.setSeekOffset).not.toHaveBeenCalledWith(1200);
// Nothing may seek before the new source is playable — the element drops it.
expect(video.currentTime).not.toBe(1200);
video._fire("canplay");
await new Promise((r) => setTimeout(r, 0));
expect(video.currentTime).toBe(1200);
video._fire("seeked");
await p;
expect(video.play).toHaveBeenCalled();
});
/** A reload to the very start has nothing to seek to; it must not stall. */
it("reloadSource() at position 0 does not wait for a seek", async () => {
video.paused = false;
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 0);
await new Promise((r) => setTimeout(r, 110));
video._fire("canplay");
await p; // resolves without any "seeked" event
expect(video.play).toHaveBeenCalled();
});
/**
* A reload that never becomes playable must be reported as a failure. It used
* to resolve on the timeout, so a quality switch whose new stream the server
* refused to serve (Jellyfin 400s the first segment when two transcode jobs
* collide) looked like a success: the picker showed the new quality selected
* over a stream that never played, and the caller had nothing to revert to.
*
* TRACES: UR-074 | DR-177 | UT-175
*/
it("reloadSource() rejects when the new stream never becomes playable", async () => {
vi.useFakeTimers();
try {
video.paused = false;
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
const assertion = expect(p).rejects.toThrow(/canplay/i);
await vi.advanceTimersByTimeAsync(11_000); // past the 10s readiness budget
await assertion;
expect(video.play).not.toHaveBeenCalled(); // nothing to resume into
} finally {
vi.useRealTimers();
}
});
it("reloadSource() does not resume when it was paused", async () => {
video.paused = true;
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 30);
await new Promise((r) => setTimeout(r, 110));
video._fire("canplay");
video._fire("seeked");
await p;
expect(video.play).not.toHaveBeenCalled();
});
it("setVolume() clamps to 0..1", () => {
adapter.setVolume(1.5);
expect(video.volume).toBe(1);
adapter.setVolume(-0.5);
expect(video.volume).toBe(0);
adapter.setVolume(0.4);
expect(video.volume).toBeCloseTo(0.4);
});
it("setMuted() sets the element muted flag", () => {
adapter.setMuted(true);
expect(video.muted).toBe(true);
});
it("getPosition() returns element time plus the transcode offset", () => {
video.currentTime = 10;
(bridge.getSeekOffset as any) = () => 100;
// Rebuild adapter with the offset-returning bridge.
const a = new Html5PlayerAdapter(host, bridge);
a.attach(video);
expect(a.getPosition()).toBe(110);
});
it("dispose() tears down hls and clears the element", async () => {
await adapter.dispose();
expect(bridge.destroyHls).toHaveBeenCalled();
expect(video.pause).toHaveBeenCalled();
// After dispose, primitives are no-ops (element detached).
await adapter.play();
// play was called once during dispose teardown? no — play only on reload/resume.
expect(video.play).not.toHaveBeenCalled();
});
it("primitives are safe no-ops before an element is attached", async () => {
const bare = new Html5PlayerAdapter(host, bridge);
await expect(bare.play()).resolves.toBeUndefined();
await expect(bare.pause()).resolves.toBeUndefined();
await expect(bare.seekElement(5, 0)).resolves.toBeUndefined();
expect(await bare.toggle()).toBe(false);
});
});
-317
View File
@@ -1,317 +0,0 @@
import type { StreamSelection } from "$lib/api/bindings";
/**
* Html5PlayerAdapter — the Linux/desktop (and interim Android) PlayerAdapter
* implementation. It owns the high-level control surface for an HTML5 `<video>`
* element and reports the element's lifecycle back into Rust via its
* {@link AdapterHost}.
*
* Design note on the split with VideoPlayer.svelte:
* The delicate, timing-sensitive parts (hls.js instance lifecycle, the transcode
* "reload stream" seek/audio-track dance with its dual-audio teardown and
* canplay waits) are inherently coupled to Svelte reactive state and the DOM
* element. Rather than relocate that reactive machinery wholesale (high
* regression risk), the adapter receives an {@link Html5ElementBridge} of narrow
* callbacks the owning component supplies. The adapter is the single OWNER of the
* control contract (play/pause/seek/track/volume) and of reporting; the bridge is
* the seam to the component's element/HLS/reactive state. This keeps all control
* intents flowing through the PlayerAdapter interface while preserving the
* hard-won element behavior verbatim.
*
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028, DR-096
*/
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("Html5PlayerAdapter");
/**
* The selection for a plain `load(url)` call.
*
* `PlayerLoadOptions` carries the backend's selection when the caller has one.
* When it does not — a local file, a live stream, a direct URL — the transport
* is inferred *once, here*, from what the caller already knows rather than from
* the URL text: a local path is a local file, and anything the backend flagged
* as transcoded is HLS, because every transcode this app requests is HLS.
*
* This is the one place a fallback is tolerable, and it is explicitly a
* fallback: the negotiated path never reaches it.
*
* TRACES: UR-079 | DR-225
*/
function selectionForLoad(streamUrl: string, options: PlayerLoadOptions): StreamSelection {
if (options.selection) return options.selection;
const transport: StreamSelection["transport"] = options.isLocalFile
? { type: "localFile" }
: options.needsTranscoding
? { type: "hls" }
: { type: "progressive" };
return {
url: streamUrl,
transport,
playbackKind: options.needsTranscoding ? { type: "transcode" } : { type: "directPlay" },
rendition: null,
available: [],
mediaSourceId: options.mediaSourceId ?? null,
playSessionId: null,
needsTranscoding: options.needsTranscoding,
};
}
/**
* Narrow seam the owning component provides so the adapter can execute the
* element/HLS-coupled parts of a control action without re-implementing the
* component's reactive HLS lifecycle. Every function here is a thin wrapper over
* work the component already does.
*/
export interface Html5ElementBridge {
/** The bound <video> element, or null before mount / after teardown. */
getElement(): HTMLVideoElement | null;
/** Current seek offset (seconds) for transcoded streams. */
getSeekOffset(): number;
setSeekOffset(offset: number): void;
/**
* Update the stream the component renders (triggers its HLS $effect).
*
* Carries the whole [`StreamSelection`], not just the URL: the component's
* effect has to know the transport to choose a loader, and deriving that from
* the URL is the substring check DR-225 removes.
*
* TRACES: UR-079 | DR-225
*/
setStreamSelection(selection: StreamSelection): void;
/** Tear down the component-owned hls.js instance (dual-audio prevention). */
destroyHls(): void;
/** Media source id for seek/audio-track URLs. */
getMediaSourceId(): string | null;
}
/**
* True for the `AbortError` the browser raises when a pending `play()` promise is
* cancelled by a `pause()` (or a source/seek change). It signals "that specific
* play attempt was superseded", not "playback failed" — hls.js' stall recovery
* produces it routinely, so it must not reach the player's error channel.
*/
function isPlayInterruptedError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const { name, message } = err as { name?: string; message?: string };
return name === "AbortError" || (message ?? "").includes("interrupted");
}
export class Html5PlayerAdapter implements PlayerAdapter {
readonly kind = "html5" as const;
private attachedElement: HTMLVideoElement | null = null;
/** In-flight play() attempt, so concurrent callers share one element.play(). */
private pendingPlay: Promise<void> | null = null;
private host: AdapterHost;
private bridge: Html5ElementBridge;
constructor(host: AdapterHost, bridge: Html5ElementBridge) {
this.host = host;
this.bridge = bridge;
}
/**
* Resolve the LIVE <video> element. The bridge's `getElement()` returns the
* component's current reactive `videoElement`, which is authoritative: the
* element can be re-bound when the {#if} block re-renders, so a value captured
* once in `attach()` may go stale (this caused play/pause to silently no-op).
* Falls back to the attach()-captured element for unit tests whose bridge
* returns null.
*/
private get element(): HTMLVideoElement | null {
return this.bridge.getElement() ?? this.attachedElement;
}
attach(element: HTMLVideoElement | null): void {
this.attachedElement = element;
}
async load(streamUrl: string, options: PlayerLoadOptions): Promise<void> {
// The component's reactive HLS $effect performs the actual attach/load when
// the selection is set; loading is therefore driven by setStreamSelection.
// The component's canplay/frag-buffered path reports readiness through the
// host.
this.bridge.setSeekOffset(0);
this.bridge.setStreamSelection(selectionForLoad(streamUrl, options));
this.host.onState("loading");
}
async play(): Promise<void> {
const el = this.element;
if (!el) return;
// Coalesce concurrent attempts. While an HLS stream stalls, the UI and the
// gap-controller recovery path can both ask to play; stacking element.play()
// calls is what turns one stall into an AbortError storm.
if (this.pendingPlay) return this.pendingPlay;
this.pendingPlay = (async () => {
try {
await el.play();
// handlePlay on the element reports "playing"; no double-report here.
} catch (err) {
// A play() aborted by a pause() is transient, not a failure: hls.js
// nudges the element to recover from a stall, which cancels the pending
// play promise while the element keeps trying. Surfacing it would report
// an error roughly once a second for the duration of the stall.
if (isPlayInterruptedError(err)) {
log.debug("play() interrupted by pause (stall recovery)");
} else {
this.host.onError(`play() failed: ${err}`);
}
} finally {
this.pendingPlay = null;
}
})();
return this.pendingPlay;
}
async pause(): Promise<void> {
this.element?.pause();
}
async toggle(): Promise<boolean> {
const el = this.element;
if (!el) return false;
if (el.paused) {
await this.play();
return true;
}
await this.pause();
return false;
}
/**
* PRIMITIVE: in-place element seek (no reload). The backend already decided
* this seek does not need a transcode reload.
*/
async seekElement(positionSeconds: number, offset: number): Promise<void> {
const el = this.element;
if (!el) return;
el.currentTime = positionSeconds;
this.bridge.setSeekOffset(offset);
await this.waitForEvent(el, "seeked", 2000);
}
/**
* PRIMITIVE: compound reload — swap the source and resume at
* `positionSeconds`, an **absolute** position on the item's own timeline.
* Contains NO strategy decision; the backend already decided to reload and
* supplied the url/position. Preserves the hard-won dual-audio teardown and
* canplay wait.
*
* The position is reached by *seeking the element*, and the transcode offset
* is cleared to zero. It used to be the other way round — the offset was set
* to the position and nothing seeked — which was correct only while the
* reloaded URL itself began there, via `StartTimeTicks`. DR-181 removes that
* parameter (on an HLS playlist it makes the server reject every segment with
* `400`), so a reloaded stream now always starts at the beginning of the item.
* Leaving the old arithmetic in place would have left `currentTime` reading
* `offset + 0` — the scrubber showing 20:00 while the opening titles play, and
* no seek ever happening.
*
* TRACES: UR-004, UR-005 | DR-181 | UT-183
*/
async reloadSource(selection: StreamSelection, positionSeconds: number): Promise<void> {
const el = this.element;
if (!el) {
// Still update the selection so the component's HLS $effect can pick it up.
this.bridge.setSeekOffset(0);
this.bridge.setStreamSelection(selection);
return;
}
const wasPlaying = !el.paused;
el.pause();
this.bridge.destroyHls();
if (el.src) {
el.removeAttribute("src");
el.load();
}
await new Promise((r) => setTimeout(r, 100));
// The reloaded stream begins at the item's zero, so there is no base to add.
this.bridge.setSeekOffset(0);
this.bridge.setStreamSelection(selection);
// A source that never becomes playable is a failed reload, not a slow one:
// the caller (quality switch, transcoded seek) has to know so it can revert
// its selection and surface the error instead of leaving the UI claiming a
// stream that is not playing.
const ready = await this.waitForEvent(el, "canplay", 10000);
if (!ready) {
throw new Error(`Reloaded stream never fired "canplay" within 10000ms`);
}
// Now that the new source is playable, put it where the caller asked for.
// Seeking before `canplay` is dropped by the element, which is why this
// follows the wait rather than riding along with the URL swap.
if (positionSeconds > 0) {
el.currentTime = positionSeconds;
await this.waitForEvent(el, "seeked", 2000);
}
if (wasPlaying) await el.play();
}
setVolume(volume: number): void {
if (this.element) this.element.volume = Math.max(0, Math.min(1, volume));
}
setMuted(muted: boolean): void {
if (this.element) this.element.muted = muted;
}
/** Subtitle selection: HTML5 toggles textTracks on the element directly. */
async selectSubtitle(streamIndex: number | null, _arrayIndex?: number): Promise<void> {
const el = this.element;
if (!el || !el.textTracks) return;
for (let i = 0; i < el.textTracks.length; i++) {
el.textTracks[i].mode = "disabled";
}
if (streamIndex !== null) {
const tracks = el.querySelectorAll("track");
tracks.forEach((track) => {
const idx = parseInt(track.getAttribute("data-stream-index") || "-1");
if (idx === streamIndex && track.track) {
track.track.mode = "showing";
}
});
}
}
getPosition(): number {
const el = this.element;
if (!el) return 0;
return el.currentTime + this.bridge.getSeekOffset();
}
async dispose(): Promise<void> {
this.bridge.destroyHls();
const el = this.element;
if (el) {
el.pause();
el.removeAttribute("src");
el.load();
}
this.attachedElement = null;
}
/** Resolve when `event` fires on `el`, or after `timeoutMs` as a fallback. */
/**
* Resolves `true` when the event fires, `false` if the budget runs out. The
* distinction is the caller's to act on: a missing `seeked` is cosmetic, a
* missing `canplay` means the reload failed.
*/
private waitForEvent(el: HTMLVideoElement, event: string, timeoutMs: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const done = (fired: boolean) => {
el.removeEventListener(event, listener);
clearTimeout(timer);
resolve(fired);
};
const listener = () => done(true);
el.addEventListener(event, listener);
// `done` closes over `timer`, but can only run once the listener fires or
// the timeout elapses — both strictly after this assignment.
const timer: ReturnType<typeof setTimeout> = setTimeout(() => done(false), timeoutMs);
});
}
}
+7 -66
View File
@@ -1,73 +1,14 @@
/** /**
* Player adapter factory + public exports. * Player adapter public exports.
* *
* `createAdapter` selects the concrete PlayerAdapter for the current platform. * Video is always drawn by a native player — mpv on the desktop, ExoPlayer on
* Rust decides *which backend this platform has* (`useHtml5Element` from * Android — behind the transparent webview, so there is one video adapter,
* `player_play_item`); this factory consumes that decision rather than * `NativePlayerAdapter`. The webview `<video>` adapter and the factory that
* re-deriving it. * chose between the two were deleted with that path (DR-235).
* `WebviewAudioAdapter` remains for audio on a desktop without mpv.
* *
* The `experimentalNativeVideo` flag is a **suppressor, never a promoter**: it * TRACES: UR-003, UR-004 | DR-004, DR-235
* can force the HTML5 path when Rust says native (so an in-progress spike cannot
* ship as a regression), but it can never select native on a platform whose Rust
* backend reported HTML5 — Linux has no way to composite a surface behind a
* WebKitGTK webview, so promoting there would produce a black screen.
*
* The previous unconditional HTML5 override cited tauri#10152 as an upstream
* blocker. That was stale: #10152 is a dormant *feature request*, the capability
* shipped in tauri 27d01834, and the black-screen bug (tauri#8381, #9408) was a
* broken `setBackgroundColor` JNI signature fixed in wry 0.39.4 — we ship 0.53.x.
*
* TRACES: UR-003, UR-004 | DR-004, DR-150 | UT-149
*/ */
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
import { NativePlayerAdapter } from "./nativeAdapter";
import type { AdapterHost, PlayerAdapter } from "./types";
export type { PlayerAdapter, AdapterHost, PlayerLoadOptions, SubtitleTrackInput } from "./types"; export type { PlayerAdapter, AdapterHost, PlayerLoadOptions, SubtitleTrackInput } from "./types";
export type { Html5ElementBridge } from "./html5Adapter";
export { Html5PlayerAdapter } from "./html5Adapter";
export { NativePlayerAdapter } from "./nativeAdapter"; export { NativePlayerAdapter } from "./nativeAdapter";
/** What the Rust `player_play_item` response says it chose. */
export type BackendKind = "html5" | "native";
export interface CreateAdapterArgs {
/** Backend kind reported by `player_play_item` (`useHtml5Element`). */
backendKind: BackendKind;
host: AdapterHost;
/** Required for the HTML5 adapter; ignored by the native adapter. */
bridge?: Html5ElementBridge;
/**
* User opt-in for the native video path. Defaults to **off**, so omitting it
* yields today's behaviour (HTML5 everywhere) rather than silently enabling
* the spike.
*/
experimentalNativeVideo?: boolean;
}
/**
* Build the adapter for this platform/stream.
*
* Native is chosen only when Rust reports a native backend AND the user has
* opted in. Every other combination is HTML5.
*/
export function createAdapter({
backendKind,
host,
bridge,
experimentalNativeVideo = false,
}: CreateAdapterArgs): PlayerAdapter {
const effectiveKind: BackendKind =
backendKind === "native" && experimentalNativeVideo ? "native" : "html5";
if (effectiveKind === "native") {
// The native surface is owned by the backend — no DOM element, no bridge.
return new NativePlayerAdapter(host);
}
if (!bridge) {
throw new Error("createAdapter: Html5ElementBridge is required for the HTML5 adapter");
}
return new Html5PlayerAdapter(host, bridge);
}
+11 -24
View File
@@ -1,29 +1,19 @@
import type { StreamSelection } from "$lib/api/bindings"; import type { StreamSelection } from "$lib/api/bindings";
/** /**
* NativePlayerAdapter — the Android/ExoPlayer PlayerAdapter implementation. * NativePlayerAdapter — the video PlayerAdapter, for every platform.
* *
* ExoPlayer is driven entirely by the Rust backend (JNI), which already emits * The native player (mpv on the desktop, ExoPlayer on Android) is driven
* PlayerStatusEvents and handles seek/audio-track internally. So this adapter is * entirely by the Rust backend, which emits PlayerStatusEvents and handles
* a thin delegate to backend commands; there is no DOM element to touch and no * seek/audio-track/quality internally. So this adapter is a thin delegate to
* hls.js. State reporting is unnecessary here because the native backend emits * backend commands; there is no DOM element to touch. State reporting is
* events directly — the adapter's job is only to forward control intents. * unnecessary because the backend emits events directly — the adapter's job is
* only to forward control intents.
* *
* NOTE: This adapter is currently unreachable — `createAdapter()` hardcodes the * It used to be the opt-in alternative to an HTML5 `<video>` adapter; that path
* HTML5 kind, so Android video runs through Html5PlayerAdapter. * was deleted (DR-235), so this is the one video adapter. The compositing it
* relies on is described in docs/architecture/05-platform-backends.md.
* *
* That override was introduced citing tauri#10152 as an upstream blocker. That * TRACES: UR-003, UR-005 | DR-004, DR-028, DR-235
* is no longer accurate: #10152 is a stale *feature request* (dead since
* 2024-07-01) asking that `transparent` not be desktop-only, and the capability
* shipped in tauri commit 27d01834 (2024-09-02). The related black/white-screen
* bug (tauri#8381, #9408) was a broken JNI signature for setBackgroundColor,
* fixed in wry 0.39.4; we ship wry 0.55.x.
*
* What is genuinely unproven is SurfaceView-behind-WebView *compositing* on
* Tauri Android — nothing upstream blocks it, and nothing upstream demonstrates
* it either. docs/architecture/05-platform-backends.md ("Native Video
* Compositing") describes the path that shipped.
*
* TRACES: UR-003, UR-005 | DR-004, DR-028
*/ */
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
@@ -40,9 +30,6 @@ export class NativePlayerAdapter implements PlayerAdapter {
this.host = host; this.host = host;
} }
// The native surface is owned by the backend; nothing to attach in the DOM.
attach(_element: HTMLVideoElement | null): void {}
async load(_streamUrl: string, options: PlayerLoadOptions): Promise<void> { async load(_streamUrl: string, options: PlayerLoadOptions): Promise<void> {
// player_play_item already initiated native playback before this adapter is // player_play_item already initiated native playback before this adapter is
// created, so there is no stream to load here — but it carries no start // created, so there is no stream to load here — but it carries no start
+8 -17
View File
@@ -1,10 +1,10 @@
import type { StreamSelection } from "$lib/api/bindings"; import type { StreamSelection } from "$lib/api/bindings";
/** /**
* PlayerAdapter contract — the decoupled boundary between the UI/backend and a * PlayerAdapter contract — the decoupled boundary between the UI/backend and a
* concrete video player implementation (Linux HTML5+hls.js, or Android native). * concrete player implementation (the native video player, or webview audio).
* *
* The whole point: UI components and the Rust backend interact with video ONLY * The whole point: UI components and the Rust backend interact with video ONLY
* through this interface. All element / hls.js / ExoPlayer / textTracks detail — * through this interface. All player detail —
* and the backend seek/audio-track *strategy* round-trip — is internal to an * and the backend seek/audio-track *strategy* round-trip — is internal to an
* implementation. A control intent (from UI or a backend lockscreen/remote/sleep * implementation. A control intent (from UI or a backend lockscreen/remote/sleep
* event) reaches the element by the facade dispatching to the active adapter. * event) reaches the element by the facade dispatching to the active adapter.
@@ -16,7 +16,7 @@ import type { StreamSelection } from "$lib/api/bindings";
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028 * TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028
*/ */
/** A subtitle track handed to the adapter at load time (WebVTT for HTML5). */ /** A subtitle track handed to the adapter at load time (WebVTT). */
export interface SubtitleTrackInput { export interface SubtitleTrackInput {
index: number; index: number;
url: string; url: string;
@@ -90,14 +90,7 @@ export interface AdapterHost {
*/ */
export interface PlayerAdapter { export interface PlayerAdapter {
/** Which platform backend this adapter represents. */ /** Which platform backend this adapter represents. */
readonly kind: "html5" | "native"; readonly kind: "native" | "webview-audio";
/**
* Bind the output target. For the HTML5 adapter this is the `<video>` element
* (pass null on teardown); the native adapter ignores it (ExoPlayer renders to
* its own surface).
*/
attach(element: HTMLVideoElement | null): void;
/** Load a stream and begin playback at `options.initialPosition`. */ /** Load a stream and begin playback at `options.initialPosition`. */
load(streamUrl: string, options: PlayerLoadOptions): Promise<void>; load(streamUrl: string, options: PlayerLoadOptions): Promise<void>;
@@ -121,9 +114,7 @@ export interface PlayerAdapter {
/** /**
* Compound reload: swap to `selection` and resume at `offset` seconds. Runs * Compound reload: swap to `selection` and resume at `offset` seconds. Runs
* the invariant mechanical sequence for this platform (html5: pause → hls * the invariant mechanical sequence for this platform. No decision is made here — the backend
* teardown → clear src → set new selection → wait ready → resume; native:
* ExoPlayer setMediaItem + seekTo). No decision is made here — the backend
* already decided to reload, and `selection.transport` says how to open it, so * already decided to reload, and `selection.transport` says how to open it, so
* no adapter has to infer that from the URL. * no adapter has to infer that from the URL.
* *
@@ -134,12 +125,12 @@ export interface PlayerAdapter {
setVolume(volume: number): void; // 0..1 setVolume(volume: number): void; // 0..1
setMuted(muted: boolean): void; setMuted(muted: boolean): void;
/** Enable a subtitle track (null disables) — DOM textTracks is a webview primitive. */ /** Enable a subtitle track (null disables). */
selectSubtitle(streamIndex: number | null, arrayIndex?: number): Promise<void>; selectSubtitle(streamIndex: number | null, arrayIndex?: number): Promise<void>;
/** Current position in seconds (adapter's own truth, e.g. element.currentTime + offset). */ /** Current position in seconds (adapter's own truth). */
getPosition(): number; getPosition(): number;
/** Tear down: destroy hls, detach element, stop reporting. Idempotent. */ /** Tear down and stop reporting. Idempotent. */
dispose(): Promise<void>; dispose(): Promise<void>;
} }
+4 -10
View File
@@ -1,11 +1,9 @@
import type { StreamSelection } from "$lib/api/bindings"; import type { StreamSelection } from "$lib/api/bindings";
/** /**
* Webview audio adapter — plays audio-only media through a hidden `<audio>` * Webview audio adapter — plays audio-only media through a hidden `<audio>`
* element on platforms with no native audio backend (currently Windows). * element on a desktop with no native audio backend — none that ships: Linux
* * and Windows play through mpv, Android through ExoPlayer. There the Rust
* All *video* already renders through the webview `<video>` element on every * `WebviewAudioBackend` hands the stream URL
* platform; libmpv/ExoPlayer only drive audio-only playback. On Windows there is
* no native audio backend, so the Rust `WebviewAudioBackend` hands the stream URL
* to the frontend via a `webview_audio_load` event and drives play/pause/seek * to the frontend via a `webview_audio_load` event and drives play/pause/seek
* through `control_command`. This adapter owns the `<audio>` element that plays * through `control_command`. This adapter owns the `<audio>` element that plays
* it and reports state/position/duration/ended back to Rust through the same * it and reports state/position/duration/ended back to Rust through the same
@@ -22,7 +20,7 @@ import type { StreamSelection } from "$lib/api/bindings";
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types"; import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
export class WebviewAudioAdapter implements PlayerAdapter { export class WebviewAudioAdapter implements PlayerAdapter {
readonly kind = "html5" as const; readonly kind = "webview-audio" as const;
private audio: HTMLAudioElement; private audio: HTMLAudioElement;
private host: AdapterHost; private host: AdapterHost;
@@ -118,10 +116,6 @@ export class WebviewAudioAdapter implements PlayerAdapter {
}); });
} }
attach(_element: HTMLVideoElement | null): void {
// The audio element is owned by the controller, not attached here.
}
setVolume(volume: number): void { setVolume(volume: number): void {
this.audio.volume = Math.max(0, Math.min(1, volume)); this.audio.volume = Math.max(0, Math.min(1, volume));
} }
-19
View File
@@ -1,19 +0,0 @@
/**
* Compatibility shim.
*
* The HTML5 → Rust reporting functions moved to `adapters/rustReportHost.ts` as
* part of the PlayerAdapter refactor. Existing callers import the reporter as
* `import * as html5Adapter from "$lib/player/html5Adapter"`; this shim keeps
* that working while the migration proceeds. New adapter code should depend on
* the `AdapterHost` interface (see `adapters/types.ts`) instead.
*/
export {
reportState,
reportPosition,
reportMediaLoaded,
resetReporting,
} from "./adapters/rustReportHost";
/** @deprecated states are defined on the AdapterHost interface now. */
export type Html5PlayerState = "playing" | "paused" | "loading" | "stopped" | "idle";
+20 -43
View File
@@ -118,21 +118,21 @@ async function stop() {
} }
async function seek(positionSeconds: number) { async function seek(positionSeconds: number) {
// Audio path: backend seeks the native backend directly. // Audio (and webview audio): the backend seeks its player directly.
if (!activeAdapter) { if (activeAdapter?.kind !== "native") {
await commands.playerSeek(positionSeconds); await commands.playerSeek(positionSeconds);
return; return;
} }
// Video path: ask the backend to DECIDE the strategy (in-place vs reload), then // Video: the backend decides the strategy (in place vs re-open) and carries
// execute the matching adapter primitive. The decision logic stays in Rust // it out (player_seek_video).
// (player_seek_video); the adapter only runs the chosen mechanical primitive.
await seekVideo(positionSeconds, null, null); await seekVideo(positionSeconds, null, null);
} }
/** /**
* Video seek: backend decides strategy, facade dispatches the chosen adapter * Video seek. The backend decides whether the stream can be moved in place or
* primitive. `mediaSourceId`/`audioTrackIndex` come from the video view (they are * has to be re-opened, and does either itself — every video renderer is a
* needed for the transcode reload URL). Requires an active video adapter. * native player (DR-235). `mediaSourceId`/`audioTrackIndex` come from the video
* view (they are needed for the re-open URL).
*/ */
async function seekVideo( async function seekVideo(
positionSeconds: number, positionSeconds: number,
@@ -144,27 +144,18 @@ async function seekVideo(
await commands.playerSeek(positionSeconds); await commands.playerSeek(positionSeconds);
return; return;
} }
const response = (await commands.playerSeekVideo( const response = await commands.playerSeekVideo(
requireHandle(), requireHandle(),
positionSeconds, positionSeconds,
mediaSourceId, mediaSourceId,
audioTrackIndex, audioTrackIndex,
adapter.kind === "html5", );
)) as any;
// Serde keeps `seek_offset` snake_case (only the "strategy" tag is camelCase).
if (response.strategy === "reloadStream") {
// `seek_offset` is the ABSOLUTE position to resume at, not a base to add to
// the element's clock: the reloaded stream starts at the item's zero since
// DR-181, so reloadSource seeks there. (The name is the wire field's.)
await adapter.reloadSource(response.selection, response.seek_offset ?? positionSeconds);
} else {
await adapter.seekElement(response.position ?? positionSeconds, 0); await adapter.seekElement(response.position ?? positionSeconds, 0);
}
} }
/** /**
* Switch audio track: backend decides (may reload the stream), facade dispatches * Switch audio track. The backend selects in place or re-opens the stream
* the resulting primitive. Requires an active video adapter. * itself. Requires an active video adapter.
*/ */
async function switchAudioTrack( async function switchAudioTrack(
streamIndex: number, streamIndex: number,
@@ -172,26 +163,20 @@ async function switchAudioTrack(
currentPosition: number | null, currentPosition: number | null,
mediaSourceId: string | null, mediaSourceId: string | null,
): Promise<void> { ): Promise<void> {
const adapter = activeAdapter; if (!activeAdapter) return;
if (!adapter) return; await commands.playerSwitchAudioTrack(
const response = (await commands.playerSwitchAudioTrack(
requireHandle(), requireHandle(),
streamIndex, streamIndex,
arrayIndex, arrayIndex,
adapter.kind === "html5",
currentPosition, currentPosition,
mediaSourceId, mediaSourceId,
)) as any; );
if (response.strategy === "reloadStream") {
await adapter.reloadSource(response.selection, response.position!);
}
} }
/** /**
* Change the bandwidth ceiling of the video playing now. The backend re-opens * Change the bandwidth ceiling of the video playing now. The backend re-opens
* the stream at the new quality and decides who reloads: it handles a native * the stream at the new quality and resumes it. Requires an active video
* backend itself, and hands HTML5 a selection for the same `reloadSource` * adapter.
* primitive the audio-track switch uses. Requires an active video adapter.
* *
* The change applies to **this playback only** — the backend sets a per-playback * The change applies to **this playback only** — the backend sets a per-playback
* override that the next item clears, leaving the durable Settings default * override that the next item clears, leaving the durable Settings default
@@ -207,22 +192,14 @@ async function setStreamQuality(
mediaSourceId: string | null, mediaSourceId: string | null,
audioTrackIndex: number | null, audioTrackIndex: number | null,
): Promise<StreamSelection | null> { ): Promise<StreamSelection | null> {
const adapter = activeAdapter; if (!activeAdapter) return null;
if (!adapter) return null; const response = await commands.playerSetStreamQuality(
const response = (await commands.playerSetStreamQuality(
requireHandle(), requireHandle(),
quality, quality,
adapter.kind === "html5",
currentPosition, currentPosition,
mediaSourceId, mediaSourceId,
audioTrackIndex, audioTrackIndex,
)) as any; );
if (response.strategy === "reloadStream") {
await adapter.reloadSource(response.selection, response.position ?? currentPosition ?? 0);
return response.selection;
}
// The native backend reloaded itself, but still reports what it opened — the
// caller needs it to show the rung actually in force.
return response.selection ?? null; return response.selection ?? null;
} }
-93
View File
@@ -1,93 +0,0 @@
/**
* The loader is chosen from the backend's `transport` tag, never from the URL.
*
* TRACES: UR-079 | DR-225 | UT-214
*/
import { describe, expect, it } from "vitest";
import { elementSrcFor, videoLoaderFor, type LoaderCapabilities } from "./streamTransport";
import type { StreamSelection, Transport } from "$lib/api/bindings";
const MODERN: LoaderCapabilities = { hlsJsSupported: true, nativeHlsSupported: false };
const SAFARI: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: true };
const NEITHER: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: false };
function selection(transport: Transport, url: string): Pick<StreamSelection, "url" | "transport"> {
return { url, transport };
}
describe("videoLoaderFor", () => {
it("attaches hls.js when the backend says HLS and hls.js is available", () => {
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe(
"hlsjs",
);
});
it("falls back to the element's own HLS loader when hls.js is unavailable", () => {
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
"nativeHls",
);
});
it("loads a progressive stream directly", () => {
expect(
videoLoaderFor(
selection({ type: "progressive" }, "https://s/Videos/1/stream?static=true"),
MODERN,
),
).toBe("direct");
});
it("loads a local file directly", () => {
expect(
videoLoaderFor(selection({ type: "localFile" }, "http://127.0.0.1:9/media/x.mkv"), MODERN),
).toBe("direct");
});
// ---------------------------------------------------------------------
// The two cases the `.m3u8` substring check gets wrong. These are the
// reason the field exists; both fail against a URL-sniffing implementation.
// ---------------------------------------------------------------------
it("does NOT attach hls.js to a progressive stream whose URL happens to end .m3u8", () => {
// A direct play served from a path containing the substring — nothing stops
// a server, a proxy, or a local cache from producing this.
expect(
videoLoaderFor(selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4"), MODERN),
).toBe("direct");
expect(
videoLoaderFor(selection({ type: "progressive" }, "https://s/x?name=master.m3u8"), MODERN),
).toBe("direct");
});
it("DOES attach hls.js to an HLS stream whose URL does not contain .m3u8", () => {
// Jellyfin's own transcoding URLs are not required to end in `.m3u8`, and a
// DASH or query-routed playlist endpoint never would.
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/Videos/1/hls"), MODERN)).toBe(
"hlsjs",
);
expect(
videoLoaderFor(selection({ type: "hls" }, "https://s/stream?format=playlist"), SAFARI),
).toBe("nativeHls");
});
it("falls back to direct when HLS is requested but nothing can play it", () => {
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), NEITHER)).toBe(
"direct",
);
});
});
describe("elementSrcFor", () => {
it("empties the element's src only when hls.js drives it", () => {
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe("");
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
"https://s/master.m3u8",
);
});
it("keeps the src for a progressive stream that looks like a playlist", () => {
const s = selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4");
expect(elementSrcFor(s, MODERN)).toBe("https://s/files/movie.m3u8.mp4");
});
});
-88
View File
@@ -1,88 +0,0 @@
/**
* Which loader opens a stream in the webview `<video>` element.
*
* Extracted from `VideoPlayer.svelte` so the decision can be unit-tested — the
* same pattern as `episodeStrip.ts` and `TrackList.logic.test.ts`.
*
* TRACES: UR-079 | DR-225 | UT-214
*/
import type { StreamSelection, Transport } from "$lib/api/bindings";
/** How the element should be fed. */
export type VideoLoader =
/** hls.js drives a MediaSource; the element's own `src` stays empty. */
| "hlsjs"
/** The element loads the playlist itself (Safari/WebKit native HLS). */
| "nativeHls"
/** The element loads the URL directly — a progressive file or a local one. */
| "direct";
/** What the running browser can do, passed in so the decision stays pure. */
export interface LoaderCapabilities {
/** `Hls.isSupported()` */
hlsJsSupported: boolean;
/** `video.canPlayType("application/vnd.apple.mpegurl")` was non-empty */
nativeHlsSupported: boolean;
}
/**
* Pick the loader from the backend's tagged `transport`.
*
* This used to read `url.includes(".m3u8")`, in two places in
* `VideoPlayer.svelte`. Rust *builds* that URL and knows exactly what it is;
* re-deriving the answer here by substring match is a domain fact reconstructed
* in the presentation layer — the same error as leaking item-type taxonomy, and
* one that fails silently in both directions: a progressive file served from a
* path containing `.m3u8` gets an HLS loader, and a playlist served from a path
* without it does not.
*
* The transport is the *stream's* property; whether a given loader exists is the
* *browser's*. Only the second is decided here.
*/
export function videoLoaderFor(
selection: Pick<StreamSelection, "url" | "transport">,
capabilities: LoaderCapabilities,
): VideoLoader {
return loaderForTransport(selection.transport.type, capabilities);
}
/**
* The same decision, taken from the transport *tag* alone.
*
* Exists because a Svelte `$effect` that reads the whole selection re-runs
* whenever the selection **object** is replaced — even with an identical URL and
* transport — and the HLS effect's teardown/rebuild is not idempotent: it
* destroys the hls.js instance and reattaches, which leaves the element with no
* video until something forces another cycle. The pre-DR-225 code read a plain
* URL *string*, so re-assigning the same value was a no-op and the effect stayed
* put. Passing primitives restores that.
*
* TRACES: UR-079 | DR-225 | UT-214
*/
export function loaderForTransport(
transport: Transport["type"],
capabilities: LoaderCapabilities,
): VideoLoader {
if (transport !== "hls") {
// Progressive and local files are what the element loads natively. No
// MediaSource, no playlist parsing.
return "direct";
}
if (capabilities.hlsJsSupported) return "hlsjs";
if (capabilities.nativeHlsSupported) return "nativeHls";
// Nothing here can parse a playlist. Handing the URL to the element is very
// likely to fail, but it is the only remaining move and it surfaces a real
// media error rather than silently doing nothing.
return "direct";
}
/** Convenience for the template: does the element's `src` stay empty? */
export function elementSrcFor(
selection: Pick<StreamSelection, "url" | "transport">,
capabilities: LoaderCapabilities,
): string {
return videoLoaderFor(selection, capabilities) === "hlsjs" ? "" : selection.url;
}
export type { Transport };
+2 -16
View File
@@ -20,26 +20,14 @@ const log = createLogger("capabilities");
export interface PlaybackCapabilities { export interface PlaybackCapabilities {
/** Audio renders through a webview `<audio>` element, not a native backend. */ /** Audio renders through a webview `<audio>` element, not a native backend. */
usesWebviewAudio: boolean; usesWebviewAudio: boolean;
/** Video can render on a native surface behind a transparent webview. */
supportsNativeVideo: boolean;
/**
* The user may send video to the webview element instead of the native
* renderer. False on Android, where ExoPlayer is the only video renderer
* (DR-293). Rust decides; see `webview_video_fallback`.
*/
webviewVideoFallback: boolean;
} }
/** /**
* Conservative defaults for when the backend cannot be reached (very early * Conservative default for when the backend cannot be reached (very early
* startup, or a command failure). Both false = "assume no special platform * startup, or a command failure): no stray `<audio>` element is mounted.
* facilities": no stray `<audio>` element is mounted, and video stays on the
* HTML5 path, which is the safe behaviour everywhere.
*/ */
const FALLBACK: PlaybackCapabilities = { const FALLBACK: PlaybackCapabilities = {
usesWebviewAudio: false, usesWebviewAudio: false,
supportsNativeVideo: false,
webviewVideoFallback: false,
}; };
let cached: PlaybackCapabilities | null = null; let cached: PlaybackCapabilities | null = null;
@@ -58,8 +46,6 @@ export async function getPlaybackCapabilities(): Promise<PlaybackCapabilities> {
const caps = (await commands.playerGetCapabilities()) as PlaybackCapabilities; const caps = (await commands.playerGetCapabilities()) as PlaybackCapabilities;
cached = { cached = {
usesWebviewAudio: !!caps?.usesWebviewAudio, usesWebviewAudio: !!caps?.usesWebviewAudio,
supportsNativeVideo: !!caps?.supportsNativeVideo,
webviewVideoFallback: !!caps?.webviewVideoFallback,
}; };
return cached; return cached;
} catch (err) { } catch (err) {
@@ -1,75 +0,0 @@
import { describe, it, expect, beforeEach, beforeAll, afterAll, vi } from "vitest";
import { get } from "svelte/store";
/**
* The stored value of the native-video preference, and what it means.
*
* The default has moved four times (see the history on `load()` in
* nativeVideo.ts), so the risk here is not "which way is it pointing" — it is
* that a flip silently overrides people who chose. The old reader was
* `getItem(KEY) === "true"`, which conflates "never chose" with "chose off";
* flipping the default under that reader re-enables the native path for
* everyone who deliberately turned it off. So the three cases are pinned
* separately rather than through the default alone.
*
* TRACES: UR-003, UR-004 | DR-188
*/
const STORAGE_KEY = "jellytau-experimental-native-video";
// jsdom here doesn't expose localStorage; stand in a minimal implementation,
// matching the viewMode/searchGroupOrder store tests.
const backing = new Map<string, string>();
const localStorageShim = {
getItem: (key: string) => backing.get(key) ?? null,
setItem: (key: string, value: string) => void backing.set(key, value),
removeItem: (key: string) => void backing.delete(key),
clear: () => backing.clear(),
};
beforeAll(() => {
vi.stubGlobal("localStorage", localStorageShim);
});
afterAll(() => {
vi.unstubAllGlobals();
});
async function freshStore() {
// The default is read at module init, so each case needs a fresh module.
vi.resetModules();
return await import("./nativeVideo");
}
describe("experimentalNativeVideo default", () => {
beforeEach(() => {
localStorage.clear();
});
it("defaults to ON when the user has never chosen", async () => {
const { experimentalNativeVideo } = await freshStore();
expect(get(experimentalNativeVideo)).toBe(true);
});
it("stays OFF for someone who deliberately turned it off", async () => {
// The regression the null check exists for: an explicit opt-out must
// survive the default flip, not be re-enabled by it.
localStorage.setItem(STORAGE_KEY, "false");
const { experimentalNativeVideo } = await freshStore();
expect(get(experimentalNativeVideo)).toBe(false);
});
it("stays ON for someone who deliberately turned it on", async () => {
localStorage.setItem(STORAGE_KEY, "true");
const { experimentalNativeVideo } = await freshStore();
expect(get(experimentalNativeVideo)).toBe(true);
});
it("persists an explicit choice in both directions", async () => {
const { experimentalNativeVideo } = await freshStore();
experimentalNativeVideo.set(false);
expect(localStorage.getItem(STORAGE_KEY)).toBe("false");
experimentalNativeVideo.set(true);
expect(localStorage.getItem(STORAGE_KEY)).toBe("true");
});
});
+8 -124
View File
@@ -1,138 +1,22 @@
// Native-video compositing state. // Native-video compositing state.
// //
// TRACES: UR-003, UR-004 | DR-150, DR-152 // TRACES: UR-003, UR-004 | DR-150, DR-152, DR-235
// //
// Two separate concerns live here, deliberately: // `nativeVideoActive` — whether a native video surface is on screen right now.
//
// 1. `experimentalNativeVideo` — the user-facing opt-in flag. Rust already
// decides *which backend this platform has* (`useHtml5Element` from
// `player_play_item`); this flag only *suppresses* that decision so a
// half-working spike cannot ship as a regression. It never turns native on
// where Rust says HTML5.
//
// 2. `nativeVideoActive` — whether a native surface is on screen right now.
// Setting it toggles `data-native-video` on <html>, which is what the CSS in // 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 SurfaceView // app.css keys off to clear the app's opaque backgrounds so the video surface
// behind the WebView is visible. It is deliberately NOT derived from the // behind the webview is visible. The backgrounds must come back the moment the
// flag: the backgrounds must come back the moment the player unmounts. // player unmounts.
// //
// Frontend-only preference, stored in localStorage per the `jellytau-view-mode` // There used to be a second concern here: `experimentalNativeVideo`, a stored
// precedent in library.ts — no Rust settings command backs this. // user switch that could force video back to the webview `<video>` element.
// That element is gone (DR-235), so there is nothing left to switch to.
import { writable } from "svelte/store"; import { writable } from "svelte/store";
const STORAGE_KEY = "jellytau-experimental-native-video";
/** The attribute app.css keys its transparency rules off. */ /** The attribute app.css keys its transparency rules off. */
const NATIVE_VIDEO_ATTR = "data-native-video"; const NATIVE_VIDEO_ATTR = "data-native-video";
/**
* Whether the native path is on, defaulting to **on** when the user has never
* chosen.
*
* This default has moved three times, so the history is the documentation:
*
* - **off** while the path was a spike (DR-150).
* - **on** for picture-in-picture (DR-161), which shipped as *audio with no
* picture* — ExoPlayer decoded correctly into a live SurfaceView while the
* page stayed opaque over it.
* - **off** again (DR-172), which named the compositing as the suspect but did
* not find it.
* - **on** now, because the four defects behind that symptom were found and
* each is fixed and verified on a device: the app shell painted over the
* surface through a CSS rule targeting an attribute nothing set (DR-185); the
* poster card had no way to lift on a path with no `<video>` element
* (DR-182); the JS bridges raced the page load, so `setTransparent(true)`
* could never arrive (DR-183); and the SurfaceView was never detached
* (DR-184). Two further UI defects that only this path could show — the play
* overlay never clearing (DR-186) and the system bars staying over the player
* (DR-187) — are fixed with it.
*
* The picture is genuinely fixed and device-verified — `WebView transparent =
* true` and `Marking media ready` now appear in logcat with video on screen,
* the pair DR-172 went looking for and could not find. The default nonetheless
* stayed **off** for a further release, because turning it on surfaced a
* different gap: the background-audio handoff (UR-040) could only *return*
* through the HTML5 element, so coming back from background audio left playback
* dead. That was the same shape of mistake as DR-161 — a verified sub-path
* shipped as a default over an unverified one — so the flip waited (DR-190).
*
* - **on** now. The two defects that were holding it back are fixed and
* verified on a device: the handoff return restarts the renderer that is
* actually on screen rather than only ever reloading the `<video>` element
* (DR-196), and the letterbox bars are painted instead of retaining whatever
* was last in the framebuffer (DR-194). The evidence standard this default
* has been held to since DR-161 is met for both: 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 anyone who turned
* it off keeps it off — hence the `null` check rather than a bare `=== "true"`,
* which would silently re-enable it for people who opted out.
*
* TRACES: UR-003, UR-004 | DR-188
*/
function load(): boolean {
if (typeof localStorage === "undefined") return true;
try {
const stored = localStorage.getItem(STORAGE_KEY);
// Never chosen → on. Chosen → honour it, in both directions.
return stored === null ? true : stored === "true";
} catch {
// Private-mode / disabled storage — same default as a fresh install.
return true;
}
}
function persist(enabled: boolean) {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, String(enabled));
} catch {
// Quota or private-mode failure — keep the in-memory value.
}
}
function createExperimentalNativeVideoStore() {
const { subscribe, set } = writable<boolean>(load());
return {
subscribe,
set(enabled: boolean) {
persist(enabled);
set(enabled);
},
/** Read the current value without subscribing (init-time decisions). */
current: load,
};
}
/**
* User preference for the native Android video path. **Defaults to on** — see
* `load()`. The name still says "experimental" because the flag remains a
* suppressor of Rust's backend choice, not a promoter of it: turning it off
* forces the webview element, turning it on never produces a native backend
* where Rust says HTML5.
*/
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
/**
* Whether video should take the native path, given the user's stored choice
* and whether this platform lets the user choose at all.
*
* On Android the answer is always native: ExoPlayer is the only video renderer
* there, and the webview element decodes none of the AC-3/E-AC-3/DTS/TrueHD
* that ExoPlayer plays through the FFmpeg extension — so a stored "off" would
* turn every original-file download into a silent film (DR-293). Rust reports
* whether a fallback exists (`webviewVideoFallback`); only then does the
* stored choice count.
*
* TRACES: UR-003, UR-071 | DR-293 | UT-262
*/
export function nativeVideoWanted(storedChoice: boolean, webviewVideoFallback: boolean): boolean {
return webviewVideoFallback ? storedChoice : true;
}
function createNativeVideoActiveStore() { function createNativeVideoActiveStore() {
const { subscribe, set } = writable<boolean>(false); const { subscribe, set } = writable<boolean>(false);
-17
View File
@@ -1,17 +0,0 @@
import { describe, it, expect } from "vitest";
import { nativeVideoWanted } from "./nativeVideo";
// TRACES: UR-003, UR-071 | DR-293 | UT-262
describe("nativeVideoWanted", () => {
it("ignores a stored 'off' where there is no webview fallback (Android)", () => {
// Someone who once switched native video off on Android must not be
// routed to the webview, which plays original-file downloads silent.
expect(nativeVideoWanted(false, false)).toBe(true);
expect(nativeVideoWanted(true, false)).toBe(true);
});
it("honours the stored choice where a fallback exists (Linux beside mpv)", () => {
expect(nativeVideoWanted(false, true)).toBe(false);
expect(nativeVideoWanted(true, true)).toBe(true);
});
});
-34
View File
@@ -22,7 +22,6 @@ interface AndroidPictureInPictureBridge {
isSupported(): boolean; isSupported(): boolean;
canEnterPip(): boolean; canEnterPip(): boolean;
setAutoEnterEnabled(enabled: boolean): void; setAutoEnterEnabled(enabled: boolean): void;
setHtml5VideoState(active: boolean, width: number, height: number, playing: boolean): void;
} }
declare global { declare global {
@@ -89,36 +88,3 @@ export function setAutoEnterEnabled(enabled: boolean): void {
log.warn("Failed to set auto-enter:", err); log.warn("Failed to set auto-enter:", err);
} }
} }
/**
* Tell native that a WebView `<video>` is (or is no longer) the playback surface.
*
* This is what makes PiP work on the HTML5 path. The native side only ever knew
* about the ExoPlayer surface, and that path is behind `experimentalNativeVideo`,
* which defaulted to off when this was written — so `canEnterPip` was always
* false and pressing the button did nothing. Reporting the element's state gives
* native a surface it can legitimately shrink into, plus the intrinsic size it
* needs for the PiP window's aspect ratio and the play state for its play/pause
* action.
*
* The flag is back to defaulting **off** (DR-172, after native video shipped as
* audio with no picture), so this is once again the path Android normally takes —
* which is why PiP does not depend on that flag being on.
*
* Pass `active: false` when the element goes away, or PiP would be offered over a
* video that is no longer there.
*
* TRACES: UR-041 | DR-160
*/
export function setHtml5VideoState(
active: boolean,
width: number,
height: number,
playing: boolean,
): void {
try {
bridge()?.setHtml5VideoState(active, Math.round(width), Math.round(height), playing);
} catch (err) {
log.warn("Failed to report HTML5 video state:", err);
}
}
+1 -2
View File
@@ -55,8 +55,7 @@ function bridge(): AndroidVideoSurfaceBridge | undefined {
/** /**
* Whether the native-surface bridge exists on this platform. This reports only * Whether the native-surface bridge exists on this platform. This reports only
* that the *plumbing* is present; whether native video should actually be used * that the *plumbing* is present; whether native video should actually be used
* is Rust's decision (`player_get_capabilities`) gated by the user's * is Rust's decision.
* `experimentalNativeVideo` flag.
*/ */
export function isNativeSurfaceBridgeAvailable(): boolean { export function isNativeSurfaceBridgeAvailable(): boolean {
try { try {
-1
View File
@@ -37,7 +37,6 @@
} from "$lib/services/playbackReporting"; } from "$lib/services/playbackReporting";
import { reportSkippedEpisode, shouldSuppressStopReport } from "$lib/services/skipReporting"; import { reportSkippedEpisode, shouldSuppressStopReport } from "$lib/services/skipReporting";
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService"; import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
import * as html5Adapter from "$lib/player/html5Adapter";
import { createLogger } from "$lib/utils/logger"; import { createLogger } from "$lib/utils/logger";
const log = createLogger("PlayerPage"); const log = createLogger("PlayerPage");
+1 -60
View File
@@ -1,6 +1,6 @@
<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057, UR-076 | DR-030, DR-048, DR-077, DR-086, DR-132, DR-209 --> <!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057, UR-076 | DR-030, DR-048, DR-077, DR-086, DR-132, DR-209 -->
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from "svelte"; import { onMount } from "svelte";
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import { profiles } from "$lib/stores/profiles"; import { profiles } from "$lib/stores/profiles";
import ProfileSecuritySettings from "$lib/components/settings/ProfileSecuritySettings.svelte"; import ProfileSecuritySettings from "$lib/components/settings/ProfileSecuritySettings.svelte";
@@ -31,8 +31,6 @@
import { library, viewMode } from "$lib/stores/library"; import { library, viewMode } from "$lib/stores/library";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { isNetworkDetectionSupported, reportNetworkState } from "$lib/services/networkType"; import { isNetworkDetectionSupported, reportNetworkState } from "$lib/services/networkType";
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
import { createLogger } from "$lib/utils/logger"; import { createLogger } from "$lib/utils/logger";
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener"; import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
import { import {
@@ -131,26 +129,6 @@
{ label: "Unlimited", bytes: 0 }, { label: "Unlimited", bytes: 0 },
]; ];
// Native-video switch. Shown only where Rust reports a webview fallback —
// beside mpv native video on Linux. Never on Android: ExoPlayer is the only
// video renderer there, and the webview would play original-file downloads
// silent (DR-293). Rust owns the decision; the toggle is hidden where it
// cannot apply.
let offerNativeVideoSwitch = $state(false);
let nativeVideoEnabled = $state(false);
const unsubscribeNativeVideo = experimentalNativeVideo.subscribe((v) => {
nativeVideoEnabled = v;
});
function handleNativeVideoToggle() {
experimentalNativeVideo.set(!nativeVideoEnabled);
}
// Not returned from onMount: that callback is async, so its return value is a
// Promise and Svelte would never invoke it as a teardown.
onDestroy(unsubscribeNativeVideo);
// Mirrors the stored setting; the picker itself always appears for a // Mirrors the stored setting; the picker itself always appears for a
// PIN-protected profile regardless of this. (DR-274) // PIN-protected profile regardless of this. (DR-274)
let askOnStart = $state(false); let askOnStart = $state(false);
@@ -158,7 +136,6 @@
onMount(async () => { onMount(async () => {
await loadSettings(); await loadSettings();
askOnStart = await commands.profilesGetAskOnStart(); askOnStart = await commands.profilesGetAskOnStart();
offerNativeVideoSwitch = (await getPlaybackCapabilities()).webviewVideoFallback;
// Which update story this platform gets. Android cannot install its own // Which update story this platform gets. Android cannot install its own
// APK, so it is offered the releases page instead of an install button. // APK, so it is offered the releases page instead of an install button.
@@ -956,42 +933,6 @@
<!-- Native video. Only rendered where Rust reports a webview fallback <!-- Native video. Only rendered where Rust reports a webview fallback
(Linux beside mpv native video); never on Android (DR-293). --> (Linux beside mpv native video); never on Android (DR-293). -->
{#if offerNativeVideoSwitch}
<div class="bg-[var(--color-surface)] rounded-lg p-6 mt-4">
<div class="flex items-center justify-between">
<div class="pr-4">
<h3 class="text-xl font-semibold text-white">
Native Video
<span
class="ml-2 align-middle text-xs font-medium uppercase tracking-wide text-amber-400 border border-amber-400/40 rounded px-1.5 py-0.5"
>
Experimental
</span>
</h3>
<p class="text-sm text-gray-400 mt-1">
Decode video with the device's hardware decoder instead of the built-in web
player, for better performance and battery life, and so picture-in-picture shows
the video rather than the app. On by default. Turn it off to fall back to the
built-in web player if a video misbehaves.
</p>
</div>
<button
onclick={handleNativeVideoToggle}
class="relative inline-flex h-8 w-14 shrink-0 items-center rounded-full transition-colors {nativeVideoEnabled
? 'bg-[var(--color-jellyfin)]'
: 'bg-gray-600'}"
aria-label="Toggle native video"
>
<span
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {nativeVideoEnabled
? 'translate-x-7'
: 'translate-x-1'}"
></span>
</button>
</div>
<p class="text-xs text-gray-500 mt-3">Takes effect the next time you start a video.</p>
</div>
{/if}
</div> </div>
<!-- Profiles. Deliberately minimal here: adding, removing and PIN changes <!-- Profiles. Deliberately minimal here: adding, removing and PIN changes