fix(player): mpv draws all Linux video, and no longer runs text from a URL

Security (DR-298, DR-299):
- The pinned libmpv crate's Mpv::command joins its arguments and calls
  mpv_command_string, which parses `;` as a command separator. Stream
  URLs carry server-controlled ids and TranscodingUrl, and a download's
  file:// path carries its track title, so a crafted title could run any
  mpv command, `run` included. Every call now goes through
  mpv_command::command, an argv built for mpv_command. The same parse
  broke loadfile for every downloaded title containing a space.
- mpv's tls-verify defaults to no, and its URLs carry the ApiKey. Every
  handle is now hardened with tls-verify=yes and ytdl=no before its
  first loadfile, and fails construction if it cannot be.

Linux video (DR-235 phase 1):
- native_video::enabled() is unconditional on Linux; the
  JELLYTAU_NATIVE_VIDEO opt-in is retired. No platform reports a
  webview video fallback, so the Settings switch no longer appears.
  Windows keeps the webview element until mpv reaches it (DR-237).
- The Linux device profile is unchanged (still h264, DR-234), so this
  ships the configuration that was tested under the env var.
This commit is contained in:
2026-09-24 20:38:32 -04:00
parent fd1277746d
commit 9d9d81bef3
13 changed files with 298 additions and 161 deletions
+8 -5
View File
@@ -2,8 +2,8 @@
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, WebKitGTK HTML5 and talks to it over Tauri v2 IPC. Targets **Linux** (libmpv for audio and video) and
`<video>` for transcoded playback) and **Android** (ExoPlayer). **Android** (ExoPlayer); Windows still renders video in the webview `<video>`.
Package manager is **bun**. Package manager is **bun**.
@@ -151,9 +151,12 @@ 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 direct playback and a WebKitGTK - **Playback layers** — Linux uses libmpv for audio and video (mpv draws video
HTML5 `<video>` element for HLS-transcoded (h264) streams; Android uses beneath the transparent webview); Windows still uses the webview HTML5
ExoPlayer with a foreground media service + `MediaSessionCompat`. `<video>` element for video; Android uses ExoPlayer with a foreground media
service + `MediaSessionCompat`. Every mpv command goes through
`player/mpv_command.rs` (argv, never a command string) and every handle is
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
command/event definitions (registered via the Builder in `src-tauri/src/lib.rs`). command/event definitions (registered via the Builder in `src-tauri/src/lib.rs`).
+5 -4
View File
@@ -95,10 +95,11 @@ flowchart LR
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in **Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
`src-tauri/src/commands/player/timers.rs` `src-tauri/src/commands/player/timers.rs`
Video on desktop (Linux WebKitGTK) is rendered by an HTML5 `<video>`/HLS element **inside the Video on **Windows** is rendered by an HTML5 `<video>`/HLS element **inside the webview**. Neither
webview**. Android no longer uses this path for video — see *The webview is not a video renderer on other platform uses this path for video: Android draws in ExoPlayer (see *The webview is not a video
Android* below. libmpv is initialized audio-only (`vo=null`, renderer on Android* below), and Linux draws in mpv beneath the webview (DR-235 phase 1 — see
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore [desktop-native-video.md](../specs/desktop-native-video.md) until it ships on Windows too). Where the
element is used, no native backend renders or observes it. The `<video>` is therefore
the real player, living outside Rust's reach. the real player, living outside Rust's reach.
To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element
+2 -2
View File
@@ -80,10 +80,10 @@ introduced during this work passed conformance.
## 3. Desktop (Linux) ## 3. Desktop (Linux)
Run with native video on, since that is what is new: mpv draws all Linux video (DR-235) — there is no switch:
```bash ```bash
JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev bun run tauri dev
``` ```
- [ ] **Direct play** — a file the server does not transcode. Picture and sound. - [ ] **Direct play** — a file the server does not transcode. Picture and sound.
+13 -6
View File
@@ -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 | Proposed | | 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-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 | 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 | Proposed |
| 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 |
@@ -499,6 +499,8 @@ Internal architecture, components, and application logic.
| DR-295 | A series page lists its episodes with one concurrent season fan-out. "More info" on Frasier took ~10 s: the page asked Rust for the episodes and for the current episode as two commands, each of which walked every season, and each walk fetched the eleven seasons one after another — so the wait was the sum of twenty-two listings, each a cache read slowed by whatever the database was writing (the catalog sync at launch measured it at ~4 s per walk). The seasons are now fetched together (`gather_season_episodes`, so the wait is the slowest season), and `repository_get_series_view` returns the episodes and the current episode from one walk, with Next Up and resume fetched alongside it | Repository | UR-062 | Done | | DR-295 | A series page lists its episodes with one concurrent season fan-out. "More info" on Frasier took ~10 s: the page asked Rust for the episodes and for the current episode as two commands, each of which walked every season, and each walk fetched the eleven seasons one after another — so the wait was the sum of twenty-two listings, each a cache read slowed by whatever the database was writing (the catalog sync at launch measured it at ~4 s per walk). The seasons are now fetched together (`gather_season_episodes`, so the wait is the slowest season), and `repository_get_series_view` returns the episodes and the current episode from one walk, with Next Up and resume fetched alongside it | Repository | UR-062 | Done |
| DR-296 | Returning from background audio resumes the item the native player is actually on, not the one the video page was mounted with. An episode that ends while backgrounded advances in the backend (`advance_to_next_episode_audio_only`), but `player_exit_background_audio` returned only a position, so the webview reloaded the *previous* episode at the new episode's timestamp. The command now returns `BackgroundAudioResume { itemId, positionSeconds }` (`PlayerController::background_audio_resume`); `planHandoffReturn` yields `other-item` when the id differs from the mounted one, and the player page navigates to that episode with `resumeAt=<seconds>`, recording the outgoing episode as watched and suppressing the stale unmount stop report | Playback | UR-040, UR-023 | Done (pending device verification) | | DR-296 | Returning from background audio resumes the item the native player is actually on, not the one the video page was mounted with. An episode that ends while backgrounded advances in the backend (`advance_to_next_episode_audio_only`), but `player_exit_background_audio` returned only a position, so the webview reloaded the *previous* episode at the new episode's timestamp. The command now returns `BackgroundAudioResume { itemId, positionSeconds }` (`PlayerController::background_audio_resume`); `planHandoffReturn` yields `other-item` when the id differs from the mounted one, and the player page navigates to that episode with `resumeAt=<seconds>`, recording the outgoing episode as watched and suppressing the stale unmount stop report | Playback | UR-040, UR-023 | Done (pending device verification) |
| DR-297 | A background refresh never blanks a library detail page that is on screen, and every load that succeeds clears the page's error. Resuming the app after a few minutes in the background replaced the Frasier series page with "Failed to load item": the resume reload (reconnect / offline-filter change) is a refresh of content the cache had already answered, but any throw in it replaced the page with an error that no later successful reload of the same item cleared — and the catch logged nothing and turned backend errors (plain strings) into the generic text. A failed refresh now keeps the page and logs the thrown value; only failing to open an item shows an error, with the backend's own message | UI | UR-062 | Done | | DR-297 | A background refresh never blanks a library detail page that is on screen, and every load that succeeds clears the page's error. Resuming the app after a few minutes in the background replaced the Frasier series page with "Failed to load item": the resume reload (reconnect / offline-filter change) is a refresh of content the cache had already answered, but any throw in it replaced the page with an error that no later successful reload of the same item cleared — and the catch logged nothing and turned backend errors (plain strings) into the generic text. A failed refresh now keeps the page and logs the thrown value; only failing to open an item shows an error, with the backend's own message | UI | UR-062 | Done |
| DR-298 | mpv receives commands as an argument vector, never as a command string. The pinned `libmpv` crate's `Mpv::command` joins its arguments with spaces and calls `mpv_command_string`, which parses input.conf syntax — whitespace splits, `;` chains a second command, `#` comments out the rest — so a stream URL carrying a server-controlled item id or `TranscodingUrl`, or a download's `file://` path built from its track title, could run any mpv command, `run` included: a crafted title tag was arbitrary code execution on the Linux desktop. The same parse broke every downloaded title containing a space, `Song.mp3` landing in `loadfile`'s flags slot. Every call goes through `mpv_command::command`, which builds a NUL-terminated `argv` for `mpv_command` so each argument reaches mpv as one opaque string, and refuses an argument containing NUL rather than loading a truncated URL | Playback | UR-003, UR-004 | Done |
| DR-299 | Every libmpv handle verifies TLS and never hands a URL to youtube-dl. mpv's `tls-verify` defaults to *no*, and the stream URLs it loads carry the account's `ApiKey`, so anyone able to present a certificate for the server's host — a hostile network, a spoofed DNS answer — read a long-lived token from the one HTTP path in the app that did not check. libmpv also loads its ytdl hook by default, which passes a URL that failed to open, token included, to an external `yt-dlp`. `mpv_command::harden` sets `tls-verify=yes` and `ytdl=no` on each handle before its first `loadfile`, and a handle that cannot be hardened fails construction instead of playing unverified | Playback | UR-012 | Done |
--- ---
@@ -510,8 +512,8 @@ Internal architecture, components, and application logic.
|----------|-------------------------|-------------------------| |----------|-------------------------|-------------------------|
| UR-001 | IR-001, IR-002 | - | | UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014, DR-294 | | UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014, DR-294 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196, DR-291 | | UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196, DR-291, DR-298 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203, DR-265, DR-293 | | UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203, DR-265, DR-293, DR-298 |
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 | | UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 | | UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262, DR-277, DR-278 | | UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262, DR-277, DR-278 |
@@ -519,7 +521,7 @@ Internal architecture, components, and application logic.
| UR-009 | IR-009, IR-010, IR-011 | - | | UR-009 | IR-009, IR-010, IR-011 | - |
| UR-010 | IR-012, IR-021 | DR-037, DR-059 | | UR-010 | IR-012, IR-021 | DR-037, DR-059 |
| UR-011 | IR-013 | DR-003, DR-015, DR-018 | | UR-011 | IR-013 | DR-003, DR-015, DR-018 |
| UR-012 | IR-009, IR-014 | DR-198 | | UR-012 | IR-009, IR-014 | DR-198, DR-299 |
| UR-013 | IR-013 | DR-017 | | UR-013 | IR-013 | DR-017 |
| UR-014 | IR-010 | DR-014, DR-019 | | UR-014 | IR-010 | DR-014, DR-019 |
| UR-015 | - | DR-005, DR-020 | | UR-015 | - | DR-005, DR-020 |
@@ -809,7 +811,7 @@ Internal architecture, components, and application logic.
| 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 | 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-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 | Done | | UT-216 | The native-video opt-in is read from one place and only explicit truthy values enable it: absent, empty, `0`, `no`, `false` and anything unrecognised all mean off, because a half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangle | DR-231 | Superseded by UT-271 |
| UT-217 | A transcoded HLS stream on the native backend re-negotiates rather than seeking in place, while the same stream under hls.js still seeks in place — the cell that native video made reachable for the first time | DR-238 | Done | | 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-218 | Every property name matched by the mpv event loop also appears in an `observe_property` call, asserted against the source because the registration cannot be observed at runtime without a live mpv | DR-239 | Done | | UT-218 | Every property name matched by the mpv event loop also appears in an `observe_property` call, asserted against the source because the registration cannot be observed at runtime without a live mpv | DR-239 | Done |
| UT-219 | A fullscreen toggle moves the document only when an in-document `<video>` renders, and moves the OS window as well when a native surface does | DR-240 | Done | | UT-219 | A fullscreen toggle moves the document only when an in-document `<video>` renders, and moves the OS window as well when a native surface does | DR-240 | Done |
@@ -852,7 +854,7 @@ Internal architecture, components, and application logic.
| UT-255 | The offline banner shows while offline on ordinary routes and never on `/player/*`, and stays off while connected or signed out | DR-291 | Done | | UT-255 | The offline banner shows while offline on ordinary routes and never on `/player/*`, and stays off while connected or signed out | DR-291 | Done |
| UT-257 | The server-only rule: true only offline with the reveal on and nothing on the device; never for a library tile; and not for a container whose children are downloaded (the greyed-album regression) | DR-292 | Done | | UT-257 | The server-only rule: true only offline with the reveal on and nothing on the device; never for a library tile; and not for a container whose children are downloaded (the greyed-album regression) | DR-292 | Done |
| UT-258 | The list view greys a server-only row, makes it inert to tap, offers the queue button (and the Queued badge once pending), and leaves downloaded rows and containers with device content alone | DR-292 | Done | | UT-258 | The list view greys a server-only row, makes it inert to tap, offers the queue button (and the Queued badge once pending), and leaves downloaded rows and containers with device content alone | DR-292 | Done |
| 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 | Done | | 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 | Done |
@@ -861,6 +863,11 @@ Internal architecture, components, and application logic.
| 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 |
| UT-266 | After a background-audio episode advance, the controller's resume point names the new episode and carries no base from the previous one | DR-296 | Done | | UT-266 | After a background-audio episode advance, the controller's resume point names the new episode and carries no base from the previous one | DR-296 | Done |
| UT-267 | A failed refresh of a detail page already on screen shows no error, a successful load clears any error, a failure to open an item shows its message, and a backend error's plain-string text is shown rather than a generic fallback | DR-297 | Done | | UT-267 | A failed refresh of a detail page already on screen shows no error, a successful load clears any error, a failure to open an item shows its message, and a backend error's plain-string text is shown rather than a generic fallback | DR-297 | Done |
| UT-268 | Against a real libmpv, a URL containing `;set volume 13;#` is loaded as one URL and the volume is unchanged, and an argument containing a space stays one argument | DR-298 | Done |
| UT-269 | Neither mpv player calls the string-joining `Mpv::command`; every command goes through `mpv_command::command` | DR-298 | Done |
| UT-270 | A hardened handle reads back `tls-verify=yes` and `ytdl=no`, and both mpv players harden the handle they create | DR-299 | Done |
| UT-271 | Native video is on for Linux whatever `JELLYTAU_NATIVE_VIDEO` says, including unset and explicit "off" values, and off where mpv is not the video renderer | 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 |
### Integration Tests ### Integration Tests
| Test ID | Test Description | Traces To | Status | | Test ID | Test Description | Traces To | Status |
+10 -1
View File
@@ -1,6 +1,15 @@
# Spec: Desktop native video — mpv renders the picture, everywhere # Spec: Desktop native video — mpv renders the picture, everywhere
**Status:** Proposed **Status:** Partially implemented — phase 1 routing shipped: mpv is the only
Linux video renderer (`native_video::enabled()` is unconditional on Linux, no
webview fallback is offered). **Left:** Linux's device profile still claims only
`h264` (DR-234), so video still arrives as a server transcode — mpv plays it, but
the direct-play gain is not yet taken; `hwdec` is unset, so mpv decodes in
software (DR-236); a failed surface attach only logs, it does not surface an
error; the phase 1 soak and X11/Wayland criteria are unrecorded; phases 2 and 3.
Phase 3 also deletes the now-inert frontend switch (`experimentalNativeVideo`,
`nativeVideoWanted`, the Settings toggle and the adapter's suppressor flag),
which no platform reaches since `webview_video_fallback` became false everywhere.
**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.
+37 -53
View File
@@ -2187,32 +2187,16 @@ pub struct PlaybackCapabilities {
/// native backend. Native audio exists on Linux (mpv) and Android /// native backend. Native audio exists on Linux (mpv) and Android
/// (ExoPlayer); everything else (Windows, future desktops) uses the webview. /// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
pub uses_webview_audio: bool, pub uses_webview_audio: bool,
/// True when video can be rendered by a native surface composited *behind* /// True when video is rendered by a native surface composited *behind* a
/// a transparent webview. Android only: ExoPlayer draws into a SurfaceView /// transparent webview: ExoPlayer's SurfaceView on Android, mpv's GL area on
/// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland /// Linux.
/// compositing), so it stays on the HTML5 element.
pub supports_native_video: bool, pub supports_native_video: bool,
/// True when the user may send video to the webview element instead of the /// 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 /// native renderer — the frontend offers the switch only then, and honours
/// the stored preference only then. See [`webview_video_fallback`]. /// the stored preference only then. False on every platform since DR-235.
pub webview_video_fallback: bool, pub webview_video_fallback: bool,
} }
/// Whether the user may send video to the webview `<video>` element instead of
/// the native renderer.
///
/// Never on Android: ExoPlayer is its only video renderer. Downloads there are
/// the untouched source file (DR-293), and the webview decodes none of the
/// AC-3/E-AC-3/DTS/TrueHD that ExoPlayer plays through the FFmpeg extension, so
/// the fallback would be a silent film. Beside mpv's native video on Linux the
/// webview is still the tested fallback; everywhere else it is the only
/// renderer and there is nothing to switch.
///
/// TRACES: UR-003, UR-071 | DR-293 | UT-259
pub fn webview_video_fallback(is_android: bool, native_video_enabled: bool) -> bool {
!is_android && native_video_enabled
}
/// Report this platform's playback capabilities to the frontend. /// Report this platform's playback capabilities to the frontend.
/// ///
/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024 /// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
@@ -2227,11 +2211,12 @@ pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
// TRACES: UR-080 | DR-235 // TRACES: UR-080 | DR-235
supports_native_video: cfg!(target_os = "android") supports_native_video: cfg!(target_os = "android")
|| crate::player::native_video::enabled(), || crate::player::native_video::enabled(),
// TRACES: UR-003, UR-071 | DR-293 // No platform offers one: Android since DR-293, Linux since DR-235,
webview_video_fallback: webview_video_fallback( // and on Windows the webview is the only video renderer, so there is
cfg!(target_os = "android"), // nothing to fall back *from*. Kept on the wire until phase 3 deletes
crate::player::native_video::enabled(), // the frontend switch with the rest of the webview video path.
), // TRACES: UR-080, UR-003 | DR-235, DR-293
webview_video_fallback: false,
}) })
} }
@@ -2246,7 +2231,8 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
// two fight over the audio. TRACES: UR-080 | DR-235 // two fight over the audio. TRACES: UR-080 | DR-235
(VideoBackend::Native, false) (VideoBackend::Native, false)
} else { } else {
// Linux and other platforms use HTML5 video element in frontend // Windows: the webview <video> element is its only video renderer
// until mpv reaches it (DR-237).
(VideoBackend::Html5, true) (VideoBackend::Html5, true)
}; };
@@ -3087,35 +3073,33 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
mod tests { mod tests {
use crate::utils::lock::MutexSafe; use crate::utils::lock::MutexSafe;
/// Android has one video renderer, ExoPlayer. The webview element could only /// The webview is not a video renderer anywhere the app ships a native one:
/// be reached by the user switching native video off, and a file downloaded /// Android since DR-293, Linux since DR-235 made mpv its only video path.
/// as the untouched original — AC-3 audio included — plays silent there, /// So the frontend is never offered the switch, and a stored "native video
/// so the switch is gone on Android (DR-293). Where mpv draws video on Linux /// off" from before cannot send Linux video back to the `<video>` element.
/// the webview is still the tested fallback, so the switch stays there;
/// everywhere else the webview is the only renderer and there is nothing to
/// switch.
/// ///
/// TRACES: UR-003, UR-071 | DR-293 | UT-259 /// TRACES: UR-080, UR-003 | DR-235, DR-293 | UT-272
#[test] #[tokio::test]
fn test_webview_video_fallback_is_offered_only_beside_mpv_native_video() { async fn test_no_platform_offers_a_webview_video_fallback() {
use super::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);
}
}
assert!( /// And the status the video page reads agrees: on Linux the frontend is told
!webview_video_fallback(true, false), /// the native backend renders, never to load a `<video>` element.
"Android: ExoPlayer is the only video renderer" ///
); /// TRACES: UR-080 | DR-235 | UT-272
assert!( #[test]
!webview_video_fallback(true, true), fn test_linux_video_is_not_sent_to_the_webview() {
"Android never falls back, whatever else is switched on" let controller = crate::player::PlayerController::default();
); let status = super::get_player_status(&controller);
assert!( if cfg!(target_os = "linux") {
webview_video_fallback(false, true), assert!(!status.use_html5_element);
"Linux with mpv native video: the webview is the fallback" }
);
assert!(
!webview_video_fallback(false, false),
"the webview is the only renderer; nothing to fall back from"
);
} }
/// UT-206 — the volume the command hands on is always a real number in /// UT-206 — the volume the command hands on is always a real number in
+9 -29
View File
@@ -1400,40 +1400,20 @@ pub fn run() {
// during its construction, and doing this in the order the code // during its construction, and doing this in the order the code
// used to read produced "no mpv handle" every time — the surface was // used to read produced "no mpv handle" every time — the surface was
// built before there was anything to draw from. // built before there was anything to draw from.
// Native video surface: put a GL area under Tauri's webview so mpv // Native video surface: mpv draws into the main window's own vbox,
// can draw beneath the controls (UR-080 / DR-231). // underneath Tauri's webview, so the Svelte controls composite over
// the picture (UR-080 / DR-231). The widget tree is left exactly as
// Tauri built it — wrapping the webview in a GtkOverlay aborts the
// process on the first click; `video_surface` explains why.
// //
// 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the // Unconditional on Linux since DR-235: mpv is the only Linux video
// first click. `tauri-runtime-wry`'s undecorated-resizing handler // renderer, so there is no webview path to fall back to if this
// walks a hard-coded two-hop path on every button press in the // fails — the warnings below are the whole diagnosis.
// webview:
// //
// webview.parent() // "This one should be GtkBox" // TRACES: UR-080 | DR-231, DR-235
// .parent() // ...and this one the GtkWindow
// .downcast::<gtk::Window>().unwrap()
//
// Wrapping the webview in a GtkOverlay makes that chain
// webview → GtkOverlay → GtkBox, the downcast fails, and because the
// panic is non-unwinding it aborts the process. The decoration check
// that would otherwise make this handler inert runs *after* the
// unwrap, so no window configuration avoids it.
//
// This is the "only place Tauri-specific behaviour could still bite"
// that the spike named as the untested half of G1. It bites. The
// surface attaches perfectly and then dies on interaction, so
// "attached successfully" in the log is not the gate — a click is.
//
// Kept behind an env var rather than deleted so the next attempt has
// something to iterate on: JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
//
// TRACES: UR-080 | DR-231
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if crate::player::native_video::enabled() { if crate::player::native_video::enabled() {
use tauri::Manager; use tauri::Manager;
log::warn!(
"[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
video surface (mpv drawn behind the webview, no reparenting)"
);
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
match window.default_vbox() { match window.default_vbox() {
Ok(vbox) => { Ok(vbox) => {
+2
View File
@@ -16,6 +16,8 @@ pub mod legacy_player;
pub mod media; pub mod media;
pub mod media_player; pub mod media_player;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod mpv_command;
#[cfg(target_os = "linux")]
pub mod mpv_player; pub mod mpv_player;
pub mod queue; pub mod queue;
pub mod seek; pub mod seek;
+16 -12
View File
@@ -148,6 +148,8 @@ impl MpvBackend {
let mpv = Mpv::new().map_err(|e| PlayerError { let mpv = Mpv::new().map_err(|e| PlayerError {
message: format!("Failed to initialize MPV: {:?}", e), message: format!("Failed to initialize MPV: {:?}", e),
})?; })?;
// TRACES: UR-012 | DR-299
super::mpv_command::harden(&mpv).map_err(|message| PlayerError { message })?;
// Detect and configure audio output // Detect and configure audio output
let audio_driver = detect_audio_system(); let audio_driver = detect_audio_system();
@@ -178,11 +180,11 @@ impl MpvBackend {
// Video is disabled unless this process is drawing it. // Video is disabled unless this process is drawing it.
// //
// `video: no` is why mpv has never decoded a frame here: Linux video has // Linux video went through the webview until DR-235, and decoding it
// always gone through the webview, and decoding it twice would burn a // here too would have burned a core for a picture nobody saw — hence
// core for a picture nobody sees. With native video on, mpv needs both // `video: no`. With native video, mpv needs both the decoder *and*
// the decoder *and* `vo=libmpv` — the render API only works through that // `vo=libmpv` — the render API only works through that output, and the
// output, and the default would try to open a window of its own. // default would try to open a window of its own.
// //
// Set at construction because mpv resolves the video output when it // Set at construction because mpv resolves the video output when it
// initialises; flipping it later does not re-open one. // initialises; flipping it later does not re-open one.
@@ -600,11 +602,13 @@ 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;
// Load the media file // Load the media file. Through `mpv_command::command`, never
self.mpv // `Mpv::command`: the URL carries server-controlled text.
.command("loadfile", &[&stream_url]) // TRACES: UR-003, UR-004 | DR-298
.map_err(|e| PlayerError { super::mpv_command::command(&self.mpv, &["loadfile", &stream_url]).map_err(|e| {
message: format!("Failed to load file: {:?}", e), PlayerError {
message: format!("Failed to load file: {e}"),
}
})?; })?;
debug!("[MpvBackend] Load command sent successfully"); debug!("[MpvBackend] Load command sent successfully");
@@ -638,8 +642,8 @@ impl PlayerBackend for MpvBackend {
fn stop(&mut self) -> Result<(), PlayerError> { fn stop(&mut self) -> Result<(), PlayerError> {
debug!("[MpvBackend] Stop command"); debug!("[MpvBackend] Stop command");
self.mpv.command("stop", &[]).map_err(|e| PlayerError { super::mpv_command::command(&self.mpv, &["stop"]).map_err(|e| PlayerError {
message: format!("Failed to stop: {:?}", e), message: format!("Failed to stop: {e}"),
})?; })?;
// Stopping ends the seek's subject along with the playback. // Stopping ends the seek's subject along with the playback.
+161
View File
@@ -0,0 +1,161 @@
//! The two things every libmpv handle in this process must get right before it
//! is handed a URL.
//!
//! **Commands are an argument vector.** The pinned `libmpv` crate's
//! `Mpv::command` joins its arguments with spaces and hands the result to
//! `mpv_command_string`, which parses it as input.conf syntax: whitespace splits
//! arguments, `;` separates commands, `#` starts a comment. Every URL this app
//! loads carries server-controlled text — item and media-source ids, the
//! server's own `TranscodingUrl`, and for a download the file name, which is the
//! track title — so a title like `x;run sh -c …;#` ran a shell command the
//! moment it played. [`command`] goes through `mpv_command` instead, where each
//! argument reaches mpv as one opaque string and nothing is parsed.
//!
//! **TLS is verified.** mpv's `tls-verify` defaults to *no*, and the stream URLs
//! it loads carry the account's `ApiKey`. Every reqwest client in the app
//! verifies certificates; without [`harden`] mpv was the one path where anyone
//! able to present a certificate for the server's host could read the token.
//! `ytdl` goes with it: libmpv loads its youtube-dl hook by default and hands a
//! URL that failed to open — token included — to an external `yt-dlp`.
//!
//! TRACES: UR-003, UR-004, UR-012 | DR-298, DR-299
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use libmpv::Mpv;
/// Run an mpv command with each argument passed through verbatim.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-268
pub fn command(mpv: &Mpv, args: &[&str]) -> Result<(), String> {
if args.is_empty() {
return Err("empty mpv command".to_string());
}
// A NUL cannot be represented in a C string; refusing is the only honest
// answer, since truncating would load a different URL than the one asked.
let owned = args
.iter()
.map(|a| CString::new(*a).map_err(|_| format!("mpv argument contains NUL: {a:?}")))
.collect::<Result<Vec<_>, _>>()?;
let mut argv: Vec<*const c_char> = owned.iter().map(|a| a.as_ptr()).collect();
argv.push(std::ptr::null());
// SAFETY: `argv` is a NULL-terminated array of pointers into `owned`, which
// outlives the call; mpv copies what it keeps. `ctx` is the live handle.
let rc = unsafe { libmpv_sys::mpv_command(mpv.ctx.as_ptr(), argv.as_mut_ptr()) };
if rc < 0 {
// SAFETY: mpv_error_string returns a static string for any code.
let msg = unsafe { CStr::from_ptr(libmpv_sys::mpv_error_string(rc)) };
return Err(format!("{} ({rc})", msg.to_string_lossy()));
}
Ok(())
}
/// Configure a freshly created handle so it will not trust an unverified server.
///
/// Must run before the first `loadfile`. Failure is an error, not a warning: a
/// handle that could not be told to verify TLS is exactly the one that leaks.
///
/// TRACES: UR-012 | DR-299 | UT-270
pub fn harden(mpv: &Mpv) -> Result<(), String> {
for (name, value) in [("tls-verify", "yes"), ("ytdl", "no")] {
mpv.set_property(name, value)
.map_err(|e| format!("could not set {name}={value}: {e:?}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A handle that decodes nothing and opens no device, so the tests need no
/// audio system and no display.
fn null_mpv() -> 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
}
/// The injection itself, against a real libmpv: a URL carrying `;` and a
/// second command must be loaded as one (unreachable) URL, not split and
/// executed. `set volume 13` stands in for `run …` — the same parse, but
/// observable without spawning a process.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-268
#[test]
fn a_url_cannot_smuggle_a_second_mpv_command() {
let mpv = null_mpv();
mpv.set_property("volume", 100i64).unwrap();
// Port 9 (discard) on loopback: nothing is fetched either way.
let url = "http://127.0.0.1:9/Audio/x;set volume 13;#/stream?ApiKey=k";
let _ = command(&mpv, &["loadfile", url, "replace"]);
let volume: i64 = mpv.get_property("volume").unwrap();
assert_eq!(
volume, 100,
"text inside a URL was executed as an mpv command"
);
}
/// An argument with a space in it — every downloaded title with one —
/// arrives as one argument rather than being split into the next slot.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-268
#[test]
fn an_argument_with_spaces_stays_one_argument() {
let mpv = null_mpv();
// Split on the space this would be `loadfile file:///tmp/My Song.mp3`
// — `Song.mp3` taken as the flags argument, which mpv rejects.
assert!(command(&mpv, &["loadfile", "file:///nonexistent/My Song.mp3"]).is_ok());
}
/// No playback path may call the string-joining `Mpv::command` directly;
/// they all go through [`command`]. Asserted against the source because the
/// dangerous call and the safe one have the same shape at the call site.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-269
#[test]
fn players_never_use_the_string_command_api() {
for (file, src) in [
("mpv_backend.rs", include_str!("mpv_backend.rs")),
("mpv_player.rs", include_str!("mpv_player.rs")),
] {
assert!(
!src.contains(".command(\""),
"{file} calls Mpv::command, which parses its arguments as a command string"
);
}
}
/// TRACES: UR-012 | DR-299 | UT-270
#[test]
fn a_hardened_handle_verifies_tls_and_never_hands_urls_to_ytdl() {
let mpv = null_mpv();
harden(&mpv).unwrap();
let tls: String = mpv.get_property("tls-verify").unwrap();
assert_eq!(tls, "yes");
let ytdl: String = mpv.get_property("ytdl").unwrap();
assert_eq!(ytdl, "no");
}
/// Both constructors apply [`harden`]; a handle built without it is the bug.
///
/// TRACES: UR-012 | DR-299 | UT-270
#[test]
fn every_player_hardens_its_handle() {
for (file, src) in [
("mpv_backend.rs", include_str!("mpv_backend.rs")),
("mpv_player.rs", include_str!("mpv_player.rs")),
] {
assert!(
src.contains("mpv_command::harden(&mpv)"),
"{file} creates an mpv handle without hardening it"
);
}
}
}
+7 -5
View File
@@ -85,6 +85,8 @@ impl MpvPlayer {
let mpv = Mpv::new().map_err(|e| PlayerError { let mpv = Mpv::new().map_err(|e| PlayerError {
message: format!("mpv_create failed: {e:?}"), message: format!("mpv_create failed: {e:?}"),
})?; })?;
// TRACES: UR-012 | DR-299
super::mpv_command::harden(&mpv).map_err(|message| PlayerError { message })?;
let set = |k: &str, v: &str| { let set = |k: &str, v: &str| {
if let Err(e) = mpv.set_property(k, v) { if let Err(e) = mpv.set_property(k, v) {
@@ -229,10 +231,10 @@ impl MediaPlayer for MpvPlayer {
})?; })?;
info!("[MpvPlayer] open {} at {:?}", req.selection.url, req.start); info!("[MpvPlayer] open {} at {:?}", req.selection.url, req.start);
self.mpv // TRACES: UR-003, UR-004 | DR-298
.command("loadfile", &[&req.selection.url, "replace"]) super::mpv_command::command(&self.mpv, &["loadfile", &req.selection.url, "replace"])
.map_err(|e| PlayerError { .map_err(|e| PlayerError {
message: format!("loadfile failed: {e:?}"), message: format!("loadfile failed: {e}"),
})?; })?;
Ok(()) Ok(())
} }
@@ -275,8 +277,8 @@ impl MediaPlayer for MpvPlayer {
} }
// Idempotent: stopping an already-stopped mpv is not an error worth // Idempotent: stopping an already-stopped mpv is not an error worth
// propagating, and callers legitimately close twice on teardown. // propagating, and callers legitimately close twice on teardown.
if let Err(e) = self.mpv.command("stop", &[]) { if let Err(e) = super::mpv_command::command(&self.mpv, &["stop"]) {
debug!("[MpvPlayer] stop on an idle player: {e:?}"); debug!("[MpvPlayer] stop on an idle player: {e}");
} }
Ok(()) Ok(())
} }
+22 -37
View File
@@ -14,65 +14,50 @@
//! //!
//! TRACES: UR-080 | DR-231, DR-235 //! TRACES: UR-080 | DR-231, DR-235
/// The opt-in for native desktop video.
///
/// Off by default while the render path is unproven — the webview path still
/// works and is what ships. This becomes the *default* (and then the only path)
/// when DR-235 lands; the variable is how it is exercised until then.
const ENV_FLAG: &str = "JELLYTAU_NATIVE_VIDEO";
/// Whether mpv should decode and draw video in this process. /// Whether mpv should decode and draw video in this process.
/// ///
/// Read fresh rather than cached: it is consulted a handful of times at startup, /// True wherever mpv is the desktop video renderer — Linux, since DR-235
/// and a `OnceLock` here would only make it harder to test. /// phase 1. There is no opt-out: the webview `<video>` path is no longer a Linux
/// video renderer, so "off" would leave nothing drawing the picture. It was the
/// `JELLYTAU_NATIVE_VIDEO` opt-in while the render path was being proven; the
/// variable is now ignored. Windows joins in DR-237, and only then does the
/// webview path go (phase 3).
/// ///
/// TRACES: UR-080 | DR-231, DR-235 /// TRACES: UR-080 | DR-231, DR-235
pub fn enabled() -> bool { pub fn enabled() -> bool {
// Only where a native renderer exists. On Android ExoPlayer already does // On Android ExoPlayer draws video and `use_html5_element` is false for
// this and `use_html5_element` is false for entirely separate reasons. // entirely separate reasons.
if !cfg!(all(target_os = "linux", not(target_os = "android"))) { cfg!(all(target_os = "linux", not(target_os = "android")))
return false;
}
matches!(
std::env::var(ENV_FLAG).as_deref(),
Ok("1") | Ok("true") | Ok("yes")
)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// Absent, empty, or anything unrecognised means off. A half-set variable /// mpv draws video on Linux with nothing to opt into, and the retired
/// must not half-enable a renderer — the failure mode would be mpv /// variable cannot opt back out: with the webview path gone from Linux, "off"
/// configured for video with nothing drawing it, i.e. audio playing over a /// would configure mpv for audio only with nothing else to draw the picture.
/// black rectangle.
/// ///
/// TRACES: UR-080 | DR-231 | UT-216 /// TRACES: UR-080 | DR-235 | UT-271
#[test] #[test]
fn test_only_explicit_truthy_values_enable_it() { fn linux_always_renders_video_natively() {
let restore = std::env::var(ENV_FLAG).ok(); let restore = std::env::var("JELLYTAU_NATIVE_VIDEO").ok();
for value in ["", "0", "no", "false", "maybe", "2"] { for value in [None, Some("0"), Some("false"), Some("1")] {
std::env::set_var(ENV_FLAG, value); match value {
assert!(!enabled(), "{value:?} must not enable native video"); Some(v) => std::env::set_var("JELLYTAU_NATIVE_VIDEO", v),
None => std::env::remove_var("JELLYTAU_NATIVE_VIDEO"),
} }
for value in ["1", "true", "yes"] {
std::env::set_var(ENV_FLAG, value);
assert_eq!( assert_eq!(
enabled(), enabled(),
cfg!(all(target_os = "linux", not(target_os = "android"))), cfg!(all(target_os = "linux", not(target_os = "android"))),
"{value:?} enables it exactly where a native renderer exists" "JELLYTAU_NATIVE_VIDEO={value:?} must not decide the renderer"
); );
} }
std::env::remove_var(ENV_FLAG);
assert!(!enabled(), "absent means off");
match restore { match restore {
Some(v) => std::env::set_var(ENV_FLAG, v), Some(v) => std::env::set_var("JELLYTAU_NATIVE_VIDEO", v),
None => std::env::remove_var(ENV_FLAG), None => std::env::remove_var("JELLYTAU_NATIVE_VIDEO"),
} }
} }
} }
+4 -5
View File
@@ -2912,16 +2912,15 @@ export type PlaybackCapabilities = {
*/ */
usesWebviewAudio: boolean; usesWebviewAudio: boolean;
/** /**
* True when video can be rendered by a native surface composited *behind* * True when video is rendered by a native surface composited *behind* a
* a transparent webview. Android only: ExoPlayer draws into a SurfaceView * transparent webview: ExoPlayer's SurfaceView on Android, mpv's GL area on
* beneath the WebView. Linux cannot do this (WebKitGTK/Wayland * Linux.
* compositing), so it stays on the HTML5 element.
*/ */
supportsNativeVideo: boolean; supportsNativeVideo: boolean;
/** /**
* True when the user may send video to the webview element instead of the * 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 * native renderer — the frontend offers the switch only then, and honours
* the stored preference only then. See [`webview_video_fallback`]. * the stored preference only then. False on every platform since DR-235.
*/ */
webviewVideoFallback: boolean } webviewVideoFallback: boolean }
/** /**