feat(leptos): Phase 4 Task 4 - NowPlaying, MusicSubPanel, ScreenSubPanel

This commit is contained in:
asepharyana
2026-07-03 21:54:47 +07:00
parent 8a3ac78bde
commit 1d7a433b3c
4 changed files with 250 additions and 0 deletions
@@ -2,8 +2,14 @@ pub mod voice_connection_card;
pub mod active_speakers;
pub mod audio_visualizer;
pub mod mic_level_meter;
pub mod now_playing;
pub mod music_sub_panel;
pub mod screen_sub_panel;
pub use voice_connection_card::VoiceConnectionCard;
pub use active_speakers::ActiveSpeakers;
pub use audio_visualizer::AudioVisualizer;
pub use mic_level_meter::MicLevelMeter;
pub use now_playing::NowPlaying;
pub use music_sub_panel::MusicSubPanel;
pub use screen_sub_panel::ScreenSubPanel;
@@ -0,0 +1,56 @@
use leptos::prelude::*;
/// MusicSubPanel — Music playlist controls and URL input
#[component]
pub fn MusicSubPanel(
#[prop(optional)] on_queue: Option<Box<dyn Fn(String) + Send + Sync + 'static>>,
) -> impl IntoView {
let (url_input, set_url_input) = create_signal::<String>(String::new());
let (is_loading, set_is_loading) = create_signal::<bool>(false);
let handle_queue_click = move |_| {
let url = url_input.get().trim().to_string();
if !url.is_empty() {
if let Some(ref cb) = on_queue {
set_is_loading.set(true);
cb(url.clone());
set_url_input.set(String::new());
set_is_loading.set(false);
}
}
};
view! {
<div class="music-sub-panel card">
<div class="card-header">
<div class="card-title flex items-center gap-2">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9 8h6v8h-6z"></path>
</svg>
"Music"
</div>
</div>
<div class="card-content space-y-3">
<div class="space-y-2">
<label class="text-xs font-medium text-foreground">"YouTube URL or Search"</label>
<input
type="text"
class="input w-full text-sm"
placeholder="youtube.com/watch?v=... or song name"
prop:value=url_input
on:input=move |ev| set_url_input.set(event_target_value(&ev))
disabled=move || is_loading.get()
/>
</div>
<button
class=move || format!("btn btn-primary w-full {}", if is_loading.get() { "opacity-50" } else { "" })
on:click=handle_queue_click
disabled=move || url_input.get().is_empty() || is_loading.get()
>
{move || if is_loading.get() { "Queuing..." } else { "Queue Music" }}
</button>
</div>
</div>
}
}
@@ -0,0 +1,104 @@
use leptos::prelude::*;
use shared_types::media::MediaState;
/// NowPlaying — Displays current media item and queue info
#[component]
pub fn NowPlaying(
#[prop(optional)] state: Option<MediaState>,
#[prop(optional)] on_skip: Option<Box<dyn Fn() + Send + Sync + 'static>>,
#[prop(optional)] on_stop: Option<Box<dyn Fn() + Send + Sync + 'static>>,
) -> impl IntoView {
let media_state = create_rw_signal::<Option<MediaState>>(state);
// Wrap callbacks in StoredValue for shareable non-Clone ownership in Leptos context
let skip_cb = StoredValue::new(on_skip);
let stop_cb = StoredValue::new(on_stop);
let has_skip = skip_cb.with_value(|v| v.is_some());
let has_stop = stop_cb.with_value(|v| v.is_some());
view! {
<div class="now-playing card">
<div class="card-header">
<div class="card-title">"Now Playing"</div>
</div>
<div class="card-content space-y-3">
{move || {
media_state.get().map(|ms| {
let current = ms.current.as_ref().cloned();
let queue_len = ms.queue.len();
view! {
<>
{current.map(|item| {
let title = item.title.clone().unwrap_or_else(|| "Unknown".to_string());
let duration_ms = item.duration_ms.unwrap_or(0);
let duration_sec = duration_ms / 1000;
view! {
<div class="space-y-2">
<div class="text-sm font-medium text-foreground truncate">
{title}
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{format!("{}s", duration_sec)}</span>
</div>
<div class="flex items-center gap-2">
{has_skip.then(|| {
view! {
<button
class="btn btn-sm btn-outline flex-1"
on:click=move |_| { skip_cb.with_value(|cb| { if let Some(cb) = cb { cb(); } }); }
>
"⏭ Skip"
</button>
}
})}
{has_stop.then(|| {
view! {
<button
class="btn btn-sm btn-destructive flex-1"
on:click=move |_| { stop_cb.with_value(|cb| { if let Some(cb) = cb { cb(); } }); }
>
"⏹ Stop"
</button>
}
})}
</div>
</div>
}
})}
{(queue_len > 0).then(|| {
view! {
<div class="border-t border-border/50 pt-3">
<div class="text-xs font-medium text-muted-foreground">
{format!("Queue: {} item{}", queue_len, if queue_len == 1 { "" } else { "s" })}
</div>
</div>
}
})}
{(queue_len == 0).then(|| {
view! {
<div class="text-xs text-muted-foreground text-center py-2">
"Queue is empty"
</div>
}
})}
</>
}
})
}}
{move || {
media_state.get().is_none().then(|| {
view! {
<div class="text-xs text-muted-foreground text-center py-4">
"No media connected"
</div>
}
})
}}
</div>
</div>
}
}
@@ -0,0 +1,84 @@
use leptos::prelude::*;
/// ScreenSubPanel — Screenshare controls
#[component]
pub fn ScreenSubPanel(
#[prop(optional)] on_start_stream: Option<Box<dyn Fn() + Send + Sync + 'static>>,
#[prop(optional)] on_stop_stream: Option<Box<dyn Fn() + Send + Sync + 'static>>,
) -> impl IntoView {
let (is_streaming, set_is_streaming) = create_signal::<bool>(false);
let has_start = on_start_stream.is_some();
let has_stop = on_stop_stream.is_some();
view! {
<div class="screen-sub-panel card">
<div class="card-header">
<div class="card-title flex items-center gap-2">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
"Screenshare"
</div>
</div>
<div class="card-content space-y-3">
<p class="text-xs text-muted-foreground">
"Stream your screen to the voice channel for everyone to see."
</p>
<div class="flex gap-2">
{has_start.then(|| {
view! {
<button
class=move || format!("btn btn-success flex-1 {}", if is_streaming.get() { "opacity-50" } else { "" })
disabled=move || is_streaming.get()
on:click=move |_| {
if !is_streaming.get() {
set_is_streaming.set(true);
if let Some(ref cb) = on_start_stream {
cb();
}
}
}
>
"🔴 Start Stream"
</button>
}
})}
{has_stop.then(|| {
view! {
<button
class=move || format!("btn btn-destructive flex-1 {}", if !is_streaming.get() { "opacity-50" } else { "" })
disabled=move || !is_streaming.get()
on:click=move |_| {
if is_streaming.get() {
set_is_streaming.set(false);
if let Some(ref cb) = on_stop_stream {
cb();
}
}
}
>
"⏹ Stop Stream"
</button>
}
})}
</div>
{move || {
is_streaming.get().then(|| {
view! {
<div class="rounded-md bg-success/10 px-2 py-1.5 text-xs text-success">
"🔴 Live streaming..."
</div>
}
})
}}
</div>
</div>
}
}