feat(leptos): Phase 4 Task 1 - voice/media control hooks

This commit is contained in:
asepharyana
2026-07-03 20:55:19 +07:00
parent 3de7f8d823
commit c9bc5f5b50
6 changed files with 359 additions and 0 deletions
@@ -0,0 +1 @@
// Empty for now - components will be added in future phases
@@ -0,0 +1,2 @@
pub mod use_voice_control;
pub mod use_media_control;
@@ -0,0 +1,157 @@
use leptos::prelude::*;
use shared_types::media::MediaState;
use crate::api::voice::{
get_media_status, media_queue, media_skip, media_stop, media_volume,
};
use std::sync::Arc;
use wasm_bindgen_futures::spawn_local;
/// Callback type for enqueue
pub type EnqueueCallback = Arc<dyn Fn(String, String) + Send + Sync>;
/// Callback type for skip_track
pub type SkipTrackCallback = Arc<dyn Fn() + Send + Sync>;
/// Callback type for stop_playback
pub type StopPlaybackCallback = Arc<dyn Fn() + Send + Sync>;
/// Callback type for set_volume
pub type SetVolumeCallback = Arc<dyn Fn(f64) + Send + Sync>;
/// Callback type for refresh
pub type RefreshCallback = Arc<dyn Fn() + Send + Sync>;
/// State returned by use_media_control hook
#[derive(Clone)]
pub struct MediaControlState {
/// Current media playback state
pub media_state: RwSignal<Option<MediaState>>,
/// Whether we're currently loading data
pub loading: RwSignal<bool>,
/// Last error message if any
pub error: RwSignal<Option<String>>,
/// Enqueue media (source URL, mode: "music" or "screen")
pub enqueue: EnqueueCallback,
/// Skip to next track
pub skip_track: SkipTrackCallback,
/// Stop all playback
pub stop_playback: StopPlaybackCallback,
/// Set volume level (0.0 - 1.0)
pub set_volume: SetVolumeCallback,
/// Refresh media status from server
pub refresh: RefreshCallback,
}
/// Hook to manage media playback state and controls
pub fn use_media_control() -> MediaControlState {
// Core signals
let media_state_signal = RwSignal::new(None::<MediaState>);
let loading_signal = RwSignal::new(false);
let error_signal = RwSignal::new(None::<String>);
// Enqueue media
let enqueue_impl = Arc::new(move |source: String, mode: String| {
spawn_local({
let source = source.clone();
let mode = mode.clone();
async move {
error_signal.set(None);
loading_signal.set(true);
match media_queue(&source, &mode).await {
Ok(state) => {
media_state_signal.set(Some(state));
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to enqueue media: {}", e)));
loading_signal.set(false);
}
}
}
});
});
// Skip to next track
let skip_track_impl = Arc::new(move || {
spawn_local(async move {
error_signal.set(None);
loading_signal.set(true);
match media_skip().await {
Ok(state) => {
media_state_signal.set(Some(state));
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to skip track: {}", e)));
loading_signal.set(false);
}
}
});
});
// Stop all playback
let stop_playback_impl = Arc::new(move || {
spawn_local(async move {
error_signal.set(None);
loading_signal.set(true);
match media_stop().await {
Ok(state) => {
media_state_signal.set(Some(state));
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to stop playback: {}", e)));
loading_signal.set(false);
}
}
});
});
// Set volume level
let set_volume_impl = Arc::new(move |volume: f64| {
spawn_local(async move {
error_signal.set(None);
loading_signal.set(true);
match media_volume(volume).await {
Ok(state) => {
media_state_signal.set(Some(state));
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to set volume: {}", e)));
loading_signal.set(false);
}
}
});
});
// Refresh media status
let refresh_impl = Arc::new(move || {
spawn_local(async move {
error_signal.set(None);
loading_signal.set(true);
match get_media_status().await {
Ok(state) => {
media_state_signal.set(Some(state));
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to refresh media status: {}", e)));
loading_signal.set(false);
}
}
});
});
MediaControlState {
media_state: media_state_signal,
loading: loading_signal,
error: error_signal,
enqueue: enqueue_impl,
skip_track: skip_track_impl,
stop_playback: stop_playback_impl,
set_volume: set_volume_impl,
refresh: refresh_impl,
}
}
@@ -0,0 +1,177 @@
use leptos::prelude::*;
use shared_types::guild::{Guild, Channel};
use shared_types::voice::VoiceStatus;
use crate::api::voice::{
get_guilds, get_voice_channels, get_text_channels, get_voice_status,
connect_voice, disconnect_voice,
};
use std::sync::Arc;
use wasm_bindgen_futures::spawn_local;
/// Callback type for join_voice
pub type JoinVoiceCallback = Arc<dyn Fn(String, String) + Send + Sync>;
/// Callback type for leave_voice
pub type LeaveVoiceCallback = Arc<dyn Fn() + Send + Sync>;
/// Callback type for load_guilds
pub type LoadGuildsCallback = Arc<dyn Fn() + Send + Sync>;
/// Callback type for load_voice_channels
pub type LoadVoiceChannelsCallback = Arc<dyn Fn(String) + Send + Sync>;
/// Callback type for load_text_channels
pub type LoadTextChannelsCallback = Arc<dyn Fn(String) + Send + Sync>;
/// State returned by use_voice_control hook
#[derive(Clone)]
pub struct VoiceControlState {
/// List of available guilds
pub guilds: RwSignal<Vec<Guild>>,
/// List of voice channels for current guild
pub voice_channels: RwSignal<Vec<Channel>>,
/// List of text channels for current guild
pub text_channels: RwSignal<Vec<Channel>>,
/// Current voice connection status
pub voice_status: RwSignal<Option<VoiceStatus>>,
/// Whether we're currently loading data
pub loading: RwSignal<bool>,
/// Last error message if any
pub error: RwSignal<Option<String>>,
/// Join a voice channel
pub join_voice: JoinVoiceCallback,
/// Leave the current voice channel
pub leave_voice: LeaveVoiceCallback,
/// Fetch list of guilds
pub load_guilds: LoadGuildsCallback,
/// Fetch voice channels for a guild
pub load_voice_channels: LoadVoiceChannelsCallback,
/// Fetch text channels for a guild
pub load_text_channels: LoadTextChannelsCallback,
}
/// Hook to manage voice connection state and controls
pub fn use_voice_control() -> VoiceControlState {
// Core signals
let guilds_signal = RwSignal::new(Vec::<Guild>::new());
let voice_channels_signal = RwSignal::new(Vec::<Channel>::new());
let text_channels_signal = RwSignal::new(Vec::<Channel>::new());
let voice_status_signal = RwSignal::new(None::<VoiceStatus>);
let loading_signal = RwSignal::new(false);
let error_signal = RwSignal::new(None::<String>);
// Join a voice channel
let join_voice_impl = Arc::new(move |guild_id: String, channel_id: String| {
spawn_local({
let guild_id = guild_id.clone();
let channel_id = channel_id.clone();
async move {
error_signal.set(None);
loading_signal.set(true);
match connect_voice(&guild_id, &channel_id).await {
Ok(status) => {
voice_status_signal.set(Some(status));
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to join voice: {}", e)));
loading_signal.set(false);
}
}
}
});
});
// Leave the current voice channel
let leave_voice_impl = Arc::new(move || {
spawn_local(async move {
error_signal.set(None);
loading_signal.set(true);
match disconnect_voice().await {
Ok(status) => {
voice_status_signal.set(Some(status));
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to leave voice: {}", e)));
loading_signal.set(false);
}
}
});
});
// Fetch list of guilds
let load_guilds_impl = Arc::new(move || {
spawn_local(async move {
error_signal.set(None);
loading_signal.set(true);
match get_guilds().await {
Ok(guilds) => {
guilds_signal.set(guilds);
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to load guilds: {}", e)));
loading_signal.set(false);
}
}
});
});
// Fetch voice channels for a guild
let load_voice_channels_impl = Arc::new(move |guild_id: String| {
spawn_local({
let guild_id = guild_id.clone();
async move {
error_signal.set(None);
loading_signal.set(true);
match get_voice_channels(&guild_id).await {
Ok(channels) => {
voice_channels_signal.set(channels);
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to load voice channels: {}", e)));
loading_signal.set(false);
}
}
}
});
});
// Fetch text channels for a guild
let load_text_channels_impl = Arc::new(move |guild_id: String| {
spawn_local({
let guild_id = guild_id.clone();
async move {
error_signal.set(None);
loading_signal.set(true);
match get_text_channels(&guild_id).await {
Ok(channels) => {
text_channels_signal.set(channels);
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to load text channels: {}", e)));
loading_signal.set(false);
}
}
}
});
});
VoiceControlState {
guilds: guilds_signal,
voice_channels: voice_channels_signal,
text_channels: text_channels_signal,
voice_status: voice_status_signal,
loading: loading_signal,
error: error_signal,
join_voice: join_voice_impl,
leave_voice: leave_voice_impl,
load_guilds: load_guilds_impl,
load_voice_channels: load_voice_channels_impl,
load_text_channels: load_text_channels_impl,
}
}
@@ -0,0 +1,21 @@
pub mod components;
pub mod hooks;
use leptos::prelude::*;
use crate::ui::card::Card;
/// Placeholder LivePanel component for Phase 4 Task 1
/// Will be expanded with voice connection, music player, and recordings components
#[component]
pub fn LivePanel() -> impl IntoView {
view! {
<Card class="p-6">
<div class="space-y-4">
<h2 class="text-xl font-bold">Live Monitoring</h2>
<p class="text-gray-600 dark:text-gray-400">
Live panel components coming in Phase 4
</p>
</div>
</Card>
}
}
@@ -1 +1,2 @@
pub mod live;
pub mod messages;