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:
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user