Merge task: Configure sound into main
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
Reference in New Issue
Block a user