use dioxus::prelude::*; use dioxus_free_icons::Icon; use dioxus_free_icons::icons::fi_icons::{ FiMaximize2, FiMinimize2, FiPause, FiPlay, FiVolume2, FiVolumeX, }; #[cfg(all(target_family = "wasm", target_os = "unknown"))] use jacquard::deps::smol_str::{SmolStr, format_smolstr}; #[cfg(all(target_family = "wasm", target_os = "unknown"))] use super::pointer::{POINTERS, pointer_position}; use super::time_format::format_time; #[cfg(all(target_family = "wasm", target_os = "unknown"))] use super::time_format::seek_position_from_click; #[cfg(all(target_family = "wasm", target_os = "unknown"))] pub fn toggle_play_on_video( vid: &web_sys::HtmlVideoElement, mut is_playing: Signal, mut is_playing_for_hide: Signal, ) { use wasm_bindgen_futures::JsFuture; if vid.paused() { is_playing.set(true); is_playing_for_hide.set(true); match vid.play() { Ok(promise) => { wasm_bindgen_futures::spawn_local(async move { if let Err(e) = JsFuture::from(promise).await { tracing::error!("playback rejected: {e:?}"); is_playing.set(false); is_playing_for_hide.set(false); } }); } Err(e) => { tracing::error!("play() call failed: {e:?}"); is_playing.set(false); is_playing_for_hide.set(false); } } } else { let _ = vid.pause(); is_playing.set(false); is_playing_for_hide.set(false); } } #[cfg(all(target_family = "wasm", target_os = "unknown"))] fn playback_storage_key(uri: &str) -> SmolStr { format_smolstr!("vodplace:playback:{uri}") } #[component] pub fn PlayerControls( mounted_event: Signal>, wrapper_mounted: Signal>, controls_visible: Signal, mut is_playing_for_hide: Signal, video_uri: jacquard::deps::smol_str::SmolStr, current_playback_time: Signal, subtitle_active: Signal, subtitle_status: Signal, subtitle_source: Signal, on_load_subtitles: EventHandler, on_clear_subtitles: EventHandler<()>, subtitle_tracks: Signal>, on_select_track: EventHandler, ) -> Element { #[allow(unused_mut)] let mut is_playing = use_signal(|| false); // Use the lifted signal from VodPlayer. The `timeupdate` listener writes // to this signal; the parent component and transcript panel also read it. #[allow(unused_mut)] let mut current_time = current_playback_time; #[allow(unused_mut)] let mut duration = use_signal(|| 0.0_f64); #[allow(unused_mut)] let mut volume = use_signal(|| 1.0_f64); #[allow(unused_mut)] let mut is_muted = use_signal(|| false); #[allow(unused_mut)] let mut is_fullscreen = use_signal(|| false); let mut progress_mounted: Signal> = use_signal(|| None); let mut volume_bar_mounted: Signal> = use_signal(|| None); // Drag state: which pointer is dragging which bar #[allow(unused_mut, unused_variables)] let mut dragging_progress: Signal> = use_signal(|| None); #[allow(unused_mut, unused_variables)] let mut dragging_volume: Signal> = use_signal(|| None); // Hover state for progress tooltip #[allow(unused_mut, unused_variables)] let mut hover_pct = use_signal(|| 0.0_f64); #[allow(unused_mut, unused_variables)] let mut hover_time = use_signal(|| 0.0_f64); // React to pointer movements for drag-to-scrub #[cfg(all(target_family = "wasm", target_os = "unknown"))] { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; use web_sys::HtmlVideoElement; let video_uri_for_drag = video_uri.clone(); use_effect(move || { // Subscribe to pointer updates let _pointers = POINTERS.read(); // Progress drag let progress_pid = *dragging_progress.read(); if let Some(pid) = progress_pid { if let Some(pos) = pointer_position(pid) { let x = pos.x; if let Some(ref prog_event) = *progress_mounted.read() { if let Some(prog_el) = prog_event.try_as_web_event() { let el: web_sys::Element = prog_el.unchecked_into(); let rect = el.get_bounding_client_rect(); let dur = *duration.read(); let ratio = ((x - rect.left()) / rect.width()).clamp(0.0, 1.0); let t = seek_position_from_click(x, rect.left(), rect.width(), dur); current_time.set(t); hover_pct.set(ratio * 100.0); hover_time.set(t); } } } else { // Pointer released — finalize seek and save position let cur = *current_time.read(); if let Some(ref vid_event) = *mounted_event.read() { if let Some(vid_el) = vid_event.try_as_web_event() { let vid: HtmlVideoElement = vid_el.unchecked_into(); vid.set_current_time(cur); } } let _ = ::set( &playback_storage_key(&video_uri_for_drag), cur, ); dragging_progress.set(None); } } // Volume drag let volume_pid = *dragging_volume.read(); if let Some(pid) = volume_pid { if let Some(pos) = pointer_position(pid) { let x = pos.x; if let Some(ref bar_event) = *volume_bar_mounted.read() { if let Some(bar_el) = bar_event.try_as_web_event() { let el: web_sys::Element = bar_el.unchecked_into(); let rect = el.get_bounding_client_rect(); let ratio = ((x - rect.left()) / rect.width()).clamp(0.0, 1.0); if let Some(ref vid_event) = *mounted_event.read() { if let Some(vid_el) = vid_event.try_as_web_event() { let vid: HtmlVideoElement = vid_el.unchecked_into(); vid.set_volume(ratio); volume.set(ratio); if ratio > 0.0 { vid.set_muted(false); is_muted.set(false); } } } } } } else { dragging_volume.set(None); } } }); } // Listen to timeupdate + durationchange on the video element #[cfg(all(target_family = "wasm", target_os = "unknown"))] { use std::cell::RefCell; use std::rc::Rc; let listeners: Rc>> = use_hook(|| Rc::new(RefCell::new(Vec::new()))); let listeners_for_drop = listeners.clone(); use_drop(move || { listeners_for_drop.borrow_mut().clear(); }); use_effect(move || { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; use web_sys::HtmlVideoElement; listeners.borrow_mut().clear(); let Some(ref event) = *mounted_event.read() else { return; }; let Some(element) = event.try_as_web_event() else { return; }; let vid: HtmlVideoElement = element.unchecked_into(); // Sync initial state current_time.set(vid.current_time()); let dur = vid.duration(); if dur.is_finite() { duration.set(dur); } is_playing.set(!vid.paused()); // Restore saved playback position let restore_key = playback_storage_key(&video_uri); if let Ok(saved) = ::get::(&restore_key) { if saved > 0.0 && saved.is_finite() { vid.set_current_time(saved); current_time.set(saved); } } let vid_for_tu = vid.clone(); let storage_key = playback_storage_key(&video_uri); let last_save = std::cell::Cell::new(0.0_f64); let timeupdate = gloo_events::EventListener::new(&vid, "timeupdate", move |_| { if dragging_progress.read().is_none() { let t = vid_for_tu.current_time(); current_time.set(t); let now = js_sys::Date::now(); if now - last_save.get() > 5_000.0 { last_save.set(now); let _ = ::set( &storage_key, t, ); } } let dur = vid_for_tu.duration(); if dur.is_finite() { duration.set(dur); } let playing = !vid_for_tu.paused(); is_playing.set(playing); is_playing_for_hide.set(playing); volume.set(vid_for_tu.volume()); is_muted.set(vid_for_tu.muted()); }); let vid_for_dc = vid.clone(); let durationchange = gloo_events::EventListener::new(&vid, "durationchange", move |_| { let dur = vid_for_dc.duration(); if dur.is_finite() { duration.set(dur); } }); listeners.borrow_mut().extend([timeupdate, durationchange]); }); } // Track fullscreen state via document fullscreenchange event #[cfg(all(target_family = "wasm", target_os = "unknown"))] { let mut fs_listener: Signal> = use_signal(|| None); use_effect(move || { if fs_listener.peek().is_some() { return; } let doc = web_sys::window().unwrap().document().unwrap(); fs_listener.set(Some(gloo_events::EventListener::new( &doc, "fullscreenchange", move |_| { let is_fs = web_sys::window() .and_then(|w| w.document()) .map(|d| d.fullscreen_element().is_some()) .unwrap_or(false); is_fullscreen.set(is_fs); }, ))); }); } let toggle_play = move |_| { #[cfg(all(target_family = "wasm", target_os = "unknown"))] { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; use web_sys::HtmlVideoElement; if let Some(ref event) = *mounted_event.read() && let Some(element) = event.try_as_web_event() { let vid: HtmlVideoElement = element.unchecked_into(); toggle_play_on_video(&vid, is_playing, is_playing_for_hide); } } }; let on_progress_pointerdown = move |evt: PointerEvent| { evt.prevent_default(); evt.stop_propagation(); #[cfg(all(target_family = "wasm", target_os = "unknown"))] { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; use web_sys::HtmlVideoElement; let click_x = evt.client_coordinates().x; if let Some(ref prog_event) = *progress_mounted.read() { if let Some(prog_el) = prog_event.try_as_web_event() { let el: web_sys::Element = prog_el.unchecked_into(); let rect = el.get_bounding_client_rect(); let seek_time = seek_position_from_click( click_x, rect.left(), rect.width(), *duration.read(), ); current_time.set(seek_time); if let Some(ref vid_event) = *mounted_event.read() { if let Some(vid_el) = vid_event.try_as_web_event() { let vid: HtmlVideoElement = vid_el.unchecked_into(); vid.set_current_time(seek_time); } } } } let pid = evt.data().pointer_id(); POINTERS.write().push(super::pointer::Pointer { id: pid, position: evt.client_coordinates(), }); dragging_progress.set(Some(pid)); } }; let on_volume_pointerdown = move |evt: PointerEvent| { evt.prevent_default(); evt.stop_propagation(); #[cfg(all(target_family = "wasm", target_os = "unknown"))] { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; use web_sys::HtmlVideoElement; let click_x = evt.client_coordinates().x; if let Some(ref bar_event) = *volume_bar_mounted.read() { if let Some(bar_el) = bar_event.try_as_web_event() { let el: web_sys::Element = bar_el.unchecked_into(); let rect = el.get_bounding_client_rect(); let ratio = ((click_x - rect.left()) / rect.width()).clamp(0.0, 1.0); if let Some(ref vid_event) = *mounted_event.read() { if let Some(vid_el) = vid_event.try_as_web_event() { let vid: HtmlVideoElement = vid_el.unchecked_into(); vid.set_volume(ratio); volume.set(ratio); if ratio > 0.0 { vid.set_muted(false); is_muted.set(false); } } } } } let pid = evt.data().pointer_id(); POINTERS.write().push(super::pointer::Pointer { id: pid, position: evt.client_coordinates(), }); dragging_volume.set(Some(pid)); } #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] let _ = evt; }; let toggle_mute = move |_| { #[cfg(all(target_family = "wasm", target_os = "unknown"))] { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; use web_sys::HtmlVideoElement; if let Some(ref event) = *mounted_event.read() && let Some(element) = event.try_as_web_event() { let vid: HtmlVideoElement = element.unchecked_into(); vid.set_muted(!vid.muted()); is_muted.set(vid.muted()); } } }; let toggle_fs = move |_| { #[cfg(all(target_family = "wasm", target_os = "unknown"))] super::component::toggle_fullscreen(wrapper_mounted); }; let dur = *duration.read(); let cur = *current_time.read(); let playing = *is_playing.read(); let muted = *is_muted.read(); let vol = *volume.read(); let fullscreen = *is_fullscreen.read(); let is_dragging = dragging_progress.read().is_some(); let hover_pct_val = *hover_pct.read(); let progress_width = format!( "width: {:.4}%", if dur > 0.0 { (cur / dur) * 100.0 } else { 0.0 } ); let progress_left = format!( "left: {:.4}%", if dur > 0.0 { (cur / dur) * 100.0 } else { 0.0 } ); let volume_width = format!("width: {:.4}%", if muted { 0.0 } else { vol * 100.0 }); let volume_left = format!("left: {:.4}%", if muted { 0.0 } else { vol * 100.0 }); let hover_left = format!("left: {:.4}%", hover_pct_val); let progress_class = if is_dragging { "player-progress player-progress-dragging" } else { "player-progress" }; rsx! { div { class: "player-controls-overlay", "data-testid": "player-controls", div { class: "{progress_class}", "data-testid": "seek-slider", onmounted: move |evt| progress_mounted.set(Some(evt)), onpointerdown: on_progress_pointerdown, onmousemove: move |evt: MouseEvent| { #[cfg(all(target_family = "wasm", target_os = "unknown"))] { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; let x = evt.client_coordinates().x; if let Some(ref prog_event) = *progress_mounted.read() { if let Some(prog_el) = prog_event.try_as_web_event() { let el: web_sys::Element = prog_el.unchecked_into(); let rect = el.get_bounding_client_rect(); let ratio = ((x - rect.left()) / rect.width()).clamp(0.0, 1.0); hover_pct.set(ratio * 100.0); hover_time.set(ratio * dur); } } } #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] let _ = evt; }, div { class: "player-progress-filled", style: "{progress_width}", } div { class: "player-progress-handle", style: "{progress_left}", } div { class: "player-progress-tooltip", style: "{hover_left}", "{format_time(*hover_time.read())}" } } div { class: "player-controls-row", button { class: "player-play-btn", "data-testid": "play-pause", onclick: toggle_play, if playing { Icon { icon: FiPause, width: 20, height: 20 } } else { Icon { icon: FiPlay, width: 20, height: 20 } } } span { class: "player-time", "data-testid": "time-display", "{format_time(cur)} / {format_time(dur)}" } div { class: "player-volume", button { class: "player-volume-btn", onclick: toggle_mute, if muted || vol < 0.01 { Icon { icon: FiVolumeX, width: 18, height: 18 } } else { Icon { icon: FiVolume2, width: 18, height: 18 } } } div { class: "player-volume-bar", onmounted: move |evt| volume_bar_mounted.set(Some(evt)), onpointerdown: on_volume_pointerdown, div { class: "player-volume-filled", style: "{volume_width}", } div { class: "player-volume-handle", style: "{volume_left}", } } } super::subtitle_controls::SubtitleControls { subtitle_active, subtitle_status, subtitle_source, on_load_subtitles, on_clear_subtitles, subtitle_tracks, on_select_track, } button { class: "player-fullscreen-btn", "aria-label": if fullscreen { "Exit fullscreen" } else { "Enter fullscreen" }, onclick: toggle_fs, if fullscreen { Icon { icon: FiMinimize2, width: 18, height: 18 } } else { Icon { icon: FiMaximize2, width: 18, height: 18 } } } } } } }