From 33da605c0323fdba8c6a3a578428d06809a4262c Mon Sep 17 00:00:00 2001 From: pavel Date: Tue, 24 Feb 2026 01:41:24 +0100 Subject: [PATCH] soundboard --- Cargo.lock | 27 ++++++ Cargo.toml | 2 +- src/db.rs | 52 ++++++++++- src/entity/mod.rs | 1 + src/entity/soundboard_sounds.rs | 49 ++++++++++ src/handlers.rs | 96 ++++++++++++++++++-- src/migration/m20260224_000005_soundboard.rs | 86 ++++++++++++++++++ src/migration/mod.rs | 2 + src/models.rs | 11 +++ src/voice.rs | 24 +++++ static/app.js | 93 +++++++++++++++++++ static/index.html | 31 +++++++ static/styles.css | 69 ++++++++++++++ 13 files changed, 534 insertions(+), 9 deletions(-) create mode 100644 src/entity/soundboard_sounds.rs create mode 100644 src/migration/m20260224_000005_soundboard.rs diff --git a/Cargo.lock b/Cargo.lock index bcfbb24..190bd84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,6 +113,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -456,6 +457,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1174,6 +1184,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" diff --git a/Cargo.toml b/Cargo.toml index 4d48561..5ab0c14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] anyhow = "1" -axum = { version = "0.8", features = ["macros", "ws"] } +axum = { version = "0.8", features = ["macros", "ws", "multipart"] } chrono = { version = "0.4", features = ["serde"] } dotenvy = "0.15" jsonwebtoken = "9" diff --git a/src/db.rs b/src/db.rs index cd6a234..2e26c94 100644 --- a/src/db.rs +++ b/src/db.rs @@ -8,10 +8,13 @@ use sea_orm::{ use uuid::Uuid; use crate::{ - entity::{channels, direct_messages, guild_members, guilds, invites, messages, users}, + entity::{ + channels, direct_messages, guild_members, guilds, invites, messages, soundboard_sounds, + users, + }, models::{ BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor, - User, + SoundboardSound, User, }, }; @@ -575,3 +578,48 @@ async fn increment_invite_use_count( active.update(txn).await?; Ok(()) } + +pub async fn list_sounds(db: &DatabaseConnection, guild_id: Uuid) -> Result> { + let rows = soundboard_sounds::Entity::find() + .filter(soundboard_sounds::Column::GuildId.eq(guild_id)) + .order_by_asc(soundboard_sounds::Column::CreatedAt) + .all(db) + .await?; + + Ok(rows.into_iter().map(map_sound).collect()) +} + +pub async fn create_sound( + db: &DatabaseConnection, + guild_id: Uuid, + created_by_user_id: Uuid, + name: &str, + icon: &str, + file_path: &str, +) -> Result { + let model = soundboard_sounds::Entity::insert(soundboard_sounds::ActiveModel { + id: Set(Uuid::new_v4()), + guild_id: Set(guild_id), + created_by_user_id: Set(created_by_user_id), + name: Set(name.to_string()), + icon: Set(icon.to_string()), + file_path: Set(file_path.to_string()), + ..Default::default() + }) + .exec_with_returning(db) + .await?; + + Ok(map_sound(model)) +} + +fn map_sound(model: soundboard_sounds::Model) -> SoundboardSound { + SoundboardSound { + id: model.id, + guild_id: model.guild_id, + name: model.name, + icon: model.icon, + file_path: model.file_path, + created_by_user_id: model.created_by_user_id, + created_at: model.created_at, + } +} diff --git a/src/entity/mod.rs b/src/entity/mod.rs index 4c990ec..d1e64f7 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -4,4 +4,5 @@ pub mod guild_members; pub mod guilds; pub mod invites; pub mod messages; +pub mod soundboard_sounds; pub mod users; diff --git a/src/entity/soundboard_sounds.rs b/src/entity/soundboard_sounds.rs new file mode 100644 index 0000000..6e5a23f --- /dev/null +++ b/src/entity/soundboard_sounds.rs @@ -0,0 +1,49 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "soundboard_sounds")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub guild_id: Uuid, + pub name: String, + pub icon: String, + pub file_path: String, + pub created_by_user_id: Uuid, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::guilds::Entity", + from = "Column::GuildId", + to = "super::guilds::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Guilds, + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::CreatedByUserId", + to = "super::users::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + Users, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Guilds.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/handlers.rs b/src/handlers.rs index ffa363b..788688d 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -11,7 +11,9 @@ use uuid::Uuid; use crate::{ AppState, auth::{self, ApiError, AuthUser}, - chat, db, voice, + chat, db, + models::{Guild, SoundboardSound}, + voice, }; pub fn routes() -> Router { @@ -37,6 +39,10 @@ pub fn routes() -> Router { ) .route("/guilds/{guild_id}/invites", post(create_invite)) .route("/invites/{code}/join", post(join_invite)) + .route( + "/guilds/{guild_id}/sounds", + get(list_sounds).post(upload_sound), + ) .route("/channels", post(create_channel)) .route( "/channels/{channel_id}/messages", @@ -399,11 +405,8 @@ async fn join_invite( State(state): State, user: AuthUser, Path(code): Path, -) -> Result { - let guild = db::join_invite(&state.db, &code.to_uppercase(), user.id) - .await - .map_err(|e| ApiError::bad_request(&format!("failed to join invite: {e}")))?; - +) -> Result, ApiError> { + let guild = db::join_invite(&state.db, &code, user.id).await?; Ok(Json(guild)) } @@ -675,6 +678,87 @@ async fn chat_ws( Ok(ws.on_upgrade(move |socket| chat::handle_socket(state, socket, user.id))) } +async fn list_sounds( + State(state): State, + user: AuthUser, + Path(guild_id): Path, +) -> Result>, ApiError> { + ensure_guild_member(&state, guild_id, user.id).await?; + let sounds = db::list_sounds(&state.db, guild_id).await?; + Ok(Json(sounds)) +} + +async fn upload_sound( + State(state): State, + user: AuthUser, + Path(guild_id): Path, + mut multipart: axum::extract::Multipart, +) -> Result, ApiError> { + ensure_guild_member(&state, guild_id, user.id).await?; + + let mut name = None; + let mut icon = None; + let mut file_data = None; + let mut file_name = None; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| ApiError::bad_request(&e.to_string()))? + { + let field_name = field.name().unwrap_or_default().to_string(); + if field_name == "name" { + name = Some( + field + .text() + .await + .map_err(|e| ApiError::bad_request(&e.to_string()))?, + ); + } else if field_name == "icon" { + icon = Some( + field + .text() + .await + .map_err(|e| ApiError::bad_request(&e.to_string()))?, + ); + } else if field_name == "file" { + file_name = Some(field.file_name().unwrap_or("sound.mp3").to_string()); + file_data = Some( + field + .bytes() + .await + .map_err(|e| ApiError::bad_request(&e.to_string()))?, + ); + } + } + + let (name, icon, file_data, file_name) = match (name, icon, file_data, file_name) { + (Some(n), Some(i), Some(d), Some(f)) => (n, i, d, f), + _ => return Err(ApiError::bad_request("missing fields")), + }; + + let extension = std::path::Path::new(&file_name) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("mp3"); + + let safe_file_name = format!("{}.{}", Uuid::new_v4(), extension); + let upload_dir = std::path::Path::new("static/uploads/soundboard"); + tokio::fs::create_dir_all(upload_dir) + .await + .map_err(|e| ApiError::internal(&e.to_string()))?; + + let file_path = upload_dir.join(&safe_file_name); + tokio::fs::write(&file_path, file_data) + .await + .map_err(|e| ApiError::internal(&e.to_string()))?; + + let web_path = format!("/static/uploads/soundboard/{}", safe_file_name); + + let sound = db::create_sound(&state.db, guild_id, user.id, &name, &icon, &web_path).await?; + Ok(Json(sound)) +} + async fn ensure_guild_member( state: &AppState, guild_id: Uuid, diff --git a/src/migration/m20260224_000005_soundboard.rs b/src/migration/m20260224_000005_soundboard.rs new file mode 100644 index 0000000..eef7e4e --- /dev/null +++ b/src/migration/m20260224_000005_soundboard.rs @@ -0,0 +1,86 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(SoundboardSounds::Table) + .if_not_exists() + .col( + ColumnDef::new(SoundboardSounds::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(SoundboardSounds::GuildId).uuid().not_null()) + .col(ColumnDef::new(SoundboardSounds::Name).string().not_null()) + .col(ColumnDef::new(SoundboardSounds::Icon).string().not_null()) + .col( + ColumnDef::new(SoundboardSounds::FilePath) + .string() + .not_null(), + ) + .col( + ColumnDef::new(SoundboardSounds::CreatedByUserId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(SoundboardSounds::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .name("fk_soundboard_sounds_guild") + .from(SoundboardSounds::Table, SoundboardSounds::GuildId) + .to(Guilds::Table, Guilds::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_soundboard_sounds_user") + .from(SoundboardSounds::Table, SoundboardSounds::CreatedByUserId) + .to(Users::Table, Users::Id), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(SoundboardSounds::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum SoundboardSounds { + Table, + Id, + GuildId, + Name, + Icon, + FilePath, + CreatedByUserId, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Guilds { + Table, + Id, +} diff --git a/src/migration/mod.rs b/src/migration/mod.rs index b05f91c..4db4f51 100644 --- a/src/migration/mod.rs +++ b/src/migration/mod.rs @@ -4,6 +4,7 @@ mod m20260213_000001_init; mod m20260213_000002_invites; mod m20260213_000003_channel_kind; mod m20260213_000004_direct_messages; +mod m20260224_000005_soundboard; pub struct Migrator; @@ -15,6 +16,7 @@ impl MigratorTrait for Migrator { Box::new(m20260213_000002_invites::Migration), Box::new(m20260213_000003_channel_kind::Migration), Box::new(m20260213_000004_direct_messages::Migration), + Box::new(m20260224_000005_soundboard::Migration), ] } } diff --git a/src/models.rs b/src/models.rs index 3384ec4..74ac74a 100644 --- a/src/models.rs +++ b/src/models.rs @@ -84,3 +84,14 @@ pub struct Invite { pub max_uses: Option, pub use_count: i32, } + +#[derive(Debug, Clone, Serialize)] +pub struct SoundboardSound { + pub id: Uuid, + pub guild_id: Uuid, + pub name: String, + pub icon: String, + pub file_path: String, + pub created_by_user_id: Uuid, + pub created_at: DateTimeWithTimeZone, +} diff --git a/src/voice.rs b/src/voice.rs index 4ea9825..b15b81c 100644 --- a/src/voice.rs +++ b/src/voice.rs @@ -64,6 +64,10 @@ enum ServerEvent { Error { message: String, }, + PlaySound { + user_id: Uuid, + sound_url: String, + }, } #[derive(Deserialize)] @@ -83,6 +87,9 @@ enum ClientEvent { SetSpeakingStatus { is_speaking: bool, }, + PlaySound { + sound_url: String, + }, } impl VoiceHub { @@ -245,6 +252,20 @@ impl VoiceHub { } } } + + pub async fn play_sound(&self, room_id: Uuid, user_id: Uuid, sound_url: String) { + let rooms = self.rooms.read().await; + let Some(room) = rooms.get(&room_id) else { + return; + }; + + for peer in room.values() { + let _ = peer.tx.send(ServerEvent::PlaySound { + user_id, + sound_url: sound_url.clone(), + }); + } + } } pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, user_id: Uuid) { @@ -305,6 +326,9 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us .set_speaking_status(room_id, user_id, is_speaking) .await; } + Ok(ClientEvent::PlaySound { sound_url }) => { + state.voice.play_sound(room_id, user_id, sound_url).await; + } Err(err) => { let _ = tx.send(ServerEvent::Error { message: format!("invalid voice message: {err}"), diff --git a/static/app.js b/static/app.js index 8f01157..f9aad1a 100644 --- a/static/app.js +++ b/static/app.js @@ -86,6 +86,18 @@ const el = { channelName: document.getElementById("channel-name"), channelModalCancel: document.getElementById("channel-modal-cancel"), + // Sound Board + soundboard: document.getElementById('soundboard'), + soundboardGrid: document.getElementById('soundboard-grid'), + addSoundBtn: document.getElementById('add-sound-btn'), + soundModal: document.getElementById('sound-modal'), + soundForm: document.getElementById('sound-form'), + soundName: document.getElementById('sound-name'), + soundIcon: document.getElementById('sound-icon'), + soundFile: document.getElementById('sound-file'), + soundModalCancel: document.getElementById('sound-modal-cancel'), + soundSubmitBtn: document.getElementById('sound-submit-btn'), + // Mobile mobileMenuBtn: document.getElementById("mobile-menu-btn"), mobileMembersBtn: document.getElementById("mobile-members-btn"), @@ -850,8 +862,10 @@ async function joinVoice() { const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId); el.vcChannelName.textContent = channel ? channel.name : "Voice"; el.voiceConnection.classList.remove("hidden"); + el.soundboard.classList.remove("hidden"); playSound('join'); refreshVoicePresence().catch(() => { }); + loadSounds().catch(() => { }); }; ws.onmessage = async (event) => { @@ -890,12 +904,16 @@ async function joinVoice() { if (videoEl) { videoEl.parentElement.classList.toggle('speaking', msg.is_speaking); } + } else if (msg.type === "play_sound") { + const audio = new Audio(msg.sound_url); + audio.play().catch(console.error); } refreshVoicePresence().catch(() => { }); }; ws.onclose = () => { el.voiceConnection.classList.add("hidden"); + el.soundboard.classList.add("hidden"); for (const pc of state.voice.peerConnections.values()) pc.close(); state.voice.peerConnections.clear(); stopAndClearAudioPipeline(); @@ -1014,6 +1032,41 @@ function toggleWatchVideo() { lucide.createIcons(); } +// --- Sound Board --- + +async function loadSounds() { + if (!state.selectedGuildId) return; + try { + const sounds = await api(`/guilds/${state.selectedGuildId}/sounds`); + renderSounds(sounds); + } catch (err) { + console.error("failed to load sounds", err); + } +} + +function renderSounds(sounds) { + el.soundboardGrid.innerHTML = ''; + sounds.forEach(sound => { + const item = document.createElement('div'); + item.className = 'sound-item'; + item.innerHTML = ` +
${sound.icon}
+
${sound.name}
+ `; + item.onclick = () => playRemoteSound(sound.file_path); + el.soundboardGrid.appendChild(item); + }); +} + +function playRemoteSound(url) { + if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { + state.voice.ws.send(JSON.stringify({ type: 'play_sound', sound_url: url })); + } + // Also play locally immediately + const audio = new Audio(url); + audio.play().catch(console.error); +} + // --- Mobile Logic --- function toggleMobileMenu() { @@ -1192,6 +1245,46 @@ async function init() { el.voiceMuteBtn.onclick = toggleMute; el.voiceLeaveBtn.onclick = leaveVoice; + el.addSoundBtn.onclick = () => { + el.soundModal.classList.remove("hidden"); + }; + el.soundModalCancel.onclick = () => { + el.soundModal.classList.add("hidden"); + }; + + el.soundForm.onsubmit = async (e) => { + e.preventDefault(); + if (!state.selectedGuildId) return; + + const formData = new FormData(); + formData.append('name', el.soundName.value); + formData.append('icon', el.soundIcon.value); + formData.append('file', el.soundFile.files[0]); + + el.soundSubmitBtn.disabled = true; + el.soundSubmitBtn.textContent = 'Uploading...'; + + try { + // Need to use fetch directly because api() might not handle FormData correctly depending on implementation + const response = await fetch(`/guilds/${state.selectedGuildId}/sounds`, { + method: 'POST', + body: formData + }); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || 'Upload failed'); + } + el.soundModal.classList.add("hidden"); + el.soundForm.reset(); + await loadSounds(); + } catch (err) { + alert(err.message); + } finally { + el.soundSubmitBtn.disabled = false; + el.soundSubmitBtn.textContent = 'Add Sound'; + } + }; + try { state.me = await api("/me"); if (state.me && state.me.display_name) { diff --git a/static/index.html b/static/index.html index 5bab6c8..dfa6732 100644 --- a/static/index.html +++ b/static/index.html @@ -91,6 +91,13 @@ +
@@ -204,6 +211,30 @@
+ + diff --git a/static/styles.css b/static/styles.css index c0b681a..8e6fefb 100644 --- a/static/styles.css +++ b/static/styles.css @@ -579,6 +579,75 @@ select { background: var(--green); } +/* Sound Board */ +.soundboard-container { + padding: 12px; + background: var(--bg-secondary); + border-top: 1px solid rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + gap: 8px; +} + +.soundboard-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.soundboard-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 8px; + max-height: 200px; + overflow-y: auto; +} + +.sound-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px; + background: var(--bg-darker); + border-radius: 8px; + cursor: pointer; + transition: transform 0.1s; +} + +.sound-item:hover { + background: var(--bg-modifier-hover); + transform: translateY(-2px); +} + +.sound-item:active { + transform: scale(0.95); +} + +.sound-icon { + font-size: 24px; +} + +.sound-name { + font-size: 11px; + text-align: center; + color: var(--text-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} + +.btn-add-sound { + font-size: 12px; + color: var(--brand); + cursor: pointer; +} + +.btn-add-sound:hover { + text-decoration: underline; +} + /* Chat Pane */ .chat-pane { display: flex;