diff --git a/backend/src/app_state.rs b/backend/src/app_state.rs index 63b1d75d..7972b8b3 100644 --- a/backend/src/app_state.rs +++ b/backend/src/app_state.rs @@ -151,4 +151,9 @@ impl AppState { let config = self.config.read().await; config.push_notifications } + + pub async fn get_sound_file(&self) -> crate::models::config::SoundFile { + let config = self.config.read().await; + config.sound_file.clone() + } } diff --git a/backend/src/bin/generate_types.rs b/backend/src/bin/generate_types.rs index e8f271cb..a8ac3c93 100644 --- a/backend/src/bin/generate_types.rs +++ b/backend/src/bin/generate_types.rs @@ -31,6 +31,26 @@ export const EDITOR_LABELS: Record = { "intellij": "IntelliJ IDEA", "zed": "Zed", "custom": "Custom" +}; + +export const SOUND_FILES: SoundFile[] = [ + "abstract-sound1", + "abstract-sound2", + "abstract-sound3", + "abstract-sound4", + "cow-mooing", + "phone-vibration", + "rooster" +]; + +export const SOUND_LABELS: Record = { + "abstract-sound1": "Gentle Chime", + "abstract-sound2": "Soft Bell", + "abstract-sound3": "Digital Tone", + "abstract-sound4": "Subtle Alert", + "cow-mooing": "Cow Mooing", + "phone-vibration": "Phone Vibration", + "rooster": "Rooster Call" };"# .to_string() } @@ -53,6 +73,9 @@ fn main() { vibe_kanban::models::config::EditorConfig::decl(), vibe_kanban::models::config::EditorType::decl(), vibe_kanban::models::config::EditorConstants::decl(), + vibe_kanban::models::config::SoundFile::decl(), + vibe_kanban::models::config::SoundConstants::decl(), + vibe_kanban::routes::config::ConfigConstants::decl(), vibe_kanban::executor::ExecutorConfig::decl(), vibe_kanban::executor::ExecutorConstants::decl(), vibe_kanban::models::project::CreateProject::decl(), diff --git a/backend/src/execution_monitor.rs b/backend/src/execution_monitor.rs index 54d2b87b..0321caf7 100644 --- a/backend/src/execution_monitor.rs +++ b/backend/src/execution_monitor.rs @@ -69,21 +69,23 @@ async fn commit_execution_changes( } /// Play a system sound notification -async fn play_sound_notification() { +async fn play_sound_notification(sound_file: &crate::models::config::SoundFile) { // Use platform-specific sound notification if cfg!(target_os = "macos") { + let sound_path = sound_file.to_path(); let _ = tokio::process::Command::new("afplay") - .arg("/System/Library/Sounds/Glass.aiff") + .arg(sound_path) .spawn(); } else if cfg!(target_os = "linux") { // Try different Linux notification sounds + let sound_path = sound_file.to_path(); if let Ok(_) = tokio::process::Command::new("paplay") - .arg("/usr/share/sounds/alsa/Front_Left.wav") + .arg(&sound_path) .spawn() { // Success with paplay } else if let Ok(_) = tokio::process::Command::new("aplay") - .arg("/usr/share/sounds/alsa/Front_Left.wav") + .arg(&sound_path) .spawn() { // Success with aplay @@ -429,7 +431,8 @@ async fn handle_coding_agent_completion( // Play sound notification if enabled if app_state.get_sound_alerts_enabled().await { - play_sound_notification().await; + let sound_file = app_state.get_sound_file().await; + play_sound_notification(&sound_file).await; } // Send push notification if enabled diff --git a/backend/src/main.rs b/backend/src/main.rs index 72ba38b2..17a9f2bf 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -83,6 +83,40 @@ async fn serve_file(path: &str) -> impl IntoResponse { } } +async fn serve_sound_file(axum::extract::Path(filename): axum::extract::Path) -> impl IntoResponse { + use tokio::fs; + use std::path::Path; + + // Validate filename contains only expected sound files + let valid_sounds = ["abstract-sound1.mp3", "abstract-sound2.mp3", "abstract-sound3.mp3", + "abstract-sound4.mp3", "cow-mooing.mp3", "phone-vibration.mp3", "rooster.mp3"]; + + if !valid_sounds.contains(&filename.as_str()) { + return Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from("Sound file not found")) + .unwrap(); + } + + let sound_path = Path::new("backend/sounds").join(&filename); + + match fs::read(&sound_path).await { + Ok(content) => { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, HeaderValue::from_static("audio/mpeg")) + .body(Body::from(content)) + .unwrap() + } + Err(_) => { + Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from("Sound file not found")) + .unwrap() + } + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt().init(); @@ -130,7 +164,8 @@ async fn main() -> anyhow::Result<()> { .merge(tasks::tasks_router()) .merge(task_attempts::task_attempts_router()) .merge(filesystem::filesystem_router()) - .merge(config::config_router()), + .merge(config::config_router()) + .route("/sounds/:filename", get(serve_sound_file)), ) .layer(Extension(pool.clone())) .layer(Extension(config_arc)); diff --git a/backend/src/models/config.rs b/backend/src/models/config.rs index a72454d3..52bcacf8 100644 --- a/backend/src/models/config.rs +++ b/backend/src/models/config.rs @@ -11,6 +11,7 @@ pub struct Config { pub disclaimer_acknowledged: bool, pub onboarding_acknowledged: bool, pub sound_alerts: bool, + pub sound_file: SoundFile, pub push_notifications: bool, pub editor: EditorConfig, } @@ -43,6 +44,19 @@ pub enum EditorType { Custom, } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "kebab-case")] +pub enum SoundFile { + AbstractSound1, + AbstractSound2, + AbstractSound3, + AbstractSound4, + CowMooing, + PhoneVibration, + Rooster, +} + // Constants for frontend #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] @@ -51,6 +65,13 @@ pub struct EditorConstants { pub editor_labels: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct SoundConstants { + pub sound_files: Vec, + pub sound_labels: Vec, +} + impl EditorConstants { pub fn new() -> Self { Self { @@ -74,6 +95,31 @@ impl EditorConstants { } } +impl SoundConstants { + pub fn new() -> Self { + Self { + sound_files: vec![ + SoundFile::AbstractSound1, + SoundFile::AbstractSound2, + SoundFile::AbstractSound3, + SoundFile::AbstractSound4, + SoundFile::CowMooing, + SoundFile::PhoneVibration, + SoundFile::Rooster, + ], + sound_labels: vec![ + "Gentle Chime".to_string(), + "Soft Bell".to_string(), + "Digital Tone".to_string(), + "Subtle Alert".to_string(), + "Cow Mooing".to_string(), + "Phone Vibration".to_string(), + "Rooster Call".to_string(), + ], + } + } +} + impl Default for Config { fn default() -> Self { Self { @@ -82,6 +128,7 @@ impl Default for Config { disclaimer_acknowledged: false, onboarding_acknowledged: false, sound_alerts: true, + sound_file: SoundFile::AbstractSound4, push_notifications: true, editor: EditorConfig::default(), } @@ -116,6 +163,24 @@ impl EditorConfig { } } +impl SoundFile { + pub fn to_filename(&self) -> &'static str { + match self { + SoundFile::AbstractSound1 => "abstract-sound1.mp3", + SoundFile::AbstractSound2 => "abstract-sound2.mp3", + SoundFile::AbstractSound3 => "abstract-sound3.mp3", + SoundFile::AbstractSound4 => "abstract-sound4.mp3", + SoundFile::CowMooing => "cow-mooing.mp3", + SoundFile::PhoneVibration => "phone-vibration.mp3", + SoundFile::Rooster => "rooster.mp3", + } + } + + pub fn to_path(&self) -> PathBuf { + PathBuf::from("backend/sounds").join(self.to_filename()) + } +} + impl Config { pub fn load(config_path: &PathBuf) -> anyhow::Result { if config_path.exists() { diff --git a/backend/src/routes/config.rs b/backend/src/routes/config.rs index 513db4f0..e891ca49 100644 --- a/backend/src/routes/config.rs +++ b/backend/src/routes/config.rs @@ -7,13 +7,16 @@ use axum::{ use std::sync::Arc; use tokio::sync::RwLock; -use crate::models::{config::Config, ApiResponse}; +use crate::models::{config::{Config, EditorConstants, SoundConstants}, ApiResponse}; use crate::utils; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; pub fn config_router() -> Router { Router::new() .route("/config", get(get_config)) .route("/config", post(update_config)) + .route("/config/constants", get(get_config_constants)) } async fn get_config( @@ -51,3 +54,23 @@ async fn update_config( }), } } + +#[derive(Debug, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ConfigConstants { + pub editor: EditorConstants, + pub sound: SoundConstants, +} + +async fn get_config_constants() -> ResponseJson> { + let constants = ConfigConstants { + editor: EditorConstants::new(), + sound: SoundConstants::new(), + }; + + ResponseJson(ApiResponse { + success: true, + data: Some(constants), + message: Some("Config constants retrieved successfully".to_string()), + }) +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index bf92c66f..76ec5b90 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -6,9 +6,9 @@ import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; -import { Loader2 } from "lucide-react"; -import type { ThemeMode, EditorType } from "shared/types"; -import { EXECUTOR_TYPES, EDITOR_TYPES, EXECUTOR_LABELS, EDITOR_LABELS } from "shared/types"; +import { Loader2, Volume2 } from "lucide-react"; +import type { ThemeMode, EditorType, SoundFile } from "shared/types"; +import { EXECUTOR_TYPES, EDITOR_TYPES, EXECUTOR_LABELS, EDITOR_LABELS, SOUND_FILES, SOUND_LABELS } from "shared/types"; import { useTheme } from "@/components/theme-provider"; import { useConfig } from "@/components/config-provider"; @@ -19,6 +19,15 @@ export function Settings() { const [success, setSuccess] = useState(false); const { setTheme } = useTheme(); + const playSound = async (soundFile: SoundFile) => { + const audio = new Audio(`/api/sounds/${soundFile}.mp3`); + try { + await audio.play(); + } catch (err) { + console.error("Failed to play sound:", err); + } + }; + const handleSave = async () => { if (!config) return; @@ -245,6 +254,40 @@ export function Settings() {

