Merge pull request #35 from cjpais/settings-hook
refactor settings into a hook.
This commit is contained in:
commit
f91325cdb4
8 changed files with 500 additions and 354 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
interface AlwaysOnMicrophoneProps {
|
interface AlwaysOnMicrophoneProps {
|
||||||
descriptionMode?: "inline" | "tooltip";
|
descriptionMode?: "inline" | "tooltip";
|
||||||
|
|
@ -11,46 +11,15 @@ export const AlwaysOnMicrophone: React.FC<AlwaysOnMicrophoneProps> = ({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [alwaysOnMode, setAlwaysOnMode] = useState<boolean>(false);
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const alwaysOnMode = getSetting("always_on_microphone") || false;
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={alwaysOnMode}
|
checked={alwaysOnMode}
|
||||||
onChange={handleAlwaysOnToggle}
|
onChange={(enabled) => updateSetting("always_on_microphone", enabled)}
|
||||||
isUpdating={isUpdating}
|
isUpdating={isUpdating("always_on_microphone")}
|
||||||
label="Always-On Microphone"
|
label="Always-On Microphone"
|
||||||
description="Keep microphone active for low latency recording. This may prevent your computer from sleeping."
|
description="Keep microphone active for low latency recording. This may prevent your computer from sleeping."
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
interface AudioFeedbackProps {
|
interface AudioFeedbackProps {
|
||||||
descriptionMode?: "inline" | "tooltip";
|
descriptionMode?: "inline" | "tooltip";
|
||||||
|
|
@ -11,55 +11,15 @@ export const AudioFeedback: React.FC<AudioFeedbackProps> = ({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [audioFeedbackEnabled, setAudioFeedbackEnabled] =
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
useState<boolean>(false);
|
|
||||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const audioFeedbackEnabled = getSetting("audio_feedback") || false;
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={audioFeedbackEnabled}
|
checked={audioFeedbackEnabled}
|
||||||
onChange={handleAudioFeedbackToggle}
|
onChange={(enabled) => updateSetting("audio_feedback", enabled)}
|
||||||
isUpdating={isUpdating}
|
isUpdating={isUpdating("audio_feedback")}
|
||||||
label="Audio Feedback"
|
label="Audio Feedback"
|
||||||
description="Play sound when recording starts and stops"
|
description="Play sound when recording starts and stops"
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
import React, { useEffect, useState, useRef } from "react";
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
import {
|
import { BindingResponseSchema, ShortcutBindingsMap } from "../../lib/types";
|
||||||
BindingResponseSchema,
|
|
||||||
SettingsSchema,
|
|
||||||
ShortcutBindingsMap,
|
|
||||||
} from "../../lib/types";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { type } from "@tauri-apps/plugin-os";
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
import { getKeyName } from "../../lib/utils/keyboard";
|
import { getKeyName } from "../../lib/utils/keyboard";
|
||||||
import ResetIcon from "../icons/ResetIcon";
|
import ResetIcon from "../icons/ResetIcon";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
interface HandyShortcutProps {
|
interface HandyShortcutProps {
|
||||||
descriptionMode?: "inline" | "tooltip";
|
descriptionMode?: "inline" | "tooltip";
|
||||||
|
|
@ -19,7 +15,8 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [bindings, setBindings] = React.useState<ShortcutBindingsMap>({});
|
const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } =
|
||||||
|
useSettings();
|
||||||
const [keyPressed, setKeyPressed] = useState<string[]>([]);
|
const [keyPressed, setKeyPressed] = useState<string[]>([]);
|
||||||
const [recordedKeys, setRecordedKeys] = useState<string[]>([]);
|
const [recordedKeys, setRecordedKeys] = useState<string[]>([]);
|
||||||
const [editingShortcutId, setEditingShortcutId] = useState<string | null>(
|
const [editingShortcutId, setEditingShortcutId] = useState<string | null>(
|
||||||
|
|
@ -27,9 +24,10 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
);
|
);
|
||||||
const [originalBinding, setOriginalBinding] = useState<string>("");
|
const [originalBinding, setOriginalBinding] = useState<string>("");
|
||||||
const [isMacOS, setIsMacOS] = useState<boolean>(false);
|
const [isMacOS, setIsMacOS] = useState<boolean>(false);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
||||||
const shortcutRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
const shortcutRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||||
|
|
||||||
|
const bindings = getSetting("bindings") || {};
|
||||||
|
|
||||||
// Check if running on macOS
|
// Check if running on macOS
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkOsType = async () => {
|
const checkOsType = async () => {
|
||||||
|
|
@ -76,28 +74,6 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
return combination.split("+").map(formatMacOSKeys).join(" + ");
|
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(() => {
|
useEffect(() => {
|
||||||
// Only add event listeners when we're in editing mode
|
// Only add event listeners when we're in editing mode
|
||||||
if (editingShortcutId === null) return;
|
if (editingShortcutId === null) return;
|
||||||
|
|
@ -140,31 +116,10 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
const newShortcut = recordedKeys.join("+");
|
const newShortcut = recordedKeys.join("+");
|
||||||
|
|
||||||
if (editingShortcutId && bindings[editingShortcutId]) {
|
if (editingShortcutId && bindings[editingShortcutId]) {
|
||||||
const updatedBinding = {
|
|
||||||
...bindings[editingShortcutId],
|
|
||||||
current_binding: newShortcut,
|
|
||||||
};
|
|
||||||
|
|
||||||
setBindings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[editingShortcutId]: updatedBinding,
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke("change_binding", {
|
await updateBinding(editingShortcutId, newShortcut);
|
||||||
id: editingShortcutId,
|
|
||||||
binding: newShortcut,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to change binding:", 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
|
// Exit editing mode and reset states
|
||||||
|
|
@ -180,22 +135,11 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
const activeElement = shortcutRefs.current.get(editingShortcutId);
|
const activeElement = shortcutRefs.current.get(editingShortcutId);
|
||||||
if (activeElement && !activeElement.contains(e.target as Node)) {
|
if (activeElement && !activeElement.contains(e.target as Node)) {
|
||||||
// Cancel shortcut recording and restore original value
|
// Cancel shortcut recording - the hook will handle rollback
|
||||||
if (editingShortcutId && bindings[editingShortcutId]) {
|
setEditingShortcutId(null);
|
||||||
setBindings((prev) => ({
|
setKeyPressed([]);
|
||||||
...prev,
|
setRecordedKeys([]);
|
||||||
[editingShortcutId]: {
|
setOriginalBinding("");
|
||||||
...prev[editingShortcutId],
|
|
||||||
current_binding: originalBinding,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Reset states
|
|
||||||
setEditingShortcutId(null);
|
|
||||||
setKeyPressed([]);
|
|
||||||
setRecordedKeys([]);
|
|
||||||
setOriginalBinding("");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -208,7 +152,14 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
window.removeEventListener("keyup", handleKeyUp);
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
window.removeEventListener("click", handleClickOutside);
|
window.removeEventListener("click", handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [keyPressed, recordedKeys, editingShortcutId, bindings, originalBinding]);
|
}, [
|
||||||
|
keyPressed,
|
||||||
|
recordedKeys,
|
||||||
|
editingShortcutId,
|
||||||
|
bindings,
|
||||||
|
originalBinding,
|
||||||
|
updateBinding,
|
||||||
|
]);
|
||||||
|
|
||||||
// Start recording a new shortcut
|
// Start recording a new shortcut
|
||||||
const startRecording = (id: string) => {
|
const startRecording = (id: string) => {
|
||||||
|
|
@ -302,24 +253,8 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
)}
|
)}
|
||||||
<button
|
<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"
|
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 () => {
|
onClick={() => resetBinding(primaryId)}
|
||||||
try {
|
disabled={isUpdating(`binding_${primaryId}`)}
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ResetIcon className="" />
|
<ResetIcon className="" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,26 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { AudioDevice } from "../../lib/types";
|
|
||||||
import { Dropdown } from "../ui/Dropdown";
|
import { Dropdown } from "../ui/Dropdown";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
import ResetIcon from "../icons/ResetIcon";
|
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 {
|
interface MicrophoneSelectorProps {
|
||||||
descriptionMode?: "inline" | "tooltip";
|
descriptionMode?: "inline" | "tooltip";
|
||||||
|
|
@ -14,73 +31,28 @@ export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = ({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [audioDevices, setAudioDevices] = useState<AudioDevice[]>([]);
|
const {
|
||||||
const [selectedMicrophone, setSelectedMicrophone] =
|
getSetting,
|
||||||
useState<string>("default");
|
updateSetting,
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
resetSetting,
|
||||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
isUpdating,
|
||||||
|
isLoading,
|
||||||
|
audioDevices,
|
||||||
|
refreshAudioDevices,
|
||||||
|
} = useSettings();
|
||||||
|
|
||||||
useEffect(() => {
|
const selectedMicrophone = getSetting("selected_microphone") || "Default";
|
||||||
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 handleMicrophoneSelect = async (deviceName: string) => {
|
const handleMicrophoneSelect = async (deviceName: string) => {
|
||||||
if (isUpdating) return;
|
await updateSetting("selected_microphone", deviceName);
|
||||||
|
console.log(
|
||||||
try {
|
`Microphone changed to: ${audioDevices.find((d) => d.name === deviceName)?.name}`,
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
if (isUpdating) return;
|
await resetSetting("selected_microphone");
|
||||||
|
console.log("Microphone reset to default");
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -96,17 +68,18 @@ export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = ({
|
||||||
selectedDevice={selectedMicrophone}
|
selectedDevice={selectedMicrophone}
|
||||||
onSelect={handleMicrophoneSelect}
|
onSelect={handleMicrophoneSelect}
|
||||||
placeholder={isLoading ? "Loading..." : "Select microphone..."}
|
placeholder={isLoading ? "Loading..." : "Select microphone..."}
|
||||||
disabled={isUpdating || isLoading}
|
disabled={isUpdating("selected_microphone") || isLoading}
|
||||||
|
refreshDevices={refreshAudioDevices}
|
||||||
/>
|
/>
|
||||||
<button
|
<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"
|
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}
|
onClick={handleReset}
|
||||||
disabled={isUpdating || isLoading}
|
disabled={isUpdating("selected_microphone") || isLoading}
|
||||||
>
|
>
|
||||||
<ResetIcon className="" />
|
<ResetIcon className="" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isUpdating && (
|
{isUpdating("selected_microphone") && (
|
||||||
<div className="absolute inset-0 bg-mid-gray/10 rounded flex items-center justify-center">
|
<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 className="w-4 h-4 border-2 border-logo-primary border-t-transparent rounded-full animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { AudioDevice } from "../../lib/types";
|
|
||||||
import { Dropdown } from "../ui/Dropdown";
|
import { Dropdown } from "../ui/Dropdown";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
import ResetIcon from "../icons/ResetIcon";
|
import ResetIcon from "../icons/ResetIcon";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
interface OutputDeviceSelectorProps {
|
interface OutputDeviceSelectorProps {
|
||||||
descriptionMode?: "inline" | "tooltip";
|
descriptionMode?: "inline" | "tooltip";
|
||||||
|
|
@ -14,75 +13,29 @@ export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = ({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [outputDevices, setOutputDevices] = useState<AudioDevice[]>([]);
|
const {
|
||||||
const [selectedOutputDevice, setSelectedOutputDevice] =
|
getSetting,
|
||||||
useState<string>("default");
|
updateSetting,
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
resetSetting,
|
||||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
isUpdating,
|
||||||
|
isLoading,
|
||||||
|
outputDevices,
|
||||||
|
refreshOutputDevices,
|
||||||
|
} = useSettings();
|
||||||
|
|
||||||
useEffect(() => {
|
const selectedOutputDevice =
|
||||||
loadOutputDevices();
|
getSetting("selected_output_device") || "Default";
|
||||||
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 handleOutputDeviceSelect = async (deviceName: string) => {
|
const handleOutputDeviceSelect = async (deviceName: string) => {
|
||||||
if (isUpdating) return;
|
await updateSetting("selected_output_device", deviceName);
|
||||||
|
console.log(
|
||||||
try {
|
`Output device changed to: ${outputDevices.find((d) => d.name === deviceName)?.name}`,
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
if (isUpdating) return;
|
await resetSetting("selected_output_device");
|
||||||
|
console.log("Output device reset to default");
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -98,17 +51,18 @@ export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = ({
|
||||||
selectedDevice={selectedOutputDevice}
|
selectedDevice={selectedOutputDevice}
|
||||||
onSelect={handleOutputDeviceSelect}
|
onSelect={handleOutputDeviceSelect}
|
||||||
placeholder={isLoading ? "Loading..." : "Select output device..."}
|
placeholder={isLoading ? "Loading..." : "Select output device..."}
|
||||||
disabled={isUpdating || isLoading}
|
disabled={isUpdating("selected_output_device") || isLoading}
|
||||||
|
refreshDevices={refreshOutputDevices}
|
||||||
/>
|
/>
|
||||||
<button
|
<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"
|
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}
|
onClick={handleReset}
|
||||||
disabled={isUpdating || isLoading}
|
disabled={isUpdating("selected_output_device") || isLoading}
|
||||||
>
|
>
|
||||||
<ResetIcon className="" />
|
<ResetIcon className="" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isUpdating && (
|
{isUpdating("selected_output_device") && (
|
||||||
<div className="absolute inset-0 bg-mid-gray/10 rounded flex items-center justify-center">
|
<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 className="w-4 h-4 border-2 border-logo-primary border-t-transparent rounded-full animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
interface PushToTalkProps {
|
interface PushToTalkProps {
|
||||||
descriptionMode?: "inline" | "tooltip";
|
descriptionMode?: "inline" | "tooltip";
|
||||||
|
|
@ -11,54 +11,15 @@ export const PushToTalk: React.FC<PushToTalkProps> = ({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [pttEnabled, setPttEnabled] = useState<boolean>(false);
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const pttEnabled = getSetting("push_to_talk") || false;
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={pttEnabled}
|
checked={pttEnabled}
|
||||||
onChange={handlePttToggle}
|
onChange={(enabled) => updateSetting("push_to_talk", enabled)}
|
||||||
isUpdating={isUpdating}
|
isUpdating={isUpdating("push_to_talk")}
|
||||||
label="Push To Talk"
|
label="Push To Talk"
|
||||||
description="Hold to record, release to stop"
|
description="Hold to record, release to stop"
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ interface DropdownProps {
|
||||||
onSelect: (deviceName: string) => void;
|
onSelect: (deviceName: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
refreshDevices?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Dropdown: React.FC<DropdownProps> = ({
|
export const Dropdown: React.FC<DropdownProps> = ({
|
||||||
|
|
@ -15,6 +16,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
|
||||||
onSelect,
|
onSelect,
|
||||||
placeholder = "Select a microphone...",
|
placeholder = "Select a microphone...",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
refreshDevices,
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
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
|
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;
|
: null;
|
||||||
|
|
||||||
const handleSelect = (deviceName: string) => {
|
const handleSelect = (deviceName: string) => {
|
||||||
|
|
@ -46,6 +58,17 @@ export const Dropdown: React.FC<DropdownProps> = ({
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
// Refresh devices when opening the dropdown
|
||||||
|
if (!isOpen && refreshDevices) {
|
||||||
|
refreshDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -55,7 +78,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
|
||||||
? "opacity-50 cursor-not-allowed"
|
? "opacity-50 cursor-not-allowed"
|
||||||
: "hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary"
|
: "hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
onClick={handleToggle}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<span className="truncate">{selectedDeviceName || placeholder}</span>
|
<span className="truncate">{selectedDeviceName || placeholder}</span>
|
||||||
|
|
|
||||||
371
src/hooks/useSettings.ts
Normal file
371
src/hooks/useSettings.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue