summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.tsx11
-rw-r--r--src/components/AmbientControl.tsx98
-rw-r--r--src/store/audioStore.ts84
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,
+ });
+ }
+ },
+}));