feat(windows): mpv plays audio on Windows, with libmpv shipped in the installer

libmpv on Windows (DR-237):
- The builder image carries zhongfly's LGPL libmpv-2.dll, pinned by
  asset name and sha256, plus an MSVC mpv.lib generated from the DLL's
  own mpv_* exports (the archive ships only a MinGW .dll.a). LGPL, not
  the GPL builds: no x264/x265, mpv -Dgpl=false; FFmpeg is version3, so
  LGPL-3.0. THIRD_PARTY_NOTICES.md records it.
- build-windows-cross.sh stages both files into src-tauri/windows-libs/;
  build.rs links mpv.lib from there and tauri.windows.conf.json bundles
  the DLL beside jellytau.exe from there, with the licence texts under
  licenses/. One directory, so the DLL shipped is the one linked.
- Workflows move to builder image 2026.09.1.

Windows audio:
- MpvBackend replaces WebviewAudioBackend on Windows (ao=wasapi), so
  volume, EQ, normalization and gapless work there as on Linux. Local
  files are passed to mpv as native paths, not file:// URLs.

Fixes found on the way:
- player_play_item decided "does the backend render video" with
  cfg!(not(linux)), true on Windows, while get_player_status sent
  Windows video to the <video> element. With mpv as the backend that
  would decode every film's soundtrack twice. All three callers now
  ask video_renders_natively() (UT-273).
- confine_queued_path rebuilt paths with PathBuf::push, so on Windows
  a queued `downloads/x` was stored as `downloads\x`, no longer the
  spelling the app built. It now keeps the caller's separator (UT-205,
  which only ever ran on Linux, failed under Windows).

