feat(leptos): Phase 4 Task 5 - RecordingsSubPanel + WaveformPlayer
This commit is contained in:
@@ -5,6 +5,8 @@ pub mod mic_level_meter;
|
||||
pub mod now_playing;
|
||||
pub mod music_sub_panel;
|
||||
pub mod screen_sub_panel;
|
||||
pub mod recordings_sub_panel;
|
||||
pub mod waveform_player;
|
||||
|
||||
pub use voice_connection_card::VoiceConnectionCard;
|
||||
pub use active_speakers::ActiveSpeakers;
|
||||
@@ -13,3 +15,5 @@ pub use mic_level_meter::MicLevelMeter;
|
||||
pub use now_playing::NowPlaying;
|
||||
pub use music_sub_panel::MusicSubPanel;
|
||||
pub use screen_sub_panel::ScreenSubPanel;
|
||||
pub use recordings_sub_panel::RecordingsSubPanel;
|
||||
pub use waveform_player::WaveformPlayer;
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::recording::VoiceRecording;
|
||||
use crate::api::recordings::{get_recordings, delete_recording};
|
||||
|
||||
/// RecordingsSubPanel — Paginated list of voice recordings
|
||||
#[component]
|
||||
pub fn RecordingsSubPanel() -> impl IntoView {
|
||||
let recordings = create_rw_signal::<Vec<VoiceRecording>>(Vec::new());
|
||||
let loading = create_rw_signal::<bool>(false);
|
||||
let has_more = create_rw_signal::<bool>(true);
|
||||
let next_cursor = create_rw_signal::<Option<String>>(None);
|
||||
|
||||
// Load recordings
|
||||
let load = move |reset: bool| {
|
||||
if loading.get() { return; }
|
||||
loading.set(true);
|
||||
|
||||
let cursor_val = if reset { None } else { next_cursor.get() };
|
||||
wasm_bindgen_futures::spawn_local({
|
||||
async move {
|
||||
match get_recordings(Some(20), cursor_val.as_deref()).await {
|
||||
Ok(resp) => {
|
||||
if reset {
|
||||
recordings.set(resp.items);
|
||||
} else {
|
||||
let mut current = recordings.get();
|
||||
current.extend(resp.items);
|
||||
recordings.set(current);
|
||||
}
|
||||
has_more.set(resp.has_more);
|
||||
next_cursor.set(resp.next_cursor);
|
||||
}
|
||||
Err(_) => {
|
||||
if reset {
|
||||
recordings.set(Vec::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Load on mount
|
||||
create_effect(move |_| {
|
||||
load(true);
|
||||
});
|
||||
|
||||
// Delete recording handler
|
||||
let do_delete = move |id: String| {
|
||||
wasm_bindgen_futures::spawn_local({
|
||||
let id = id.clone();
|
||||
async move {
|
||||
let _ = delete_recording(&id).await;
|
||||
recordings.update(|r| r.retain(|rec| rec.id != id));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="recordings-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">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
||||
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||
<line x1="8" y1="23" x2="16" y2="23"></line>
|
||||
</svg>
|
||||
"Recordings"
|
||||
</div>
|
||||
<p class="card-description">"Voice channel recordings from all sessions."</p>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
{move || {
|
||||
let recs = recordings.get();
|
||||
if recs.is_empty() && !loading.get() {
|
||||
view! {
|
||||
<div class="flex flex-col items-center justify-center py-8 gap-2">
|
||||
<p class="text-sm text-muted-foreground">"No recordings yet."</p>
|
||||
<p class="text-xs text-muted-foreground">"Join a voice channel to start recording."</p>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="space-y-2">
|
||||
{recs.iter().map(|rec| {
|
||||
let id = rec.id.clone();
|
||||
let username = rec.username.clone();
|
||||
let channel_name = rec.channel_name.clone().unwrap_or_default();
|
||||
let created_at = format_timestamp(rec.created_at);
|
||||
let has_url = rec.download_url.is_some();
|
||||
let url = rec.download_url.clone().unwrap_or_default();
|
||||
|
||||
view! {
|
||||
<div class="recording-item flex items-center gap-3 p-3 rounded-lg border border-border/50 hover:bg-accent/5 transition-colors">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-foreground truncate">
|
||||
{username}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{channel_name}</span>
|
||||
<span>"·"</span>
|
||||
<span>{format_size(rec.size_bytes)}</span>
|
||||
<span>"·"</span>
|
||||
<span>{created_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
{has_url.then(|| {
|
||||
view! {
|
||||
<a
|
||||
href=url
|
||||
target="_blank"
|
||||
class="btn btn-sm btn-outline"
|
||||
>
|
||||
"Download"
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
<button
|
||||
class="btn btn-sm btn-ghost text-destructive hover:text-destructive"
|
||||
on:click=move |_| do_delete(id.clone())
|
||||
>
|
||||
"🗑"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
}}
|
||||
|
||||
{move || {
|
||||
(has_more.get() && !loading.get()).then(|| {
|
||||
view! {
|
||||
<div class="mt-3 text-center">
|
||||
<button
|
||||
class="btn btn-sm btn-outline"
|
||||
on:click=move |_| load(false)
|
||||
>
|
||||
"Load more"
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Format file size bytes to human readable
|
||||
fn format_size(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{} B", bytes)
|
||||
} else if bytes < 1024 * 1024 {
|
||||
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||
} else {
|
||||
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Format timestamp i64 to readable date
|
||||
fn format_timestamp(ts: i64) -> String {
|
||||
let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64((ts as f64) * 1000.0));
|
||||
d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED).into()
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// WaveformPlayer — Audio player with waveform progress bar
|
||||
#[component]
|
||||
pub fn WaveformPlayer(
|
||||
audio_url: String,
|
||||
#[prop(default = "Recording".to_string())] title: String,
|
||||
) -> impl IntoView {
|
||||
let is_playing = create_rw_signal::<bool>(false);
|
||||
let current_time = create_rw_signal::<f64>(0.0);
|
||||
let duration = create_rw_signal::<f64>(0.0);
|
||||
let audio_id = format!("audio_{}", &audio_url);
|
||||
|
||||
// Clone audio_url for the audio element
|
||||
let audio_src = audio_url.clone();
|
||||
let audio_src_for_id = audio_src.clone();
|
||||
|
||||
let toggle_play = move |_| {
|
||||
let doc = web_sys::window().unwrap().document().unwrap();
|
||||
let audio_opt = doc.get_element_by_id(&format!("audio_{}", &audio_src_for_id));
|
||||
if let Some(audio_el) = audio_opt {
|
||||
if let Ok(audio) = audio_el.dyn_into::<web_sys::HtmlAudioElement>() {
|
||||
if is_playing.get() {
|
||||
let _ = audio.pause();
|
||||
is_playing.set(false);
|
||||
} else {
|
||||
if audio.ended() {
|
||||
audio.set_current_time(0.0);
|
||||
}
|
||||
if let Ok(_) = audio.play() {
|
||||
is_playing.set(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let _ = audio_url; // Mark as used for the audio_id
|
||||
|
||||
view! {
|
||||
<div class="waveform-player border border-border/50 rounded-lg p-3 bg-surface/30">
|
||||
<audio
|
||||
id=audio_id.clone()
|
||||
preload="auto"
|
||||
src=audio_src
|
||||
class="hidden"
|
||||
on:timeupdate=move |ev| {
|
||||
if let Some(target) = ev.target() {
|
||||
if let Ok(audio) = target.dyn_into::<web_sys::HtmlAudioElement>() {
|
||||
let ct = audio.current_time();
|
||||
let dur = audio.duration();
|
||||
current_time.set(ct);
|
||||
if dur.is_finite() && dur > 0.0 {
|
||||
duration.set(dur);
|
||||
}
|
||||
if audio.ended() {
|
||||
is_playing.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
></audio>
|
||||
|
||||
<div class="h-2 rounded-full bg-surface border border-border/50 overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all duration-200"
|
||||
style=move || format!("width: {}%", progress_pct(current_time.get(), duration.get()))
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<button
|
||||
class=move || format!("btn btn-sm {}", if is_playing.get() { "btn-secondary" } else { "btn-primary" })
|
||||
on:click=toggle_play
|
||||
>
|
||||
{move || if is_playing.get() { "⏸" } else { "▶" }}
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground font-mono">
|
||||
<span>{move || format_time(current_time.get())}</span>
|
||||
<span class="max-w-32 truncate">{title.clone()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn progress_pct(current: f64, dur: f64) -> f64 {
|
||||
if dur > 0.0 { (current / dur * 100.0).min(100.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
fn format_time(secs: f64) -> String {
|
||||
if !secs.is_finite() || secs < 0.0 { return "00:00".to_string(); }
|
||||
let total = secs as u32;
|
||||
format!("{:02}:{:02}", total / 60, total % 60)
|
||||
}
|
||||
Reference in New Issue
Block a user