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 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;
|
||||||
|
|||||||
+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