This commit is contained in:
parent
be1451ce45
commit
33da605c03
13 changed files with 534 additions and 9 deletions
27
Cargo.lock
generated
27
Cargo.lock
generated
|
|
@ -113,6 +113,7 @@ dependencies = [
|
||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
|
@ -456,6 +457,15 @@ dependencies = [
|
||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
|
|
@ -1174,6 +1184,23 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
axum = { version = "0.8", features = ["macros", "ws"] }
|
axum = { version = "0.8", features = ["macros", "ws", "multipart"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
|
|
|
||||||
52
src/db.rs
52
src/db.rs
|
|
@ -8,10 +8,13 @@ use sea_orm::{
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
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::{
|
models::{
|
||||||
BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor,
|
BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor,
|
||||||
User,
|
SoundboardSound, User,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -575,3 +578,48 @@ async fn increment_invite_use_count(
|
||||||
active.update(txn).await?;
|
active.update(txn).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_sounds(db: &DatabaseConnection, guild_id: Uuid) -> Result<Vec<SoundboardSound>> {
|
||||||
|
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<SoundboardSound> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,5 @@ pub mod guild_members;
|
||||||
pub mod guilds;
|
pub mod guilds;
|
||||||
pub mod invites;
|
pub mod invites;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
pub mod soundboard_sounds;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
|
||||||
49
src/entity/soundboard_sounds.rs
Normal file
49
src/entity/soundboard_sounds.rs
Normal file
|
|
@ -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<super::guilds::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Guilds.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::users::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Users.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|
@ -11,7 +11,9 @@ use uuid::Uuid;
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
auth::{self, ApiError, AuthUser},
|
auth::{self, ApiError, AuthUser},
|
||||||
chat, db, voice,
|
chat, db,
|
||||||
|
models::{Guild, SoundboardSound},
|
||||||
|
voice,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
|
|
@ -37,6 +39,10 @@ pub fn routes() -> Router<AppState> {
|
||||||
)
|
)
|
||||||
.route("/guilds/{guild_id}/invites", post(create_invite))
|
.route("/guilds/{guild_id}/invites", post(create_invite))
|
||||||
.route("/invites/{code}/join", post(join_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", post(create_channel))
|
||||||
.route(
|
.route(
|
||||||
"/channels/{channel_id}/messages",
|
"/channels/{channel_id}/messages",
|
||||||
|
|
@ -399,11 +405,8 @@ async fn join_invite(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
Path(code): Path<String>,
|
Path(code): Path<String>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<Json<Guild>, ApiError> {
|
||||||
let guild = db::join_invite(&state.db, &code.to_uppercase(), user.id)
|
let guild = db::join_invite(&state.db, &code, user.id).await?;
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError::bad_request(&format!("failed to join invite: {e}")))?;
|
|
||||||
|
|
||||||
Ok(Json(guild))
|
Ok(Json(guild))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -675,6 +678,87 @@ async fn chat_ws(
|
||||||
Ok(ws.on_upgrade(move |socket| chat::handle_socket(state, socket, user.id)))
|
Ok(ws.on_upgrade(move |socket| chat::handle_socket(state, socket, user.id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_sounds(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Path(guild_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Vec<SoundboardSound>>, 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<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Path(guild_id): Path<Uuid>,
|
||||||
|
mut multipart: axum::extract::Multipart,
|
||||||
|
) -> Result<Json<SoundboardSound>, 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(
|
async fn ensure_guild_member(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
guild_id: Uuid,
|
guild_id: Uuid,
|
||||||
|
|
|
||||||
86
src/migration/m20260224_000005_soundboard.rs
Normal file
86
src/migration/m20260224_000005_soundboard.rs
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ mod m20260213_000001_init;
|
||||||
mod m20260213_000002_invites;
|
mod m20260213_000002_invites;
|
||||||
mod m20260213_000003_channel_kind;
|
mod m20260213_000003_channel_kind;
|
||||||
mod m20260213_000004_direct_messages;
|
mod m20260213_000004_direct_messages;
|
||||||
|
mod m20260224_000005_soundboard;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
|
|
@ -15,6 +16,7 @@ impl MigratorTrait for Migrator {
|
||||||
Box::new(m20260213_000002_invites::Migration),
|
Box::new(m20260213_000002_invites::Migration),
|
||||||
Box::new(m20260213_000003_channel_kind::Migration),
|
Box::new(m20260213_000003_channel_kind::Migration),
|
||||||
Box::new(m20260213_000004_direct_messages::Migration),
|
Box::new(m20260213_000004_direct_messages::Migration),
|
||||||
|
Box::new(m20260224_000005_soundboard::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,3 +84,14 @@ pub struct Invite {
|
||||||
pub max_uses: Option<i32>,
|
pub max_uses: Option<i32>,
|
||||||
pub use_count: i32,
|
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,
|
||||||
|
}
|
||||||
|
|
|
||||||
24
src/voice.rs
24
src/voice.rs
|
|
@ -64,6 +64,10 @@ enum ServerEvent {
|
||||||
Error {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
PlaySound {
|
||||||
|
user_id: Uuid,
|
||||||
|
sound_url: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -83,6 +87,9 @@ enum ClientEvent {
|
||||||
SetSpeakingStatus {
|
SetSpeakingStatus {
|
||||||
is_speaking: bool,
|
is_speaking: bool,
|
||||||
},
|
},
|
||||||
|
PlaySound {
|
||||||
|
sound_url: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VoiceHub {
|
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) {
|
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)
|
.set_speaking_status(room_id, user_id, is_speaking)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
Ok(ClientEvent::PlaySound { sound_url }) => {
|
||||||
|
state.voice.play_sound(room_id, user_id, sound_url).await;
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let _ = tx.send(ServerEvent::Error {
|
let _ = tx.send(ServerEvent::Error {
|
||||||
message: format!("invalid voice message: {err}"),
|
message: format!("invalid voice message: {err}"),
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,18 @@ const el = {
|
||||||
channelName: document.getElementById("channel-name"),
|
channelName: document.getElementById("channel-name"),
|
||||||
channelModalCancel: document.getElementById("channel-modal-cancel"),
|
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
|
// Mobile
|
||||||
mobileMenuBtn: document.getElementById("mobile-menu-btn"),
|
mobileMenuBtn: document.getElementById("mobile-menu-btn"),
|
||||||
mobileMembersBtn: document.getElementById("mobile-members-btn"),
|
mobileMembersBtn: document.getElementById("mobile-members-btn"),
|
||||||
|
|
@ -850,8 +862,10 @@ async function joinVoice() {
|
||||||
const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId);
|
const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId);
|
||||||
el.vcChannelName.textContent = channel ? channel.name : "Voice";
|
el.vcChannelName.textContent = channel ? channel.name : "Voice";
|
||||||
el.voiceConnection.classList.remove("hidden");
|
el.voiceConnection.classList.remove("hidden");
|
||||||
|
el.soundboard.classList.remove("hidden");
|
||||||
playSound('join');
|
playSound('join');
|
||||||
refreshVoicePresence().catch(() => { });
|
refreshVoicePresence().catch(() => { });
|
||||||
|
loadSounds().catch(() => { });
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = async (event) => {
|
ws.onmessage = async (event) => {
|
||||||
|
|
@ -890,12 +904,16 @@ async function joinVoice() {
|
||||||
if (videoEl) {
|
if (videoEl) {
|
||||||
videoEl.parentElement.classList.toggle('speaking', msg.is_speaking);
|
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(() => { });
|
refreshVoicePresence().catch(() => { });
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
el.voiceConnection.classList.add("hidden");
|
el.voiceConnection.classList.add("hidden");
|
||||||
|
el.soundboard.classList.add("hidden");
|
||||||
for (const pc of state.voice.peerConnections.values()) pc.close();
|
for (const pc of state.voice.peerConnections.values()) pc.close();
|
||||||
state.voice.peerConnections.clear();
|
state.voice.peerConnections.clear();
|
||||||
stopAndClearAudioPipeline();
|
stopAndClearAudioPipeline();
|
||||||
|
|
@ -1014,6 +1032,41 @@ function toggleWatchVideo() {
|
||||||
lucide.createIcons();
|
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 = `
|
||||||
|
<div class="sound-icon">${sound.icon}</div>
|
||||||
|
<div class="sound-name">${sound.name}</div>
|
||||||
|
`;
|
||||||
|
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 ---
|
// --- Mobile Logic ---
|
||||||
|
|
||||||
function toggleMobileMenu() {
|
function toggleMobileMenu() {
|
||||||
|
|
@ -1192,6 +1245,46 @@ async function init() {
|
||||||
el.voiceMuteBtn.onclick = toggleMute;
|
el.voiceMuteBtn.onclick = toggleMute;
|
||||||
el.voiceLeaveBtn.onclick = leaveVoice;
|
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 {
|
try {
|
||||||
state.me = await api("/me");
|
state.me = await api("/me");
|
||||||
if (state.me && state.me.display_name) {
|
if (state.me && state.me.display_name) {
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,13 @@
|
||||||
<button id="voice-leave-btn" title="Disconnect"><i data-lucide="phone-off"></i></button>
|
<button id="voice-leave-btn" title="Disconnect"><i data-lucide="phone-off"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="soundboard" class="soundboard-container hidden">
|
||||||
|
<div class="soundboard-header">
|
||||||
|
<span>Sound Board</span>
|
||||||
|
<span id="add-sound-btn" class="btn-add-sound">+ Add Sound</span>
|
||||||
|
</div>
|
||||||
|
<div id="soundboard-grid" class="soundboard-grid"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="user-panel">
|
<div class="user-panel">
|
||||||
|
|
@ -204,6 +211,30 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="sound-modal" class="modal-container hidden">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Add Sound</h2>
|
||||||
|
<form id="sound-form" class="modal-form">
|
||||||
|
<div class="form-item">
|
||||||
|
<label for="sound-name">NAME</label>
|
||||||
|
<input id="sound-name" placeholder="Quack" maxlength="32" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<label for="sound-icon">ICON (Emoji or Initials)</label>
|
||||||
|
<input id="sound-icon" placeholder="🦆" maxlength="4" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<label for="sound-file">AUDIO FILE (MP3/WAV)</label>
|
||||||
|
<input id="sound-file" type="file" accept="audio/*" required />
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="cancel-btn" id="sound-modal-cancel">Cancel</button>
|
||||||
|
<button type="submit" class="submit-btn" id="sound-submit-btn">Add Sound</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js" defer></script>
|
<script src="/static/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -579,6 +579,75 @@ select {
|
||||||
background: var(--green);
|
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 */
|
||||||
.chat-pane {
|
.chat-pane {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue