use dioxus::prelude::*;
use dioxus_sdk::time::{TimeoutHandle, use_timeout};
use std::time::Duration;
use super::controls::PlayerControls;
const HIDE_DELAY: Duration = Duration::from_secs(3);
/// Tracks the current state of the subtitle rendering pipeline.
#[derive(Clone, Debug, PartialEq)]
#[allow(dead_code)] // Variants constructed on wasm target only
pub enum SubtitleStatus {
/// No subtitle file loaded.
Idle,
/// A subtitle file has been submitted to the worker and is being parsed.
Loading,
/// The worker has successfully parsed and is rendering subtitles.
Ready,
/// The worker reported an error while loading or parsing.
Error(String),
}
/// Identifies the source of the currently active subtitles.
#[derive(Clone, Debug, PartialEq)]
#[allow(dead_code)]
pub enum SubtitleSource {
/// No subtitle source selected.
None,
/// Loaded from a local file.
File(String),
/// Selected from an HLS manifest track.
Track(u32),
/// Ionosphere transcript (auto-loaded from AT Protocol).
Ionosphere,
}
/// A request from the parent component to load subtitles into the player.
#[derive(Clone, Debug)]
#[allow(dead_code)] // Fields read on wasm target only.
pub struct SubtitleRequest {
/// The converted ASS script text.
pub ass_text: String,
/// Where the subtitles came from.
pub source: SubtitleSource,
}
/// Maximum number of automatic content reloads before surfacing the error.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
const MAX_AUTO_RETRIES: u32 = 2;
/// If this many rebuffering cycles occur within `STALL_WINDOW` without the
/// playback position advancing by at least `STALL_POSITION_THRESHOLD`, we
/// treat it as a stall and trigger an auto-reload.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
const STALL_CYCLE_THRESHOLD: u32 = 5;
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
const STALL_WINDOW_MS: f64 = 20_000.0;
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
const STALL_POSITION_THRESHOLD: f64 = 2.0;
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
pub fn toggle_fullscreen(wrapper_mounted: Signal>) {
_toggle_fullscreen_impl(wrapper_mounted);
}
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
fn _toggle_fullscreen_impl(wrapper_mounted: Signal >) {
use dioxus::web::WebEventExt;
use wasm_bindgen::JsCast;
let doc = web_sys::window()
.and_then(|w| w.document())
.expect("no document");
if doc.fullscreen_element().is_some() {
doc.exit_fullscreen();
} else if let Some(ref evt) = *wrapper_mounted.read() {
if let Some(el) = evt.try_as_web_event() {
let element: web_sys::Element = el.unchecked_into();
let _ = element.request_fullscreen();
}
}
}
#[component]
pub fn VodPlayer(
uri: jacquard::deps::smol_str::SmolStr,
video_mounted: Signal >,
wrapper_mounted: Signal >,
current_playback_time: Signal,
mut subtitle_request: Signal>,
) -> Element {
let mut error_message: Signal > = use_signal(|| None);
let mut mounted_event = video_mounted;
let mut controls_visible: Signal = use_signal(|| true);
let is_playing_for_hide: Signal = use_signal(|| false);
let mut current_hide: Signal> = use_signal(|| None);
let mut wrapper_mounted = wrapper_mounted;
// Recovery signals.
#[allow(unused_mut)]
let mut reload_request: Signal = use_signal(|| 0);
#[allow(unused_mut)]
let mut auto_retry_count: Signal = use_signal(|| 0);
// Subtitle state signals.
#[allow(unused_mut)]
let mut subtitle_active: Signal = use_signal(|| false);
#[allow(unused_mut)]
let mut subtitle_status: Signal = use_signal(|| SubtitleStatus::Idle);
#[allow(unused_mut, unused_variables)]
let subtitle_tracks: Signal> =
use_signal(Vec::new);
#[allow(unused_mut)]
let mut subtitle_source: Signal = use_signal(|| SubtitleSource::None);
let timeout = use_timeout(HIDE_DELAY, move |()| {
controls_visible.set(false);
current_hide.set(None);
});
let mut reset_visibility = move || {
if let Some(handle) = current_hide.take() {
handle.cancel();
}
controls_visible.set(true);
if *is_playing_for_hide.read() {
let handle = timeout.action(());
current_hide.set(Some(handle));
}
};
// Schedule initial auto-hide when playback starts
use_effect(move || {
let playing = *is_playing_for_hide.read();
if playing && *controls_visible.read() && current_hide.read().is_none() {
let handle = timeout.action(());
current_hide.set(Some(handle));
}
});
// Store the bridge in a signal to keep it alive for the component lifetime.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
let mut bridge: Signal> = use_signal(|| None);
// React to subtitle requests from the parent component (e.g., ionosphere
// transcript auto-load). Consumes the request only when the bridge is
// ready — if the bridge isn't available yet, the request stays pending
// and this effect re-fires when the bridge initializes.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
use_effect(move || {
let req = subtitle_request.read().clone();
// Read bridge so Dioxus tracks it — re-runs when bridge initializes.
let guard = bridge.read();
if let (Some(req), Some(wb)) = (req, &*guard) {
subtitle_source.set(req.source);
subtitle_status.set(SubtitleStatus::Loading);
subtitle_active.set(true);
wb.load_subtitles(req.ass_text);
subtitle_request.set(None);
}
});
// Subtitle canvas signals.
// `canvas_mounted` holds the mounted canvas element event.
// `canvas_sent` prevents a second transfer attempt (OffscreenCanvas can
// only be transferred once per canvas element — once transferred the DOM
// element is neutered). When the worker is restarted (retry_count bumped),
// the canvas element is re-keyed so Dioxus recreates it, and `canvas_sent`
// is reset to false so the new element can be transferred to the new worker.
#[allow(unused_mut)]
let mut canvas_mounted: Signal > = use_signal(|| None);
#[allow(unused_mut, unused_variables)]
let mut canvas_sent: Signal = use_signal(|| false);
// Retry counter: incrementing this re-triggers bridge creation.
#[allow(unused_mut, unused_variables)]
let mut retry_count = use_signal(|| 0u32);
// When retry_count changes, reset canvas state so the new canvas element
// (re-keyed in the RSX below) can be transferred to the new worker.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
{
use_effect(move || {
let _count = retry_count(); // track this dependency
// Reset canvas state on every retry so the new DOM element can
// be transferred to the freshly spawned worker.
canvas_sent.set(false);
canvas_mounted.set(None);
});
}
// Transfer the OffscreenCanvas to the worker once both the canvas element
// is mounted and the WorkerBridge is available.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
{
use_effect(move || {
// Read all reactive signals first so Dioxus tracks the dependencies.
let canvas_ready = canvas_mounted.read().is_some();
let bridge_ready = bridge.read().is_some();
let already_sent = *canvas_sent.read();
if canvas_ready && bridge_ready && !already_sent {
transfer_offscreen_canvas(canvas_mounted, bridge, canvas_sent);
}
});
}
// Track the subtitle canvas render dimensions so we can detect changes
// and send SubtitleResize to the worker.
#[allow(unused_variables)]
let subtitle_last_size: Signal<(u32, u32)> = use_signal(|| (0u32, 0u32));
// Update subtitle render resolution on fullscreen toggle or window resize.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
{
let mut subtitle_resize_listener: Signal> =
use_signal(|| None);
let mut subtitle_win_resize_listener: Signal > =
use_signal(|| None);
use_effect(move || {
if subtitle_resize_listener.peek().is_some() {
return;
}
let doc = web_sys::window().unwrap().document().unwrap();
subtitle_resize_listener.set(Some(gloo_events::EventListener::new(
&doc,
"fullscreenchange",
move |_| {
send_subtitle_resize(wrapper_mounted, bridge, subtitle_last_size);
},
)));
let window = web_sys::window().unwrap();
subtitle_win_resize_listener.set(Some(gloo_events::EventListener::new(
&window,
"resize",
move |_| {
send_subtitle_resize(wrapper_mounted, bridge, subtitle_last_size);
},
)));
// Also listen for loadedmetadata on the video element — this fires
// once the video's intrinsic dimensions are known, letting us send
// the correct subtitle canvas size (the initial resize may fire
// before the video has dimensions).
if let Some(ref evt) = *wrapper_mounted.peek() {
use dioxus::web::WebEventExt;
use wasm_bindgen::JsCast;
if let Some(el) = evt.try_as_web_event() {
let element: web_sys::Element = el.unchecked_into();
if let Some(video) = element.query_selector("video").ok().flatten() {
let _listener = gloo_events::EventListener::new(
&video,
"loadedmetadata",
move |_| {
send_subtitle_resize(wrapper_mounted, bridge, subtitle_last_size);
},
);
// Keep the listener alive by leaking it — it only fires
// once per video load and the component owns the video.
std::mem::forget(_listener);
}
}
}
});
}
let _uri_for_mount = uri.clone();
let on_mounted = move |event: MountedEvent| {
mounted_event.set(Some(event.clone()));
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
{
create_bridge(
event,
&_uri_for_mount,
&mut error_message,
&mut bridge,
reload_request,
auto_retry_count,
subtitle_active,
subtitle_status,
subtitle_source,
subtitle_tracks,
);
}
};
// When retry_count changes (after initial render), re-create the bridge.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
{
let uri_for_retry = uri.clone();
use_effect(move || {
let count = retry_count();
// Skip the initial render (count == 0); bridge is created in onmounted.
if count == 0 {
return;
}
let Some(ref event) = *mounted_event.read() else {
return;
};
create_bridge(
event.clone(),
&uri_for_retry,
&mut error_message,
&mut bridge,
reload_request,
auto_retry_count,
subtitle_active,
subtitle_status,
subtitle_source,
subtitle_tracks,
);
});
}
// Auto-reload: when reload_request changes, tell the existing bridge to
// reload content. The worker calls load_content() internally, which
// resets its state and sends AttachMediaSource — handled by
// MseController::reset() on this side.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
{
let uri_for_reload = uri.clone();
use_effect(move || {
let req = reload_request();
// Skip initial render.
if req == 0 {
return;
}
let Some(ref wb) = *bridge.read() else {
return;
};
let playlist_url = super::uri::resolve_playlist_url(uri_for_reload.as_str());
tracing::info!("auto-reloading content (request #{req})");
wb.reload_content(&playlist_url);
});
}
let has_error = error_message.read().is_some();
rsx! {
div {
"data-testid": "vod-player",
div {
class: if *controls_visible.read() { "player-wrapper" } else { "player-wrapper player-controls-hidden" },
onmounted: move |evt| wrapper_mounted.set(Some(evt)),
onmousemove: move |_| reset_visibility(),
onclick: move |_| reset_visibility(),
ontouchstart: move |_| reset_visibility(),
video {
"data-testid": "player-video",
onmounted: on_mounted,
autoplay: true,
playsinline: true,
class: "player-video",
}
// Subtitle overlay canvas.
// The OffscreenCanvas is transferred to the worker once per worker
// lifetime (see transfer_offscreen_canvas below). The element is keyed
// to retry_count so that Dioxus recreates it whenever the worker is
// restarted — the previous canvas element is neutered after
// transfer_control_to_offscreen() and cannot be reused.
//
// The subtitle render resolution is set to match the player
// wrapper's actual display dimensions (not a fixed 1920x1080)
// so subtitles render at the correct size. A resize listener
// updates the render resolution on fullscreen toggle and
// window resize.
canvas {
key: "{retry_count}",
"data-testid": "subtitle-canvas",
class: "subtitle-canvas",
onmounted: move |evt| {
canvas_mounted.set(Some(evt));
// Try to transfer immediately if the bridge is
// already available. The use_effect may not re-run
// in time due to Dioxus reactivity timing.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
if bridge.read().is_some() && !*canvas_sent.read() {
transfer_offscreen_canvas(canvas_mounted, bridge, canvas_sent);
// Send initial subtitle dimensions from wrapper.
send_subtitle_resize(wrapper_mounted, bridge, subtitle_last_size);
}
},
}
// Error overlay — sits on top of video, doesn't replace it.
if let Some(ref err) = *error_message.read() {
div {
"data-testid": "player-error",
class: "player-error-overlay",
"Error: {err}"
button {
onclick: move |_| {
error_message.set(None);
auto_retry_count.set(0);
reload_request += 1;
},
"Retry"
}
}
}
// Hide controls when error is visible.
if !has_error {
PlayerControls {
mounted_event,
wrapper_mounted,
controls_visible,
is_playing_for_hide,
video_uri: uri.clone(),
current_playback_time,
subtitle_active,
subtitle_status,
subtitle_tracks,
on_load_subtitles: {
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
let bridge_for_subs = bridge;
EventHandler::new(move |ass_text: String| {
let _ = &ass_text;
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
{
let guard = bridge_for_subs.read();
if let Some(ref wb) = *guard {
wb.load_subtitles(ass_text);
}
}
})
},
subtitle_source,
on_clear_subtitles: EventHandler::new(move |_: ()| {
subtitle_active.set(false);
subtitle_status.set(SubtitleStatus::Idle);
subtitle_source.set(SubtitleSource::None);
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
{
let guard = bridge.read();
if let Some(ref wb) = *guard {
wb.clear_subtitles();
}
}
}),
on_select_track: {
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
let bridge_for_track = bridge;
EventHandler::new(move |track_id: u32| {
let _ = track_id;
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
{
let guard = bridge_for_track.read();
if let Some(ref wb) = *guard {
wb.select_subtitle_track(track_id);
}
}
})
},
}
}
}
}
}
}
/// Create the worker bridge and wire it up to the video element.
///
/// Extracted so both `onmounted` and retry can call the same logic.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
fn create_bridge(
event: MountedEvent,
uri: &str,
error_message: &mut Signal >,
bridge: &mut Signal >,
mut reload_request: Signal,
mut auto_retry_count: Signal,
mut subtitle_active: Signal,
mut subtitle_status: Signal,
mut subtitle_source: Signal,
mut subtitle_tracks: Signal>,
) {
use std::cell::Cell;
use std::rc::Rc;
use dioxus::web::WebEventExt;
use wasm_bindgen::JsCast;
use web_sys::HtmlVideoElement;
use vodplace_player_protocol::PlayerEvent;
use super::uri::resolve_playlist_url;
let Some(element) = event.try_as_web_event() else {
error_message.set(Some(
"failed to capture video element from mount event".to_string(),
));
return;
};
let video: HtmlVideoElement = element.unchecked_into();
let err_sig = *error_message;
// Stall detection state — shared with the event callback.
let stall_cycle_count = Rc::new(Cell::new(0u32));
let stall_first_cycle_time = Rc::new(Cell::new(0.0f64));
let stall_start_position = Rc::new(Cell::new(0.0f64));
let stall_is_rebuffering = Rc::new(Cell::new(false));
let video_for_stall = video.clone();
let stall_cycle_count_c = stall_cycle_count.clone();
let stall_first_cycle_time_c = stall_first_cycle_time.clone();
let stall_start_position_c = stall_start_position.clone();
let stall_is_rebuffering_c = stall_is_rebuffering.clone();
let on_event: super::worker_bridge::EventCallback =
use_callback(move |evt: PlayerEvent| {
match evt {
PlayerEvent::Error { fatal, message } => {
if fatal {
let count = *auto_retry_count.peek();
if count < MAX_AUTO_RETRIES {
tracing::warn!(
"fatal player error, auto-retrying ({}/{}): {message}",
count + 1, MAX_AUTO_RETRIES
);
auto_retry_count.set(count + 1);
reload_request += 1;
} else {
tracing::error!("fatal player error, retries exhausted: {message}");
let mut sig = err_sig;
sig.set(Some(message));
}
} else {
tracing::warn!("non-fatal player error: {message}");
}
}
PlayerEvent::Rebuffering { active } => {
let was_rebuffering = stall_is_rebuffering_c.get();
stall_is_rebuffering_c.set(active);
if active && !was_rebuffering {
let now = js_sys::Date::now();
let current_pos = video_for_stall.current_time();
let count = stall_cycle_count_c.get();
// If position jumped significantly (seek), reset
// the tracker — a seek is not a stall.
let pos_delta = (current_pos - stall_start_position_c.get()).abs();
if count > 0 && pos_delta > 5.0 {
stall_cycle_count_c.set(0);
}
let count = stall_cycle_count_c.get();
if count == 0 {
stall_first_cycle_time_c.set(now);
stall_start_position_c.set(current_pos);
}
let elapsed = now - stall_first_cycle_time_c.get();
let pos_advanced = (current_pos - stall_start_position_c.get()).abs();
if elapsed > STALL_WINDOW_MS {
stall_cycle_count_c.set(1);
stall_first_cycle_time_c.set(now);
stall_start_position_c.set(current_pos);
} else {
let new_count = count + 1;
stall_cycle_count_c.set(new_count);
if new_count >= STALL_CYCLE_THRESHOLD
&& pos_advanced < STALL_POSITION_THRESHOLD
{
let retries = *auto_retry_count.peek();
if retries < MAX_AUTO_RETRIES {
tracing::warn!(
"stall detected: {new_count} rebuffer cycles in {elapsed:.0}ms, \
position advanced {pos_advanced:.2}s — triggering reload ({}/{})",
retries + 1, MAX_AUTO_RETRIES
);
auto_retry_count.set(retries + 1);
stall_cycle_count_c.set(0);
reload_request += 1;
}
}
}
}
}
PlayerEvent::ContentInfo { .. } => {
// Successful content load — reset the auto-retry counter.
auto_retry_count.set(0);
// Reset stall tracker on fresh content.
stall_cycle_count_c.set(0);
}
PlayerEvent::SubtitleReady => {
subtitle_active.set(true);
subtitle_status.set(SubtitleStatus::Ready);
}
PlayerEvent::SubtitleError { message } => {
subtitle_status.set(SubtitleStatus::Error(message));
}
PlayerEvent::SubtitleTracksAvailable { tracks } => {
subtitle_tracks.set(tracks);
}
_ => {}
}
});
match super::worker_bridge::WorkerBridge::new(&video, on_event) {
Ok(wb) => {
#[cfg(debug_assertions)]
{
expose_worker_for_testing(wb.worker());
expose_crash_function(&wb);
}
let playlist_url = resolve_playlist_url(uri);
wb.send_command(
&vodplace_player_protocol::PlayerCommand::LoadContent {
playlist_url: playlist_url.clone(),
},
);
bridge.set(Some(wb));
// Probe for a sidecar .ass subtitle file alongside the playlist.
if let Some(ass_url) = super::uri::sidecar_subtitle_url(&playlist_url) {
// Resolve relative URLs against the page origin for reqwest.
let ass_url = if ass_url.starts_with('/') {
let origin = web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_default();
format!("{origin}{ass_url}")
} else {
ass_url
};
let bridge = *bridge;
tracing::debug!("probing sidecar subtitle: {ass_url}");
spawn(async move {
let resp = match reqwest::get(&ass_url).await {
Ok(r) => r,
Err(e) => {
tracing::warn!("sidecar fetch failed: {e}");
return;
}
};
if !resp.status().is_success() {
tracing::debug!("sidecar not found: {}", resp.status());
return;
}
let Ok(text) = resp.text().await else {
return;
};
tracing::debug!("sidecar loaded: {} bytes", text.len());
if let Some(ref wb) = *bridge.peek() {
subtitle_source.set(SubtitleSource::File(
ass_url.rsplit('/').next().unwrap_or("sidecar.ass").to_string(),
));
subtitle_status.set(SubtitleStatus::Loading);
subtitle_active.set(true);
wb.load_subtitles(text);
}
});
}
}
Err(e) => {
let msg = format!("failed to initialize player: {e:?}");
tracing::error!("{msg}");
error_message.set(Some(msg));
}
}
}
/// Transfer the subtitle `OffscreenCanvas` to the worker.
///
/// Extracts the `HtmlCanvasElement` from `canvas_mounted`, calls
/// `transfer_control_to_offscreen()` to obtain an `OffscreenCanvas`, then
/// calls `WorkerBridge::send_subtitle_canvas()`.
///
/// Sets `canvas_sent` to `true` immediately to prevent a second transfer.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
fn transfer_offscreen_canvas(
canvas_mounted: Signal>,
bridge: Signal >,
mut canvas_sent: Signal,
) {
use dioxus::web::WebEventExt;
use wasm_bindgen::JsCast;
use web_sys::HtmlCanvasElement;
// Mark as sent before doing anything to prevent re-entrant calls.
canvas_sent.set(true);
let canvas_el: HtmlCanvasElement = {
let guard = canvas_mounted.read();
let Some(ref evt) = *guard else { return; };
let Some(raw) = evt.try_as_web_event() else {
tracing::error!("subtitle canvas: failed to get web event from MountedEvent");
return;
};
raw.unchecked_into()
};
let offscreen = match canvas_el.transfer_control_to_offscreen() {
Ok(oc) => oc,
Err(e) => {
tracing::error!("subtitle canvas: transfer_control_to_offscreen failed: {e:?}");
return;
}
};
let guard = bridge.read();
let Some(ref wb) = *guard else { return; };
wb.send_subtitle_canvas(offscreen);
tracing::debug!("subtitle canvas: OffscreenCanvas transferred to worker");
}
/// Send the wrapper element's current dimensions to the worker as a
/// `SubtitleResize` command. Only sends if the dimensions changed since
/// the last call (tracked via `last_size`).
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
fn send_subtitle_resize(
wrapper_mounted: Signal>,
bridge: Signal >,
mut last_size: Signal<(u32, u32)>,
) {
use dioxus::web::WebEventExt;
use wasm_bindgen::JsCast;
let guard = wrapper_mounted.read();
let Some(ref evt) = *guard else {
tracing::warn!("subtitle resize: wrapper not mounted yet");
return;
};
let Some(el) = evt.try_as_web_event() else {
tracing::warn!("subtitle resize: wrapper has no web event");
return;
};
let element: web_sys::Element = el.unchecked_into();
let dpr = web_sys::window()
.map(|w| w.device_pixel_ratio())
.unwrap_or(1.0);
// Measure the video element's bounding rect directly. The wrapper may
// have a different height before video metadata loads. Fall back to
// wrapper rect if the video element isn't found.
let rect = element
.query_selector("video")
.ok()
.flatten()
.map(|v| v.get_bounding_client_rect())
.unwrap_or_else(|| element.get_bounding_client_rect());
let w = (rect.width() * dpr).round().max(1.0) as u32;
let h = (rect.height() * dpr).round().max(1.0) as u32;
if (w, h) != *last_size.peek() {
last_size.set((w, h));
let bridge_guard = bridge.read();
if let Some(ref wb) = *bridge_guard {
tracing::debug!("subtitle resize: {w}×{h} (dpr={dpr})");
wb.subtitle_resize(w, h);
} else {
tracing::warn!("subtitle resize: bridge not available");
}
}
}
#[cfg(all(debug_assertions, target_family = "wasm", target_os = "unknown"))]
fn expose_worker_for_testing(worker: &web_sys::Worker) {
let window = web_sys::window().expect("no global window");
let _ = js_sys::Reflect::set(&window, &"__playerWorker".into(), worker);
}
/// Expose `window.__playerWorkerCrash()` for E2E tests.
///
/// Calling this function sends a `DebugCrash` command to the worker, which
/// panics and fires the worker's `onerror` handler. This is more reliable
/// than `worker.terminate()`, which does not trigger `onerror`.
#[cfg(all(debug_assertions, target_family = "wasm", target_os = "unknown"))]
fn expose_crash_function(wb: &super::worker_bridge::WorkerBridge) {
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
let worker = wb.worker().clone();
let crash_fn = Closure::wrap(Box::new(move || {
let cmd = vodplace_player_protocol::PlayerCommand::DebugCrash;
let bytes = match vodplace_player_protocol::serialize_command(&cmd) {
Ok(b) => b,
Err(e) => {
tracing::error!("failed to serialize DebugCrash: {e}");
return;
}
};
let array = js_sys::Uint8Array::from(bytes.as_slice());
if let Err(e) = worker.post_message(&array) {
tracing::error!("failed to send DebugCrash to worker: {e:?}");
}
}) as Box);
let window = web_sys::window().expect("no global window");
let _ = js_sys::Reflect::set(
&window,
&"__playerWorkerCrash".into(),
crash_fn.as_ref().unchecked_ref(),
);
crash_fn.forget();
}