Handy/src/hooks/useSettings.ts

387 lines
11 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Settings, AudioDevice } from "../lib/types";
interface SettingsState {
settings: Settings | null;
isLoading: boolean;
isUpdating: Record<string, boolean>;
audioDevices: AudioDevice[];
outputDevices: AudioDevice[];
}
interface UseSettingsReturn {
// State
settings: Settings | null;
isLoading: boolean;
isUpdating: (key: string) => boolean;
audioDevices: AudioDevice[];
outputDevices: AudioDevice[];
// Actions
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>;
// Binding-specific actions
updateBinding: (id: string, binding: string) => Promise<void>;
resetBinding: (id: string) => Promise<void>;
// Convenience getters
getSetting: <K extends keyof Settings>(key: K) => Settings[K] | undefined;
}
export const useSettings = (): UseSettingsReturn => {
const [state, setState] = useState<SettingsState>({
settings: null,
isLoading: true,
isUpdating: {},
audioDevices: [],
outputDevices: [],
});
// Load settings from store
const loadSettings = useCallback(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",
};
setState((prev) => ({
...prev,
settings: mergedSettings,
isLoading: false,
}));
} catch (error) {
console.error("Failed to load settings:", error);
setState((prev) => ({
...prev,
isLoading: false,
}));
}
}, []);
// Load audio devices
const loadAudioDevices = useCallback(async () => {
try {
const devices: AudioDevice[] = await invoke("get_available_microphones");
// Always ensure "Default" is available as the first option
const devicesWithDefault = [
{ index: "default", name: "Default", is_default: true },
...devices.filter((d) => d.name !== "Default" && d.name !== "default"),
];
setState((prev) => ({
...prev,
audioDevices: devicesWithDefault,
}));
} catch (error) {
console.error("Failed to load audio devices:", error);
setState((prev) => ({
...prev,
audioDevices: [{ index: "default", name: "Default", is_default: true }],
}));
}
}, []);
// Load output devices
const loadOutputDevices = useCallback(async () => {
try {
const devices: AudioDevice[] = await invoke(
"get_available_output_devices",
);
// Always ensure "Default" is available as the first option
const devicesWithDefault = [
{ index: "default", name: "Default", is_default: true },
...devices.filter((d) => d.name !== "Default" && d.name !== "default"),
];
setState((prev) => ({
...prev,
outputDevices: devicesWithDefault,
}));
} catch (error) {
console.error("Failed to load output devices:", error);
setState((prev) => ({
...prev,
outputDevices: [
{ index: "default", name: "Default", is_default: true },
],
}));
}
}, []);
// Update a specific setting
const updateSetting = useCallback(
async <K extends keyof Settings>(key: K, value: Settings[K]) => {
const updateKey = String(key);
// Set updating state
setState((prev) => ({
...prev,
isUpdating: { ...prev.isUpdating, [updateKey]: true },
}));
// Store original value for rollback
const originalValue = state.settings?.[key];
try {
// Optimistic update
setState((prev) => ({
...prev,
settings: prev.settings ? { ...prev.settings, [key]: value } : null,
}));
// Invoke the appropriate backend method based on the setting
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":
// Map "Default" to "default" for backend compatibility
const micDeviceName = value === "Default" ? "default" : value;
await invoke("set_selected_microphone", {
deviceName: micDeviceName,
});
break;
case "selected_output_device":
// Map "Default" to "default" for backend compatibility
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 "show_overlay":
await invoke("change_show_overlay_setting", { enabled: value });
break;
case "bindings":
// Handle bindings separately - they use their own invoke methods
break;
case "selected_model":
// Handle model selection if needed
break;
default:
console.warn(`No handler for setting: ${String(key)}`);
}
console.log(`Setting ${String(key)} updated to:`, value);
} catch (error) {
console.error(`Failed to update setting ${String(key)}:`, error);
// Rollback on error
setState((prev) => ({
...prev,
settings: prev.settings
? { ...prev.settings, [key]: originalValue }
: null,
}));
} finally {
// Clear updating state
setState((prev) => ({
...prev,
isUpdating: { ...prev.isUpdating, [updateKey]: false },
}));
}
},
[state.settings],
);
// Reset a setting to its default value
const resetSetting = useCallback(
async (key: keyof Settings) => {
// Define default values
const defaults: 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",
show_overlay: true,
};
const defaultValue = defaults[key];
if (defaultValue !== undefined) {
await updateSetting(key, defaultValue as any);
}
},
[updateSetting],
);
// Convenience getter
const getSetting = useCallback(
<K extends keyof Settings>(key: K): Settings[K] | undefined => {
return state.settings?.[key];
},
[state.settings],
);
// Update a specific binding
const updateBinding = useCallback(
async (id: string, binding: string) => {
const updateKey = `binding_${id}`;
// Set updating state
setState((prev) => ({
...prev,
isUpdating: { ...prev.isUpdating, [updateKey]: true },
}));
// Store original binding for rollback
const originalBinding = state.settings?.bindings?.[id]?.current_binding;
try {
// Optimistic update
setState((prev) => ({
...prev,
settings: prev.settings
? {
...prev.settings,
bindings: {
...prev.settings.bindings,
[id]: {
...prev.settings.bindings[id],
current_binding: binding,
},
},
}
: null,
}));
await invoke("change_binding", { id, binding });
console.log(`Binding ${id} updated to: ${binding}`);
} catch (error) {
console.error(`Failed to update binding ${id}:`, error);
// Rollback on error
if (originalBinding) {
setState((prev) => ({
...prev,
settings: prev.settings
? {
...prev.settings,
bindings: {
...prev.settings.bindings,
[id]: {
...prev.settings.bindings[id],
current_binding: originalBinding,
},
},
}
: null,
}));
}
} finally {
// Clear updating state
setState((prev) => ({
...prev,
isUpdating: { ...prev.isUpdating, [updateKey]: false },
}));
}
},
[state.settings],
);
// Reset a specific binding
const resetBinding = useCallback(
async (id: string) => {
const updateKey = `binding_${id}`;
// Set updating state
setState((prev) => ({
...prev,
isUpdating: { ...prev.isUpdating, [updateKey]: true },
}));
try {
const result = await invoke("reset_binding", { id });
// Refresh settings to get the updated binding
await loadSettings();
console.log(`Binding ${id} reset to default`);
} catch (error) {
console.error(`Failed to reset binding ${id}:`, error);
} finally {
// Clear updating state
setState((prev) => ({
...prev,
isUpdating: { ...prev.isUpdating, [updateKey]: false },
}));
}
},
[loadSettings],
);
// Initialize
useEffect(() => {
loadSettings();
loadAudioDevices();
loadOutputDevices();
}, [loadSettings, loadAudioDevices, loadOutputDevices]);
return {
settings: state.settings,
isLoading: state.isLoading,
isUpdating: (key: string) => state.isUpdating[key] || false,
audioDevices: state.audioDevices,
outputDevices: state.outputDevices,
updateSetting,
resetSetting,
refreshSettings: loadSettings,
refreshAudioDevices: loadAudioDevices,
refreshOutputDevices: loadOutputDevices,
updateBinding,
resetBinding,
getSetting,
};
};