From a4703633dcaf6cc9fef165e663a3ecf763d725af Mon Sep 17 00:00:00 2001 From: Truong Trung Nghia <149591043+ttncode@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:55:56 +0700 Subject: [PATCH] feat: selectable sound themes with a preview picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Selectable sound themes. Each theme is a complete set covering every moment, so switching never leaves a gap by accident. | Moment | Classic (default) | Glass | Wood | Singing bowl | Soft | Silent | | --- | --- | --- | --- | --- | --- | --- | | Start focus | E5→A5 chime | E6 ping | C–E–G marimba | C4 bowl | A5 blip | — | | Finish focus | G5–E5–C5 bell | A6–E6–A5 pings | G–E–C–G…C phrase | two G3 strikes | 3 blips | — | | Start break | chime | falling pings | falling E→C | low F3 strike | falling blips | — | | Back to work | bell | rising pings | rising C–E–G–C | rising A3→D4 | rising blips | — | | Pause / resume | — / chime | quiet low / high ping | soft G4 / C5 | — | tiny blips | — | | Button tap | — | 3.2 kHz tick | woodblock | — | — | — | Design rules applied: - **Start and finish are easy to tell apart without looking**; finish is the most noticeable sound in every theme, since it pulls you back after 25 minutes. - **Breaks get their own direction**: falling pitch to relax, rising pitch for back to work. - **Soft attacks and a gain ceiling of 0.15**, so nothing startles mid-focus; "Soft" stays at about a third of that for shared spaces. - **Taps are near-subliminal**, play on any button or link, and are skipped on timer buttons that already make their own sound (`data-own-sound`). - **Classic reproduces today's sounds exactly**, including silent pause and taps, so nothing changes for existing users. - All sounds are synthesized with Web Audio: no audio files, nothing to license. **Menu → Settings → Sounds** opens a picker styled like the shortcuts panel: a radio card per theme with a one-line character description and a preview chip for every moment (moments a theme keeps silent are struck through). Choosing a theme plays its start sound; the choice is stored in `localStorage`. ## Testing - Lint, typecheck, unit tests and build pass. - Instrumented `OscillatorNode.start` in the browser with Wood selected: a nav button plays the woodblock tap; Start Focus plays only the C–E–G phrase (no extra tap); Pause plays G4; Resume plays C5; the session end plays the finish phrase. - Picker renders all six themes, previews play, and the selection persists. --- README.md | 1 + src/components/focus/FocusTimer.tsx | 22 +-- src/components/focus/sounds.ts | 233 +++++++++++++++++++++++---- src/components/icons.ts | 1 + src/components/shell/SoundsPanel.tsx | 134 +++++++++++++++ src/components/shell/StartMenu.tsx | 15 +- 6 files changed, 364 insertions(+), 42 deletions(-) create mode 100644 src/components/shell/SoundsPanel.tsx diff --git a/README.md b/README.md index e23e030..bdcbfbe 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Runs as a web app or as a browser extension, and keeps every bit of data in your - **Focus timer** with pause, resume, breaks, extend-on-complete and goal history (press ↑/↓ in the goal field). - **Notifications** when a focus session or break runs out. The app asks for permission with Chrome's own prompt the first time you open it; **Menu → Settings → Notifications** shows the current state and asks again if you dismissed the prompt. If nothing appears, allow notifications for your browser in your system settings and turn off Do Not Disturb / Focus. +- **Sound themes**: Classic (default), Glass, Wood, Singing bowl, Soft or Silent, each covering start, finish, break, back to work, pause, resume and button taps. Preview and pick them in **Menu → Settings → Sounds**. All sounds are synthesized in the browser. - **Pixel-art progress animations** — a coffee cup that fills, a battery that charges, a rocket that lifts off — plus an auto-shuffle option. - **To-dos** with time estimates, notes, drag-and-drop ordering, a backlog and archive; start a focus session straight from a to-do. - **Habits** with 7- and 30-day grids and notes per day. diff --git a/src/components/focus/FocusTimer.tsx b/src/components/focus/FocusTimer.tsx index 6dbc5b3..8f46455 100644 --- a/src/components/focus/FocusTimer.tsx +++ b/src/components/focus/FocusTimer.tsx @@ -10,7 +10,7 @@ import { ANIMATIONS, DEFAULT_ANIMATION, currentFrame, isAnimationType, metaOf, t import { GoalForm, hotkeyHint } from "./GoalForm"; import { PixelArt } from "./PixelArt"; import { notify } from "./notifications"; -import { playSound } from "./sounds"; +import { OWN_SOUND_ATTRIBUTE, playSound } from "./sounds"; import { useDialog } from "../dialogs/DialogProvider"; const DEFAULT_TITLE = "Boost Focus Timer"; @@ -121,7 +121,7 @@ function TimerButton({ children: ReactNode; }) { return ( - ); @@ -293,7 +293,7 @@ export const FocusTimer = forwardRef(function titleSuffix: goal || "Focus", onTick: (minutes, seconds) => setFocus((current) => ({ ...current, minutes, seconds })), onComplete: () => { - playSound("end"); + playSound("focusEnd"); notify("Focus session complete", `${goal.trim() || "Nice work"} · time for a break.`); setFocusStart(null); setFocus({ ...IDLE, isCompleted: true }); @@ -323,7 +323,7 @@ export const FocusTimer = forwardRef(function titleSuffix: "Break", onTick: (minutes, seconds) => setBreakTimer((current) => ({ ...current, minutes, seconds })), onComplete: () => { - playSound("end"); + playSound("breakEnd"); notify("Break is over", "Ready for the next focus session?"); void completeBreakSession(); finishBreak(); @@ -338,7 +338,7 @@ export const FocusTimer = forwardRef(function try { const created = await databaseService.createFocusSession({ goal, planned_duration_minutes: minutes, session_type: "focus" }); setSession(created); - playSound("start"); + playSound("focusStart"); if (goal.trim()) setGoalHistory((history) => [goal, ...history]); setFocusStart(Date.now()); setFocusTotalMs(minutes * MINUTE_MS); @@ -355,6 +355,7 @@ export const FocusTimer = forwardRef(function }; const pauseFocus = async () => { + playSound("pause"); if (focusStart === null) return setFocus((current) => ({ ...current, isRunning: false })); const remaining = Math.max(0, focusTotalMs - (Date.now() - focusStart)); setFocus((current) => ({ ...current, ...splitMs(remaining), isRunning: false })); @@ -366,7 +367,7 @@ export const FocusTimer = forwardRef(function }; const resumeFocus = async () => { - playSound("start"); + playSound("resume"); await endPause(pauseId, setPauseId); sendHeartbeat(session); setFocusStart(Date.now() - (focusTotalMs - remainingMsOf(focus))); @@ -374,7 +375,7 @@ export const FocusTimer = forwardRef(function }; const completeEarly = async () => { - playSound("end"); + playSound("focusEnd"); document.title = DEFAULT_TITLE; setFocusStart(null); await endPause(pauseId, setPauseId); @@ -406,7 +407,7 @@ export const FocusTimer = forwardRef(function .createFocusSession({ goal: "Break time", planned_duration_minutes: minutes, session_type: "break" }) .catch(logError("Failed to start break session:")); if (created) setBreakSession(created); - playSound("start"); + playSound("breakStart"); setBreakStart(Date.now()); setBreakTotalMs(minutes * MINUTE_MS); setBreakTimer({ minutes, seconds: 0, isRunning: true, isCompleted: false }); @@ -415,6 +416,7 @@ export const FocusTimer = forwardRef(function }; const pauseBreak = async () => { + playSound("pause"); if (breakStart === null) return setBreakTimer((current) => ({ ...current, isRunning: false })); const remaining = Math.max(0, breakTotalMs - (Date.now() - breakStart)); setBreakTimer((current) => ({ ...current, ...splitMs(remaining), isRunning: false })); @@ -426,14 +428,14 @@ export const FocusTimer = forwardRef(function }; const resumeBreak = async () => { - playSound("start"); + playSound("resume"); await endPause(breakPauseId, setBreakPauseId); setBreakStart(Date.now() - (breakTotalMs - remainingMsOf(breakTimer))); setBreakTimer((current) => ({ ...current, isRunning: true })); }; const endBreak = async () => { - playSound("end"); + playSound("breakEnd"); document.title = DEFAULT_TITLE; await completeBreakSession(); finishBreak(); diff --git a/src/components/focus/sounds.ts b/src/components/focus/sounds.ts index d367e9c..7c61657 100644 --- a/src/components/focus/sounds.ts +++ b/src/components/focus/sounds.ts @@ -1,26 +1,169 @@ -export type SoundName = "start" | "end"; +/* + * Sound themes, all synthesized with Web Audio (no audio files). + * Every theme covers the same moments so switching themes never leaves a moment unexpectedly silent, + * except where a theme deliberately stays quiet (null). + */ + +export type SoundEvent = "tap" | "focusStart" | "focusEnd" | "breakStart" | "breakEnd" | "pause" | "resume"; + +interface Overtone { + /** Frequency multiple of the note. */ + readonly ratio: number; + /** Volume relative to the note. */ + readonly volume: number; + /** Duration relative to the note. */ + readonly length: number; +} interface Note { readonly frequency: number; + /** Seconds after the sound starts. */ readonly delay: number; + /** Seconds until the note has faded out. */ readonly duration: number; + /** Peak gain; kept at or below 0.15 so no theme startles. */ readonly volume: number; + readonly wave?: OscillatorType; + readonly attack?: number; + readonly overtones?: readonly Overtone[]; } -const START_CHIME: readonly Note[] = [ - { frequency: 659.25, delay: 0, duration: 0.35, volume: 0.12 }, - { frequency: 880, delay: 0.12, duration: 0.5, volume: 0.12 }, +export type SoundThemeId = "classic" | "glass" | "wood" | "bowl" | "soft" | "silent"; + +export interface SoundTheme { + readonly id: SoundThemeId; + readonly name: string; + readonly description: string; + readonly sounds: Readonly>; +} + +// A quiet inharmonic overtone is what makes a plain sine read as a bell. +const BELL: readonly Overtone[] = [{ ratio: 2.76, volume: 0.25, length: 0.5 }]; +const GLASS: readonly Overtone[] = [ + { ratio: 2, volume: 0.35, length: 0.6 }, + { ratio: 3.01, volume: 0.12, length: 0.35 }, +]; +// Marimba bars ring at roughly 1 : 3.9, which gives the woody "tock". +const MALLET: readonly Overtone[] = [{ ratio: 3.93, volume: 0.2, length: 0.3 }]; +const BOWL: readonly Overtone[] = [ + { ratio: 2.76, volume: 0.35, length: 0.6 }, + { ratio: 5.4, volume: 0.12, length: 0.3 }, +]; + +const note = (frequency: number, delay: number, duration: number, volume: number, extra: Partial = {}): Note => ({ + frequency, + delay, + duration, + volume, + ...extra, +}); +const ping = (frequency: number, delay: number, duration = 1, volume = 0.09) => note(frequency, delay, duration, volume, { overtones: GLASS }); +const mallet = (frequency: number, delay: number, duration = 0.45, volume = 0.13) => + note(frequency, delay, duration, volume, { wave: "triangle", overtones: MALLET }); +const strike = (frequency: number, delay: number, duration = 3.5, volume = 0.12) => + note(frequency, delay, duration, volume, { attack: 0.02, overtones: BOWL }); +const blip = (frequency: number, delay: number, duration = 0.2, volume = 0.05) => note(frequency, delay, duration, volume); + +const CLASSIC_CHIME = [note(659.25, 0, 0.35, 0.12), note(880, 0.12, 0.5, 0.12)]; +const CLASSIC_BELL = [ + note(783.99, 0, 1.2, 0.14, { overtones: BELL }), + note(659.25, 0.28, 1.2, 0.14, { overtones: BELL }), + note(523.25, 0.56, 1.6, 0.14, { overtones: BELL }), ]; -const END_BELL: readonly Note[] = [ - { frequency: 783.99, delay: 0, duration: 1.2, volume: 0.14 }, - { frequency: 659.25, delay: 0.28, duration: 1.2, volume: 0.14 }, - { frequency: 523.25, delay: 0.56, duration: 1.6, volume: 0.14 }, +export const SOUND_THEMES: readonly SoundTheme[] = [ + { + id: "classic", + name: "Classic", + description: "Bright two-note chime to start, warm bell to finish. The original sounds.", + sounds: { + tap: null, + focusStart: CLASSIC_CHIME, + focusEnd: CLASSIC_BELL, + breakStart: CLASSIC_CHIME, + breakEnd: CLASSIC_BELL, + pause: null, + resume: CLASSIC_CHIME, + }, + }, + { + id: "glass", + name: "Glass", + description: "Crystal pings with a soft tick on every button. Light and precise.", + sounds: { + tap: [note(3200, 0, 0.03, 0.025)], + focusStart: [ping(1318.51, 0, 0.9)], + focusEnd: [ping(1760, 0, 1.4), ping(1318.51, 0.22, 1.4), ping(880, 0.44, 2)], + breakStart: [ping(1318.51, 0, 0.9), ping(987.77, 0.16, 1.2)], + breakEnd: [ping(987.77, 0, 0.9), ping(1318.51, 0.16, 1), ping(1760, 0.32, 1.4)], + pause: [ping(987.77, 0, 0.35, 0.05)], + resume: [ping(1318.51, 0, 0.35, 0.05)], + }, + }, + { + id: "wood", + name: "Wood", + description: "Warm marimba phrases and a woodblock tap. Friendly and musical.", + sounds: { + tap: [note(1100, 0, 0.05, 0.05, { wave: "triangle" })], + focusStart: [mallet(523.25, 0), mallet(659.25, 0.09), mallet(783.99, 0.18)], + focusEnd: [mallet(783.99, 0), mallet(659.25, 0.12), mallet(523.25, 0.24), mallet(392, 0.4), mallet(523.25, 0.62, 0.9)], + breakStart: [mallet(659.25, 0), mallet(523.25, 0.12, 0.6)], + breakEnd: [mallet(523.25, 0), mallet(659.25, 0.08), mallet(783.99, 0.16), mallet(1046.5, 0.24, 0.7)], + pause: [mallet(392, 0, 0.3, 0.07)], + resume: [mallet(523.25, 0, 0.3, 0.07)], + }, + }, + { + id: "bowl", + name: "Singing bowl", + description: "Deep, slowly fading bowl strikes. Calm; quiet for pause and taps.", + sounds: { + tap: null, + focusStart: [strike(261.63, 0, 3)], + focusEnd: [strike(196, 0, 4), strike(196, 1.4, 4.5)], + breakStart: [strike(174.61, 0)], + breakEnd: [strike(220, 0, 2.5), strike(293.66, 0.9)], + pause: null, + resume: null, + }, + }, + { + id: "soft", + name: "Soft", + description: "Short, low-volume blips for libraries and shared offices.", + sounds: { + tap: null, + focusStart: [blip(880, 0)], + focusEnd: [blip(880, 0, 0.25), blip(659.25, 0.22, 0.25), blip(880, 0.44, 0.3)], + breakStart: [blip(659.25, 0), blip(523.25, 0.18)], + breakEnd: [blip(523.25, 0), blip(659.25, 0.18), blip(880, 0.36)], + pause: [blip(523.25, 0, 0.1, 0.03)], + resume: [blip(659.25, 0, 0.1, 0.03)], + }, + }, + { + id: "silent", + name: "Silent", + description: "No sounds at all. Notifications still appear when they're on.", + sounds: { tap: null, focusStart: null, focusEnd: null, breakStart: null, breakEnd: null, pause: null, resume: null }, + }, ]; -// A quiet inharmonic partial is what makes a plain sine read as a bell. -const BELL_PARTIAL = 2.76; -const BELL_PARTIAL_VOLUME = 0.25; +export const DEFAULT_SOUND_THEME: SoundThemeId = "classic"; +const THEME_KEY = "bft:soundTheme"; + +const themeById = (id: SoundThemeId): SoundTheme => SOUND_THEMES.find((theme) => theme.id === id) ?? SOUND_THEMES[0]; + +export function getSoundThemeId(): SoundThemeId { + const stored = typeof window === "undefined" ? null : localStorage.getItem(THEME_KEY); + return SOUND_THEMES.some((theme) => theme.id === stored) ? (stored as SoundThemeId) : DEFAULT_SOUND_THEME; +} + +export function setSoundThemeId(id: SoundThemeId): void { + localStorage.setItem(THEME_KEY, id); +} + const ATTACK_SECONDS = 0.01; const SILENCE = 0.0001; @@ -37,38 +180,66 @@ function audioContext(): AudioContext | null { } } -// Browsers only let audio start after a user gesture, and the end sound fires from a timer. -if (typeof window !== "undefined") { - window.addEventListener("pointerdown", audioContext, { once: true }); - window.addEventListener("keydown", audioContext, { once: true }); -} - -function playTone(audio: AudioContext, note: Note): void { - const startAt = audio.currentTime + note.delay; +function playTone(audio: AudioContext, tone: Note): void { + const startAt = audio.currentTime + tone.delay; const oscillator = audio.createOscillator(); const gain = audio.createGain(); - oscillator.type = "sine"; - oscillator.frequency.value = note.frequency; + oscillator.type = tone.wave ?? "sine"; + oscillator.frequency.value = tone.frequency; gain.gain.setValueAtTime(SILENCE, startAt); - gain.gain.exponentialRampToValueAtTime(note.volume, startAt + ATTACK_SECONDS); - gain.gain.exponentialRampToValueAtTime(SILENCE, startAt + note.duration); + gain.gain.exponentialRampToValueAtTime(tone.volume, startAt + (tone.attack ?? ATTACK_SECONDS)); + gain.gain.exponentialRampToValueAtTime(SILENCE, startAt + tone.duration); oscillator.connect(gain).connect(audio.destination); oscillator.start(startAt); - oscillator.stop(startAt + note.duration); + oscillator.stop(startAt + tone.duration); } -const withPartial = (note: Note): Note[] => [ - note, - { ...note, frequency: note.frequency * BELL_PARTIAL, duration: note.duration / 2, volume: note.volume * BELL_PARTIAL_VOLUME }, +const withOvertones = (tone: Note): Note[] => [ + tone, + ...(tone.overtones ?? []).map((overtone) => ({ + ...tone, + frequency: tone.frequency * overtone.ratio, + volume: tone.volume * overtone.volume, + duration: tone.duration * overtone.length, + overtones: undefined, + })), ]; -export function playSound(name: SoundName): void { +function playNotes(notes: readonly Note[] | null, label: string): void { + if (!notes) return; const audio = audioContext(); if (!audio) return; - const notes = name === "start" ? START_CHIME : END_BELL.flatMap(withPartial); try { - notes.forEach((note) => playTone(audio, note)); + notes.flatMap(withOvertones).forEach((tone) => playTone(audio, tone)); } catch (error) { - console.error(`Failed to play ${name} sound:`, error); + console.error(`Failed to play ${label} sound:`, error); } } + +/** Plays `event` in the user's chosen theme. */ +export function playSound(event: SoundEvent): void { + playNotes(themeById(getSoundThemeId()).sounds[event], event); +} + +/** Plays `event` in any theme, for previews in the sound picker. */ +export function previewSound(themeId: SoundThemeId, event: SoundEvent): void { + playNotes(themeById(themeId).sounds[event], `${themeId} ${event}`); +} + +/** Marks a button that plays its own sound, so it doesn't also tick. */ +export const OWN_SOUND_ATTRIBUTE = "data-own-sound"; + +if (typeof window !== "undefined") { + // Browsers only let audio start after a user gesture, and the end sound fires from a timer. + window.addEventListener("pointerdown", audioContext, { once: true }); + window.addEventListener("keydown", audioContext, { once: true }); + window.addEventListener( + "click", + (event) => { + const target = event.target instanceof Element ? event.target : null; + const button = target?.closest("button, [role='button'], a[href]"); + if (button && !button.closest(`[${OWN_SOUND_ATTRIBUTE}]`)) playSound("tap"); + }, + true, + ); +} diff --git a/src/components/icons.ts b/src/components/icons.ts index f97d2bd..9971118 100644 --- a/src/components/icons.ts +++ b/src/components/icons.ts @@ -53,5 +53,6 @@ export const Target = createIcon("Target", [["circle",{cx:"12",cy:"12",r:"10"}], export const Trash2 = createIcon("Trash2", [["path",{d:"M3 6h18"}],["path",{d:"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"}],["path",{d:"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"}],["line",{x1:"10",x2:"10",y1:"11",y2:"17"}],["line",{x1:"14",x2:"14",y1:"11",y2:"17"}]]); export const TrendingUp = createIcon("TrendingUp", [["polyline",{points:"22 7 13.5 15.5 8.5 10.5 2 17"}],["polyline",{points:"16 7 22 7 22 13"}]]); export const User = createIcon("User", [["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"}],["circle",{cx:"12",cy:"7",r:"4"}]]); +export const Volume2 = createIcon("Volume2", [["polygon",{points:"11 5 6 9 2 9 2 15 6 15 11 19 11 5"}],["path",{d:"M15.54 8.46a5 5 0 0 1 0 7.07"}],["path",{d:"M19.07 4.93a10 10 0 0 1 0 14.14"}]]); export const XCircle = createIcon("XCircle", [["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"m15 9-6 6"}],["path",{d:"m9 9 6 6"}]]); export const X = createIcon("X", [["path",{d:"M18 6 6 18"}],["path",{d:"m6 6 12 12"}]]); diff --git a/src/components/shell/SoundsPanel.tsx b/src/components/shell/SoundsPanel.tsx new file mode 100644 index 0000000..5708264 --- /dev/null +++ b/src/components/shell/SoundsPanel.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + getSoundThemeId, + OWN_SOUND_ATTRIBUTE, + previewSound, + setSoundThemeId, + SOUND_THEMES, + type SoundEvent, + type SoundThemeId, +} from "../focus/sounds"; +import { Check, Play } from "../icons"; + +const MOMENTS: readonly { event: SoundEvent; label: string }[] = [ + { event: "focusStart", label: "Start" }, + { event: "focusEnd", label: "Finish" }, + { event: "breakStart", label: "Break" }, + { event: "breakEnd", label: "Back to work" }, + { event: "pause", label: "Pause" }, + { event: "resume", label: "Resume" }, + { event: "tap", label: "Tap" }, +]; + +const ownSound = { [OWN_SOUND_ATTRIBUTE]: "" }; + +export function SoundsPanel({ onClose }: { onClose(): void }) { + const [selected, setSelected] = useState(getSoundThemeId); + const doneRef = useRef(null); + + useEffect(() => { + doneRef.current?.focus(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + const choose = (id: SoundThemeId) => { + setSoundThemeId(id); + setSelected(id); + previewSound(id, "focusStart"); + }; + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + role="presentation" + > +
+
+

+ Sounds +

+ +
+
+

Pick a sound theme. Tap a moment to hear it.

+
+ {SOUND_THEMES.map((theme) => { + const isSelected = theme.id === selected; + return ( +
+ + {theme.id === "silent" ? null : ( +
+ {MOMENTS.map(({ event, label }) => { + const isQuiet = theme.sounds[event] === null; + return ( + + ); + })} +
+ )} +
+ ); + })} +
+
+
+
+ ); +} diff --git a/src/components/shell/StartMenu.tsx b/src/components/shell/StartMenu.tsx index 907122b..c808c82 100644 --- a/src/components/shell/StartMenu.tsx +++ b/src/components/shell/StartMenu.tsx @@ -2,9 +2,10 @@ import { useEffect, useRef, useState, type ComponentType } from "react"; import { notificationStatus, requestNotificationPermission, type NotificationStatus } from "../focus/notifications"; -import { Bell, ChevronDown, CircleUser, Menu, Settings, Tag } from "../icons"; +import { Bell, ChevronDown, CircleUser, Menu, Settings, Tag, Volume2 } from "../icons"; import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; import type { Tab } from "../types"; +import { SoundsPanel } from "./SoundsPanel"; import { MOBILE_NAV_OFFSET, MOBILE_QUERY, useMediaQuery } from "./use-media-query"; import { useDialog } from "../dialogs/DialogProvider"; @@ -52,6 +53,7 @@ function NotificationsItem() { export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { const [isOpen, setIsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(true); + const [isSoundsOpen, setIsSoundsOpen] = useState(false); const menuRef = useRef(null); const isMobile = useMediaQuery(MOBILE_QUERY); useHotkey("nav.menu", () => setIsOpen((open) => !open)); @@ -93,6 +95,16 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { {label} ))} + ) : null} @@ -108,6 +120,7 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { > + {isSoundsOpen ? setIsSoundsOpen(false)} /> : null} ); }