Verified: jellytau.exe imports libmpv-2.dll; the full unit suite
cross-compiled for Windows passes under wine against the shipped DLL
(943/943, including the mpv injection and TLS tests); the NSIS
installer contains the DLL and licence texts. Not yet run on real
Windows hardware.
This commit is contained in:
2026-09-24 22:25:31 -04:00
parent 9d9d81bef3
commit 4daf172834
24 changed files with 1163 additions and 70 deletions
+4
View File
@@ -22,3 +22,7 @@ local.properties
# macOS
.DS_Store
# Windows libmpv (import library + DLL), staged from the builder image by
# scripts/build-windows-cross.sh. Never committed: 100 MB of LGPL binary.
/windows-libs/
+7 -3
View File
@@ -108,9 +108,10 @@ tiny_http = { version = "0.12.0", default-features = false }
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
# Linux-specific dependencies
[target.'cfg(target_os = "linux")'.dependencies]
hostname = "0.4"
# mpv platforms: Linux (system libmpv) and Windows (libmpv-2.dll shipped in the
# installer, import library generated in the builder image — see build.rs and
# scripts/build-windows-cross.sh). TRACES: UR-004 | DR-237
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
libc = "0.2"
# The crates.io release of libmpv predates the MPV versions we support, so this
# tracks the upstream git repo.
@@ -137,6 +138,9 @@ libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c38
# TRACES: UR-080 | DR-230, IR-033
libmpv-sys = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7" }
# Linux-specific dependencies
[target.'cfg(target_os = "linux")'.dependencies]
hostname = "0.4"
# Same major as the one Tauri/wry already resolve, so `gtk_window()` and
# `default_vbox()` hand back types this crate can name rather than a second,
# incompatible GTK.
+29
View File
@@ -1,3 +1,32 @@
use std::path::PathBuf;
fn main() {
link_windows_libmpv();
tauri_build::build()
}
/// Point the MSVC linker at `mpv.lib` for a Windows target.
///
/// `libmpv-sys` emits `rustc-link-lib=mpv` and nothing else; on Linux the
/// system library satisfies it. For Windows the import library and the DLL it
/// names are staged into `src-tauri/windows-libs/` by
/// `scripts/build-windows-cross.sh` from the builder image's pinned libmpv
/// build — the same directory `tauri.windows.conf.json` bundles the DLL from,
/// so what is linked against and what is shipped cannot come from two places.
///
/// TRACES: UR-004 | DR-237
fn link_windows_libmpv() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
let dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("windows-libs");
println!("cargo:rerun-if-changed={}", dir.join("mpv.lib").display());
if !dir.join("mpv.lib").is_file() {
panic!(
"{} is missing. Windows builds link libmpv from the builder image; \
build through scripts/build-windows-cross.sh, which stages it.",
dir.join("mpv.lib").display()
);
}
println!("cargo:rustc-link-search=native={}", dir.display());
}
+31 -4
View File
@@ -185,18 +185,45 @@ fn confine_to_root(root: &Path, candidate: &Path) -> Result<PathBuf, String> {
///
/// TRACES: DR-211 | UT-205
fn confine_queued_path(root: &Path, file_path: &str) -> Result<String, String> {
// The returned spelling keeps the caller's own separator. Rebuilding it with
// `PathBuf::push` rewrote `downloads/x` as `downloads\x` on Windows, so the
// stored row no longer spelled the path the app built — the contract the
// test below pins, which only ever ran on Linux.
// TRACES: DR-211 | UT-205
let sep = file_path
.chars()
.find(|c| std::path::is_separator(*c))
.unwrap_or('/');
let mut sanitized = PathBuf::new();
let mut spelled = String::new();
let mut need_sep = false;
for component in Path::new(file_path).components() {
match component {
Component::Normal(part) => sanitized.push(sanitize_filename(&part.to_string_lossy())),
let piece = match component {
Component::Normal(part) => sanitize_filename(&part.to_string_lossy()),
// Kept as they are, so `confine_to_root` is the single thing
// deciding whether what they add up to is still inside the root.
other => sanitized.push(other),
other => other.as_os_str().to_string_lossy().into_owned(),
};
sanitized.push(&piece);
match component {
Component::Prefix(_) => spelled.push_str(&piece),
Component::RootDir => {
spelled.push(sep);
need_sep = false;
continue;
}
_ => {
if need_sep {
spelled.push(sep);
}
spelled.push_str(&piece);
}
}
need_sep = !matches!(component, Component::Prefix(_));
}
confine_to_root(root, &root.join(&sanitized))?;
Ok(sanitized.to_string_lossy().to_string())
Ok(spelled)
}
/// Request payload for download_item_and_start (bundled to stay within specta's
+55 -7
View File
@@ -740,9 +740,11 @@ pub async fn player_play_item(
//
// 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
let renders_natively = cfg!(not(target_os = "linux")) || crate::player::native_video::enabled();
// TRACES: UR-080 | DR-231, DR-235, DR-237
let renders_natively = video_renders_natively();
if renders_natively {
controller
.play_item(media_item)
@@ -2203,14 +2205,18 @@ pub struct PlaybackCapabilities {
#[tauri::command]
#[specta::specta]
pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
// Mirrors the cfg gates the backends themselves are built under.
let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
// Mirrors the cfg gates the backends themselves are built under: mpv on
// Linux and Windows (DR-237), ExoPlayer on Android.
let native_audio = cfg!(any(
target_os = "android",
target_os = "linux",
target_os = "windows"
));
Ok(PlaybackCapabilities {
uses_webview_audio: !native_audio,
// TRACES: UR-080 | DR-235
supports_native_video: cfg!(target_os = "android")
|| crate::player::native_video::enabled(),
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
@@ -2220,12 +2226,25 @@ pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
})
}
/// 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 {
// 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 crate::player::native_video::enabled() {
} 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
@@ -3089,6 +3108,35 @@ mod tests {
}
}
/// 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
#[tokio::test]
async fn test_video_routing_has_one_answer() {
let src = include_str!("mod.rs");
let routing = src
.split("let renders_natively =")
.nth(1)
.and_then(|rest| rest.split(';').next())
.expect("player_play_item decides renders_natively");
assert_eq!(
routing.trim(),
"video_renders_natively()",
"player_play_item must ask the same question as get_player_status"
);
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
/// the native backend renders, never to load a `<video>` element.
///
+20 -9
View File
@@ -352,7 +352,7 @@ use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmi
// still launch (browse library, manage downloads, see an error) instead of crashing.
use player::NullBackend;
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "windows"))]
use player::MpvBackend;
use settings::VideoSettings;
use storage::Database;
@@ -694,15 +694,26 @@ fn create_player_backend(
}
}
// For Linux, use MPV backend for audio playback
#[cfg(target_os = "linux")]
// Linux and Windows: mpv. On Windows libmpv-2.dll ships beside the exe in
// the installer (DR-237); on Linux it is the system library.
#[cfg(any(target_os = "linux", target_os = "windows"))]
{
info!("Linux platform detected - initializing MPV backend for audio");
info!("Initializing MPV backend");
match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
Ok(backend) => {
info!("Successfully initialized MPV backend for Linux");
info!("Successfully initialized MPV backend");
Box::new(backend)
}
#[cfg(target_os = "windows")]
Err(e) => {
// The DLL ships in the installer, so there is no package to
// tell the user to install; a failure here is a broken install.
error!("FATAL ERROR: Failed to initialize MPV backend: {}", e);
error!("libmpv-2.dll should sit beside jellytau.exe; reinstall JellyTau.");
emit_backend_init_failed(&app_handle, "mpv", e.to_string());
Box::new(NullBackend::new())
}
#[cfg(target_os = "linux")]
Err(e) => {
error!("\n========================================");
error!("FATAL ERROR: Failed to initialize MPV backend");
@@ -731,10 +742,10 @@ fn create_player_backend(
}
}
// Platforms with no native audio backend (e.g. Windows): render audio-only
// playback through a webview <audio> element (all video already renders in
// the webview). Falls back to NullBackend only if the backend can't init.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
// Platforms with no native audio backend (none that ships since Windows
// moved to mpv): render audio-only playback through a webview <audio>
// element. Falls back to NullBackend only if the backend can't init.
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
{
info!("No native audio backend for this platform - using webview <audio> backend");
match player::WebviewAudioBackend::new(_event_emitter) {
+8 -7
View File
@@ -15,7 +15,7 @@ mod fake_player_conformance;
pub mod legacy_player;
pub mod media;
pub mod media_player;
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub mod mpv_command;
#[cfg(target_os = "linux")]
pub mod mpv_player;
@@ -42,7 +42,7 @@ pub mod jni_guard;
#[cfg(target_os = "android")]
pub mod android;
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub mod mpv_backend;
/// Whether this process renders video natively — one answer, three consumers
@@ -64,9 +64,10 @@ pub mod mpv_render;
#[cfg(target_os = "linux")]
pub mod video_surface;
// Platforms with no native audio backend (e.g. Windows) render audio-only
// playback through a webview <audio> element, mirroring how all video renders.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
// Platforms with no native audio backend render audio-only playback through a
// webview <audio> element. None that ships: Windows moved to mpv (DR-237); this
// remains for an unported desktop (macOS).
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
pub mod webview_audio_backend;
// Re-export commonly used types
@@ -88,10 +89,10 @@ pub use track_switch::{determine_audio_track_switch_strategy, AudioTrackSwitchSt
#[cfg(target_os = "android")]
pub use android::ExoPlayerBackend;
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub use mpv_backend::MpvBackend;
#[cfg(not(any(target_os = "linux", target_os = "android")))]
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
pub use webview_audio_backend::WebviewAudioBackend;
#[cfg(target_os = "android")]
+23 -2
View File
@@ -9,7 +9,6 @@ use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
use crate::utils::lock::MutexSafe;
use libmpv::Mpv;
use log::{debug, error, info, warn};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -54,8 +53,21 @@ struct InternalState {
volume: f32,
}
/// Detect which audio system is available on the system
/// The audio output mpv should use on Windows: WASAPI, the only one it ships
/// there. Nothing to probe — and spawning `pactl` from a GUI app on Windows
/// would at best fail and at worst flash a console window.
///
/// TRACES: UR-004 | DR-237
#[cfg(target_os = "windows")]
fn detect_audio_system() -> String {
"wasapi".to_string()
}
/// Detect which audio system is available on the system
#[cfg(not(target_os = "windows"))]
fn detect_audio_system() -> String {
use std::process::Command;
info!("[MpvBackend] Detecting audio system...");
// Try PulseAudio/PipeWire first (most common on modern Linux)
@@ -95,6 +107,13 @@ fn detect_audio_system() -> String {
fn get_stream_url(media: &MediaItem) -> String {
match &media.source {
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
// A Windows path is not a URL (`file://C:\...` is malformed); mpv
// takes the native path as it is. Safe to pass verbatim because it goes
// to mpv as one argv element (DR-298), not through a command string.
// TRACES: UR-004, UR-071 | DR-237
MediaSource::Local { file_path, .. } if cfg!(target_os = "windows") => {
file_path.to_string_lossy().into_owned()
}
MediaSource::Local { file_path, .. } => {
format!("file://{}", file_path.to_string_lossy())
}
@@ -121,6 +140,8 @@ static MPV_HANDLE: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
/// can fail, and the app falls back to a no-op backend rather than dying).
///
/// TRACES: UR-080 | DR-231
// Only the Linux video surface reads it until Windows gets one (DR-237).
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
MPV_HANDLE
.get()
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"resources": {
"windows-libs/libmpv-2.dll": "libmpv-2.dll",
"../THIRD_PARTY_NOTICES.md": "licenses/THIRD_PARTY_NOTICES.md",
"../packaging/windows/LGPL-3.0.txt": "licenses/LGPL-3.0.txt",
"../packaging/windows/GPL-3.0.txt": "licenses/GPL-3.0.txt",
"../LICENSE": "licenses/LICENSE-JellyTau-MIT.txt"
}
}
}