Something went wrong. Try again.
Streamplace AtmosphereConf 2026 VOD JAM
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801use 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 onlypub 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<Option<MountedEvent>>) { _toggle_fullscreen_impl(wrapper_mounted);}
#[cfg(all(target_family = "wasm", target_os = "unknown"))]fn _toggle_fullscreen_impl(wrapper_mounted: Signal<Option<MountedEvent>>) { 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<Option<MountedEvent>>, wrapper_mounted: Signal<Option<MountedEvent>>, current_playback_time: Signal<f64>, mut subtitle_request: Signal<Option<SubtitleRequest>>,) -> Element { let mut error_message: Signal<Option<String>> = use_signal(|| None); let mut mounted_event = video_mounted; let mut controls_visible: Signal<bool> = use_signal(|| true); let is_playing_for_hide: Signal<bool> = use_signal(|| false); let mut current_hide: Signal<Option<TimeoutHandle>> = use_signal(|| None); let mut wrapper_mounted = wrapper_mounted;
// Recovery signals. #[allow(unused_mut)] let mut reload_request: Signal<u32> = use_signal(|| 0); #[allow(unused_mut)] let mut auto_retry_count: Signal<u32> = use_signal(|| 0);
// Subtitle state signals. #[allow(unused_mut)] let mut subtitle_active: Signal<bool> = use_signal(|| false); #[allow(unused_mut)] let mut subtitle_status: Signal<SubtitleStatus> = use_signal(|| SubtitleStatus::Idle); #[allow(unused_mut, unused_variables)] let subtitle_tracks: Signal<Vec<vodplace_player_protocol::SubtitleTrackInfo>> = use_signal(Vec::new); #[allow(unused_mut)] let mut subtitle_source: Signal<SubtitleSource> = 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<Option<super::worker_bridge::WorkerBridge>> = 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<Option<MountedEvent>> = use_signal(|| None); #[allow(unused_mut, unused_variables)] let mut canvas_sent: Signal<bool> = 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<Option<gloo_events::EventListener>> = use_signal(|| None); let mut subtitle_win_resize_listener: Signal<Option<gloo_events::EventListener>> = 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<Option<String>>, bridge: &mut Signal<Option<super::worker_bridge::WorkerBridge>>, mut reload_request: Signal<u32>, mut auto_retry_count: Signal<u32>, mut subtitle_active: Signal<bool>, mut subtitle_status: Signal<SubtitleStatus>, mut subtitle_source: Signal<SubtitleSource>, mut subtitle_tracks: Signal<Vec<vodplace_player_protocol::SubtitleTrackInfo>>,) { 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<Option<MountedEvent>>, bridge: Signal<Option<super::worker_bridge::WorkerBridge>>, mut canvas_sent: Signal<bool>,) { 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<Option<MountedEvent>>, bridge: Signal<Option<super::worker_bridge::WorkerBridge>>, 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<dyn FnMut()>);
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();}