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 { 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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
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