Something went wrong. Try again.
Streamplace AtmosphereConf 2026 VOD JAM
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543use 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<bool>, mut is_playing_for_hide: Signal<bool>,) { 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<Option<MountedEvent>>, wrapper_mounted: Signal<Option<MountedEvent>>, controls_visible: Signal<bool>, mut is_playing_for_hide: Signal<bool>, video_uri: jacquard::deps::smol_str::SmolStr, current_playback_time: Signal<f64>, subtitle_active: Signal<bool>, subtitle_status: Signal<super::component::SubtitleStatus>, subtitle_source: Signal<super::component::SubtitleSource>, on_load_subtitles: EventHandler<String>, on_clear_subtitles: EventHandler<()>, subtitle_tracks: Signal<Vec<vodplace_player_protocol::SubtitleTrackInfo>>, on_select_track: EventHandler<u32>,) -> 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<Option<MountedEvent>> = use_signal(|| None); let mut volume_bar_mounted: Signal<Option<MountedEvent>> = use_signal(|| None);
// Drag state: which pointer is dragging which bar #[allow(unused_mut, unused_variables)] let mut dragging_progress: Signal<Option<i32>> = use_signal(|| None); #[allow(unused_mut, unused_variables)] let mut dragging_volume: Signal<Option<i32>> = 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 _ = <gloo_storage::LocalStorage as gloo_storage::Storage>::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<RefCell<Vec<gloo_events::EventListener>>> = 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) = <gloo_storage::LocalStorage as gloo_storage::Storage>::get::<f64>(&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 _ = <gloo_storage::LocalStorage as gloo_storage::Storage>::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<Option<gloo_events::EventListener>> = 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 } } } } } }}