From a1078e0cc70ab29b844f6bd40f4747260ca92765 Mon Sep 17 00:00:00 2001 From: cottongin Date: Sun, 10 May 2026 14:37:00 -0400 Subject: [PATCH] feat: add alarm sounds and sound controls to countdown timer widget - Add four synthesized alarm sounds (Digital Beep, Gentle Chime, Urgent Alarm, Bell) using Web Audio API oscillators - Add sound enable/mute toggle button and sound selector dropdown - Add preview button to test selected alarm before countdown - Play selected alarm sound when countdown expires (if enabled) - Default widget mode to 'timer' instead of 'stopwatch' - Extract shared inputClass constant for timer input styling - Hide native number input spinners via Tailwind utility classes Co-authored-by: Cursor --- frontend/src/components/StopwatchWidget.jsx | 151 +++++++++++++++++++- 1 file changed, 144 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/StopwatchWidget.jsx b/frontend/src/components/StopwatchWidget.jsx index cf07c95..6abe753 100644 --- a/frontend/src/components/StopwatchWidget.jsx +++ b/frontend/src/components/StopwatchWidget.jsx @@ -29,11 +29,90 @@ const COUNTDOWN_PRESETS = [ { label: '5:00', ms: 300000 }, ]; +const ALARM_SOUNDS = [ + { + id: 'digital', + label: 'Digital Beep', + play: () => { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const beep = (startTime) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.frequency.value = 830; + osc.type = 'square'; + gain.gain.value = 0.3; + osc.start(startTime); + osc.stop(startTime + 0.15); + }; + beep(ctx.currentTime); + beep(ctx.currentTime + 0.25); + beep(ctx.currentTime + 0.5); + }, + }, + { + id: 'chime', + label: 'Gentle Chime', + play: () => { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + [523, 659, 784].forEach((freq, i) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.frequency.value = freq; + osc.type = 'sine'; + gain.gain.setValueAtTime(0.25, ctx.currentTime + i * 0.3); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + i * 0.3 + 0.4); + osc.start(ctx.currentTime + i * 0.3); + osc.stop(ctx.currentTime + i * 0.3 + 0.4); + }); + }, + }, + { + id: 'urgent', + label: 'Urgent Alarm', + play: () => { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + for (let i = 0; i < 5; i++) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.frequency.value = 1000; + osc.type = 'sawtooth'; + gain.gain.value = 0.2; + osc.start(ctx.currentTime + i * 0.12); + osc.stop(ctx.currentTime + i * 0.12 + 0.08); + } + }, + }, + { + id: 'bell', + label: 'Bell', + play: () => { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.frequency.value = 660; + osc.type = 'sine'; + gain.gain.setValueAtTime(0.4, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.5); + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + 1.5); + }, + }, +]; + +const inputClass = 'w-10 bg-gray-800 border border-gray-600 rounded px-1 py-0.5 text-center text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'; + export default function StopwatchWidget({ visible, onHide }) { const isDesktop = useMediaQuery('(min-width: 640px)'); - // Mode: 'stopwatch' | 'timer' - const [mode, setMode] = useState('stopwatch'); + const [mode, setMode] = useState('timer'); // Stopwatch state const [swRunning, setSwRunning] = useState(false); @@ -53,6 +132,10 @@ export default function StopwatchWidget({ visible, onHide }) { const cdStartRef = useRef(0); const cdRemainingAtStartRef = useRef(0); + // Sound state + const [soundEnabled, setSoundEnabled] = useState(true); + const [selectedSound, setSelectedSound] = useState('digital'); + // Drag state (desktop only) const [position, setPosition] = useState({ x: window.innerWidth - 300, y: 80 }); const [isDragging, setIsDragging] = useState(false); @@ -134,13 +217,22 @@ export default function StopwatchWidget({ visible, onHide }) { } }; - // Expired flash effect + // Expired flash + sound useEffect(() => { if (cdExpired) { + if (soundEnabled) { + const sound = ALARM_SOUNDS.find(s => s.id === selectedSound); + if (sound) sound.play(); + } const timeout = setTimeout(() => setCdExpired(false), 3000); return () => clearTimeout(timeout); } - }, [cdExpired]); + }, [cdExpired, soundEnabled, selectedSound]); + + const handlePreviewSound = () => { + const sound = ALARM_SOUNDS.find(s => s.id === selectedSound); + if (sound) sound.play(); + }; // Drag handlers (desktop) const handleDragStart = useCallback((e) => { @@ -223,7 +315,7 @@ export default function StopwatchWidget({ visible, onHide }) { {/* Display */} -
+
setCdInputMin(e.target.value)} - className="w-10 bg-gray-800 border border-gray-600 rounded px-1 py-0.5 text-center text-white" + className={inputClass} /> m setCdInputSec(e.target.value)} - className="w-10 bg-gray-800 border border-gray-600 rounded px-1 py-0.5 text-center text-white" + className={inputClass} /> s +
+ + + + +
+ +
)}