diff --git a/src/App.css b/src/App.css
index 89d5f94..93484f9 100644
--- a/src/App.css
+++ b/src/App.css
@@ -54,6 +54,13 @@
min-height: 52px;
}
+.timer-footer-controls {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-shrink: 0;
+}
+
/* Small settings gear */
.timer-settings-btn {
width: 28px;
@@ -84,6 +91,41 @@
cursor: not-allowed;
}
+.timer-mic-btn {
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--color-text-faint);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition:
+ color 0.15s,
+ background 0.15s;
+}
+
+.timer-mic-btn:hover:not(:disabled) {
+ color: var(--color-btn-text);
+ background: var(--color-settings-hover-bg);
+}
+
+.timer-mic-btn.active {
+ color: #53b36c;
+}
+
+.timer-mic-btn.detected {
+ color: #e8a838;
+}
+
+.timer-mic-btn:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
+
/* GitHub / sponsor links */
.timer-display-links {
display: flex;
diff --git a/src/App.tsx b/src/App.tsx
index 06c83ef..05b877e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,6 +1,7 @@
import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
import { useAppStore, useSettingsStore } from './store';
import { usePhaseRunner } from './hooks/usePhaseRunner';
+import { useAudio } from './hooks/useAudio';
import { useWakeLock } from './hooks/useWakeLock';
import { useTheme } from './hooks/useTheme';
import { useUrlParams } from './hooks/useUrlParams';
@@ -11,6 +12,7 @@ import { Gen4Panel } from './components/Gen4Panel';
import { Gen3Panel } from './components/Gen3Panel';
import { CustomPanel } from './components/CustomPanel';
import { SettingsDialog } from './components/SettingsDialog';
+import { resumeAudioSync } from './audio/sounds';
import './App.css';
const TAB_LABELS = ['Gen 5', 'Gen 4', 'Gen 3', 'Custom'];
@@ -27,7 +29,16 @@ export default function App() {
const running = useAppStore((s) => s.running);
const setPhases = useAppStore((s) => s.setPhases);
- const { toggle, registerFlash } = usePhaseRunner();
+ const { start, toggle, registerFlash } = usePhaseRunner();
+ const { isListening, isDetected, startListening, stopListening } = useAudio({
+ onDetect: () => {
+ if (useAppStore.getState().running) return;
+ start();
+ stopListening();
+ setStatusMessage('Sound detected — timer started.');
+ setTimeout(() => setStatusMessage('Ready'), 4000);
+ },
+ });
useWakeLock();
useUrlParams();
useTheme();
@@ -79,7 +90,7 @@ export default function App() {
setStatusMessage('Calibration applied.');
setTimeout(() => setStatusMessage('Ready'), 4000);
}
- }, [running, currentRef, updatePhases]);
+ }, [running, currentRef, updatePhases, setStatusMessage]);
const handleReset = useCallback(() => {
if (running) return;
@@ -90,15 +101,33 @@ export default function App() {
}, [running, currentRef, updatePhases]);
const handleToggle = useCallback(() => {
+ if (!running && isListening) {
+ stopListening();
+ }
toggle();
- }, [toggle]);
+ }, [running, isListening, stopListening, toggle]);
+
+ const handleAudioToggle = useCallback(() => {
+ if (running) return;
+ if (isListening) {
+ stopListening();
+ setStatusMessage('Audio trigger stopped.');
+ } else {
+ resumeAudioSync();
+ startListening().then(
+ () => setStatusMessage('Listening for sound to start timer...'),
+ (err: unknown) =>
+ setStatusMessage(`Microphone error: ${err instanceof Error ? err.message : err}`),
+ );
+ }
+ }, [running, isListening, startListening, stopListening, setStatusMessage]);
const handleSettingsClose = useCallback(
(accepted: boolean) => {
setSettingsOpen(false);
if (accepted) setTimeout(updatePhases, 0);
},
- [updatePhases],
+ [updatePhases, setSettingsOpen],
);
// Keyboard shortcuts
@@ -133,6 +162,13 @@ export default function App() {
prevRunning.current = running;
}, [running]);
+ // Stop microphone when a run becomes active.
+ useEffect(() => {
+ if (running && isListening) {
+ stopListening();
+ }
+ }, [running, isListening, stopListening]);
+
return (
@@ -141,7 +177,10 @@ export default function App() {
registerFlash={registerFlash}
onToggle={handleToggle}
onSettings={() => setSettingsOpen(true)}
+ onToggleAudioListening={handleAudioToggle}
settingsDisabled={running}
+ audioListening={isListening}
+ audioDetected={isDetected}
/>
{/* Tab panel */}
diff --git a/src/audio/sounds.ts b/src/audio/sounds.ts
index d19a266..b452495 100644
--- a/src/audio/sounds.ts
+++ b/src/audio/sounds.ts
@@ -145,6 +145,11 @@ function ensureRunning(): Promise
{
return resumePromise;
}
+/** Fire-and-forget wrapper for use in event handlers. */
+export function resumeAudioSync(): void {
+ resumeAudio();
+}
+
// ─── Playback (fire-and-forget) ───
function playBuffer(buffer: Promise, label: string, receivedAt: number): void {
diff --git a/src/components/TimerDisplay.tsx b/src/components/TimerDisplay.tsx
index 33a777e..cc3000b 100644
--- a/src/components/TimerDisplay.tsx
+++ b/src/components/TimerDisplay.tsx
@@ -23,14 +23,20 @@ interface TimerDisplayProps {
registerFlash: (fn: () => void) => void;
onToggle: () => void;
onSettings: () => void;
+ onToggleAudioListening: () => void;
settingsDisabled: boolean;
+ audioListening: boolean;
+ audioDetected: boolean;
}
export function TimerDisplay({
registerFlash,
onToggle,
onSettings,
+ onToggleAudioListening,
settingsDisabled,
+ audioListening,
+ audioDetected,
}: TimerDisplayProps) {
const phases = useAppStore((s) => s.phases);
const currentPhaseIndex = useAppStore((s) => s.currentPhaseIndex);
@@ -127,27 +133,54 @@ export function TimerDisplay({
)}
-
+
+
+
+