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(); }