use dioxus::document::eval; use dioxus::prelude::*; use super::utils::format_duration; #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct QueueItem { pub media_id: String, pub title: String, pub artist: Option, pub duration_secs: Option, pub media_type: String, pub stream_url: String, pub thumbnail_url: Option, } #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] pub enum RepeatMode { Off, One, All, } #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct PlayQueue { pub items: Vec, pub current_index: usize, pub repeat: RepeatMode, pub shuffle: bool, } impl Default for PlayQueue { fn default() -> Self { Self { items: Vec::new(), current_index: 0, repeat: RepeatMode::Off, shuffle: false, } } } impl PlayQueue { pub fn is_empty(&self) -> bool { self.items.is_empty() } pub fn current(&self) -> Option<&QueueItem> { self.items.get(self.current_index) } pub fn next(&mut self) -> Option<&QueueItem> { if self.items.is_empty() { return None; } match self.repeat { RepeatMode::One => self.items.get(self.current_index), RepeatMode::All => { self.current_index = (self.current_index + 1) % self.items.len(); self.items.get(self.current_index) } RepeatMode::Off => { if self.current_index + 1 < self.items.len() { self.current_index += 1; self.items.get(self.current_index) } else { None } } } } pub fn previous(&mut self) -> Option<&QueueItem> { if self.items.is_empty() { return None; } if self.current_index > 0 { self.current_index -= 1; } else if self.repeat == RepeatMode::All { self.current_index = self.items.len() - 1; } self.items.get(self.current_index) } pub fn add(&mut self, item: QueueItem) { self.items.push(item); } pub fn remove(&mut self, index: usize) { if index < self.items.len() { self.items.remove(index); if self.current_index >= self.items.len() && !self.items.is_empty() { self.current_index = self.items.len() - 1; } } } pub fn clear(&mut self) { self.items.clear(); self.current_index = 0; } pub fn toggle_repeat(&mut self) { self.repeat = match self.repeat { RepeatMode::Off => RepeatMode::All, RepeatMode::All => RepeatMode::One, RepeatMode::One => RepeatMode::Off, }; } pub fn toggle_shuffle(&mut self) { self.shuffle = !self.shuffle; } } #[component] pub fn MediaPlayer( src: String, media_type: String, #[props(default)] title: Option, #[props(default)] thumbnail_url: Option, #[props(default = false)] autoplay: bool, #[props(default)] on_track_ended: Option>, ) -> Element { let mut playing = use_signal(|| false); let mut current_time = use_signal(|| 0.0f64); let mut duration = use_signal(|| 0.0f64); let mut volume = use_signal(|| 1.0f64); let mut muted = use_signal(|| false); let is_video = media_type == "video"; let is_playing = *playing.read(); let cur_time = *current_time.read(); let dur = *duration.read(); let vol = *volume.read(); let is_muted = *muted.read(); let time_str = format_duration(cur_time); let dur_str = format_duration(dur); let vol_pct = (vol * 100.0) as u32; // Poll playback state every 250ms let src_clone = src.clone(); let on_ended = on_track_ended; use_effect(move || { let _ = &src_clone; let on_ended = on_ended; spawn(async move { loop { tokio::time::sleep(std::time::Duration::from_millis(250)).await; let result = eval( r#" let el = document.getElementById('pinakes-player'); if (el) { return JSON.stringify({ currentTime: el.currentTime, duration: el.duration || 0, paused: el.paused, volume: el.volume, muted: el.muted, ended: el.ended }); } return "null"; "#, ) .await; if let Ok(val) = result && let Some(s) = val.as_str() && s != "null" && let Ok(state) = serde_json::from_str::(s) { if let Some(ct) = state["currentTime"].as_f64() { current_time.set(ct); } if let Some(d) = state["duration"].as_f64() && d.is_finite() { duration.set(d); } if let Some(p) = state["paused"].as_bool() { playing.set(!p); } if let Some(true) = state["ended"].as_bool() && let Some(ref handler) = on_ended { handler.call(()); } } } }); }); // Autoplay on mount if autoplay { let src_auto = src.clone(); use_effect(move || { let _ = &src_auto; spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(100)).await; let _ = eval("document.getElementById('pinakes-player')?.play()").await; }); }); } let toggle_play = move |_| { spawn(async move { if *playing.read() { let _ = eval("document.getElementById('pinakes-player')?.pause()").await; } else { let _ = eval("document.getElementById('pinakes-player')?.play()").await; } }); }; let toggle_mute = move |_| { let new_muted = !*muted.read(); muted.set(new_muted); let js = format!( "let e = document.getElementById('pinakes-player'); if(e) e.muted = {};", new_muted ); spawn(async move { let _ = eval(&js).await; }); }; let on_seek = move |e: Event| { if let Ok(t) = e.value().parse::() { current_time.set(t); let js = format!( "let e = document.getElementById('pinakes-player'); if(e) e.currentTime = {};", t ); spawn(async move { let _ = eval(&js).await; }); } }; let on_volume = move |e: Event| { if let Ok(v) = e.value().parse::() { let vol_val = v / 100.0; volume.set(vol_val); let js = format!( "let e = document.getElementById('pinakes-player'); if(e) e.volume = {};", vol_val ); spawn(async move { let _ = eval(&js).await; }); } }; let on_fullscreen = move |_| { spawn(async move { let _ = eval( "let e = document.getElementById('pinakes-player'); if(e) { if(document.fullscreenElement) document.exitFullscreen(); else e.requestFullscreen(); }", ).await; }); }; // Keyboard controls let on_keydown = move |evt: KeyboardEvent| { let key = evt.key(); match key { Key::Character(ref c) if c == " " => { evt.prevent_default(); spawn(async move { if *playing.read() { let _ = eval("document.getElementById('pinakes-player')?.pause()").await; } else { let _ = eval("document.getElementById('pinakes-player')?.play()").await; } }); } Key::ArrowLeft => { evt.prevent_default(); spawn(async move { let _ = eval("let e = document.getElementById('pinakes-player'); if(e) e.currentTime = Math.max(0, e.currentTime - 5);").await; }); } Key::ArrowRight => { evt.prevent_default(); spawn(async move { let _ = eval("let e = document.getElementById('pinakes-player'); if(e) e.currentTime = Math.min(e.duration || 0, e.currentTime + 5);").await; }); } Key::ArrowUp => { evt.prevent_default(); let new_vol = (vol + 0.1).min(1.0); volume.set(new_vol); let js = format!( "let e = document.getElementById('pinakes-player'); if(e) e.volume = {};", new_vol ); spawn(async move { let _ = eval(&js).await; }); } Key::ArrowDown => { evt.prevent_default(); let new_vol = (vol - 0.1).max(0.0); volume.set(new_vol); let js = format!( "let e = document.getElementById('pinakes-player'); if(e) e.volume = {};", new_vol ); spawn(async move { let _ = eval(&js).await; }); } Key::Character(ref c) if c == "m" || c == "M" => { let new_muted = !*muted.read(); muted.set(new_muted); let js = format!( "let e = document.getElementById('pinakes-player'); if(e) e.muted = {};", new_muted ); spawn(async move { let _ = eval(&js).await; }); } Key::Character(ref c) if c == "f" || c == "F" => { spawn(async move { let _ = eval("let e = document.getElementById('pinakes-player'); if(e) { if(document.fullscreenElement) document.exitFullscreen(); else e.requestFullscreen(); }").await; }); } _ => {} } }; let play_icon = if is_playing { "\u{23f8}" } else { "\u{25b6}" }; let mute_icon = if is_muted { "\u{1f507}" } else { "\u{1f50a}" }; rsx! { div { class: if is_video { "media-player media-player-video" } else { "media-player media-player-audio" }, tabindex: "0", onkeydown: on_keydown, // Hidden native element if is_video { video { id: "pinakes-player", src: "{src}", style: if is_video { "width: 100%; display: block;" } else { "display: none;" }, preload: "metadata", } } else { audio { id: "pinakes-player", src: "{src}", style: "display: none;", preload: "metadata", } } // Album art for audio if !is_video { div { class: "player-artwork", if let Some(ref thumb) = thumbnail_url { img { src: "{thumb}", alt: "Cover art" } } else { div { class: "player-artwork-placeholder", "\u{266b}" } } if let Some(ref t) = title { div { class: "player-title", "{t}" } } } } // Custom controls div { class: "player-controls", button { class: "play-btn", onclick: toggle_play, title: if is_playing { "Pause" } else { "Play" }, "{play_icon}" } span { class: "player-time", "{time_str}" } input { r#type: "range", class: "seek-bar", min: "0", max: "{dur}", step: "0.1", value: "{cur_time}", oninput: on_seek, } span { class: "player-time", "{dur_str}" } button { class: "mute-btn", onclick: toggle_mute, title: if is_muted { "Unmute" } else { "Mute" }, "{mute_icon}" } input { r#type: "range", class: "volume-slider", min: "0", max: "100", value: "{vol_pct}", oninput: on_volume, } if is_video { button { class: "fullscreen-btn", onclick: on_fullscreen, title: "Fullscreen", "\u{26f6}" } } } } } } #[component] pub fn QueuePanel( queue: PlayQueue, on_select: EventHandler, on_remove: EventHandler, on_clear: EventHandler<()>, on_toggle_repeat: EventHandler<()>, on_toggle_shuffle: EventHandler<()>, on_next: EventHandler<()>, on_previous: EventHandler<()>, ) -> Element { let repeat_label = match queue.repeat { RepeatMode::Off => "Repeat: Off", RepeatMode::One => "Repeat: One", RepeatMode::All => "Repeat: All", }; let shuffle_label = if queue.shuffle { "Shuffle: On" } else { "Shuffle: Off" }; let current_idx = queue.current_index; rsx! { div { class: "queue-panel", div { class: "queue-header", h3 { "Play Queue ({queue.items.len()})" } div { class: "queue-controls", button { class: "btn btn-sm btn-ghost", onclick: move |_| on_previous.call(()), title: "Previous (P)", "\u{23ee}" } button { class: "btn btn-sm btn-ghost", onclick: move |_| on_next.call(()), title: "Next (N)", "\u{23ed}" } button { class: "btn btn-sm btn-ghost", onclick: move |_| on_toggle_repeat.call(()), title: "{repeat_label}", "\u{1f501}" } button { class: "btn btn-sm btn-ghost", onclick: move |_| on_toggle_shuffle.call(()), title: "{shuffle_label}", "\u{1f500}" } button { class: "btn btn-sm btn-ghost", onclick: move |_| on_clear.call(()), title: "Clear Queue", "\u{1f5d1}" } } } if queue.items.is_empty() { div { class: "queue-empty", "Queue is empty. Add items from the library." } } else { div { class: "queue-list", for (i, item) in queue.items.iter().enumerate() { { let is_current = i == current_idx; let item_class = if is_current { "queue-item queue-item-active" } else { "queue-item" }; let title = item.title.clone(); let artist = item.artist.clone().unwrap_or_default(); rsx! { div { key: "q-{i}", class: "{item_class}", onclick: move |_| on_select.call(i), div { class: "queue-item-info", span { class: "queue-item-title", "{title}" } if !artist.is_empty() { span { class: "queue-item-artist", "{artist}" } } } button { class: "btn btn-sm btn-ghost queue-item-remove", onclick: move |e: Event| { e.stop_propagation(); on_remove.call(i); }, "\u{2715}" } } } } } } } } } }