+ + {config.sound_alerts && ( +
+ +
+ + +
+

+ Choose the sound to play when tasks complete. Click the volume button to preview. +

+
+ )}
= { success: boolean, data: T | null, message: string | null, }; -export type Config = { theme: ThemeMode, executor: ExecutorConfig, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, sound_alerts: boolean, push_notifications: boolean, editor: EditorConfig, }; +export type Config = { theme: ThemeMode, executor: ExecutorConfig, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, sound_alerts: boolean, sound_file: SoundFile, push_notifications: boolean, editor: EditorConfig, }; export type ThemeMode = "light" | "dark" | "system"; @@ -14,6 +14,12 @@ export type EditorType = "vscode" | "cursor" | "windsurf" | "intellij" | "zed" | export type EditorConstants = { editor_types: Array, editor_labels: Array, }; +export type SoundFile = "abstract-sound1" | "abstract-sound2" | "abstract-sound3" | "abstract-sound4" | "cow-mooing" | "phone-vibration" | "rooster"; + +export type SoundConstants = { sound_files: Array, sound_labels: Array, }; + +export type ConfigConstants = { editor: EditorConstants, sound: SoundConstants, }; + export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "amp" }; export type ExecutorConstants = { executor_types: Array, executor_labels: Array, }; @@ -111,4 +117,24 @@ export const EDITOR_LABELS: Record = { "intellij": "IntelliJ IDEA", "zed": "Zed", "custom": "Custom" +}; + +export const SOUND_FILES: SoundFile[] = [ + "abstract-sound1", + "abstract-sound2", + "abstract-sound3", + "abstract-sound4", + "cow-mooing", + "phone-vibration", + "rooster" +]; + +export const SOUND_LABELS: Record = { + "abstract-sound1": "Gentle Chime", + "abstract-sound2": "Soft Bell", + "abstract-sound3": "Digital Tone", + "abstract-sound4": "Subtle Alert", + "cow-mooing": "Cow Mooing", + "phone-vibration": "Phone Vibration", + "rooster": "Rooster Call" }; \ No newline at end of file