initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
This commit is contained in:
commit
6a73d11c4b
124 changed files with 34856 additions and 0 deletions
516
crates/pinakes-ui/src/components/media_player.rs
Normal file
516
crates/pinakes-ui/src/components/media_player.rs
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue