feat(video): let mpv decode video at all, behind one shared flag

mpv has never decoded a video frame in this app: the backend sets `video: no`
unconditionally, because Linux video has always been the webview's job and
decoding it twice would burn a core for a picture nobody sees. The render path
built in the previous commit therefore had nothing to draw.

With native video on, mpv is configured for video *and* `vo=libmpv` — the render
API only works through that output, and the default would try to open a window
of its own. Set at construction, because mpv resolves its video output when it
initialises and flipping the property later does not re-open one.

The flag lives in `player::native_video`, read by all three things that must
agree: the backend (configured before anything plays), the surface (nothing to
draw otherwise), and `get_player_status` (which tells the frontend whether to
use a `<video>` element — two decoders on one stream would fight over the
audio). A function rather than three `env::var` checks, because a capability
answered in several places is a capability whose answers drift: four separate
bugs this cycle came from exactly that shape.

Also fixes an ordering bug the first run exposed. The surface was attached in
`setup` before the player backend was constructed, and the mpv handle is
registered *during* that construction — so it found nothing every time and
logged "no mpv handle". Attaching after the backend exists is the whole fix.

Confirmed on a real run: mpv accepts `vo=libmpv`, the GL context comes up on
Tauri's vbox, and `mpv_render_context_create` succeeds — which also proves the
libepoxy data-symbol handling is right, since a wrong `get_proc_address` would
have taken SIGSEGV on the first GL call rather than returning cleanly.

No frame has reached the screen yet. The webview is still opaque, so it will
paint over anything drawn beneath it until transparency is set up.

Security: quick-xml 0.38.4 carried RUSTSEC-2026-0194 (quadratic parse on
duplicate attribute names) and RUSTSEC-2026-0195 (unbounded namespace
allocation, memory-exhaustion DoS). `cargo deny` gates CI on advisories, so this
would have failed the next release. Fixed by plist 1.8 -> 1.10, which pulls
quick-xml 0.41. Licences, bans and sources still pass.

UT-216 pins the flag's parsing: absent, empty, `0`, `no` and anything
unrecognised all mean off. A half-set variable that half-enabled the renderer
would configure mpv for video with nothing drawing it — audio over a black
rectangle.

Also removes a wall-clock timer from the waitForRepository late-arrival test,
which failed once under load. The assertion is about ordering, so it now
publishes on a microtask and cannot race.
This commit is contained in:
2026-08-22 13:45:04 +02:00
parent 45144cb6b0
commit 2f637d4775
8 changed files with 933 additions and 758 deletions
+4
View File
@@ -24,6 +24,10 @@ pub mod android;
#[cfg(target_os = "linux")]
pub mod mpv_backend;
/// Whether this process renders video natively — one answer, three consumers
/// (UR-080 / DR-231, DR-235).
pub mod native_video;
/// mpv's render API into a framebuffer we own (UR-080 / DR-231, IR-033).
///
/// Deliberately *not* GTK-gated beyond the platform that currently builds it:
+22 -3
View File
@@ -163,9 +163,28 @@ impl MpvBackend {
message: format!("Failed to configure MPV audio-display: {:?}", e),
})?;
mpv.set_property("video", "no").map_err(|e| PlayerError {
message: format!("Failed to configure MPV video: {:?}", e),
})?;
// Video is disabled unless this process is drawing it.
//
// `video: no` is why mpv has never decoded a frame here: Linux video has
// always gone through the webview, and decoding it twice would burn a
// core for a picture nobody sees. With native video on, mpv needs both
// the decoder *and* `vo=libmpv` — the render API only works through that
// output, and the default would try to open a window of its own.
//
// Set at construction because mpv resolves the video output when it
// initialises; flipping it later does not re-open one.
//
// TRACES: UR-080 | DR-231, DR-235
if super::native_video::enabled() {
mpv.set_property("vo", "libmpv").map_err(|e| PlayerError {
message: format!("Failed to select the libmpv video output: {:?}", e),
})?;
info!("[MpvBackend] native video enabled (vo=libmpv)");
} else {
mpv.set_property("video", "no").map_err(|e| PlayerError {
message: format!("Failed to configure MPV video: {:?}", e),
})?;
}
// Set volume to 100% (we'll control via MPV's volume property)
mpv.set_property("volume", 100i64)
+78
View File
@@ -0,0 +1,78 @@
//! Whether this process renders video natively, answered once.
//!
//! Three things need this and must agree: the mpv backend (which has to be
//! configured for video *at construction*, before anything plays), the video
//! surface (which has nothing to draw otherwise), and `get_player_status`
//! (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
//! codebase keeps rediscovering: a capability answered in several places is a
//! capability whose answers drift. Four separate bugs this cycle came from
//! exactly that shape — a webview's decode limits applied to ExoPlayer, a
//! transcode target contradicting a direct-play claim, a codec list hardcoded in
//! a URL builder. One source, read by everyone.
//!
//! 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.
///
/// Read fresh rather than cached: it is consulted a handful of times at startup,
/// and a `OnceLock` here would only make it harder to test.
///
/// TRACES: UR-080 | DR-231, DR-235
pub fn enabled() -> bool {
// Only where a native renderer exists. On Android ExoPlayer already does
// this and `use_html5_element` is false for entirely separate reasons.
if !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)]
mod tests {
use super::*;
/// Absent, empty, or anything unrecognised means off. A half-set variable
/// must not half-enable a renderer — the failure mode would be mpv
/// configured for video with nothing drawing it, i.e. audio playing over a
/// black rectangle.
///
/// TRACES: UR-080 | DR-231 | UT-216
#[test]
fn test_only_explicit_truthy_values_enable_it() {
let restore = std::env::var(ENV_FLAG).ok();
for value in ["", "0", "no", "false", "maybe", "2"] {
std::env::set_var(ENV_FLAG, value);
assert!(!enabled(), "{value:?} must not enable native video");
}
for value in ["1", "true", "yes"] {
std::env::set_var(ENV_FLAG, value);
assert_eq!(
enabled(),
cfg!(all(target_os = "linux", not(target_os = "android"))),
"{value:?} enables it exactly where a native renderer exists"
);
}
std::env::remove_var(ENV_FLAG);
assert!(!enabled(), "absent means off");
match restore {
Some(v) => std::env::set_var(ENV_FLAG, v),
None => std::env::remove_var(ENV_FLAG),
}
}
}