Merge pull request #35 from cjpais/settings-hook

refactor settings into a hook.
This commit is contained in:
CJ Pais 2025-07-11 17:13:14 -07:00 committed by GitHub
commit f91325cdb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 500 additions and 354 deletions

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import React from "react";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { useSettings } from "../../hooks/useSettings";
interface AlwaysOnMicrophoneProps {
descriptionMode?: "inline" | "tooltip";
@ -11,46 +11,15 @@ export const AlwaysOnMicrophone: React.FC<AlwaysOnMicrophoneProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const [alwaysOnMode, setAlwaysOnMode] = useState<boolean>(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const { getSetting, updateSetting, isUpdating } = useSettings();
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
const alwaysOn: boolean = await invoke("get_microphone_mode");
setAlwaysOnMode(alwaysOn);
} catch (error) {
console.error("Failed to load always-on microphone setting:", error);
}
};
const handleAlwaysOnToggle = async (enabled: boolean) => {
if (isUpdating) return;
try {
setIsUpdating(true);
await invoke("update_microphone_mode", { alwaysOn: enabled });
setAlwaysOnMode(enabled);
// Provide user feedback about the change
console.log(`Always-on microphone ${enabled ? "enabled" : "disabled"}`);
} catch (error) {
console.error("Failed to update microphone mode:", error);
// Revert the toggle if the update failed
setAlwaysOnMode(!enabled);
} finally {
setIsUpdating(false);
}
};
const alwaysOnMode = getSetting("always_on_microphone") || false;
return (
<ToggleSwitch
checked={alwaysOnMode}
onChange={handleAlwaysOnToggle}
isUpdating={isUpdating}
onChange={(enabled) => updateSetting("always_on_microphone", enabled)}
isUpdating={isUpdating("always_on_microphone")}
label="Always-On Microphone"
description="Keep microphone active for low latency recording. This may prevent your computer from sleeping."
descriptionMode={descriptionMode}

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import React from "react";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { useSettings } from "../../hooks/useSettings";
interface AudioFeedbackProps {
descriptionMode?: "inline" | "tooltip";
@ -11,55 +11,15 @@ export const AudioFeedback: React.FC<AudioFeedbackProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const [audioFeedbackEnabled, setAudioFeedbackEnabled] =
useState<boolean>(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const { getSetting, updateSetting, isUpdating } = useSettings();
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
// Load from the store since this setting is handled differently
const { load } = await import("@tauri-apps/plugin-store");
const store = await load("settings_store.json", { autoSave: false });
const settings = await store.get("settings");
if (
settings &&
typeof settings === "object" &&
"audio_feedback" in settings
) {
setAudioFeedbackEnabled(settings.audio_feedback as boolean);
}
} catch (error) {
console.error("Failed to load audio feedback setting:", error);
}
};
const handleAudioFeedbackToggle = async (enabled: boolean) => {
if (isUpdating) return;
try {
setIsUpdating(true);
await invoke("change_audio_feedback_setting", { enabled });
setAudioFeedbackEnabled(enabled);
console.log(`Audio feedback ${enabled ? "enabled" : "disabled"}`);
} catch (error) {
console.error("Failed to update audio feedback setting:", error);
// Revert the toggle if the update failed
setAudioFeedbackEnabled(!enabled);
} finally {
setIsUpdating(false);
}
};
const audioFeedbackEnabled = getSetting("audio_feedback") || false;
return (
<ToggleSwitch
checked={audioFeedbackEnabled}
onChange={handleAudioFeedbackToggle}
isUpdating={isUpdating}
onChange={(enabled) => updateSetting("audio_feedback", enabled)}
isUpdating={isUpdating("audio_feedback")}
label="Audio Feedback"
description="Play sound when recording starts and stops"
descriptionMode={descriptionMode}

View file

@ -1,14 +1,10 @@
import React, { useEffect, useState, useRef } from "react";
import {
BindingResponseSchema,
SettingsSchema,
ShortcutBindingsMap,
} from "../../lib/types";
import { invoke } from "@tauri-apps/api/core";
import { BindingResponseSchema, ShortcutBindingsMap } from "../../lib/types";
import { type } from "@tauri-apps/plugin-os";
import { getKeyName } from "../../lib/utils/keyboard";
import ResetIcon from "../icons/ResetIcon";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings";
interface HandyShortcutProps {
descriptionMode?: "inline" | "tooltip";
@ -19,7 +15,8 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const [bindings, setBindings] = React.useState<ShortcutBindingsMap>({});
const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } =
useSettings();
const [keyPressed, setKeyPressed] = useState<string[]>([]);
const [recordedKeys, setRecordedKeys] = useState<string[]>([]);
const [editingShortcutId, setEditingShortcutId] = useState<string | null>(
@ -27,9 +24,10 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
);
const [originalBinding, setOriginalBinding] = useState<string>("");
const [isMacOS, setIsMacOS] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
const shortcutRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
const bindings = getSetting("bindings") || {};
// Check if running on macOS
useEffect(() => {
const checkOsType = async () => {
@ -76,28 +74,6 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
return combination.split("+").map(formatMacOSKeys).join(" + ");
};
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
setIsLoading(true);
const { load } = await import("@tauri-apps/plugin-store");
const store = await load("settings_store.json", { autoSave: false });
const settings = await store.get("settings");
if (settings) {
const parsedSettings = SettingsSchema.parse(settings);
setBindings(parsedSettings.bindings);
}
} catch (error) {
console.error("Failed to load settings:", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
// Only add event listeners when we're in editing mode
if (editingShortcutId === null) return;
@ -140,31 +116,10 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
const newShortcut = recordedKeys.join("+");
if (editingShortcutId && bindings[editingShortcutId]) {
const updatedBinding = {
...bindings[editingShortcutId],
current_binding: newShortcut,
};
setBindings((prev) => ({
...prev,
[editingShortcutId]: updatedBinding,
}));
try {
await invoke("change_binding", {
id: editingShortcutId,
binding: newShortcut,
});
await updateBinding(editingShortcutId, newShortcut);
} catch (error) {
console.error("Failed to change binding:", error);
// Restore original binding on error
setBindings((prev) => ({
...prev,
[editingShortcutId]: {
...prev[editingShortcutId],
current_binding: originalBinding,
},
}));
}
// Exit editing mode and reset states
@ -180,22 +135,11 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
const handleClickOutside = (e: MouseEvent) => {
const activeElement = shortcutRefs.current.get(editingShortcutId);
if (activeElement && !activeElement.contains(e.target as Node)) {
// Cancel shortcut recording and restore original value
if (editingShortcutId && bindings[editingShortcutId]) {
setBindings((prev) => ({
...prev,
[editingShortcutId]: {
...prev[editingShortcutId],
current_binding: originalBinding,
},
}));
// Reset states
setEditingShortcutId(null);
setKeyPressed([]);
setRecordedKeys([]);
setOriginalBinding("");
}
// Cancel shortcut recording - the hook will handle rollback
setEditingShortcutId(null);
setKeyPressed([]);
setRecordedKeys([]);
setOriginalBinding("");
}
};
@ -208,7 +152,14 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("click", handleClickOutside);
};
}, [keyPressed, recordedKeys, editingShortcutId, bindings, originalBinding]);
}, [
keyPressed,
recordedKeys,
editingShortcutId,
bindings,
originalBinding,
updateBinding,
]);
// Start recording a new shortcut
const startRecording = (id: string) => {
@ -302,24 +253,8 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
)}
<button
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"
onClick={async () => {
try {
const b = await invoke("reset_binding", { id: primaryId });
console.log("reset");
const newBinding = BindingResponseSchema.parse(b);
if (!newBinding.success) {
console.error("Error resetting binding:", newBinding.error);
return;
}
const binding = newBinding.binding!;
setBindings({ ...bindings, [binding.id]: binding });
} catch (error) {
console.error("Failed to reset binding:", error);
}
}}
onClick={() => resetBinding(primaryId)}
disabled={isUpdating(`binding_${primaryId}`)}
>
<ResetIcon className="" />
</button>

