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:
@@ -12,6 +12,13 @@ pub struct MascotChatResponse {
|
|||||||
pub timestamp: String,
|
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> {
|
pub async fn send_mascot_message(message: &str) -> Result<MascotChatResponse, ApiError> {
|
||||||
let body = serde_json::to_string(&MascotChatRequest { message }).map_err(|err| ApiError {
|
let body = serde_json::to_string(&MascotChatRequest { message }).map_err(|err| ApiError {
|
||||||
message: format!("Failed to serialize mascot request: {}", err),
|
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
|
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
|
/// GET /api/review?params
|
||||||
|
/// Backend `GET /review` accepts `channelId` and `limit` (not guildId).
|
||||||
pub async fn get_review_messages(
|
pub async fn get_review_messages(
|
||||||
guild_id: &str,
|
|
||||||
limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
channel_id: Option<&str>,
|
channel_id: Option<&str>,
|
||||||
) -> Result<PageResult<MessageRecord>, ApiError> {
|
) -> 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 {
|
if let Some(l) = limit {
|
||||||
path.push_str(&format!("&limit={}", l));
|
params.push(format!("limit={}", l));
|
||||||
}
|
}
|
||||||
if let Some(c) = channel_id {
|
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
|
request("GET", &path, None).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,13 @@ pub fn MusicSubPanel(
|
|||||||
#[prop(optional)] on_queue: Option<Box<dyn Fn(String) + Send + Sync + 'static>>,
|
#[prop(optional)] on_queue: Option<Box<dyn Fn(String) + Send + Sync + 'static>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let (url_input, set_url_input) = signal::<String>(String::new());
|
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 handle_queue_click = move |_| {
|
||||||
let url = url_input.get_untracked().trim().to_string();
|
let url = url_input.get_untracked().trim().to_string();
|
||||||
if !url.is_empty() {
|
if !url.is_empty() {
|
||||||
if let Some(ref cb) = on_queue {
|
if let Some(ref cb) = on_queue {
|
||||||
set_is_loading.set(true);
|
|
||||||
cb(url.clone());
|
cb(url.clone());
|
||||||
set_url_input.set(String::new());
|
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"
|
placeholder="youtube.com/watch?v=... or song name"
|
||||||
prop:value=url_input
|
prop:value=url_input
|
||||||
on:input=move |ev| set_url_input.set(event_target_value(&ev))
|
on:input=move |ev| set_url_input.set(event_target_value(&ev))
|
||||||
disabled=move || is_loading.get()
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ use leptos::prelude::*;
|
|||||||
use shared_types::media::MediaState;
|
use shared_types::media::MediaState;
|
||||||
|
|
||||||
/// NowPlaying — Displays current media item and queue info
|
/// NowPlaying — Displays current media item and queue info
|
||||||
|
/// Accepts an optional RwSignal to enable real-time updates from WebSocket events.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn NowPlaying(
|
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_skip: Option<Box<dyn Fn() + Send + Sync + 'static>>,
|
||||||
#[prop(optional)] on_stop: Option<Box<dyn Fn() + Send + Sync + 'static>>,
|
#[prop(optional)] on_stop: Option<Box<dyn Fn() + Send + Sync + 'static>>,
|
||||||
) -> impl IntoView {
|
) -> 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
|
// Wrap callbacks in StoredValue for shareable non-Clone ownership in Leptos context
|
||||||
let skip_cb = StoredValue::new(on_skip);
|
let skip_cb = StoredValue::new(on_skip);
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ use leptos::prelude::*;
|
|||||||
use shared_types::recording::VoiceRecording;
|
use shared_types::recording::VoiceRecording;
|
||||||
|
|
||||||
/// RecordingsSubPanel — Paginated list of voice recordings
|
/// RecordingsSubPanel — Paginated list of voice recordings
|
||||||
|
/// Accepts an optional refresh_trigger signal to reload when a new recording is uploaded.
|
||||||
#[component]
|
#[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 recordings = RwSignal::new(Vec::<VoiceRecording>::new());
|
||||||
let loading = RwSignal::new(false);
|
let loading = RwSignal::new(false);
|
||||||
let has_more = RwSignal::new(true);
|
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 |_| {
|
Effect::new(move |_| {
|
||||||
|
if let Some(trigger) = refresh_trigger {
|
||||||
|
trigger.get(); // Track — re-run when WS signals a new recording
|
||||||
|
}
|
||||||
load(true);
|
load(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use crate::features::live::audio::pcm_decoder::decode_pcm_frame;
|
use crate::features::live::audio::pcm_decoder::decode_pcm_frame;
|
||||||
use crate::features::live::audio::ring_buffer::SharedRingBuffer;
|
use crate::features::live::audio::ring_buffer::SharedRingBuffer;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// AudioPlaybackState — Manages PCM audio playback from WebSocket binary frames
|
/// AudioPlaybackState — Manages PCM audio playback from WebSocket binary frames
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct AudioPlaybackState {
|
pub struct AudioPlaybackState {
|
||||||
/// Ring buffer for incoming PCM data
|
/// Ring buffer for incoming PCM data
|
||||||
pub buffer: SharedRingBuffer,
|
pub buffer: SharedRingBuffer,
|
||||||
@@ -10,6 +13,8 @@ pub struct AudioPlaybackState {
|
|||||||
pub active: RwSignal<bool>,
|
pub active: RwSignal<bool>,
|
||||||
/// Volume level (0.0-1.0)
|
/// Volume level (0.0-1.0)
|
||||||
pub volume: RwSignal<f64>,
|
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
|
/// Create and initialize audio playback state
|
||||||
@@ -22,6 +27,7 @@ pub fn use_audio_playback() -> AudioPlaybackState {
|
|||||||
buffer,
|
buffer,
|
||||||
active,
|
active,
|
||||||
volume,
|
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
|
/// 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) {
|
pub fn start_playback(state: &AudioPlaybackState) {
|
||||||
if state.active.get_untracked() {
|
if state.active.get_untracked() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.active.set(true);
|
state.active.set(true);
|
||||||
|
// Reset abort flag for a fresh start
|
||||||
|
state.abort.store(false, Ordering::Relaxed);
|
||||||
|
|
||||||
let buffer = state.buffer.clone();
|
let buffer = state.buffer.clone();
|
||||||
let active = state.active;
|
let active = state.active;
|
||||||
|
let abort = state.abort.clone();
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
let ctx = match web_sys::AudioContext::new() {
|
let ctx = match web_sys::AudioContext::new() {
|
||||||
@@ -55,7 +65,7 @@ pub fn start_playback(state: &AudioPlaybackState) {
|
|||||||
let ctx_ref = &ctx;
|
let ctx_ref = &ctx;
|
||||||
let _ = ctx_ref.resume();
|
let _ = ctx_ref.resume();
|
||||||
|
|
||||||
while active.get_untracked() {
|
while active.get_untracked() && !abort.load(Ordering::Relaxed) {
|
||||||
let available = buffer.available_samples();
|
let available = buffer.available_samples();
|
||||||
if available >= 4410 {
|
if available >= 4410 {
|
||||||
// ~100ms worth at 44.1kHz
|
// ~100ms worth at 44.1kHz
|
||||||
@@ -73,6 +83,7 @@ pub fn start_playback(state: &AudioPlaybackState) {
|
|||||||
|
|
||||||
/// Stop playback and clear buffer
|
/// Stop playback and clear buffer
|
||||||
pub fn stop_playback(state: &AudioPlaybackState) {
|
pub fn stop_playback(state: &AudioPlaybackState) {
|
||||||
|
state.abort.store(true, Ordering::Relaxed);
|
||||||
state.active.set(false);
|
state.active.set(false);
|
||||||
state.buffer.clear();
|
state.buffer.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,72 @@ pub mod hooks;
|
|||||||
|
|
||||||
use crate::app::AuthContext;
|
use crate::app::AuthContext;
|
||||||
use crate::auth::AuthOverlay;
|
use crate::auth::AuthOverlay;
|
||||||
|
use crate::ws::context::WsContext;
|
||||||
use components::{
|
use components::{
|
||||||
ActiveSpeakers, AudioVisualizer, MusicSubPanel, NowPlaying, RecordingsSubPanel, ScreenSubPanel,
|
ActiveSpeakers, AudioVisualizer, MusicSubPanel, NowPlaying, RecordingsSubPanel, ScreenSubPanel,
|
||||||
VoiceConnectionCard,
|
VoiceConnectionCard,
|
||||||
};
|
};
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use shared_types::media::MediaState;
|
||||||
|
use shared_types::voice::ActiveSpeaker;
|
||||||
|
|
||||||
/// LivePanel — Composition shell for all voice and media components.
|
/// LivePanel — Composition shell for all voice and media components.
|
||||||
/// Shows an auth overlay if not authenticated, otherwise shows voice controls.
|
/// Shows an auth overlay if not authenticated, otherwise shows voice controls.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn LivePanel() -> impl IntoView {
|
pub fn LivePanel() -> impl IntoView {
|
||||||
let auth = use_context::<AuthContext>().expect("AuthContext not provided");
|
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! {
|
view! {
|
||||||
<div class="live-panel">
|
<div class="live-panel">
|
||||||
@@ -37,7 +92,7 @@ pub fn LivePanel() -> impl IntoView {
|
|||||||
<VoiceConnectionCard />
|
<VoiceConnectionCard />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<ActiveSpeakers />
|
<ActiveSpeakers speakers=speakers />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -54,7 +109,7 @@ pub fn LivePanel() -> impl IntoView {
|
|||||||
{/* Media controls: Now Playing + Music + Screen */}
|
{/* Media controls: Now Playing + Music + Screen */}
|
||||||
<div class="grid gap-6 lg:grid-cols-3">
|
<div class="grid gap-6 lg:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<NowPlaying />
|
<NowPlaying media_rw=media_state />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<MusicSubPanel />
|
<MusicSubPanel />
|
||||||
@@ -65,7 +120,7 @@ pub fn LivePanel() -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recordings */}
|
{/* Recordings */}
|
||||||
<RecordingsSubPanel />
|
<RecordingsSubPanel refresh_trigger=recordings_refresh />
|
||||||
</div>
|
</div>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -29,6 +29,36 @@ pub fn MascotChatbot() -> impl IntoView {
|
|||||||
.to_string(),
|
.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 send_message = move || {
|
||||||
let text = input.get_untracked().trim().to_string();
|
let text = input.get_untracked().trim().to_string();
|
||||||
if text.is_empty() || loading.get_untracked() {
|
if text.is_empty() || loading.get_untracked() {
|
||||||
|
|||||||
Reference in New Issue
Block a user