diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.tsx | 11 | ||||
| -rw-r--r-- | src/components/AmbientControl.tsx | 98 | ||||
| -rw-r--r-- | src/store/audioStore.ts | 84 |
3 files changed, 191 insertions, 2 deletions
diff --git a/src/App.tsx b/src/App.tsx index 966e8e1..cf1e6a6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,8 +3,10 @@ import { TimerView } from './components/TimerView'; import { TaskList } from './components/TaskList'; import { SettingsPanel } from './components/SettingsPanel'; import { NotificationOverlay } from './components/NotificationOverlay'; +import { AmbientControl } from './components/AmbientControl'; import { useTimerEvents } from './hooks/useTimerEvents'; import { useTaskStore } from './store/taskStore'; +import { useAudioStore } from './store/audioStore'; import { useSettingsStore } from './store/settingsStore'; function GearIcon() { @@ -32,12 +34,14 @@ function App() { const fetchTasks = useTaskStore((s) => s.fetchTasks); const fetchSettings = useSettingsStore((s) => s.fetchSettings); + const fetchAudioStatus = useAudioStore((s) => s.fetchStatus); // Bootstrap data on mount useEffect(() => { fetchTasks(); fetchSettings(); - }, [fetchTasks, fetchSettings]); + fetchAudioStatus(); + }, [fetchTasks, fetchSettings, fetchAudioStatus]); const handleCompleted = useCallback((taskId: string | null) => { setNotifTaskId(taskId); @@ -67,11 +71,14 @@ function App() { style={{ display: 'flex', alignItems: 'center', - justifyContent: 'flex-end', + justifyContent: 'space-between', + gap: '16px', padding: '12px 16px', borderBottom: '1px solid var(--line-1)', }} > + <AmbientControl /> + <button onClick={() => setSettingsOpen(true)} title="Settings" diff --git a/src/components/AmbientControl.tsx b/src/components/AmbientControl.tsx new file mode 100644 index 0000000..5b5017a --- /dev/null +++ b/src/components/AmbientControl.tsx @@ -0,0 +1,98 @@ +import { ChangeEvent } from 'react'; +import { useAudioStore, type AmbientSound } from '../store/audioStore'; + +const SOUND_OPTIONS: Array<{ value: AmbientSound; label: string }> = [ + { value: 'none', label: 'Silent' }, + { value: 'rain', label: 'Rain' }, + { value: 'cafe', label: 'Cafe' }, + { value: 'white_noise', label: 'White Noise' }, +]; + +export function AmbientControl() { + const { available, sound, volume, setSound, setVolume } = useAudioStore(); + + const handleSoundChange = async (event: ChangeEvent<HTMLSelectElement>) => { + await setSound(event.target.value as AmbientSound); + }; + + const handleVolumeChange = async (event: ChangeEvent<HTMLInputElement>) => { + await setVolume(Number(event.target.value)); + }; + + return ( + <div + style={{ + display: 'flex', + alignItems: 'center', + gap: '12px', + padding: '8px 10px', + border: '1px solid var(--line-2)', + borderRadius: 'var(--r-3)', + background: 'rgba(17, 21, 28, 0.85)', + boxShadow: 'var(--shadow-1)', + }} + > + <div style={{ display: 'flex', flexDirection: 'column', gap: '2px', minWidth: '90px' }}> + <span className="eyebrow" style={{ letterSpacing: '0.14em' }}> + Ambient + </span> + <span + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '12px', + color: available ? 'var(--fg-3)' : 'var(--negative)', + }} + > + {available ? 'Looping background audio' : 'Audio unavailable'} + </span> + </div> + + <select + value={sound} + onChange={handleSoundChange} + disabled={!available} + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '13px', + color: available ? 'var(--fg-1)' : 'var(--fg-4)', + background: 'var(--ink-3)', + border: '1px solid var(--line-2)', + borderRadius: 'var(--r-2)', + padding: '6px 10px', + outline: 'none', + minWidth: '132px', + }} + > + {SOUND_OPTIONS.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> + + <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> + <input + type="range" + min={0} + max={1} + step={0.05} + value={volume} + onChange={handleVolumeChange} + disabled={!available} + style={{ width: '112px', accentColor: 'var(--brass)' }} + /> + <span + style={{ + fontFamily: 'var(--font-mono)', + fontSize: '12px', + color: available ? 'var(--fg-2)' : 'var(--fg-4)', + width: '36px', + textAlign: 'right', + }} + > + {Math.round(volume * 100)}% + </span> + </div> + </div> + ); +} diff --git a/src/store/audioStore.ts b/src/store/audioStore.ts new file mode 100644 index 0000000..39cbf8b --- /dev/null +++ b/src/store/audioStore.ts @@ -0,0 +1,84 @@ +import { invoke } from '@tauri-apps/api/core'; +import { create } from 'zustand'; + +export type AmbientSound = 'none' | 'rain' | 'cafe' | 'white_noise'; + +interface AudioStatus { + available: boolean; + playing: boolean; + sound: AmbientSound | null; + volume: number; +} + +interface AudioStore { + available: boolean; + playing: boolean; + sound: AmbientSound; + volume: number; + fetchStatus: () => Promise<void>; + setSound: (sound: AmbientSound) => Promise<void>; + setVolume: (volume: number) => Promise<void>; +} + +function normalizeSound(sound: AudioStatus['sound'], playing: boolean): AmbientSound { + if (!playing || sound === null) { + return 'none'; + } + + return sound; +} + +export const useAudioStore = create<AudioStore>((set, get) => ({ + available: true, + playing: false, + sound: 'none', + volume: 0.5, + + fetchStatus: async () => { + try { + const status = await invoke<AudioStatus>('get_audio_status'); + set({ + available: status.available, + playing: status.playing, + sound: normalizeSound(status.sound, status.playing), + volume: status.volume, + }); + } catch (error) { + console.error('get_audio_status error:', error); + set({ available: false, playing: false, sound: 'none' }); + } + }, + + setSound: async (sound) => { + try { + if (sound === 'none') { + await invoke('stop_ambient'); + set({ playing: false, sound: 'none' }); + return; + } + + await invoke('play_ambient', { sound }); + set({ available: true, playing: true, sound }); + } catch (error) { + console.error('play_ambient error:', error); + set({ available: false, playing: false, sound: 'none' }); + } + }, + + setVolume: async (volume) => { + const nextVolume = Math.min(1, Math.max(0, volume)); + set({ volume: nextVolume }); + + try { + await invoke('set_ambient_volume', { volume: nextVolume }); + set({ available: true }); + } catch (error) { + console.error('set_ambient_volume error:', error); + set({ + available: false, + playing: false, + sound: get().sound, + }); + } + }, +})); |
