pinakes-ui: use system player for HLS streams

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I0255e79e25cde100a063476de2c8fe0d6a6a6964
This commit is contained in:
raf 2026-03-21 15:19:28 +03:00
commit 76cb98375c
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View file

@ -1,7 +1,4 @@
use dioxus::{ use dioxus::{document::eval, prelude::*};
document::{Script, eval},
prelude::*,
};
use super::utils::format_duration; use super::utils::format_duration;
@ -126,41 +123,30 @@ impl PlayQueue {
} }
} }
/// Generate JavaScript to initialize hls.js for an HLS stream URL. /// Open a URL in the system's default media player.
/// ///
/// Destroys any previously created instance stored at `window.__hlsInstances` /// Uses `xdg-open` on Linux, `open` on macOS, and `cmd /c start` on Windows.
/// keyed by element ID before creating a new one, preventing memory leaks and /// The call is fire-and-forget; failures are logged as warnings.
/// multiple instances competing for the same video element across re-renders. fn open_in_system_player(url: &str) {
fn hls_init_script(video_id: &str, hls_url: &str) -> String { #[cfg(target_os = "linux")]
// JSON-encode both values so embedded quotes/backslashes cannot break out of let result = std::process::Command::new("xdg-open").arg(url).spawn();
// the JS string. #[cfg(target_os = "macos")]
let encoded_url = let result = std::process::Command::new("open").arg(url).spawn();
serde_json::to_string(hls_url).unwrap_or_else(|_| "\"\"".to_string()); #[cfg(target_os = "windows")]
let encoded_id = let result = std::process::Command::new("cmd")
serde_json::to_string(video_id).unwrap_or_else(|_| "\"\"".to_string()); .args(["/c", "start", "", url])
format!( .spawn();
r#" #[cfg(not(any(
(function() {{ target_os = "linux",
window.__hlsInstances = window.__hlsInstances || {{}}; target_os = "macos",
var existing = window.__hlsInstances[{encoded_id}]; target_os = "windows"
if (existing) {{ )))]
existing.destroy(); let result: std::io::Result<std::process::Child> =
window.__hlsInstances[{encoded_id}] = null; Err(std::io::Error::other("unsupported platform"));
}}
if (typeof Hls !== 'undefined' && Hls.isSupported()) {{ if let Err(e) = result {
var hls = new Hls(); tracing::warn!("failed to open system player: {e}");
hls.loadSource({encoded_url}); }
hls.attachMedia(document.getElementById({encoded_id}));
window.__hlsInstances[{encoded_id}] = hls;
}} else {{
var video = document.getElementById({encoded_id});
if (video && video.canPlayType('application/vnd.apple.mpegurl')) {{
video.src = {encoded_url};
}}
}}
}})();
"#
)
} }
#[component] #[component]
@ -179,6 +165,10 @@ pub fn MediaPlayer(
let mut muted = use_signal(|| false); let mut muted = use_signal(|| false);
let is_video = media_type == "video"; let is_video = media_type == "video";
// HLS adaptive streams (.m3u8) cannot be decoded natively by webkit2gtk on
// Linux. Detect them and show an external-player button instead of attempting
// in-process playback via a JavaScript library.
let is_hls = src.ends_with(".m3u8") || src.contains("/stream/hls/");
let is_playing = *playing.read(); let is_playing = *playing.read();
let cur_time = *current_time.read(); let cur_time = *current_time.read();
let dur = *duration.read(); let dur = *duration.read();
@ -240,35 +230,6 @@ pub fn MediaPlayer(
}); });
}); });
// HLS initialization for .m3u8 streams.
// use_effect must be called unconditionally to maintain stable hook ordering.
let is_hls = src.ends_with(".m3u8");
let hls_src = src.clone();
use_effect(move || {
if !hls_src.ends_with(".m3u8") {
return;
}
let js = hls_init_script("pinakes-player", &hls_src);
spawn(async move {
// Poll until hls.js is loaded rather than using a fixed delay, so we
// initialize as soon as the script is ready without timing out on slow
// connections. Max wait: 25 * 100ms = 2.5s.
const MAX_POLLS: u32 = 25;
for _ in 0..MAX_POLLS {
if let Ok(val) = eval("typeof Hls !== 'undefined'").await {
if val == serde_json::Value::Bool(true) {
let _ = eval(&js).await;
return;
}
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
tracing::warn!(
"hls.js did not load within 2.5s; HLS stream will not play"
);
});
});
// Autoplay on mount // Autoplay on mount
if autoplay { if autoplay {
let src_auto = src.clone(); let src_auto = src.clone();
@ -435,32 +396,41 @@ pub fn MediaPlayer(
let play_icon = if is_playing { "\u{23f8}" } else { "\u{25b6}" }; let play_icon = if is_playing { "\u{23f8}" } else { "\u{25b6}" };
let mute_icon = if is_muted { "\u{1f507}" } else { "\u{1f50a}" }; let mute_icon = if is_muted { "\u{1f507}" } else { "\u{1f50a}" };
rsx! { let open_src = src.clone();
// Load hls.js for HLS stream support when needed
if is_hls {
// Pin to a specific release so unexpected upstream changes cannot
// break playback. Update this when intentionally upgrading hls.js.
Script { src: "https://cdn.jsdelivr.net/npm/hls.js@1.5.15/dist/hls.min.js" }
}
rsx! {
div { div {
class: if is_video { "media-player media-player-video" } else { "media-player media-player-audio" }, class: if is_video { "media-player media-player-video" } else { "media-player media-player-audio" },
tabindex: "0", tabindex: "0",
onkeydown: on_keydown, onkeydown: on_keydown,
// Hidden native element; for HLS streams skip the src attr (hls.js attaches it) // HLS adaptive streams cannot be decoded natively in the desktop WebView.
// Show a prompt to open in the system's default media player instead.
if is_hls {
div { class: "player-hls-notice",
p { class: "text-muted",
"Adaptive (HLS) streams require an external media player."
}
button {
class: "btn btn-primary",
onclick: move |_| open_in_system_player(&open_src),
"Open in system player"
}
}
} else {
if is_video { if is_video {
video { video {
id: "pinakes-player", id: "pinakes-player",
class: "player-native-video", class: "player-native-video",
src: if is_hls { String::new() } else { src.clone() }, src: src.clone(),
preload: "metadata", preload: "metadata",
} }
} else { } else {
audio { audio {
id: "pinakes-player", id: "pinakes-player",
class: "player-native-audio", class: "player-native-audio",
src: if is_hls { String::new() } else { src.clone() }, src: src.clone(),
preload: "metadata", preload: "metadata",
} }
} }
@ -521,6 +491,7 @@ pub fn MediaPlayer(
} }
} }
} }
} // end else (non-HLS)
} }
} }
} }