fix(fe): wiring WS voice/media events, perbaiki race condition, leak, dan history fetch

- Wiring WS events di LivePanel:
  - on_voice_active_user -> update ActiveSpeakers signal
  - on_media_state -> update NowPlaying signal
  - on_voice_recording_uploaded -> trigger RecordingsSubPanel refresh
  - on_binary -> process PCM data dan auto-start audio playback
- AudioPlaybackState: tambah Clone derive + abort AtomicBool
  untuk mencegah loop leak saat teardown
- MusicSubPanel: hapus loading state yang premature reset
  (race condition: loading false sebelum async selesai)
- NowPlaying: ganti prop state jadi RwSignal agar reaktif ke WS
- RecordingsSubPanel: tambah refresh_trigger signal untuk reload
  dari WS event
- MascotChatbot: fetch chat history dari backend saat panel dibuka
- get_review_messages: hapus guildId param (backend ignore)
This commit is contained in:
asepharyana
2026-07-04 05:59:15 +07:00
parent b063524759
commit efb1f4e4a5
8 changed files with 135 additions and 19 deletions
@@ -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<MascotChatResponse, ApiError> {
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<MascotChatResponse, Ap
})?;
request("POST", "/api/mascot/chat", Some(&body)).await
}
/// GET /api/mascot/chat/history
pub async fn get_chat_history() -> Result<Vec<ChatHistoryMessage>, ApiError> {
request("GET", "/api/mascot/chat/history", None).await
}
@@ -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<u32>,
channel_id: Option<&str>,
) -> Result<PageResult<MessageRecord>, 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
}
@@ -6,16 +6,13 @@ pub fn MusicSubPanel(
#[prop(optional)] on_queue: Option<Box<dyn Fn(String) + Send + Sync + 'static>>,
) -> impl IntoView {
let (url_input, set_url_input) = signal::<String>(String::new());
let (is_loading, set_is_loading) = signal::<bool>(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()
/>
</div>
<button
class=move || format!("btn btn-primary w-full {}", if is_loading.get() { "opacity-50" } else { "" })
class="btn btn-primary w-full"
on:click=handle_queue_click
disabled=move || url_input.get().is_empty() || is_loading.get()
disabled=move || url_input.get().is_empty()
>
{move || if is_loading.get() { "Queuing..." } else { "Queue Music" }}
"Queue Music"
</button>
</div>
</div>
@@ -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<MediaState>,
#[prop(optional)] media_rw: Option<RwSignal<Option<MediaState>>>,
#[prop(optional)] on_skip: Option<Box<dyn Fn() + Send + Sync + 'static>>,
#[prop(optional)] on_stop: Option<Box<dyn Fn() + Send + Sync + 'static>>,
) -> 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);
@@ -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<ReadSignal<u64>>,
) -> impl IntoView {
let recordings = RwSignal::new(Vec::<VoiceRecording>::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);
});
@@ -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<bool>,
/// Volume level (0.0-1.0)
pub volume: RwSignal<f64>,
/// Abort flag to stop the playback loop (prevents leak on teardown)
pub abort: Arc<AtomicBool>,
}
/// 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<u8>) {
}
/// 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();
}
@@ -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::<AuthContext>().expect("AuthContext not provided");
let ws = use_context::<WsContext>();
// ── Shared state for WS-driven components ──────────────
let speakers = RwSignal::new(Vec::<ActiveSpeaker>::new());
let media_state = RwSignal::new(None::<MediaState>);
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<u8>| {
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! {
<div class="live-panel">
@@ -37,7 +92,7 @@ pub fn LivePanel() -> impl IntoView {
<VoiceConnectionCard />
</div>
<div>
<ActiveSpeakers />
<ActiveSpeakers speakers=speakers />
</div>
</div>
@@ -54,7 +109,7 @@ pub fn LivePanel() -> impl IntoView {
{/* Media controls: Now Playing + Music + Screen */}
<div class="grid gap-6 lg:grid-cols-3">
<div>
<NowPlaying />
<NowPlaying media_rw=media_state />
</div>
<div>
<MusicSubPanel />
@@ -65,7 +120,7 @@ pub fn LivePanel() -> impl IntoView {
</div>
{/* Recordings */}
<RecordingsSubPanel />
<RecordingsSubPanel refresh_trigger=recordings_refresh />
</div>
}.into_any()
} else {
@@ -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() {