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 <cursoragent@cursor.com>
This commit is contained in:
@@ -29,11 +29,90 @@ const COUNTDOWN_PRESETS = [
|
|||||||
{ label: '5:00', ms: 300000 },
|
{ 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 }) {
|
export default function StopwatchWidget({ visible, onHide }) {
|
||||||
const isDesktop = useMediaQuery('(min-width: 640px)');
|
const isDesktop = useMediaQuery('(min-width: 640px)');
|
||||||
|
|
||||||
// Mode: 'stopwatch' | 'timer'
|
const [mode, setMode] = useState('timer');
|
||||||
const [mode, setMode] = useState('stopwatch');
|
|
||||||
|
|
||||||
// Stopwatch state
|
// Stopwatch state
|
||||||
const [swRunning, setSwRunning] = useState(false);
|
const [swRunning, setSwRunning] = useState(false);
|
||||||
@@ -53,6 +132,10 @@ export default function StopwatchWidget({ visible, onHide }) {
|
|||||||
const cdStartRef = useRef(0);
|
const cdStartRef = useRef(0);
|
||||||
const cdRemainingAtStartRef = useRef(0);
|
const cdRemainingAtStartRef = useRef(0);
|
||||||
|
|
||||||
|
// Sound state
|
||||||
|
const [soundEnabled, setSoundEnabled] = useState(true);
|
||||||
|
const [selectedSound, setSelectedSound] = useState('digital');
|
||||||
|
|
||||||
// Drag state (desktop only)
|
// Drag state (desktop only)
|
||||||
const [position, setPosition] = useState({ x: window.innerWidth - 300, y: 80 });
|
const [position, setPosition] = useState({ x: window.innerWidth - 300, y: 80 });
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@@ -134,13 +217,22 @@ export default function StopwatchWidget({ visible, onHide }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Expired flash effect
|
// Expired flash + sound
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cdExpired) {
|
if (cdExpired) {
|
||||||
|
if (soundEnabled) {
|
||||||
|
const sound = ALARM_SOUNDS.find(s => s.id === selectedSound);
|
||||||
|
if (sound) sound.play();
|
||||||
|
}
|
||||||
const timeout = setTimeout(() => setCdExpired(false), 3000);
|
const timeout = setTimeout(() => setCdExpired(false), 3000);
|
||||||
return () => clearTimeout(timeout);
|
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)
|
// Drag handlers (desktop)
|
||||||
const handleDragStart = useCallback((e) => {
|
const handleDragStart = useCallback((e) => {
|
||||||
@@ -223,7 +315,7 @@ export default function StopwatchWidget({ visible, onHide }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display */}
|
{/* Display */}
|
||||||
<div className="px-3 pb-2">
|
<div className="px-3 pb-3">
|
||||||
<div className="bg-black/40 rounded-lg px-4 py-3 sm:py-2 text-center mb-3">
|
<div className="bg-black/40 rounded-lg px-4 py-3 sm:py-2 text-center mb-3">
|
||||||
<span
|
<span
|
||||||
className={`font-mono tracking-wider ${isDesktop ? 'text-2xl' : 'text-3xl'} ${mode === 'timer' && cdRemaining < 10000 && cdRunning ? 'text-red-400' : 'text-green-400'}`}
|
className={`font-mono tracking-wider ${isDesktop ? 'text-2xl' : 'text-3xl'} ${mode === 'timer' && cdRemaining < 10000 && cdRunning ? 'text-red-400' : 'text-green-400'}`}
|
||||||
@@ -297,7 +389,7 @@ export default function StopwatchWidget({ visible, onHide }) {
|
|||||||
max="99"
|
max="99"
|
||||||
value={cdInputMin}
|
value={cdInputMin}
|
||||||
onChange={(e) => setCdInputMin(e.target.value)}
|
onChange={(e) => 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}
|
||||||
/>
|
/>
|
||||||
<span className="text-gray-400">m</span>
|
<span className="text-gray-400">m</span>
|
||||||
<input
|
<input
|
||||||
@@ -306,7 +398,7 @@ export default function StopwatchWidget({ visible, onHide }) {
|
|||||||
max="59"
|
max="59"
|
||||||
value={cdInputSec}
|
value={cdInputSec}
|
||||||
onChange={(e) => setCdInputSec(e.target.value)}
|
onChange={(e) => 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}
|
||||||
/>
|
/>
|
||||||
<span className="text-gray-400">s</span>
|
<span className="text-gray-400">s</span>
|
||||||
<button
|
<button
|
||||||
@@ -318,6 +410,51 @@ export default function StopwatchWidget({ visible, onHide }) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Sound controls */}
|
||||||
|
<div className="flex items-center gap-2 mt-3 pt-2 border-t border-gray-700/50">
|
||||||
|
<button
|
||||||
|
onClick={() => setSoundEnabled(s => !s)}
|
||||||
|
className={`p-1 rounded transition ${soundEnabled ? 'text-indigo-400 hover:text-indigo-300' : 'text-gray-500 hover:text-gray-400'}`}
|
||||||
|
title={soundEnabled ? 'Mute alarm' : 'Enable alarm sound'}
|
||||||
|
>
|
||||||
|
{soundEnabled ? (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M17.95 6.05a8 8 0 010 11.9M6.5 8H4a1 1 0 00-1 1v6a1 1 0 001 1h2.5l4.5 4V4l-4.5 4z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707A1 1 0 0112 5v14a1 1 0 01-1.707.707L5.586 15z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<select
|
||||||
|
value={selectedSound}
|
||||||
|
onChange={(e) => setSelectedSound(e.target.value)}
|
||||||
|
disabled={!soundEnabled}
|
||||||
|
className="w-full appearance-none bg-gray-800 border border-gray-600 rounded px-2 py-1 pr-6 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed transition"
|
||||||
|
>
|
||||||
|
{ALARM_SOUNDS.map(s => (
|
||||||
|
<option key={s.id} value={s.id}>{s.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<svg className="absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handlePreviewSound}
|
||||||
|
disabled={!soundEnabled}
|
||||||
|
className="p-1 rounded text-gray-400 hover:text-white transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
title="Preview sound"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user