feat(leptos): Phase 4 Task 4 - NowPlaying, MusicSubPanel, ScreenSubPanel
This commit is contained in:
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user