Merge task: Configure sound into main

This commit is contained in:
Louis Knight-Webb
2025-06-24 11:01:28 +01:00
8 changed files with 234 additions and 11 deletions

View File

@@ -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()
}
}

View File

@@ -31,6 +31,26 @@ export const EDITOR_LABELS: Record<string, string> = {
"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<string, string> = {
"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(),

View File

@@ -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

View File

@@ -83,6 +83,40 @@ async fn serve_file(path: &str) -> impl IntoResponse {
}
}
async fn serve_sound_file(axum::extract::Path(filename): axum::extract::Path<String>) -> 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));

View File

@@ -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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct SoundConstants {
pub sound_files: Vec<SoundFile>,
pub sound_labels: Vec<String>,
}
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<Self> {
if config_path.exists() {

View File

@@ -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<ApiResponse<ConfigConstants>> {
let constants = ConfigConstants {
editor: EditorConstants::new(),
sound: SoundConstants::new(),
};
ResponseJson(ApiResponse {
success: true,
data: Some(constants),
message: Some("Config constants retrieved successfully".to_string()),
})
}

View File

@@ -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() {
</p>
</div>
</div>
{config.sound_alerts && (
<div className="space-y-2 ml-6">
<Label htmlFor="sound-file">Sound</Label>
<div className="flex items-center gap-2">
<Select
value={config.sound_file}
onValueChange={(value: SoundFile) => updateConfig({ sound_file: value })}
>
<SelectTrigger id="sound-file" className="flex-1">
<SelectValue placeholder="Select sound" />
</SelectTrigger>
<SelectContent>
{SOUND_FILES.map((soundFile) => (
<SelectItem key={soundFile} value={soundFile}>
{SOUND_LABELS[soundFile]}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => playSound(config.sound_file)}
className="px-3"
>
<Volume2 className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
Choose the sound to play when tasks complete. Click the volume button to preview.
</p>
</div>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="push-notifications"

View File

@@ -4,7 +4,7 @@
export type ApiResponse<T> = { 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<EditorType>, editor_labels: Array<string>, };
export type SoundFile = "abstract-sound1" | "abstract-sound2" | "abstract-sound3" | "abstract-sound4" | "cow-mooing" | "phone-vibration" | "rooster";
export type SoundConstants = { sound_files: Array<SoundFile>, sound_labels: Array<string>, };
export type ConfigConstants = { editor: EditorConstants, sound: SoundConstants, };
export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "amp" };
export type ExecutorConstants = { executor_types: Array<ExecutorConfig>, executor_labels: Array<string>, };
@@ -111,4 +117,24 @@ export const EDITOR_LABELS: Record<string, string> = {
"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<string, string> = {
"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"
};