Handy/src/stores/settingsStore.ts
CJ Pais d6684ccb21
UI v2 (#103)
* wip ui

* pretty much fully new settings page

* proper dark/light mode

* slight style tweak for sidebar

* add about
2025-09-05 12:56:55 -07:00

300 lines
9.8 KiB
TypeScript

import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { Settings, AudioDevice } from '../lib/types';
interface SettingsStore {
settings: Settings | null;
isLoading: boolean;
isUpdating: Record<string, boolean>;
audioDevices: AudioDevice[];
outputDevices: AudioDevice[];
// Actions
initialize: () => Promise<void>;
updateSetting: <K extends keyof Settings>(key: K, value: Settings[K]) => Promise<void>;
resetSetting: (key: keyof Settings) => Promise<void>;
refreshSettings: () => Promise<void>;
refreshAudioDevices: () => Promise<void>;
refreshOutputDevices: () => Promise<void>;
updateBinding: (id: string, binding: string) => Promise<void>;
resetBinding: (id: string) => Promise<void>;
getSetting: <K extends keyof Settings>(key: K) => Settings[K] | undefined;
isUpdatingKey: (key: string) => boolean;
// Internal state setters
setSettings: (settings: Settings | null) => void;
setLoading: (loading: boolean) => void;
setUpdating: (key: string, updating: boolean) => void;
setAudioDevices: (devices: AudioDevice[]) => void;
setOutputDevices: (devices: AudioDevice[]) => void;
}
const DEFAULT_SETTINGS: Partial<Settings> = {
always_on_microphone: false,
audio_feedback: true,
push_to_talk: false,
selected_microphone: "Default",
selected_output_device: "Default",
translate_to_english: false,
selected_language: "auto",
overlay_position: "bottom",
debug_mode: false,
custom_words: [],
};
const DEFAULT_AUDIO_DEVICE: AudioDevice = {
index: "default",
name: "Default",
is_default: true
};
export const useSettingsStore = create<SettingsStore>()(
subscribeWithSelector((set, get) => ({
settings: null,
isLoading: true,
isUpdating: {},
audioDevices: [],
outputDevices: [],
// Internal setters
setSettings: (settings) => set({ settings }),
setLoading: (isLoading) => set({ isLoading }),
setUpdating: (key, updating) =>
set((state) => ({
isUpdating: { ...state.isUpdating, [key]: updating }
})),
setAudioDevices: (audioDevices) => set({ audioDevices }),
setOutputDevices: (outputDevices) => set({ outputDevices }),
// Getters
getSetting: (key) => get().settings?.[key],
isUpdatingKey: (key) => get().isUpdating[key] || false,
// Load settings from store
refreshSettings: async () => {
try {
const { load } = await import("@tauri-apps/plugin-store");
const store = await load("settings_store.json", { autoSave: false });
const settings = (await store.get("settings")) as Settings;
// Load additional settings that come from invoke calls
const [microphoneMode, selectedMicrophone, selectedOutputDevice] =
await Promise.allSettled([
invoke("get_microphone_mode"),
invoke("get_selected_microphone"),
invoke("get_selected_output_device"),
]);
// Merge all settings
const mergedSettings: Settings = {
...settings,
always_on_microphone:
microphoneMode.status === "fulfilled"
? (microphoneMode.value as boolean)
: false,
selected_microphone:
selectedMicrophone.status === "fulfilled"
? (selectedMicrophone.value as string)
: "Default",
selected_output_device:
selectedOutputDevice.status === "fulfilled"
? (selectedOutputDevice.value as string)
: "Default",
};
set({ settings: mergedSettings, isLoading: false });
} catch (error) {
console.error("Failed to load settings:", error);
set({ isLoading: false });
}
},
// Load audio devices
refreshAudioDevices: async () => {
try {
const devices: AudioDevice[] = await invoke("get_available_microphones");
const devicesWithDefault = [
DEFAULT_AUDIO_DEVICE,
...devices.filter((d) => d.name !== "Default" && d.name !== "default"),
];
set({ audioDevices: devicesWithDefault });
} catch (error) {
console.error("Failed to load audio devices:", error);
set({ audioDevices: [DEFAULT_AUDIO_DEVICE] });
}
},
// Load output devices
refreshOutputDevices: async () => {
try {
const devices: AudioDevice[] = await invoke("get_available_output_devices");
const devicesWithDefault = [
DEFAULT_AUDIO_DEVICE,
...devices.filter((d) => d.name !== "Default" && d.name !== "default"),
];
set({ outputDevices: devicesWithDefault });
} catch (error) {
console.error("Failed to load output devices:", error);
set({ outputDevices: [DEFAULT_AUDIO_DEVICE] });
}
},
// Update a specific setting
updateSetting: async <K extends keyof Settings>(key: K, value: Settings[K]) => {
const { settings, setUpdating, refreshSettings } = get();
const updateKey = String(key);
const originalValue = settings?.[key];
setUpdating(updateKey, true);
try {
// Optimistic update
set((state) => ({
settings: state.settings ? { ...state.settings, [key]: value } : null,
}));
// Invoke the appropriate backend method
switch (key) {
case "always_on_microphone":
await invoke("update_microphone_mode", { alwaysOn: value });
break;
case "audio_feedback":
await invoke("change_audio_feedback_setting", { enabled: value });
break;
case "push_to_talk":
await invoke("change_ptt_setting", { enabled: value });
break;
case "selected_microphone":
const micDeviceName = value === "Default" ? "default" : value;
await invoke("set_selected_microphone", { deviceName: micDeviceName });
break;
case "selected_output_device":
const outputDeviceName = value === "Default" ? "default" : value;
await invoke("set_selected_output_device", { deviceName: outputDeviceName });
break;
case "translate_to_english":
await invoke("change_translate_to_english_setting", { enabled: value });
break;
case "selected_language":
await invoke("change_selected_language_setting", { language: value });
break;
case "overlay_position":
await invoke("change_overlay_position_setting", { position: value });
break;
case "debug_mode":
await invoke("change_debug_mode_setting", { enabled: value });
break;
case "custom_words":
await invoke("update_custom_words", { words: value });
break;
case "word_correction_threshold":
await invoke("change_word_correction_threshold_setting", { threshold: value });
break;
case "bindings":
case "selected_model":
break;
default:
console.warn(`No handler for setting: ${String(key)}`);
}
} catch (error) {
console.error(`Failed to update setting ${String(key)}:`, error);
// Rollback on error
set((state) => ({
settings: state.settings
? { ...state.settings, [key]: originalValue }
: null,
}));
} finally {
setUpdating(updateKey, false);
}
},
// Reset a setting to its default value
resetSetting: async (key) => {
const defaultValue = DEFAULT_SETTINGS[key];
if (defaultValue !== undefined) {
await get().updateSetting(key, defaultValue as any);
}
},
// Update a specific binding
updateBinding: async (id, binding) => {
const { settings, setUpdating } = get();
const updateKey = `binding_${id}`;
const originalBinding = settings?.bindings?.[id]?.current_binding;
setUpdating(updateKey, true);
try {
// Optimistic update
set((state) => ({
settings: state.settings
? {
...state.settings,
bindings: {
...state.settings.bindings,
[id]: {
...state.settings.bindings[id],
current_binding: binding,
},
},
}
: null,
}));
await invoke("change_binding", { id, binding });
} catch (error) {
console.error(`Failed to update binding ${id}:`, error);
// Rollback on error
if (originalBinding && get().settings) {
set((state) => ({
settings: state.settings
? {
...state.settings,
bindings: {
...state.settings.bindings,
[id]: {
...state.settings.bindings[id],
current_binding: originalBinding,
},
},
}
: null,
}));
}
} finally {
setUpdating(updateKey, false);
}
},
// Reset a specific binding
resetBinding: async (id) => {
const { setUpdating, refreshSettings } = get();
const updateKey = `binding_${id}`;
setUpdating(updateKey, true);
try {
await invoke("reset_binding", { id });
await refreshSettings();
} catch (error) {
console.error(`Failed to reset binding ${id}:`, error);
} finally {
setUpdating(updateKey, false);
}
},
// Initialize everything
initialize: async () => {
const { refreshSettings, refreshAudioDevices, refreshOutputDevices } = get();
await Promise.all([
refreshSettings(),
refreshAudioDevices(),
refreshOutputDevices(),
]);
},
}))
);