diff --git a/services/frontend/frontend/src/api/mascot.rs b/services/frontend/frontend/src/api/mascot.rs index 3b1d266..53d03d1 100644 --- a/services/frontend/frontend/src/api/mascot.rs +++ b/services/frontend/frontend/src/api/mascot.rs @@ -12,6 +12,13 @@ pub struct MascotChatResponse { pub timestamp: String, } +#[derive(Debug, Deserialize)] +pub struct ChatHistoryMessage { + pub role: String, + pub content: String, + pub timestamp: String, +} + pub async fn send_mascot_message(message: &str) -> Result { let body = serde_json::to_string(&MascotChatRequest { message }).map_err(|err| ApiError { message: format!("Failed to serialize mascot request: {}", err), @@ -19,3 +26,8 @@ pub async fn send_mascot_message(message: &str) -> Result Result, ApiError> { + request("GET", "/api/mascot/chat/history", None).await +} diff --git a/services/frontend/frontend/src/api/messages.rs b/services/frontend/frontend/src/api/messages.rs index 3a78132..2d9e36d 100644 --- a/services/frontend/frontend/src/api/messages.rs +++ b/services/frontend/frontend/src/api/messages.rs @@ -22,17 +22,21 @@ pub async fn get_messages( } /// GET /api/review?params +/// Backend `GET /review` accepts `channelId` and `limit` (not guildId). pub async fn get_review_messages( - guild_id: &str, limit: Option, channel_id: Option<&str>, ) -> Result, ApiError> { - let mut path = format!("/api/review?guildId={}", guild_id); + let mut path = "/api/review".to_string(); + let mut params = vec![]; if let Some(l) = limit { - path.push_str(&format!("&limit={}", l)); + params.push(format!("limit={}", l)); } if let Some(c) = channel_id { - path.push_str(&format!("&channelId={}", c)); + params.push(format!("channelId={}", c)); + } + if !params.is_empty() { + path.push_str(&format!("?{}", params.join("&"))); } request("GET", &path, None).await } diff --git a/services/frontend/frontend/src/features/live/components/music_sub_panel.rs b/services/frontend/frontend/src/features/live/components/music_sub_panel.rs index 8671e49..07784fc 100644 --- a/services/frontend/frontend/src/features/live/components/music_sub_panel.rs +++ b/services/frontend/frontend/src/features/live/components/music_sub_panel.rs @@ -6,16 +6,13 @@ pub fn MusicSubPanel( #[prop(optional)] on_queue: Option>, ) -> impl IntoView { let (url_input, set_url_input) = signal::(String::new()); - let (is_loading, set_is_loading) = signal::(false); let handle_queue_click = move |_| { let url = url_input.get_untracked().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); } } }; @@ -40,15 +37,14 @@ pub fn MusicSubPanel( 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() /> diff --git a/services/frontend/frontend/src/features/live/components/now_playing.rs b/services/frontend/frontend/src/features/live/components/now_playing.rs index 44dd1b0..0081582 100644 --- a/services/frontend/frontend/src/features/live/components/now_playing.rs +++ b/services/frontend/frontend/src/features/live/components/now_playing.rs @@ -2,13 +2,15 @@ use leptos::prelude::*; use shared_types::media::MediaState; /// NowPlaying — Displays current media item and queue info +/// Accepts an optional RwSignal to enable real-time updates from WebSocket events. #[component] pub fn NowPlaying( - #[prop(optional)] state: Option, + #[prop(optional)] media_rw: Option>>, #[prop(optional)] on_skip: Option>, #[prop(optional)] on_stop: Option>, ) -> impl IntoView { - let media_state = RwSignal::new(state); + // Use provided signal, or fall back to a local one for static usage + let media_state = media_rw.unwrap_or_else(|| RwSignal::new(None)); // Wrap callbacks in StoredValue for shareable non-Clone ownership in Leptos context let skip_cb = StoredValue::new(on_skip); diff --git a/services/frontend/frontend/src/features/live/components/recordings_sub_panel.rs b/services/frontend/frontend/src/features/live/components/recordings_sub_panel.rs index cd56b71..9432469 100644 --- a/services/frontend/frontend/src/features/live/components/recordings_sub_panel.rs +++ b/services/frontend/frontend/src/features/live/components/recordings_sub_panel.rs @@ -3,8 +3,11 @@ use leptos::prelude::*; use shared_types::recording::VoiceRecording; /// RecordingsSubPanel — Paginated list of voice recordings +/// Accepts an optional refresh_trigger signal to reload when a new recording is uploaded. #[component] -pub fn RecordingsSubPanel() -> impl IntoView { +pub fn RecordingsSubPanel( + #[prop(optional)] refresh_trigger: Option>, +) -> impl IntoView { let recordings = RwSignal::new(Vec::::new()); let loading = RwSignal::new(false); let has_more = RwSignal::new(true); @@ -47,8 +50,11 @@ pub fn RecordingsSubPanel() -> impl IntoView { }); }; - // Load on mount + // Load on mount, and reload when refresh_trigger changes (e.g., new recording uploaded) Effect::new(move |_| { + if let Some(trigger) = refresh_trigger { + trigger.get(); // Track — re-run when WS signals a new recording + } load(true); }); diff --git a/services/frontend/frontend/src/features/live/hooks/use_audio_playback.rs b/services/frontend/frontend/src/features/live/hooks/use_audio_playback.rs index 79d87a4..ad846b6 100644 --- a/services/frontend/frontend/src/features/live/hooks/use_audio_playback.rs +++ b/services/frontend/frontend/src/features/live/hooks/use_audio_playback.rs @@ -1,8 +1,11 @@ use crate::features::live::audio::pcm_decoder::decode_pcm_frame; use crate::features::live::audio::ring_buffer::SharedRingBuffer; use leptos::prelude::*; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; /// AudioPlaybackState — Manages PCM audio playback from WebSocket binary frames +#[derive(Clone)] pub struct AudioPlaybackState { /// Ring buffer for incoming PCM data pub buffer: SharedRingBuffer, @@ -10,6 +13,8 @@ pub struct AudioPlaybackState { pub active: RwSignal, /// Volume level (0.0-1.0) pub volume: RwSignal, + /// Abort flag to stop the playback loop (prevents leak on teardown) + pub abort: Arc, } /// Create and initialize audio playback state @@ -22,6 +27,7 @@ pub fn use_audio_playback() -> AudioPlaybackState { buffer, active, volume, + abort: Arc::new(AtomicBool::new(false)), } } @@ -34,14 +40,18 @@ pub fn process_pcm_data(state: &AudioPlaybackState, data: Vec) { } /// Start consuming the ring buffer and playing through AudioContext +/// The loop respects the abort flag in `AudioPlaybackState` for clean teardown. pub fn start_playback(state: &AudioPlaybackState) { if state.active.get_untracked() { return; } state.active.set(true); + // Reset abort flag for a fresh start + state.abort.store(false, Ordering::Relaxed); let buffer = state.buffer.clone(); let active = state.active; + let abort = state.abort.clone(); wasm_bindgen_futures::spawn_local(async move { let ctx = match web_sys::AudioContext::new() { @@ -55,7 +65,7 @@ pub fn start_playback(state: &AudioPlaybackState) { let ctx_ref = &ctx; let _ = ctx_ref.resume(); - while active.get_untracked() { + while active.get_untracked() && !abort.load(Ordering::Relaxed) { let available = buffer.available_samples(); if available >= 4410 { // ~100ms worth at 44.1kHz @@ -73,6 +83,7 @@ pub fn start_playback(state: &AudioPlaybackState) { /// Stop playback and clear buffer pub fn stop_playback(state: &AudioPlaybackState) { + state.abort.store(true, Ordering::Relaxed); state.active.set(false); state.buffer.clear(); } diff --git a/services/frontend/frontend/src/features/live/mod.rs b/services/frontend/frontend/src/features/live/mod.rs index 2aadf52..e429a13 100644 --- a/services/frontend/frontend/src/features/live/mod.rs +++ b/services/frontend/frontend/src/features/live/mod.rs @@ -4,17 +4,72 @@ pub mod hooks; use crate::app::AuthContext; use crate::auth::AuthOverlay; +use crate::ws::context::WsContext; use components::{ ActiveSpeakers, AudioVisualizer, MusicSubPanel, NowPlaying, RecordingsSubPanel, ScreenSubPanel, VoiceConnectionCard, }; use leptos::prelude::*; +use shared_types::media::MediaState; +use shared_types::voice::ActiveSpeaker; /// LivePanel — Composition shell for all voice and media components. /// Shows an auth overlay if not authenticated, otherwise shows voice controls. #[component] pub fn LivePanel() -> impl IntoView { let auth = use_context::().expect("AuthContext not provided"); + let ws = use_context::(); + + // ── Shared state for WS-driven components ────────────── + let speakers = RwSignal::new(Vec::::new()); + let media_state = RwSignal::new(None::); + let (recordings_refresh, set_recordings_refresh) = signal(0u64); + let audio_playback = hooks::use_audio_playback::use_audio_playback(); + + // ── Wire WS events (runs on mount, persists while LivePanel is active) ── + if let Some(ref ws) = ws { + // Voice active user — update speakers list + *ws.on_voice_active_user.borrow_mut() = Some(Box::new({ + let speakers = speakers.clone(); + move |speaker: ActiveSpeaker| { + speakers.update(|list| { + if let Some(pos) = list.iter().position(|s| s.user_id == speaker.user_id) { + list[pos] = speaker; + } else { + list.push(speaker); + } + }); + } + })); + + // Media state — update NowPlaying + *ws.on_media_state.borrow_mut() = Some(Box::new({ + let ms = media_state.clone(); + move |state: MediaState| { + ms.set(Some(state)); + } + })); + + // Recording uploaded — trigger recordings list refresh + *ws.on_voice_recording_uploaded.borrow_mut() = Some(Box::new({ + let set_refresh = set_recordings_refresh; + move |_recording| { + set_refresh.update(|v| *v = v.wrapping_add(1)); + } + })); + + // Binary PCM data — process and play audio + *ws.on_binary.borrow_mut() = Some(Box::new({ + let playback = audio_playback.clone(); + move |data: Vec| { + hooks::use_audio_playback::process_pcm_data(&playback, data); + // Auto-start playback on first PCM data + if !playback.active.get_untracked() { + hooks::use_audio_playback::start_playback(&playback); + } + } + })); + } view! {
@@ -37,7 +92,7 @@ pub fn LivePanel() -> impl IntoView {
- +
@@ -54,7 +109,7 @@ pub fn LivePanel() -> impl IntoView { {/* Media controls: Now Playing + Music + Screen */}
- +
@@ -65,7 +120,7 @@ pub fn LivePanel() -> impl IntoView {
{/* Recordings */} - +
}.into_any() } else { diff --git a/services/frontend/frontend/src/features/polish/components/mascot_chatbot.rs b/services/frontend/frontend/src/features/polish/components/mascot_chatbot.rs index 4db8f31..d9d710b 100644 --- a/services/frontend/frontend/src/features/polish/components/mascot_chatbot.rs +++ b/services/frontend/frontend/src/features/polish/components/mascot_chatbot.rs @@ -29,6 +29,36 @@ pub fn MascotChatbot() -> impl IntoView { .to_string(), }]); + // Fetch chat history when the panel opens + Effect::new(move |_| { + if open.get() { + spawn_local(async move { + if let Ok(history) = crate::api::mascot::get_chat_history().await { + messages.update(|list| { + // Keep the initial greeting, then append history messages + let greeting = list.first().cloned(); + list.clear(); + if let Some(g) = greeting { + list.push(g); + } + for msg in history { + let role = if msg.role == "user" { + ChatRole::User + } else { + ChatRole::Mascot + }; + list.push(ChatMessage { + id: format!("hist-{}", list.len()), + role, + content: msg.content, + }); + } + }); + } + }); + } + }); + let send_message = move || { let text = input.get_untracked().trim().to_string(); if text.is_empty() || loading.get_untracked() {