Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
516 lines
17 KiB
Rust
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}"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|