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:
@@ -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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user