View file

@ -1,9 +1,26 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { AudioDevice } from "../../lib/types";
import React from "react";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import ResetIcon from "../icons/ResetIcon";
import { useSettings } from "../../hooks/useSettings";
// Simple refresh icon component
const RefreshIcon: React.FC<{ className?: string }> = ({ className = "" }) => (
<svg
className={`w-4 h-4 ${className}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
);
interface MicrophoneSelectorProps {
descriptionMode?: "inline" | "tooltip";
@ -14,73 +31,28 @@ export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const [audioDevices, setAudioDevices] = useState<AudioDevice[]>([]);
const [selectedMicrophone, setSelectedMicrophone] =
useState<string>("default");
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const {
getSetting,
updateSetting,
resetSetting,
isUpdating,
isLoading,
audioDevices,
refreshAudioDevices,
} = useSettings();
useEffect(() => {
loadAudioDevices();
loadSettings();
}, []);
const loadAudioDevices = async () => {
try {
const devices: AudioDevice[] = await invoke("get_available_microphones");
setAudioDevices(devices);
} catch (error) {
console.error("Failed to load audio devices:", error);
setAudioDevices([]);
}
};
const loadSettings = async () => {
try {
setIsLoading(true);
const selectedMic: string = await invoke("get_selected_microphone");
setSelectedMicrophone(selectedMic);
} catch (error) {
console.error("Failed to load microphone settings:", error);
} finally {
setIsLoading(false);
}
};
const selectedMicrophone = getSetting("selected_microphone") || "Default";
const handleMicrophoneSelect = async (deviceName: string) => {
if (isUpdating) return;
try {
setIsUpdating(true);
await invoke("set_selected_microphone", { deviceName });
setSelectedMicrophone(deviceName);
console.log(
`Microphone changed to: ${audioDevices.find((d) => d.name === deviceName)?.name}`,
);
} catch (error) {
console.error("Failed to set microphone:", error);
// Revert selection if update failed
loadSettings();
} finally {
setIsUpdating(false);
}
await updateSetting("selected_microphone", deviceName);
console.log(
`Microphone changed to: ${audioDevices.find((d) => d.name === deviceName)?.name}`,
);
};
const handleReset = async () => {
if (isUpdating) return;
try {
setIsUpdating(true);
await invoke("set_selected_microphone", { deviceName: "default" });
setSelectedMicrophone("default");
console.log("Microphone reset to default");
} catch (error) {
console.error("Failed to reset microphone:", error);
// Revert selection if reset failed
loadSettings();
} finally {
setIsUpdating(false);
}
await resetSetting("selected_microphone");
console.log("Microphone reset to default");
};
return (
@ -96,17 +68,18 @@ export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = ({
selectedDevice={selectedMicrophone}
onSelect={handleMicrophoneSelect}
placeholder={isLoading ? "Loading..." : "Select microphone..."}
disabled={isUpdating || isLoading}
disabled={isUpdating("selected_microphone") || isLoading}
refreshDevices={refreshAudioDevices}
/>
<button
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"
onClick={handleReset}
disabled={isUpdating || isLoading}
disabled={isUpdating("selected_microphone") || isLoading}
>
<ResetIcon className="" />
</button>
</div>
{isUpdating && (
{isUpdating("selected_microphone") && (
<div className="absolute inset-0 bg-mid-gray/10 rounded flex items-center justify-center">
<div className="w-4 h-4 border-2 border-logo-primary border-t-transparent rounded-full animate-spin"></div>
</div>

View file

@ -1,9 +1,8 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { AudioDevice } from "../../lib/types";
import React from "react";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import ResetIcon from "../icons/ResetIcon";
import { useSettings } from "../../hooks/useSettings";
interface OutputDeviceSelectorProps {
descriptionMode?: "inline" | "tooltip";
@ -14,75 +13,29 @@ export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const [outputDevices, setOutputDevices] = useState<AudioDevice[]>([]);
const [selectedOutputDevice, setSelectedOutputDevice] =
useState<string>("default");
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const {
getSetting,
updateSetting,
resetSetting,
isUpdating,
isLoading,
outputDevices,
refreshOutputDevices,
} = useSettings();
useEffect(() => {
loadOutputDevices();
loadSettings();
}, []);
const loadOutputDevices = async () => {
try {
const devices: AudioDevice[] = await invoke(
"get_available_output_devices",
);
setOutputDevices(devices);
} catch (error) {
console.error("Failed to load output devices:", error);
setOutputDevices([]);
}
};
const loadSettings = async () => {
try {
setIsLoading(true);
const selectedDevice: string = await invoke("get_selected_output_device");
setSelectedOutputDevice(selectedDevice);
} catch (error) {
console.error("Failed to load output device settings:", error);
} finally {
setIsLoading(false);
}
};
const selectedOutputDevice =
getSetting("selected_output_device") || "Default";
const handleOutputDeviceSelect = async (deviceName: string) => {
if (isUpdating) return;
try {
setIsUpdating(true);
await invoke("set_selected_output_device", { deviceName });
setSelectedOutputDevice(deviceName);
console.log(
`Output device changed to: ${outputDevices.find((d) => d.name === deviceName)?.name}`,
);
} catch (error) {
console.error("Failed to set output device:", error);
// Revert selection if update failed
loadSettings();
} finally {
setIsUpdating(false);
}
await updateSetting("selected_output_device", deviceName);
console.log(
`Output device changed to: ${outputDevices.find((d) => d.name === deviceName)?.name}`,
);
};
const handleReset = async () => {
if (isUpdating) return;
try {
setIsUpdating(true);
await invoke("set_selected_output_device", { deviceName: "default" });
setSelectedOutputDevice("default");
console.log("Output device reset to default");
} catch (error) {
console.error("Failed to reset output device:", error);
// Revert selection if reset failed
loadSettings();
} finally {
setIsUpdating(false);
}
await resetSetting("selected_output_device");
console.log("Output device reset to default");
};
return (
@ -98,17 +51,18 @@ export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = ({
selectedDevice={selectedOutputDevice}
onSelect={handleOutputDeviceSelect}
placeholder={isLoading ? "Loading..." : "Select output device..."}
disabled={isUpdating || isLoading}
disabled={isUpdating("selected_output_device") || isLoading}
refreshDevices={refreshOutputDevices}
/>
<button
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"
onClick={handleReset}
disabled={isUpdating || isLoading}
disabled={isUpdating("selected_output_device") || isLoading}
>
<ResetIcon className="" />
</button>
</div>
{isUpdating && (
{isUpdating("selected_output_device") && (
<div className="absolute inset-0 bg-mid-gray/10 rounded flex items-center justify-center">
<div className="w-4 h-4 border-2 border-logo-primary border-t-transparent rounded-full animate-spin"></div>
</div>

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import React from "react";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { useSettings } from "../../hooks/useSettings";
interface PushToTalkProps {
descriptionMode?: "inline" | "tooltip";
@ -11,54 +11,15 @@ export const PushToTalk: React.FC<PushToTalkProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const [pttEnabled, setPttEnabled] = useState<boolean>(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const { getSetting, updateSetting, isUpdating } = useSettings();
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
// Load from the store since this setting is handled differently
const { load } = await import("@tauri-apps/plugin-store");
const store = await load("settings_store.json", { autoSave: false });
const settings = await store.get("settings");
if (
settings &&
typeof settings === "object" &&
"push_to_talk" in settings
) {
setPttEnabled(settings.push_to_talk as boolean);
}
} catch (error) {
console.error("Failed to load push-to-talk setting:", error);
}
};
const handlePttToggle = async (enabled: boolean) => {
if (isUpdating) return;
try {
setIsUpdating(true);
await invoke("change_ptt_setting", { enabled });
setPttEnabled(enabled);
console.log(`Push-to-talk ${enabled ? "enabled" : "disabled"}`);
} catch (error) {
console.error("Failed to update push-to-talk setting:", error);
// Revert the toggle if the update failed
setPttEnabled(!enabled);
} finally {
setIsUpdating(false);
}
};
const pttEnabled = getSetting("push_to_talk") || false;
return (
<ToggleSwitch
checked={pttEnabled}
onChange={handlePttToggle}
isUpdating={isUpdating}
onChange={(enabled) => updateSetting("push_to_talk", enabled)}
isUpdating={isUpdating("push_to_talk")}
label="Push To Talk"
description="Hold to record, release to stop"
descriptionMode={descriptionMode}

View file

@ -7,6 +7,7 @@ interface DropdownProps {
onSelect: (deviceName: string) => void;
placeholder?: string;
disabled?: boolean;
refreshDevices?: () => void;
}
export const Dropdown: React.FC<DropdownProps> = ({
@ -15,6 +16,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
onSelect,
placeholder = "Select a microphone...",
disabled = false,
refreshDevices,
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@ -36,9 +38,19 @@ export const Dropdown: React.FC<DropdownProps> = ({
};
}, []);
// Find the selected device name
// Find the selected device name with proper default handling
const selectedDeviceName = selectedDevice
? devices.find((d) => d.name === selectedDevice)?.name || "Unknown Device"
? (() => {
// First try exact match
let device = devices.find((d) => d.name === selectedDevice);
// If no exact match and selected is "default" or "Default", try both variants
if (!device && selectedDevice.toLowerCase() === "default") {
device = devices.find((d) => d.name.toLowerCase() === "default");
}
return device?.name || "Unknown Device";
})()
: null;
const handleSelect = (deviceName: string) => {
@ -46,6 +58,17 @@ export const Dropdown: React.FC<DropdownProps> = ({
setIsOpen(false);
};
const handleToggle = () => {
if (disabled) return;
// Refresh devices when opening the dropdown
if (!isOpen && refreshDevices) {
refreshDevices();
}
setIsOpen(!isOpen);
};
return (
<div className="relative" ref={dropdownRef}>
<button
@ -55,7 +78,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
? "opacity-50 cursor-not-allowed"
: "hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary"
}`}
onClick={() => !disabled && setIsOpen(!isOpen)}
onClick={handleToggle}
disabled={disabled}
>
<span className="truncate">{selectedDeviceName || placeholder}</span>

371
src/hooks/useSettings.ts Normal file
View file

@ -0,0 +1,371 @@
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 "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",
};
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,
};
};