diff --git a/services/frontend-leptos/frontend/src/features/live/components/mod.rs b/services/frontend-leptos/frontend/src/features/live/components/mod.rs index 09f2c04..41dfc1a 100644 --- a/services/frontend-leptos/frontend/src/features/live/components/mod.rs +++ b/services/frontend-leptos/frontend/src/features/live/components/mod.rs @@ -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; diff --git a/services/frontend-leptos/frontend/src/features/live/components/recordings_sub_panel.rs b/services/frontend-leptos/frontend/src/features/live/components/recordings_sub_panel.rs new file mode 100644 index 0000000..5e3d2e9 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/recordings_sub_panel.rs @@ -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::new()); + let loading = create_rw_signal::(false); + let has_more = create_rw_signal::(true); + let next_cursor = create_rw_signal::>(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! { +
+
+
+ + + + + + + "Recordings" +
+

"Voice channel recordings from all sessions."

+
+
+ {move || { + let recs = recordings.get(); + if recs.is_empty() && !loading.get() { + view! { +
+

"No recordings yet."

+

"Join a voice channel to start recording."

+
+ }.into_any() + } else { + view! { +
+ {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! { +
+
+
+ {username} +
+
+ {channel_name} + "·" + {format_size(rec.size_bytes)} + "·" + {created_at} +
+
+
+ {has_url.then(|| { + view! { + + "Download" + + } + })} + +
+
+ } + }).collect::>()} +
+ }.into_any() + } + }} + + {move || { + (has_more.get() && !loading.get()).then(|| { + view! { +
+ +
+ } + }) + }} +
+
+ } +} + +/// 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() +} diff --git a/services/frontend-leptos/frontend/src/features/live/components/waveform_player.rs b/services/frontend-leptos/frontend/src/features/live/components/waveform_player.rs new file mode 100644 index 0000000..2b7f5e2 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/waveform_player.rs @@ -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::(false); + let current_time = create_rw_signal::(0.0); + let duration = create_rw_signal::(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::() { + 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! { +
+ + +
+
+
+ +
+ + +
+ {move || format_time(current_time.get())} + {title.clone()} +
+
+
+ } +} + +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) +}