mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 16:02:42 +00:00
feat: add notification sound volume slider (#2391)
* feat: add notification sound volume slider Custom audio files vary widely in loudness; the new 0-100% slider in Settings → Notifications lets the user lower an over-amplified sound without re-encoding the file. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: harden notification sound volume setting --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
Neil
parent
e49bb2a175
commit
cbf2a52e5d
@@ -1,10 +1,11 @@
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { Button } from '../ui/button'
|
||||
import { Label } from '../ui/label'
|
||||
import { Separator } from '../ui/separator'
|
||||
import { BellRing, Bot, FileAudio, Siren, X } from 'lucide-react'
|
||||
import { Slider } from '../ui/slider'
|
||||
import { BellRing, Bot, FileAudio, Siren, Volume2, X } from 'lucide-react'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
import { basename } from '@/lib/path'
|
||||
|
||||
@@ -35,6 +36,11 @@ export const NOTIFICATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
'Choose one local audio file (MP3, WAV, OGG, M4A, AAC, or FLAC) for all delivered desktop notifications.',
|
||||
keywords: ['notifications', 'sound', 'audio', 'mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac']
|
||||
},
|
||||
{
|
||||
title: 'Notification Volume',
|
||||
description: 'Playback volume for the custom notification sound.',
|
||||
keywords: ['notifications', 'sound', 'volume', 'loudness']
|
||||
},
|
||||
{
|
||||
title: 'Send Test Notification',
|
||||
description: 'Trigger a sample desktop notification using the native delivery path.',
|
||||
@@ -71,17 +77,33 @@ export function NotificationsPane({
|
||||
updateSettings
|
||||
}: NotificationsPaneProps): React.JSX.Element {
|
||||
const notificationSettings = settings.notifications
|
||||
const notificationSettingsRef = useRef(notificationSettings)
|
||||
const [isPickingSound, setIsPickingSound] = useState(false)
|
||||
|
||||
const updateNotificationSettings = (updates: Partial<GlobalSettings['notifications']>): void => {
|
||||
updateSettings({
|
||||
notifications: {
|
||||
...notificationSettings,
|
||||
...notificationSettingsRef.current,
|
||||
...updates
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: keep dragging local and persist only on Radix's commit event. That
|
||||
// avoids IPC on every tick without a debounce timer that can race settings updates.
|
||||
const [volumeDraft, setVolumeDraft] = useState(notificationSettings.customSoundVolume)
|
||||
|
||||
useEffect(() => {
|
||||
notificationSettingsRef.current = notificationSettings
|
||||
setVolumeDraft(notificationSettings.customSoundVolume)
|
||||
}, [notificationSettings])
|
||||
|
||||
const handleVolumeCommit = (value: number): void => {
|
||||
if (notificationSettingsRef.current.customSoundVolume !== value) {
|
||||
updateNotificationSettings({ customSoundVolume: value })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendTestNotification = async (): Promise<void> => {
|
||||
// Why: Electron main cannot reliably read macOS notification authorization,
|
||||
// but the renderer exposes it. Without this check, dev builds can report
|
||||
@@ -103,7 +125,10 @@ export function NotificationsPane({
|
||||
// it twice in quick succession — the in-flight dedupe is for incidental
|
||||
// bursts of real notifications, not for an explicit user action.
|
||||
const soundResult = notificationSettings.customSoundPath
|
||||
? await window.api.notifications.playSound({ force: true })
|
||||
? await window.api.notifications.playSound({
|
||||
force: true,
|
||||
volume: volumeDraft
|
||||
})
|
||||
: null
|
||||
if (notificationSettings.customSoundPath && soundResult && !soundResult.played) {
|
||||
toast.error('Custom notification sound could not be played')
|
||||
@@ -232,6 +257,25 @@ export function NotificationsPane({
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedSoundPath ? (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<Volume2 className="size-4 text-muted-foreground" />
|
||||
<Slider
|
||||
value={[volumeDraft]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
disabled={!notificationSettings.enabled}
|
||||
onValueChange={([value]) => setVolumeDraft(value)}
|
||||
onValueCommit={([value]) => handleVolumeCommit(value)}
|
||||
className="flex-1"
|
||||
aria-label="Notification sound volume"
|
||||
/>
|
||||
<span className="w-10 text-right font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{volumeDraft}%
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -166,6 +166,7 @@ export function dispatchTerminalNotification(
|
||||
const worktree = getWorktreeMapFromState(state).get(worktreeId)
|
||||
const repo = worktree ? getRepoMapFromState(state).get(worktree.repoId) : null
|
||||
const customSoundPath = state.settings?.notifications?.customSoundPath ?? null
|
||||
const customSoundVolume = state.settings?.notifications?.customSoundVolume ?? null
|
||||
const agentStatus =
|
||||
event.source === 'agent-task-complete' && event.paneKey
|
||||
? state.agentStatusByPaneKey[event.paneKey]
|
||||
@@ -200,7 +201,7 @@ export function dispatchTerminalNotification(
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.delivered) {
|
||||
void playDesktopNotificationSound(customSoundPath)
|
||||
void playDesktopNotificationSound(customSoundPath, customSoundVolume)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react'
|
||||
import { Slider as SliderPrimitive } from 'radix-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>): React.ReactElement {
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
className={cn(
|
||||
'relative flex w-full touch-none select-none items-center',
|
||||
'data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20"
|
||||
>
|
||||
<SliderPrimitive.Range data-slot="slider-range" className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
className={cn(
|
||||
'block size-4 rounded-full border border-primary/40 bg-background shadow-sm',
|
||||
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'disabled:pointer-events-none disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
@@ -1,12 +1,15 @@
|
||||
export async function playDesktopNotificationSound(
|
||||
customSoundPath: string | null | undefined
|
||||
customSoundPath: string | null | undefined,
|
||||
customSoundVolume?: number | null
|
||||
): Promise<boolean> {
|
||||
if (!customSoundPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.api.notifications.playSound()
|
||||
const result = await window.api.notifications.playSound({
|
||||
volume: customSoundVolume ?? undefined
|
||||
})
|
||||
// Why: 'deduped' is expected when bursts of notifications coalesce — not a failure.
|
||||
if (!result.played && result.reason !== 'deduped') {
|
||||
console.warn('Failed to play custom notification sound:', result.reason)
|
||||
|
||||
Reference in New Issue
Block a user