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 (
-
+
{children}
);
@@ -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
+
+
+ Done
+
+
+
+
Pick a sound theme. Tap a moment to hear it.
+
+ {SOUND_THEMES.map((theme) => {
+ const isSelected = theme.id === selected;
+ return (
+
+
choose(theme.id)}
+ className="w-full flex items-start gap-3 text-left"
+ {...ownSound}
+ >
+
+ {isSelected ? : null}
+
+
+
+ {theme.name}
+ {theme.id === "classic" ? Default : null}
+
+ {theme.description}
+
+
+ {theme.id === "silent" ? null : (
+
+ {MOMENTS.map(({ event, label }) => {
+ const isQuiet = theme.sounds[event] === null;
+ return (
+
previewSound(theme.id, event)}
+ disabled={isQuiet}
+ title={isQuiet ? `${label}: silent in this theme` : `Play “${label}”`}
+ className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs bg-gray-700/70 text-gray-200 hover:bg-gray-600 transition-colors disabled:bg-transparent disabled:text-gray-600 disabled:line-through disabled:cursor-default"
+ {...ownSound}
+ >
+ {isQuiet ? null : }
+ {label}
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+
+
+
+ );
+}
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}
))}
+ {
+ setIsSoundsOpen(true);
+ setIsOpen(false);
+ }}
+ className={SUB_ITEM}
+ >
+
+ Sounds
+
) : null}
@@ -108,6 +120,7 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) {
>
+ {isSoundsOpen ? setIsSoundsOpen(false)} /> : null}
);
}