feat(leptos): Phase 4 Task 5 - RecordingsSubPanel + WaveformPlayer

This commit is contained in:
asepharyana
2026-07-03 21:57:45 +07:00
parent 1d7a433b3c
commit aaa8faad55
3 changed files with 271 additions and 0 deletions
@@ -5,6 +5,8 @@ pub mod mic_level_meter;
pub mod now_playing; pub mod now_playing;
pub mod music_sub_panel; pub mod music_sub_panel;
pub mod screen_sub_panel; pub mod screen_sub_panel;
pub mod recordings_sub_panel;
pub mod waveform_player;
pub use voice_connection_card::VoiceConnectionCard; pub use voice_connection_card::VoiceConnectionCard;
pub use active_speakers::ActiveSpeakers; pub use active_speakers::ActiveSpeakers;
@@ -13,3 +15,5 @@ pub use mic_level_meter::MicLevelMeter;
pub use now_playing::NowPlaying; pub use now_playing::NowPlaying;
pub use music_sub_panel::MusicSubPanel; pub use music_sub_panel::MusicSubPanel;
pub use screen_sub_panel::ScreenSubPanel; pub use screen_sub_panel::ScreenSubPanel;
pub use recordings_sub_panel::RecordingsSubPanel;
pub use waveform_player::WaveformPlayer;
@@ -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)
}