pinakes/crates/pinakes-ui/src/components/media_player.rs
NotAShelf 6a73d11c4b
initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
2026-01-31 15:20:30 +03:00

516 lines
17 KiB
Rust

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<String>,
pub duration_secs: Option<f64>,
pub media_type: String,
pub stream_url: String,
pub thumbnail_url: Option<String>,
}
#[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<QueueItem>,
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<String>,
#[props(default)] thumbnail_url: Option<String>,
#[props(default = false)] autoplay: bool,
#[props(default)] on_track_ended: Option<EventHandler<()>>,
) -> 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::<serde_json::Value>(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<FormData>| {
if let Ok(t) = e.value().parse::<f64>() {
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<FormData>| {
if let Ok(v) = e.value().parse::<f64>() {
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<usize>,
on_remove: EventHandler<usize>,
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<MouseData>| {
e.stop_propagation();
on_remove.call(i);
},
"\u{2715}"
}
}
}
}
}
}
}
}
}
}