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 c1a1f4a600
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View file

@ -1,7 +1,4 @@
use dioxus::{
document::{Script, eval},
prelude::*,
};
use dioxus::{document::eval, prelude::*};
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`
/// keyed by element ID before creating a new one, preventing memory leaks and
/// multiple instances competing for the same video element across re-renders.
fn hls_init_script(video_id: &str, hls_url: &str) -> String {
// JSON-encode both values so embedded quotes/backslashes cannot break out of
// the JS string.
let encoded_url =
serde_json::to_string(hls_url).unwrap_or_else(|_| "\"\"".to_string());
let encoded_id =
serde_json::to_string(video_id).unwrap_or_else(|_| "\"\"".to_string());
format!(
r#"
(function() {{
window.__hlsInstances = window.__hlsInstances || {{}};
var existing = window.__hlsInstances[{encoded_id}];
if (existing) {{
existing.destroy();
window.__hlsInstances[{encoded_id}] = null;
}}
if (typeof Hls !== 'undefined' && Hls.isSupported()) {{
var hls = new Hls();
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};
}}
}}
}})();
"#
)
/// Uses `xdg-open` on Linux, `open` on macOS, and `cmd /c start` on Windows.
/// The call is fire-and-forget; failures are logged as warnings.
fn open_in_system_player(url: &str) {
#[cfg(target_os = "linux")]
let result = std::process::Command::new("xdg-open").arg(url).spawn();
#[cfg(target_os = "macos")]
let result = std::process::Command::new("open").arg(url).spawn();
#[cfg(target_os = "windows")]
let result = std::process::Command::new("cmd")
.args(["/c", "start", "", url])
.spawn();
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows"
)))]
let result: std::io::Result<std::process::Child> =
Err(std::io::Error::other("unsupported platform"));
if let Err(e) = result {
tracing::warn!("failed to open system player: {e}");
}
}
#[component]
@ -179,6 +165,10 @@ pub fn MediaPlayer(
let mut muted = use_signal(|| false);
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 cur_time = *current_time.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
if autoplay {
let src_auto = src.clone();
@ -435,32 +396,41 @@ pub fn MediaPlayer(
let play_icon = if is_playing { "\u{23f8}" } else { "\u{25b6}" };
let mute_icon = if is_muted { "\u{1f507}" } else { "\u{1f50a}" };
rsx! {
// 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" }
}
let open_src = src.clone();
rsx! {
div {
class: if is_video { "media-player media-player-video" } else { "media-player media-player-audio" },
tabindex: "0",
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 {
video {
id: "pinakes-player",
class: "player-native-video",
src: if is_hls { String::new() } else { src.clone() },
src: src.clone(),
preload: "metadata",
}
} else {
audio {
id: "pinakes-player",
class: "player-native-audio",
src: if is_hls { String::new() } else { src.clone() },
src: src.clone(),
preload: "metadata",
}
}
@ -521,6 +491,7 @@ pub fn MediaPlayer(
}
}
}
} // end else (non-HLS)
}
}
}