diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 0de8a79d347..55663a86558 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -6,6 +6,7 @@ import { createExternalRouter } from "./external"; import { createMenuRouter } from "./menu"; import { createNotificationsRouter } from "./notifications"; import { createProjectsRouter } from "./projects"; +import { createRingtoneRouter } from "./ringtone"; import { createSettingsRouter } from "./settings"; import { createTerminalRouter } from "./terminal"; import { createUiStateRouter } from "./ui-state"; @@ -32,6 +33,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { settings: createSettingsRouter(), config: createConfigRouter(), uiState: createUiStateRouter(), + ringtone: createRingtoneRouter(), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts new file mode 100644 index 00000000000..ea41aba3612 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts @@ -0,0 +1,177 @@ +import type { ChildProcess } from "node:child_process"; +import { execFile } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { app } from "electron"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +/** + * Track current playing session to handle race conditions. + * Each play operation gets a unique session ID. When stop is called, + * the session is invalidated so any pending fallback processes won't start. + */ +let currentSession: { + id: number; + process: ChildProcess | null; +} | null = null; +let nextSessionId = 0; + +/** + * Gets the path to a ringtone sound file. + * In development, reads from src/resources. In production, reads from the bundled resources. + */ +function getRingtonePath(filename: string): string { + const isDev = !app.isPackaged; + + if (isDev) { + return join(app.getAppPath(), "src/resources/sounds", filename); + } + return join(process.resourcesPath, "resources/sounds", filename); +} + +/** + * Gets the directory containing ringtone files + */ +function getRingtonesDirectory(): string { + const isDev = !app.isPackaged; + + if (isDev) { + return join(app.getAppPath(), "src/resources/sounds"); + } + return join(process.resourcesPath, "resources/sounds"); +} + +/** + * Stops the currently playing sound and invalidates the session + */ +function stopCurrentSound(): void { + if (currentSession) { + if (currentSession.process) { + // Use SIGKILL for immediate termination (afplay doesn't always respond to SIGTERM) + currentSession.process.kill("SIGKILL"); + } + currentSession = null; + } +} + +/** + * Plays a sound file using platform-specific commands. + * Uses session tracking to prevent race conditions with fallback audio players. + */ +function playSoundFile(soundPath: string): void { + if (!existsSync(soundPath)) { + console.warn(`[ringtone] Sound file not found: ${soundPath}`); + return; + } + + // Stop any currently playing sound first + stopCurrentSound(); + + // Create a new session for this play operation + const sessionId = nextSessionId++; + currentSession = { id: sessionId, process: null }; + + if (process.platform === "darwin") { + currentSession.process = execFile("afplay", [soundPath], () => { + // Only clear if this session is still active + if (currentSession?.id === sessionId) { + currentSession = null; + } + }); + } else if (process.platform === "win32") { + currentSession.process = execFile( + "powershell", + ["-c", `(New-Object Media.SoundPlayer '${soundPath}').PlaySync()`], + () => { + if (currentSession?.id === sessionId) { + currentSession = null; + } + }, + ); + } else { + // Linux - try common audio players with race-safe fallback + currentSession.process = execFile("paplay", [soundPath], (error) => { + // Check if this session is still active before proceeding + if (currentSession?.id !== sessionId) { + return; // Session was stopped, don't start fallback + } + + if (error) { + // paplay failed, try aplay as fallback + currentSession.process = execFile("aplay", [soundPath], () => { + if (currentSession?.id === sessionId) { + currentSession = null; + } + }); + } else { + currentSession = null; + } + }); + } +} + +/** + * Ringtone router for audio preview and playback operations + */ +export const createRingtoneRouter = () => { + return router({ + /** + * Preview a ringtone sound by filename + */ + preview: publicProcedure + .input(z.object({ filename: z.string() })) + .mutation(({ input }) => { + // Handle "none" case - no sound + if (!input.filename || input.filename === "") { + return { success: true as const }; + } + + const soundPath = getRingtonePath(input.filename); + playSoundFile(soundPath); + return { success: true as const }; + }), + + /** + * Stop the currently playing ringtone preview + */ + stop: publicProcedure.mutation(() => { + stopCurrentSound(); + return { success: true as const }; + }), + + /** + * Get the list of available ringtone files from the sounds directory + */ + list: publicProcedure.query(() => { + const ringtonesDir = getRingtonesDirectory(); + const files: string[] = []; + + // Add ringtones from the sounds directory if it exists + if (existsSync(ringtonesDir)) { + const dirFiles = readdirSync(ringtonesDir).filter( + (file) => + file.endsWith(".mp3") || + file.endsWith(".wav") || + file.endsWith(".ogg"), + ); + files.push(...dirFiles); + } + + return files; + }), + }); +}; + +/** + * Plays the notification sound based on the selected ringtone. + * This is used by the notification system. + */ +export function playNotificationRingtone(filename: string): void { + if (!filename || filename === "") { + return; // No sound for "none" option + } + + const soundPath = getRingtonePath(filename); + playSoundFile(soundPath); +} diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index cfc1097e59a..0007907dbe6 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -1,8 +1,12 @@ import { db } from "main/lib/db"; import { nanoid } from "nanoid"; +import { DEFAULT_RINGTONE_ID, RINGTONES } from "shared/ringtones"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +/** Valid ringtone IDs for validation */ +const VALID_RINGTONE_IDS = RINGTONES.map((r) => r.id); + export const createSettingsRouter = () => { return router({ getLastUsedApp: publicProcedure.query(() => { @@ -76,6 +80,44 @@ export const createSettingsRouter = () => { ); }); + return { success: true }; + }), + + getSelectedRingtoneId: publicProcedure.query(async () => { + const storedId = db.data.settings.selectedRingtoneId; + + // If no stored ID, return default + if (!storedId) { + return DEFAULT_RINGTONE_ID; + } + + // If stored ID is valid, return it + if (VALID_RINGTONE_IDS.includes(storedId)) { + return storedId; + } + + // Stored ID is invalid/stale - self-heal by persisting the default + console.warn( + `[settings] Invalid ringtone ID "${storedId}" found, resetting to default`, + ); + await db.update((data) => { + data.settings.selectedRingtoneId = DEFAULT_RINGTONE_ID; + }); + return DEFAULT_RINGTONE_ID; + }), + + setSelectedRingtoneId: publicProcedure + .input(z.object({ ringtoneId: z.string() })) + .mutation(async ({ input }) => { + // Validate ringtone ID exists + if (!VALID_RINGTONE_IDS.includes(input.ringtoneId)) { + throw new Error(`Invalid ringtone ID: ${input.ringtoneId}`); + } + + await db.update((data) => { + data.settings.selectedRingtoneId = input.ringtoneId; + }); + return { success: true }; }), }); diff --git a/apps/desktop/src/main/lib/db/schemas.ts b/apps/desktop/src/main/lib/db/schemas.ts index 91cb77db130..81bf2053c75 100644 --- a/apps/desktop/src/main/lib/db/schemas.ts +++ b/apps/desktop/src/main/lib/db/schemas.ts @@ -107,6 +107,7 @@ export interface Settings { lastActiveWorkspaceId?: string; lastUsedApp?: ExternalApp; terminalPresets?: TerminalPreset[]; + selectedRingtoneId?: string; } export interface Database { diff --git a/apps/desktop/src/main/lib/notification-sound.ts b/apps/desktop/src/main/lib/notification-sound.ts index c858be84a53..08ce0b92757 100644 --- a/apps/desktop/src/main/lib/notification-sound.ts +++ b/apps/desktop/src/main/lib/notification-sound.ts @@ -2,28 +2,53 @@ import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { app } from "electron"; +import { + DEFAULT_RINGTONE_ID, + getRingtoneFilename, +} from "../../shared/ringtones"; +import { db } from "./db"; /** - * Gets the path to the notification sound file. + * Gets the path to a ringtone sound file. * In development, reads from src/resources. In production, reads from the bundled resources. */ -function getNotificationSoundPath(): string { +function getRingtonePath(filename: string): string { const isDev = !app.isPackaged; if (isDev) { - return join(app.getAppPath(), "src/resources/sounds/arcade.mp3"); + return join(app.getAppPath(), "src/resources/sounds", filename); } - - return join(process.resourcesPath, "resources/sounds/arcade.mp3"); + return join(process.resourcesPath, "resources/sounds", filename); } /** - * Plays the custom notification sound. - * Uses platform-specific commands to play the audio file. + * Gets the selected ringtone filename from the database. + * Falls back to default ringtone if the stored ID is invalid/stale. */ -export function playNotificationSound(): void { - const soundPath = getNotificationSoundPath(); +function getSelectedRingtoneFilename(): string { + const defaultFilename = getRingtoneFilename(DEFAULT_RINGTONE_ID); + + try { + const selectedId = + db.data.settings.selectedRingtoneId ?? DEFAULT_RINGTONE_ID; + + // "none" means silent - return empty string intentionally + if (selectedId === "none") { + return ""; + } + const filename = getRingtoneFilename(selectedId); + // Fall back to default if stored ID is stale/unknown + return filename || defaultFilename; + } catch { + return defaultFilename; + } +} + +/** + * Plays a sound file using platform-specific commands + */ +function playSoundFile(soundPath: string): void { if (!existsSync(soundPath)) { console.warn(`[notification-sound] Sound file not found: ${soundPath}`); return; @@ -45,3 +70,19 @@ export function playNotificationSound(): void { }); } } + +/** + * Plays the notification sound based on user's selected ringtone. + * Uses platform-specific commands to play the audio file. + */ +export function playNotificationSound(): void { + const filename = getSelectedRingtoneFilename(); + + // No sound if "none" is selected + if (!filename) { + return; + } + + const soundPath = getRingtonePath(filename); + playSoundFile(soundPath); +} diff --git a/apps/desktop/src/renderer/lib/trpc-storage.ts b/apps/desktop/src/renderer/lib/trpc-storage.ts index d91e47be3c7..0d855eca06f 100644 --- a/apps/desktop/src/renderer/lib/trpc-storage.ts +++ b/apps/desktop/src/renderer/lib/trpc-storage.ts @@ -60,3 +60,23 @@ export const trpcThemeStorage = createJSONStorage(() => set: (input) => trpcClient.uiState.theme.set.mutate(input as any), }), ); + +/** + * Zustand storage adapter for ringtone state using tRPC. + * Only the selectedRingtoneId is persisted. + */ +export const trpcRingtoneStorage = createJSONStorage(() => + createTrpcStorageAdapter({ + get: async () => { + const ringtoneId = + await trpcClient.settings.getSelectedRingtoneId.query(); + return { selectedRingtoneId: ringtoneId }; + }, + set: async (input) => { + const state = input as { selectedRingtoneId: string }; + await trpcClient.settings.setSelectedRingtoneId.mutate({ + ringtoneId: state.selectedRingtoneId, + }); + }, + }), +); diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/RingtonesSettings/RingtonesSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/RingtonesSettings/RingtonesSettings.tsx new file mode 100644 index 00000000000..85d4d7fee61 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/RingtonesSettings/RingtonesSettings.tsx @@ -0,0 +1,267 @@ +import { cn } from "@superset/ui/utils"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { HiBellSlash, HiCheck, HiPlay, HiStop } from "react-icons/hi2"; +import { trpcClient } from "renderer/lib/trpc-client"; +import { + AVAILABLE_RINGTONES, + type Ringtone, + useSelectedRingtoneId, + useSetRingtone, +} from "renderer/stores"; + +function formatDuration(seconds: number): string { + return `${seconds}s`; +} + +interface RingtoneCardProps { + ringtone: Ringtone; + isSelected: boolean; + isPlaying: boolean; + onSelect: () => void; + onTogglePlay: () => void; +} + +function RingtoneCard({ + ringtone, + isSelected, + isPlaying, + onSelect, + onTogglePlay, +}: RingtoneCardProps) { + const isSilent = ringtone.id === "none"; + + // Silent card has a distinct style + if (isSilent) { + return ( + + ); + } + + return ( + // biome-ignore lint/a11y/useSemanticElements: Using div with role="button" to allow nested play/stop button +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(); + } + }} + className={cn( + "relative flex flex-col rounded-lg border-2 overflow-hidden transition-all text-left cursor-pointer", + isSelected + ? "border-primary ring-2 ring-primary/20" + : "border-border hover:border-muted-foreground/50", + )} + > + {/* Preview area */} +
+ {/* Emoji */} + {ringtone.emoji} + + {/* Duration badge */} + {ringtone.duration && ( + + {formatDuration(ringtone.duration)} + + )} + + {/* Play/Stop button */} + +
+ + {/* Info */} +
+
+
{ringtone.name}
+
+ {ringtone.description} +
+
+ {isSelected && ( +
+ +
+ )} +
+
+ ); +} + +export function RingtonesSettings() { + const selectedRingtoneId = useSelectedRingtoneId(); + const setRingtone = useSetRingtone(); + const [playingId, setPlayingId] = useState(null); + const previewTimerRef = useRef | null>(null); + + // Clean up timer and stop any playing sound on unmount + useEffect(() => { + return () => { + if (previewTimerRef.current) { + clearTimeout(previewTimerRef.current); + } + // Stop any in-progress preview when navigating away + trpcClient.ringtone.stop.mutate().catch(() => { + // Ignore errors during cleanup + }); + }; + }, []); + + const handleTogglePlay = useCallback( + async (ringtone: Ringtone) => { + if (ringtone.id === "none" || !ringtone.filename) { + return; + } + + // Clear any pending timer + if (previewTimerRef.current) { + clearTimeout(previewTimerRef.current); + previewTimerRef.current = null; + } + + // If this ringtone is already playing, stop it + if (playingId === ringtone.id) { + try { + await trpcClient.ringtone.stop.mutate(); + } catch (error) { + console.error("Failed to stop ringtone:", error); + } + setPlayingId(null); + return; + } + + // Stop any currently playing sound first + try { + await trpcClient.ringtone.stop.mutate(); + } catch (error) { + console.error("Failed to stop ringtone:", error); + } + + // Play the new sound + setPlayingId(ringtone.id); + + try { + await trpcClient.ringtone.preview.mutate({ + filename: ringtone.filename, + }); + } catch (error) { + console.error("Failed to play ringtone:", error); + setPlayingId(null); + } + + // Auto-reset after the ringtone's actual duration (with 500ms buffer) + const durationMs = ((ringtone.duration ?? 5) + 0.5) * 1000; + previewTimerRef.current = setTimeout(() => { + setPlayingId((current) => (current === ringtone.id ? null : current)); + previewTimerRef.current = null; + }, durationMs); + }, + [playingId], + ); + + const handleSelect = useCallback( + (ringtoneId: string) => { + setRingtone(ringtoneId); + }, + [setRingtone], + ); + + return ( +
+
+

Ringtones

+

+ Choose the notification sound for completed tasks +

+
+ +
+ {/* Ringtone Section */} +
+

Notification Sound

+
+ {AVAILABLE_RINGTONES.map((ringtone) => ( + handleSelect(ringtone.id)} + onTogglePlay={() => handleTogglePlay(ringtone)} + /> + ))} +
+
+ + {/* Tip */} +
+

+ Click the play button to preview a sound. Click stop or play another + to stop the current sound. +

+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/RingtonesSettings/index.ts b/apps/desktop/src/renderer/screens/main/components/SettingsView/RingtonesSettings/index.ts new file mode 100644 index 00000000000..07f05ce9abc --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/RingtonesSettings/index.ts @@ -0,0 +1 @@ +export { RingtonesSettings } from "./RingtonesSettings"; diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx index 947c1c00b81..b5c85608158 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx @@ -3,6 +3,7 @@ import { AppearanceSettings } from "./AppearanceSettings"; import { KeyboardShortcutsSettings } from "./KeyboardShortcutsSettings"; import { PresetsSettings } from "./PresetsSettings"; import { ProjectSettings } from "./ProjectSettings"; +import { RingtonesSettings } from "./RingtonesSettings"; import { WorkspaceSettings } from "./WorkspaceSettings"; interface SettingsContentProps { @@ -15,6 +16,7 @@ export function SettingsContent({ activeSection }: SettingsContentProps) { {activeSection === "project" && } {activeSection === "workspace" && } {activeSection === "appearance" && } + {activeSection === "ringtones" && } {activeSection === "keyboard" && } {activeSection === "presets" && } diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx index c51082c02f3..afa75ffb669 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx @@ -1,5 +1,6 @@ import { cn } from "@superset/ui/utils"; import { + HiOutlineBell, HiOutlineCog6Tooth, HiOutlineCommandLine, HiOutlinePaintBrush, @@ -21,6 +22,11 @@ const GENERAL_SECTIONS: { label: "Appearance", icon: , }, + { + id: "ringtones", + label: "Ringtones", + icon: , + }, { id: "keyboard", label: "Keyboard Shortcuts", diff --git a/apps/desktop/src/renderer/stores/app-state.ts b/apps/desktop/src/renderer/stores/app-state.ts index d5291d2889e..1f7890321b1 100644 --- a/apps/desktop/src/renderer/stores/app-state.ts +++ b/apps/desktop/src/renderer/stores/app-state.ts @@ -7,7 +7,8 @@ export type SettingsSection = | "workspace" | "appearance" | "keyboard" - | "presets"; + | "presets" + | "ringtones"; interface AppState { currentView: AppView; diff --git a/apps/desktop/src/renderer/stores/index.ts b/apps/desktop/src/renderer/stores/index.ts index eeef9957e68..f27ff6aefdc 100644 --- a/apps/desktop/src/renderer/stores/index.ts +++ b/apps/desktop/src/renderer/stores/index.ts @@ -5,6 +5,7 @@ */ export * from "./app-state"; +export * from "./ringtone"; export * from "./sidebar-state"; export * from "./tabs"; // Now exports from tabs/index.ts export * from "./theme"; diff --git a/apps/desktop/src/renderer/stores/ringtone/index.ts b/apps/desktop/src/renderer/stores/ringtone/index.ts new file mode 100644 index 00000000000..f5990c259bc --- /dev/null +++ b/apps/desktop/src/renderer/stores/ringtone/index.ts @@ -0,0 +1 @@ +export * from "./store"; diff --git a/apps/desktop/src/renderer/stores/ringtone/store.ts b/apps/desktop/src/renderer/stores/ringtone/store.ts new file mode 100644 index 00000000000..52fa6c8cab4 --- /dev/null +++ b/apps/desktop/src/renderer/stores/ringtone/store.ts @@ -0,0 +1,103 @@ +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; +import { + DEFAULT_RINGTONE_ID, + RINGTONES, + type RingtoneData, +} from "../../../shared/ringtones"; +import { trpcRingtoneStorage } from "../../lib/trpc-storage"; + +// Re-export shared types and data for convenience +export type Ringtone = RingtoneData; +export const AVAILABLE_RINGTONES = RINGTONES; +export { DEFAULT_RINGTONE_ID }; + +interface RingtoneState { + /** Current selected ringtone ID */ + selectedRingtoneId: string; + + /** Set the active ringtone by ID */ + setRingtone: (ringtoneId: string) => void; + + /** Get the currently selected ringtone (always returns valid ringtone, falls back to default) */ + getSelectedRingtone: () => Ringtone; +} + +/** Check if a ringtone ID is valid */ +function isValidRingtoneId(id: string): boolean { + return AVAILABLE_RINGTONES.some((r) => r.id === id); +} + +/** Get default ringtone (guaranteed to exist) */ +function getDefaultRingtone(): Ringtone { + const ringtone = AVAILABLE_RINGTONES.find( + (r) => r.id === DEFAULT_RINGTONE_ID, + ); + if (!ringtone) { + throw new Error(`Default ringtone "${DEFAULT_RINGTONE_ID}" not found`); + } + return ringtone; +} + +export const useRingtoneStore = create()( + devtools( + persist( + (set, get) => ({ + selectedRingtoneId: DEFAULT_RINGTONE_ID, + + setRingtone: (ringtoneId: string) => { + const ringtone = AVAILABLE_RINGTONES.find((r) => r.id === ringtoneId); + if (!ringtone) { + console.error(`Ringtone not found: ${ringtoneId}`); + return; + } + set({ selectedRingtoneId: ringtoneId }); + }, + + getSelectedRingtone: () => { + const state = get(); + const ringtone = AVAILABLE_RINGTONES.find( + (r) => r.id === state.selectedRingtoneId, + ); + // Fall back to default if persisted ID is stale/invalid + if (!ringtone) { + set({ selectedRingtoneId: DEFAULT_RINGTONE_ID }); + return getDefaultRingtone(); + } + return ringtone; + }, + }), + { + name: "ringtone-storage", + storage: trpcRingtoneStorage, + partialize: (state) => ({ + selectedRingtoneId: state.selectedRingtoneId, + }), + onRehydrateStorage: () => (state) => { + // Validate persisted ringtone ID on rehydration + if (state && !isValidRingtoneId(state.selectedRingtoneId)) { + console.warn( + `[RingtoneStore] Invalid ringtone ID "${state.selectedRingtoneId}", resetting to default`, + ); + state.selectedRingtoneId = DEFAULT_RINGTONE_ID; + } + }, + }, + ), + { name: "RingtoneStore" }, + ), +); + +// Convenience hooks +export const useSelectedRingtoneId = () => + useRingtoneStore((state) => state.selectedRingtoneId); +export const useSetRingtone = () => + useRingtoneStore((state) => state.setRingtone); +export const useSelectedRingtone = () => + useRingtoneStore((state) => { + const ringtone = AVAILABLE_RINGTONES.find( + (r) => r.id === state.selectedRingtoneId, + ); + // Fall back to default if ID is invalid + return ringtone ?? getDefaultRingtone(); + }); diff --git a/apps/desktop/src/resources/sounds/agentisdonewoman.mp3 b/apps/desktop/src/resources/sounds/agentisdonewoman.mp3 new file mode 100644 index 00000000000..da37f0b5e91 Binary files /dev/null and b/apps/desktop/src/resources/sounds/agentisdonewoman.mp3 differ diff --git a/apps/desktop/src/resources/sounds/codecompleteafrican.mp3 b/apps/desktop/src/resources/sounds/codecompleteafrican.mp3 new file mode 100644 index 00000000000..e0aa187851a Binary files /dev/null and b/apps/desktop/src/resources/sounds/codecompleteafrican.mp3 differ diff --git a/apps/desktop/src/resources/sounds/codecompleteafrobeat.mp3 b/apps/desktop/src/resources/sounds/codecompleteafrobeat.mp3 new file mode 100644 index 00000000000..3e627a87be8 Binary files /dev/null and b/apps/desktop/src/resources/sounds/codecompleteafrobeat.mp3 differ diff --git a/apps/desktop/src/resources/sounds/codecompleteedm.mp3 b/apps/desktop/src/resources/sounds/codecompleteedm.mp3 new file mode 100644 index 00000000000..85a370c65cb Binary files /dev/null and b/apps/desktop/src/resources/sounds/codecompleteedm.mp3 differ diff --git a/apps/desktop/src/resources/sounds/comebacktothecode.mp3 b/apps/desktop/src/resources/sounds/comebacktothecode.mp3 new file mode 100644 index 00000000000..75969f8c3b1 Binary files /dev/null and b/apps/desktop/src/resources/sounds/comebacktothecode.mp3 differ diff --git a/apps/desktop/src/resources/sounds/shabalabadingdong.mp3 b/apps/desktop/src/resources/sounds/shabalabadingdong.mp3 new file mode 100644 index 00000000000..ca56b1900ae Binary files /dev/null and b/apps/desktop/src/resources/sounds/shabalabadingdong.mp3 differ diff --git a/apps/desktop/src/resources/sounds/supersetdoowap.mp3 b/apps/desktop/src/resources/sounds/supersetdoowap.mp3 new file mode 100644 index 00000000000..d29a8662779 Binary files /dev/null and b/apps/desktop/src/resources/sounds/supersetdoowap.mp3 differ diff --git a/apps/desktop/src/resources/sounds/supersetquick.mp3 b/apps/desktop/src/resources/sounds/supersetquick.mp3 new file mode 100644 index 00000000000..01729118099 Binary files /dev/null and b/apps/desktop/src/resources/sounds/supersetquick.mp3 differ diff --git a/apps/desktop/src/shared/ringtones.ts b/apps/desktop/src/shared/ringtones.ts new file mode 100644 index 00000000000..4f6c0ce087e --- /dev/null +++ b/apps/desktop/src/shared/ringtones.ts @@ -0,0 +1,129 @@ +/** + * Shared ringtone data used by both main and renderer processes. + * This is the single source of truth for ringtone metadata. + */ + +export interface RingtoneData { + id: string; + name: string; + description: string; + filename: string; + emoji: string; + color: string; + /** Duration in seconds */ + duration?: number; +} + +/** + * Built-in ringtones available in the app. + * Files are located in src/resources/sounds/ + */ +export const RINGTONES: RingtoneData[] = [ + { + id: "default", + name: "Classic", + description: "Simple & clean", + filename: "notification.mp3", + emoji: "🔔", + color: "from-slate-500 to-slate-600", + duration: 1, + }, + { + id: "quick", + name: "Quick Ping", + description: "Short & sweet", + filename: "supersetquick.mp3", + emoji: "⚡", + color: "from-yellow-400 to-orange-500", + duration: 3, + }, + { + id: "doowap", + name: "Doo-Wap", + description: "Retro vibes", + filename: "supersetdoowap.mp3", + emoji: "🎷", + color: "from-purple-500 to-pink-500", + duration: 10, + }, + { + id: "woman", + name: "Agent Complete", + description: "Your agent is done!", + filename: "agentisdonewoman.mp3", + emoji: "👩‍💻", + color: "from-cyan-400 to-blue-500", + duration: 8, + }, + { + id: "african", + name: "African Beats", + description: "World music energy", + filename: "codecompleteafrican.mp3", + emoji: "🌍", + color: "from-amber-500 to-red-500", + duration: 9, + }, + { + id: "afrobeat", + name: "Afrobeat", + description: "Groovy celebration", + filename: "codecompleteafrobeat.mp3", + emoji: "🥁", + color: "from-green-400 to-emerald-600", + duration: 9, + }, + { + id: "edm", + name: "EDM Drop", + description: "Bass goes brrrr", + filename: "codecompleteedm.mp3", + emoji: "🎧", + color: "from-violet-500 to-fuchsia-500", + duration: 56, + }, + { + id: "comeback", + name: "Come Back!", + description: "Code needs you", + filename: "comebacktothecode.mp3", + emoji: "📢", + color: "from-rose-400 to-red-500", + duration: 7, + }, + { + id: "shabala", + name: "Shabalaba", + description: "Ding dong vibes", + filename: "shabalabadingdong.mp3", + emoji: "🎉", + color: "from-indigo-400 to-purple-600", + duration: 7, + }, + { + id: "none", + name: "Silent", + description: "Notifications without sound", + filename: "", + emoji: "🔇", + color: "from-gray-400 to-gray-500", + }, +]; + +export const DEFAULT_RINGTONE_ID = "default"; + +/** + * Get a ringtone by ID + */ +export function getRingtoneById(id: string): RingtoneData | undefined { + return RINGTONES.find((r) => r.id === id); +} + +/** + * Get the filename for a ringtone ID. + * Returns empty string for "none" (silent) or if not found. + */ +export function getRingtoneFilename(id: string): string { + const ringtone = getRingtoneById(id); + return ringtone?.filename ?? ""; +} diff --git a/apps/desktop/test-setup.ts b/apps/desktop/test-setup.ts index 2a90f26fdf6..ca1768df626 100644 --- a/apps/desktop/test-setup.ts +++ b/apps/desktop/test-setup.ts @@ -48,21 +48,6 @@ const mockClassList = new Set(); // Electron Preload Mocks (exposed via contextBridge in real app) // ============================================================================= -const mockStorage = new Map(); - -global.window = { - electronStore: { - get: async (key: string) => mockStorage.get(key) || null, - set: async (key: string, value: string) => { - mockStorage.set(key, value); - }, - delete: async (key: string) => { - mockStorage.delete(key); - }, - }, - // biome-ignore lint/suspicious/noExplicitAny: Test setup requires partial window mock -} as any; - // trpc-electron expects this global for renderer-side communication // biome-ignore lint/suspicious/noExplicitAny: Test setup requires extending globalThis (globalThis as any).electronTRPC = {