UI v2 (#103)
* wip ui * pretty much fully new settings page * proper dark/light mode * slight style tweak for sidebar * add about
This commit is contained in:
parent
cd6102c69a
commit
d6684ccb21
22 changed files with 783 additions and 522 deletions
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
|
@ -23,12 +23,14 @@
|
|||
"@tauri-apps/plugin-stronghold": "~2",
|
||||
"@tauri-apps/plugin-updater": "~2",
|
||||
"@tauri-apps/plugin-upload": "~2",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwindcss": "^4.0.2",
|
||||
"tauri-plugin-macos-permissions-api": "^2.0.4",
|
||||
"zod": "^3.24.4"
|
||||
"zod": "^3.24.4",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@
|
|||
"windows": [
|
||||
{
|
||||
"title": "Handy",
|
||||
"width": 540,
|
||||
"height": 740,
|
||||
"minWidth": 540,
|
||||
"minHeight": 740,
|
||||
"width": 680,
|
||||
"height": 510,
|
||||
"minWidth": 680,
|
||||
"minHeight": 510,
|
||||
"resizable": true,
|
||||
"maximizable": false
|
||||
}
|
||||
|
|
|
|||
64
src/App.tsx
64
src/App.tsx
|
|
@ -6,15 +6,50 @@ import AccessibilityPermissions from "./components/AccessibilityPermissions";
|
|||
import Footer from "./components/footer";
|
||||
import HandyTextLogo from "./components/icons/HandyTextLogo";
|
||||
import Onboarding from "./components/onboarding";
|
||||
import { Settings } from "./components/settings/Settings";
|
||||
import { Sidebar, SidebarSection, SECTIONS_CONFIG } from "./components/Sidebar";
|
||||
import { useSettings } from "./hooks/useSettings";
|
||||
|
||||
const renderSettingsContent = (section: SidebarSection) => {
|
||||
const ActiveComponent =
|
||||
SECTIONS_CONFIG[section]?.component || SECTIONS_CONFIG.general.component;
|
||||
return <ActiveComponent />;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
|
||||
const [currentSection, setCurrentSection] =
|
||||
useState<SidebarSection>("general");
|
||||
const { settings, updateSetting } = useSettings();
|
||||
|
||||
useEffect(() => {
|
||||
checkOnboardingStatus();
|
||||
}, []);
|
||||
|
||||
// Handle keyboard shortcuts for debug mode toggle
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Check for Ctrl+Shift+D (Windows/Linux) or Cmd+Shift+D (macOS)
|
||||
const isDebugShortcut =
|
||||
event.shiftKey &&
|
||||
event.key.toLowerCase() === "d" &&
|
||||
(event.ctrlKey || event.metaKey);
|
||||
|
||||
if (isDebugShortcut) {
|
||||
event.preventDefault();
|
||||
const currentDebugMode = settings?.debug_mode ?? false;
|
||||
updateSetting("debug_mode", !currentDebugMode);
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener when component mounts
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
// Cleanup event listener when component unmounts
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [settings?.debug_mode, updateSetting]);
|
||||
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
// Always check if they have any models available
|
||||
|
|
@ -33,23 +68,36 @@ function App() {
|
|||
|
||||
if (showOnboarding) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col w-full">
|
||||
<div className="flex flex-col items-center p-4 gap-4 flex-1">
|
||||
<div className="h-screen flex flex-col">
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-4 gap-4">
|
||||
<HandyTextLogo width={200} />
|
||||
<Onboarding onModelSelected={handleModelSelected} />
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col w-full">
|
||||
<div className="h-screen flex flex-col">
|
||||
<Toaster />
|
||||
<div className="flex flex-col items-center p-4 gap-4 flex-1">
|
||||
<HandyTextLogo width={200} />
|
||||
<AccessibilityPermissions />
|
||||
<Settings />
|
||||
{/* Main content area that takes remaining space */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<Sidebar
|
||||
activeSection={currentSection}
|
||||
onSectionChange={setCurrentSection}
|
||||
/>
|
||||
{/* Scrollable content area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col items-center p-4 gap-4">
|
||||
<AccessibilityPermissions />
|
||||
{renderSettingsContent(currentSection)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Fixed footer at bottom */}
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
105
src/components/Sidebar.tsx
Normal file
105
src/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React from "react";
|
||||
import { Cog, FlaskConical, History, Info } from "lucide-react";
|
||||
import HandyTextLogo from "./icons/HandyTextLogo";
|
||||
import HandyHand from "./icons/HandyHand";
|
||||
import { useSettings } from "../hooks/useSettings";
|
||||
import {
|
||||
GeneralSettings,
|
||||
AdvancedSettings,
|
||||
HistorySettings,
|
||||
DebugSettings,
|
||||
AboutSettings,
|
||||
} from "./settings";
|
||||
|
||||
export type SidebarSection = keyof typeof SECTIONS_CONFIG;
|
||||
|
||||
interface IconProps {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
size?: number | string;
|
||||
className?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface SectionConfig {
|
||||
label: string;
|
||||
icon: React.ComponentType<IconProps>;
|
||||
component: React.ComponentType;
|
||||
enabled: (settings: any) => boolean;
|
||||
}
|
||||
|
||||
export const SECTIONS_CONFIG = {
|
||||
general: {
|
||||
label: "General",
|
||||
icon: HandyHand,
|
||||
component: GeneralSettings,
|
||||
enabled: () => true,
|
||||
},
|
||||
advanced: {
|
||||
label: "Advanced",
|
||||
icon: Cog,
|
||||
component: AdvancedSettings,
|
||||
enabled: () => true,
|
||||
},
|
||||
// history: {
|
||||
// label: "History",
|
||||
// icon: History,
|
||||
// component: HistorySettings,
|
||||
// enabled: () => true,
|
||||
// },
|
||||
debug: {
|
||||
label: "Debug",
|
||||
icon: FlaskConical,
|
||||
component: DebugSettings,
|
||||
enabled: (settings) => settings?.debug_mode ?? false,
|
||||
},
|
||||
about: {
|
||||
label: "About",
|
||||
icon: Info,
|
||||
component: AboutSettings,
|
||||
enabled: () => true,
|
||||
},
|
||||
} as const satisfies Record<string, SectionConfig>;
|
||||
|
||||
interface SidebarProps {
|
||||
activeSection: SidebarSection;
|
||||
onSectionChange: (section: SidebarSection) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
activeSection,
|
||||
onSectionChange,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
|
||||
const availableSections = Object.entries(SECTIONS_CONFIG)
|
||||
.filter(([_, config]) => config.enabled(settings))
|
||||
.map(([id, config]) => ({ id: id as SidebarSection, ...config }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-40 h-full border-r border-mid-gray/20 items-center px-2">
|
||||
<HandyTextLogo width={120} className="m-4" />
|
||||
<div className="flex flex-col w-full items-center gap-1 pt-2 border-t border-mid-gray/20">
|
||||
{availableSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
const isActive = activeSection === section.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={section.id}
|
||||
className={`flex gap-2 items-center p-2 w-full rounded-lg cursor-pointer transition-colors ${
|
||||
isActive
|
||||
? "bg-logo-primary/80"
|
||||
: "hover:bg-mid-gray/20 hover:opacity-100 opacity-85"
|
||||
}`}
|
||||
onClick={() => onSectionChange(section.id)}
|
||||
>
|
||||
<Icon width={24} height={24} />
|
||||
<p className="text-sm font-medium">{section.label}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -22,7 +22,7 @@ const Footer: React.FC = () => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full border-t border-mid-gray/20 mt-4 pt-3">
|
||||
<div className="w-full border-t border-mid-gray/20 pt-3">
|
||||
<div className="flex justify-between items-center text-xs px-4 pb-3 text-text/60">
|
||||
<div className="flex items-center gap-4">
|
||||
<ModelSelector />
|
||||
|
|
|
|||
24
src/components/icons/HandyHand.tsx
Normal file
24
src/components/icons/HandyHand.tsx
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -3,14 +3,17 @@ import React from "react";
|
|||
const HandyTextLogo = ({
|
||||
width,
|
||||
height,
|
||||
className,
|
||||
}: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 930 328"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
|
|||
70
src/components/settings/AboutSettings.tsx
Normal file
70
src/components/settings/AboutSettings.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||
import { SettingContainer } from "../ui/SettingContainer";
|
||||
import { Button } from "../ui/Button";
|
||||
|
||||
export const AboutSettings: React.FC = () => {
|
||||
const [version, setVersion] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const appVersion = await getVersion();
|
||||
setVersion(appVersion);
|
||||
} catch (error) {
|
||||
console.error("Failed to get app version:", error);
|
||||
setVersion("0.1.2");
|
||||
}
|
||||
};
|
||||
|
||||
fetchVersion();
|
||||
}, []);
|
||||
|
||||
const handleDonateClick = async () => {
|
||||
try {
|
||||
await openUrl("https://handy.computer/donate");
|
||||
} catch (error) {
|
||||
console.error("Failed to open donate link:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
||||
<SettingsGroup title="About">
|
||||
<SettingContainer
|
||||
title="Version"
|
||||
description="Current version of Handy"
|
||||
grouped={true}
|
||||
>
|
||||
<span className="text-sm font-mono">v{version}</span>
|
||||
</SettingContainer>
|
||||
|
||||
<SettingContainer
|
||||
title="Support Development"
|
||||
description="Help us continue building Handy"
|
||||
grouped={true}
|
||||
>
|
||||
<Button variant="primary" size="md" onClick={handleDonateClick}>
|
||||
Donate
|
||||
</Button>
|
||||
</SettingContainer>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Acknowledgments">
|
||||
<SettingContainer
|
||||
title="Whisper.cpp"
|
||||
description="High-performance inference of OpenAI's Whisper automatic speech recognition model"
|
||||
grouped={true}
|
||||
layout="stacked"
|
||||
>
|
||||
<div className="text-sm text-mid-gray">
|
||||
Handy uses Whisper.cpp for fast, local speech-to-text processing.
|
||||
Thanks to the amazing work by Georgi Gerganov and contributors.
|
||||
</div>
|
||||
</SettingContainer>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
21
src/components/settings/AdvancedSettings.tsx
Normal file
21
src/components/settings/AdvancedSettings.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React from "react";
|
||||
import { ShowOverlay } from "./ShowOverlay";
|
||||
import { TranslateToEnglish } from "./TranslateToEnglish";
|
||||
import { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout";
|
||||
import { CustomWords } from "./CustomWords";
|
||||
import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
|
||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||
|
||||
export const AdvancedSettings: React.FC = () => {
|
||||
return (
|
||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
||||
<SettingsGroup title="Advanced">
|
||||
<ShowOverlay descriptionMode="tooltip" grouped={true} />
|
||||
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
|
||||
<ModelUnloadTimeoutSetting descriptionMode="tooltip" grouped={true} />
|
||||
<CustomWords descriptionMode="tooltip" grouped />
|
||||
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -15,15 +15,16 @@ export const AudioFeedback: React.FC<AudioFeedbackProps> = React.memo(({
|
|||
|
||||
const audioFeedbackEnabled = getSetting("audio_feedback") || false;
|
||||
|
||||
return (
|
||||
<ToggleSwitch
|
||||
checked={audioFeedbackEnabled}
|
||||
onChange={(enabled) => updateSetting("audio_feedback", enabled)}
|
||||
isUpdating={isUpdating("audio_feedback")}
|
||||
label="Audio Feedback"
|
||||
description="Play sound when recording starts and stops"
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<ToggleSwitch
|
||||
checked={audioFeedbackEnabled}
|
||||
onChange={(enabled) => updateSetting("audio_feedback", enabled)}
|
||||
isUpdating={isUpdating("audio_feedback")}
|
||||
label="Audio Feedback"
|
||||
description="Play sound when recording starts and stops"
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
15
src/components/settings/DebugSettings.tsx
Normal file
15
src/components/settings/DebugSettings.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import React from "react";
|
||||
import { WordCorrectionThreshold } from "./debug/WordCorrectionThreshold";
|
||||
import { AppDataDirectory } from "./AppDataDirectory";
|
||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||
|
||||
export const DebugSettings: React.FC = () => {
|
||||
return (
|
||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
||||
<SettingsGroup title="Debug">
|
||||
<WordCorrectionThreshold descriptionMode="tooltip" grouped={true} />
|
||||
<AppDataDirectory descriptionMode="tooltip" grouped={true} />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
31
src/components/settings/GeneralSettings.tsx
Normal file
31
src/components/settings/GeneralSettings.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import React from "react";
|
||||
import { MicrophoneSelector } from "./MicrophoneSelector";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { HandyShortcut } from "./HandyShortcut";
|
||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||
import { OutputDeviceSelector } from "./OutputDeviceSelector";
|
||||
import { PushToTalk } from "./PushToTalk";
|
||||
import { AudioFeedback } from "./AudioFeedback";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
|
||||
export const GeneralSettings: React.FC = () => {
|
||||
const { audioFeedbackEnabled } = useSettings();
|
||||
return (
|
||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
||||
<SettingsGroup title="General">
|
||||
<HandyShortcut descriptionMode="tooltip" grouped={true} />
|
||||
<LanguageSelector descriptionMode="tooltip" grouped={true} />
|
||||
<PushToTalk descriptionMode="tooltip" grouped={true} />
|
||||
</SettingsGroup>
|
||||
<SettingsGroup title="Sound">
|
||||
<MicrophoneSelector descriptionMode="tooltip" grouped={true} />
|
||||
<AudioFeedback descriptionMode="tooltip" grouped={true} />
|
||||
<OutputDeviceSelector
|
||||
descriptionMode="tooltip"
|
||||
grouped={true}
|
||||
disabled={!audioFeedbackEnabled}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
12
src/components/settings/HistorySettings.tsx
Normal file
12
src/components/settings/HistorySettings.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import React from "react";
|
||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||
|
||||
export const HistorySettings: React.FC = () => {
|
||||
return (
|
||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
||||
<SettingsGroup title="History">
|
||||
<></>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,62 +3,79 @@ import { Dropdown } from "../ui/Dropdown";
|
|||
import { SettingContainer } from "../ui/SettingContainer";
|
||||
import { ResetButton } from "../ui/ResetButton";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import { AudioDevice } from "../../lib/types";
|
||||
|
||||
interface OutputDeviceSelectorProps {
|
||||
descriptionMode?: "inline" | "tooltip";
|
||||
grouped?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = React.memo(({
|
||||
descriptionMode = "tooltip",
|
||||
grouped = false,
|
||||
}) => {
|
||||
const {
|
||||
getSetting,
|
||||
updateSetting,
|
||||
resetSetting,
|
||||
isUpdating,
|
||||
isLoading,
|
||||
outputDevices,
|
||||
refreshOutputDevices,
|
||||
} = useSettings();
|
||||
export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> =
|
||||
React.memo(
|
||||
({ descriptionMode = "tooltip", grouped = false, disabled = false }) => {
|
||||
const {
|
||||
getSetting,
|
||||
updateSetting,
|
||||
resetSetting,
|
||||
isUpdating,
|
||||
isLoading,
|
||||
outputDevices,
|
||||
refreshOutputDevices,
|
||||
} = useSettings();
|
||||
|
||||
const selectedOutputDevice = getSetting("selected_output_device") === "default" ? "Default" : getSetting("selected_output_device") || "Default";
|
||||
const selectedOutputDevice =
|
||||
getSetting("selected_output_device") === "default"
|
||||
? "Default"
|
||||
: getSetting("selected_output_device") || "Default";
|
||||
|
||||
const handleOutputDeviceSelect = async (deviceName: string) => {
|
||||
await updateSetting("selected_output_device", deviceName);
|
||||
};
|
||||
const handleOutputDeviceSelect = async (deviceName: string) => {
|
||||
await updateSetting("selected_output_device", deviceName);
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
await resetSetting("selected_output_device");
|
||||
};
|
||||
const handleReset = async () => {
|
||||
await resetSetting("selected_output_device");
|
||||
};
|
||||
|
||||
const outputDeviceOptions = outputDevices.map(device => ({
|
||||
value: device.name,
|
||||
label: device.name
|
||||
}));
|
||||
const outputDeviceOptions = outputDevices.map((device: AudioDevice) => ({
|
||||
value: device.name,
|
||||
label: device.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<SettingContainer
|
||||
title="Output Device"
|
||||
description="Select your preferred audio output device for feedback sounds"
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Dropdown
|
||||
options={outputDeviceOptions}
|
||||
selectedValue={selectedOutputDevice}
|
||||
onSelect={handleOutputDeviceSelect}
|
||||
placeholder={isLoading || outputDevices.length === 0 ? "Loading..." : "Select output device..."}
|
||||
disabled={isUpdating("selected_output_device") || isLoading || outputDevices.length === 0}
|
||||
onRefresh={refreshOutputDevices}
|
||||
/>
|
||||
<ResetButton
|
||||
onClick={handleReset}
|
||||
disabled={isUpdating("selected_output_device") || isLoading}
|
||||
/>
|
||||
</div>
|
||||
</SettingContainer>
|
||||
return (
|
||||
<SettingContainer
|
||||
title="Output Device"
|
||||
description="Select your preferred audio output device for feedback sounds"
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Dropdown
|
||||
options={outputDeviceOptions}
|
||||
selectedValue={selectedOutputDevice}
|
||||
onSelect={handleOutputDeviceSelect}
|
||||
placeholder={
|
||||
isLoading || outputDevices.length === 0
|
||||
? "Loading..."
|
||||
: "Select output device..."
|
||||
}
|
||||
disabled={
|
||||
disabled ||
|
||||
isUpdating("selected_output_device") ||
|
||||
isLoading ||
|
||||
outputDevices.length === 0
|
||||
}
|
||||
onRefresh={refreshOutputDevices}
|
||||
/>
|
||||
<ResetButton
|
||||
onClick={handleReset}
|
||||
disabled={
|
||||
disabled || isUpdating("selected_output_device") || isLoading
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SettingContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { MicrophoneSelector } from "./MicrophoneSelector";
|
||||
import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
|
||||
import { PushToTalk } from "./PushToTalk";
|
||||
import { AudioFeedback } from "./AudioFeedback";
|
||||
import { OutputDeviceSelector } from "./OutputDeviceSelector";
|
||||
import { ShowOverlay } from "./ShowOverlay";
|
||||
import { HandyShortcut } from "./HandyShortcut";
|
||||
import { TranslateToEnglish } from "./TranslateToEnglish";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { CustomWords } from "./CustomWords";
|
||||
import { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout";
|
||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||
import { WordCorrectionThreshold } from "./debug/WordCorrectionThreshold";
|
||||
import { AppDataDirectory } from "./AppDataDirectory";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
|
||||
export const Settings: React.FC = () => {
|
||||
const { settings, updateSetting } = useSettings();
|
||||
|
||||
// Handle keyboard shortcuts for debug mode toggle
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Check for Ctrl+Shift+D (Windows/Linux) or Cmd+Shift+D (macOS)
|
||||
const isDebugShortcut =
|
||||
event.shiftKey &&
|
||||
event.key.toLowerCase() === "d" &&
|
||||
(event.ctrlKey || event.metaKey);
|
||||
|
||||
if (isDebugShortcut) {
|
||||
event.preventDefault();
|
||||
const currentDebugMode = settings?.debug_mode ?? false;
|
||||
updateSetting("debug_mode", !currentDebugMode);
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener when component mounts
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
// Cleanup event listener when component unmounts
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [settings?.debug_mode, updateSetting]);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
||||
<SettingsGroup>
|
||||
<HandyShortcut descriptionMode="tooltip" grouped={true} />
|
||||
<MicrophoneSelector descriptionMode="tooltip" grouped={true} />
|
||||
<LanguageSelector descriptionMode="tooltip" grouped={true} />
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Advanced">
|
||||
<PushToTalk descriptionMode="tooltip" grouped={true} />
|
||||
<AudioFeedback descriptionMode="tooltip" grouped={true} />
|
||||
<OutputDeviceSelector descriptionMode="tooltip" grouped={true} />
|
||||
<ShowOverlay descriptionMode="tooltip" grouped={true} />
|
||||
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
|
||||
<ModelUnloadTimeoutSetting descriptionMode="tooltip" grouped={true} />
|
||||
<CustomWords descriptionMode="tooltip" grouped />
|
||||
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
|
||||
</SettingsGroup>
|
||||
|
||||
{settings?.debug_mode && (
|
||||
<SettingsGroup title="Debug">
|
||||
<WordCorrectionThreshold descriptionMode="tooltip" grouped={true} />
|
||||
<AppDataDirectory descriptionMode="tooltip" grouped={true} />
|
||||
</SettingsGroup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +1,11 @@
|
|||
export { Settings } from "./Settings";
|
||||
// Settings section components
|
||||
export { GeneralSettings } from "./GeneralSettings";
|
||||
export { AdvancedSettings } from "./AdvancedSettings";
|
||||
export { DebugSettings } from "./DebugSettings";
|
||||
export { HistorySettings } from "./HistorySettings";
|
||||
export { AboutSettings } from "./AboutSettings";
|
||||
|
||||
// Individual setting components
|
||||
export { MicrophoneSelector } from "./MicrophoneSelector";
|
||||
export { OutputDeviceSelector } from "./OutputDeviceSelector";
|
||||
export { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const Button: React.FC<ButtonProps> = ({
|
|||
|
||||
const variantClasses = {
|
||||
primary:
|
||||
"text-white bg-background-ui hover:bg-background-ui/90 focus:ring-1 focus:ring-background-ui",
|
||||
"text-white bg-background-ui hover:bg-background-ui/80 focus:ring-1 focus:ring-background-ui",
|
||||
secondary: "bg-mid-gray/10 hover:bg-background-ui/30 focus:outline-none",
|
||||
danger:
|
||||
"text-white bg-red-600 hover:bg-red-700 focus:ring-1 focus:ring-red-500",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ interface ResetButtonProps {
|
|||
export const ResetButton: React.FC<ResetButtonProps> = React.memo(
|
||||
({ onClick, disabled = false, className = "" }) => (
|
||||
<button
|
||||
className={`p-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:translate-y-[1px] rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150 text-text/80 ${className}`}
|
||||
className={`p-1 rounded fill-text border border-transparent transition-all duration-150 ${
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed text-text/40"
|
||||
: "hover:bg-logo-primary/30 active:bg-logo-primary/50 active:translate-y-[1px] hover:cursor-pointer hover:border-logo-primary text-text/80"
|
||||
} ${className}`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ interface SettingContainerProps {
|
|||
descriptionMode?: "inline" | "tooltip";
|
||||
grouped?: boolean;
|
||||
layout?: "horizontal" | "stacked";
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SettingContainer: React.FC<SettingContainerProps> = ({
|
||||
|
|
@ -16,6 +17,7 @@ export const SettingContainer: React.FC<SettingContainerProps> = ({
|
|||
descriptionMode = "tooltip",
|
||||
grouped = false,
|
||||
layout = "horizontal",
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -51,7 +53,11 @@ export const SettingContainer: React.FC<SettingContainerProps> = ({
|
|||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
<h3
|
||||
className={`text-sm font-medium ${disabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="relative"
|
||||
|
|
@ -99,8 +105,12 @@ export const SettingContainer: React.FC<SettingContainerProps> = ({
|
|||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="mb-2">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
<p className="text-sm">{description}</p>
|
||||
<h3 className={`text-sm font-medium ${disabled ? "opacity-50" : ""}`}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className={`text-sm ${disabled ? "opacity-50" : ""}`}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full">{children}</div>
|
||||
</div>
|
||||
|
|
@ -117,7 +127,11 @@ export const SettingContainer: React.FC<SettingContainerProps> = ({
|
|||
<div className={horizontalContainerClasses}>
|
||||
<div className="max-w-2/3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium ">{title}</h3>
|
||||
<h3
|
||||
className={`text-sm font-medium ${disabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="relative"
|
||||
|
|
@ -166,8 +180,12 @@ export const SettingContainer: React.FC<SettingContainerProps> = ({
|
|||
return (
|
||||
<div className={horizontalContainerClasses}>
|
||||
<div className="max-w-2/3">
|
||||
<h3 className="text-sm font-medium ">{title}</h3>
|
||||
<p className="text-sm">{description}</p>
|
||||
<h3 className={`text-sm font-medium ${disabled ? "opacity-50" : ""}`}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className={`text-sm ${disabled ? "opacity-50" : ""}`}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,7 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect } from "react";
|
||||
import { useSettingsStore } from "../stores/settingsStore";
|
||||
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;
|
||||
|
|
@ -17,6 +9,7 @@ interface UseSettingsReturn {
|
|||
isUpdating: (key: string) => boolean;
|
||||
audioDevices: AudioDevice[];
|
||||
outputDevices: AudioDevice[];
|
||||
audioFeedbackEnabled: boolean;
|
||||
|
||||
// Actions
|
||||
updateSetting: <K extends keyof Settings>(
|
||||
|
|
@ -37,366 +30,29 @@ interface UseSettingsReturn {
|
|||
}
|
||||
|
||||
export const useSettings = (): UseSettingsReturn => {
|
||||
const [state, setState] = useState<SettingsState>({
|
||||
settings: null,
|
||||
isLoading: true,
|
||||
isUpdating: {},
|
||||
audioDevices: [],
|
||||
outputDevices: [],
|
||||
});
|
||||
const store = useSettingsStore();
|
||||
|
||||
// Load settings from store
|
||||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
const { load } = await import("@tauri-apps/plugin-store");
|
||||
const store = await load("settings_store.json", { autoSave: false });
|
||||
const settings = (await store.get("settings")) as Settings;
|
||||
|
||||
// Load additional settings that come from invoke calls
|
||||
const [microphoneMode, selectedMicrophone, selectedOutputDevice] =
|
||||
await Promise.allSettled([
|
||||
invoke("get_microphone_mode"),
|
||||
invoke("get_selected_microphone"),
|
||||
invoke("get_selected_output_device"),
|
||||
]);
|
||||
|
||||
// Merge all settings
|
||||
const mergedSettings: Settings = {
|
||||
...settings,
|
||||
always_on_microphone:
|
||||
microphoneMode.status === "fulfilled"
|
||||
? (microphoneMode.value as boolean)
|
||||
: false,
|
||||
selected_microphone:
|
||||
selectedMicrophone.status === "fulfilled"
|
||||
? (selectedMicrophone.value as string)
|
||||
: "Default",
|
||||
selected_output_device:
|
||||
selectedOutputDevice.status === "fulfilled"
|
||||
? (selectedOutputDevice.value as string)
|
||||
: "Default",
|
||||
};
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
settings: mergedSettings,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load audio devices
|
||||
const loadAudioDevices = useCallback(async () => {
|
||||
try {
|
||||
const devices: AudioDevice[] = await invoke("get_available_microphones");
|
||||
|
||||
// Always ensure "Default" is available as the first option
|
||||
const devicesWithDefault = [
|
||||
{ index: "default", name: "Default", is_default: true },
|
||||
...devices.filter((d) => d.name !== "Default" && d.name !== "default"),
|
||||
];
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
audioDevices: devicesWithDefault,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to load audio devices:", error);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
audioDevices: [{ index: "default", name: "Default", is_default: true }],
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load output devices
|
||||
const loadOutputDevices = useCallback(async () => {
|
||||
try {
|
||||
const devices: AudioDevice[] = await invoke(
|
||||
"get_available_output_devices",
|
||||
);
|
||||
|
||||
// Always ensure "Default" is available as the first option
|
||||
const devicesWithDefault = [
|
||||
{ index: "default", name: "Default", is_default: true },
|
||||
...devices.filter((d) => d.name !== "Default" && d.name !== "default"),
|
||||
];
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
outputDevices: devicesWithDefault,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to load output devices:", error);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
outputDevices: [
|
||||
{ index: "default", name: "Default", is_default: true },
|
||||
],
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update a specific setting
|
||||
const updateSetting = useCallback(
|
||||
async <K extends keyof Settings>(key: K, value: Settings[K]) => {
|
||||
const updateKey = String(key);
|
||||
|
||||
// Set updating state
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isUpdating: { ...prev.isUpdating, [updateKey]: true },
|
||||
}));
|
||||
|
||||
// Store original value for rollback
|
||||
const originalValue = state.settings?.[key];
|
||||
|
||||
try {
|
||||
// Optimistic update
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
settings: prev.settings ? { ...prev.settings, [key]: value } : null,
|
||||
}));
|
||||
|
||||
// Invoke the appropriate backend method based on the setting
|
||||
switch (key) {
|
||||
case "always_on_microphone":
|
||||
await invoke("update_microphone_mode", { alwaysOn: value });
|
||||
break;
|
||||
case "audio_feedback":
|
||||
await invoke("change_audio_feedback_setting", { enabled: value });
|
||||
break;
|
||||
case "push_to_talk":
|
||||
await invoke("change_ptt_setting", { enabled: value });
|
||||
break;
|
||||
case "selected_microphone":
|
||||
// Map "Default" to "default" for backend compatibility
|
||||
const micDeviceName = value === "Default" ? "default" : value;
|
||||
await invoke("set_selected_microphone", {
|
||||
deviceName: micDeviceName,
|
||||
});
|
||||
break;
|
||||
case "selected_output_device":
|
||||
// Map "Default" to "default" for backend compatibility
|
||||
const outputDeviceName = value === "Default" ? "default" : value;
|
||||
await invoke("set_selected_output_device", {
|
||||
deviceName: outputDeviceName,
|
||||
});
|
||||
break;
|
||||
case "translate_to_english":
|
||||
await invoke("change_translate_to_english_setting", {
|
||||
enabled: value,
|
||||
});
|
||||
break;
|
||||
case "selected_language":
|
||||
await invoke("change_selected_language_setting", {
|
||||
language: value,
|
||||
});
|
||||
break;
|
||||
case "overlay_position":
|
||||
await invoke("change_overlay_position_setting", {
|
||||
position: value,
|
||||
});
|
||||
break;
|
||||
case "debug_mode":
|
||||
await invoke("change_debug_mode_setting", { enabled: value });
|
||||
break;
|
||||
case "custom_words":
|
||||
await invoke("update_custom_words", { words: value });
|
||||
break;
|
||||
case "word_correction_threshold":
|
||||
await invoke("change_word_correction_threshold_setting", {
|
||||
threshold: value,
|
||||
});
|
||||
break;
|
||||
case "bindings":
|
||||
// Handle bindings separately - they use their own invoke methods
|
||||
break;
|
||||
case "selected_model":
|
||||
// Handle model selection if needed
|
||||
break;
|
||||
default:
|
||||
console.warn(`No handler for setting: ${String(key)}`);
|
||||
}
|
||||
|
||||
// console.log(`Setting ${String(key)} updated to:`, value);
|
||||
} catch (error) {
|
||||
console.error(`Failed to update setting ${String(key)}:`, error);
|
||||
|
||||
// Rollback on error
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
settings: prev.settings
|
||||
? { ...prev.settings, [key]: originalValue }
|
||||
: null,
|
||||
}));
|
||||
} finally {
|
||||
// Clear updating state
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isUpdating: { ...prev.isUpdating, [updateKey]: false },
|
||||
}));
|
||||
}
|
||||
},
|
||||
[state.settings],
|
||||
);
|
||||
|
||||
// Reset a setting to its default value
|
||||
const resetSetting = useCallback(
|
||||
async (key: keyof Settings) => {
|
||||
// Define default values
|
||||
const defaults: Partial<Settings> = {
|
||||
always_on_microphone: false,
|
||||
audio_feedback: true,
|
||||
push_to_talk: false,
|
||||
selected_microphone: "Default",
|
||||
selected_output_device: "Default",
|
||||
translate_to_english: false,
|
||||
selected_language: "auto",
|
||||
overlay_position: "bottom",
|
||||
debug_mode: false,
|
||||
custom_words: [],
|
||||
};
|
||||
|
||||
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
|
||||
// Initialize on first mount
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
loadAudioDevices();
|
||||
loadOutputDevices();
|
||||
}, [loadSettings, loadAudioDevices, loadOutputDevices]);
|
||||
if (store.isLoading) {
|
||||
store.initialize();
|
||||
}
|
||||
}, [store.initialize, store.isLoading]);
|
||||
|
||||
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,
|
||||
settings: store.settings,
|
||||
isLoading: store.isLoading,
|
||||
isUpdating: store.isUpdatingKey,
|
||||
audioDevices: store.audioDevices,
|
||||
outputDevices: store.outputDevices,
|
||||
audioFeedbackEnabled: store.settings?.audio_feedback || false,
|
||||
updateSetting: store.updateSetting,
|
||||
resetSetting: store.resetSetting,
|
||||
refreshSettings: store.refreshSettings,
|
||||
refreshAudioDevices: store.refreshAudioDevices,
|
||||
refreshOutputDevices: store.refreshOutputDevices,
|
||||
updateBinding: store.updateBinding,
|
||||
resetBinding: store.resetBinding,
|
||||
getSetting: store.getSetting,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
300
src/stores/settingsStore.ts
Normal file
300
src/stores/settingsStore.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Settings, AudioDevice } from '../lib/types';
|
||||
|
||||
interface SettingsStore {
|
||||
settings: Settings | null;
|
||||
isLoading: boolean;
|
||||
isUpdating: Record<string, boolean>;
|
||||
audioDevices: AudioDevice[];
|
||||
outputDevices: AudioDevice[];
|
||||
|
||||
// Actions
|
||||
initialize: () => Promise<void>;
|
||||
updateSetting: <K extends keyof Settings>(key: K, value: Settings[K]) => Promise<void>;
|
||||
resetSetting: (key: keyof Settings) => Promise<void>;
|
||||
refreshSettings: () => Promise<void>;
|
||||
refreshAudioDevices: () => Promise<void>;
|
||||
refreshOutputDevices: () => Promise<void>;
|
||||
updateBinding: (id: string, binding: string) => Promise<void>;
|
||||
resetBinding: (id: string) => Promise<void>;
|
||||
getSetting: <K extends keyof Settings>(key: K) => Settings[K] | undefined;
|
||||
isUpdatingKey: (key: string) => boolean;
|
||||
|
||||
// Internal state setters
|
||||
setSettings: (settings: Settings | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setUpdating: (key: string, updating: boolean) => void;
|
||||
setAudioDevices: (devices: AudioDevice[]) => void;
|
||||
setOutputDevices: (devices: AudioDevice[]) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Partial<Settings> = {
|
||||
always_on_microphone: false,
|
||||
audio_feedback: true,
|
||||
push_to_talk: false,
|
||||
selected_microphone: "Default",
|
||||
selected_output_device: "Default",
|
||||
translate_to_english: false,
|
||||
selected_language: "auto",
|
||||
overlay_position: "bottom",
|
||||
debug_mode: false,
|
||||
custom_words: [],
|
||||
};
|
||||
|
||||
const DEFAULT_AUDIO_DEVICE: AudioDevice = {
|
||||
index: "default",
|
||||
name: "Default",
|
||||
is_default: true
|
||||
};
|
||||
|
||||
export const useSettingsStore = create<SettingsStore>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
settings: null,
|
||||
isLoading: true,
|
||||
isUpdating: {},
|
||||
audioDevices: [],
|
||||
outputDevices: [],
|
||||
|
||||
// Internal setters
|
||||
setSettings: (settings) => set({ settings }),
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
setUpdating: (key, updating) =>
|
||||
set((state) => ({
|
||||
isUpdating: { ...state.isUpdating, [key]: updating }
|
||||
})),
|
||||
setAudioDevices: (audioDevices) => set({ audioDevices }),
|
||||
setOutputDevices: (outputDevices) => set({ outputDevices }),
|
||||
|
||||
// Getters
|
||||
getSetting: (key) => get().settings?.[key],
|
||||
isUpdatingKey: (key) => get().isUpdating[key] || false,
|
||||
|
||||
// Load settings from store
|
||||
refreshSettings: async () => {
|
||||
try {
|
||||
const { load } = await import("@tauri-apps/plugin-store");
|
||||
const store = await load("settings_store.json", { autoSave: false });
|
||||
const settings = (await store.get("settings")) as Settings;
|
||||
|
||||
// Load additional settings that come from invoke calls
|
||||
const [microphoneMode, selectedMicrophone, selectedOutputDevice] =
|
||||
await Promise.allSettled([
|
||||
invoke("get_microphone_mode"),
|
||||
invoke("get_selected_microphone"),
|
||||
invoke("get_selected_output_device"),
|
||||
]);
|
||||
|
||||
// Merge all settings
|
||||
const mergedSettings: Settings = {
|
||||
...settings,
|
||||
always_on_microphone:
|
||||
microphoneMode.status === "fulfilled"
|
||||
? (microphoneMode.value as boolean)
|
||||
: false,
|
||||
selected_microphone:
|
||||
selectedMicrophone.status === "fulfilled"
|
||||
? (selectedMicrophone.value as string)
|
||||
: "Default",
|
||||
selected_output_device:
|
||||
selectedOutputDevice.status === "fulfilled"
|
||||
? (selectedOutputDevice.value as string)
|
||||
: "Default",
|
||||
};
|
||||
|
||||
set({ settings: mergedSettings, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// Load audio devices
|
||||
refreshAudioDevices: async () => {
|
||||
try {
|
||||
const devices: AudioDevice[] = await invoke("get_available_microphones");
|
||||
const devicesWithDefault = [
|
||||
DEFAULT_AUDIO_DEVICE,
|
||||
...devices.filter((d) => d.name !== "Default" && d.name !== "default"),
|
||||
];
|
||||
set({ audioDevices: devicesWithDefault });
|
||||
} catch (error) {
|
||||
console.error("Failed to load audio devices:", error);
|
||||
set({ audioDevices: [DEFAULT_AUDIO_DEVICE] });
|
||||
}
|
||||
},
|
||||
|
||||
// Load output devices
|
||||
refreshOutputDevices: async () => {
|
||||
try {
|
||||
const devices: AudioDevice[] = await invoke("get_available_output_devices");
|
||||
const devicesWithDefault = [
|
||||
DEFAULT_AUDIO_DEVICE,
|
||||
...devices.filter((d) => d.name !== "Default" && d.name !== "default"),
|
||||
];
|
||||
set({ outputDevices: devicesWithDefault });
|
||||
} catch (error) {
|
||||
console.error("Failed to load output devices:", error);
|
||||
set({ outputDevices: [DEFAULT_AUDIO_DEVICE] });
|
||||
}
|
||||
},
|
||||
|
||||
// Update a specific setting
|
||||
updateSetting: async <K extends keyof Settings>(key: K, value: Settings[K]) => {
|
||||
const { settings, setUpdating, refreshSettings } = get();
|
||||
const updateKey = String(key);
|
||||
const originalValue = settings?.[key];
|
||||
|
||||
setUpdating(updateKey, true);
|
||||
|
||||
try {
|
||||
// Optimistic update
|
||||
set((state) => ({
|
||||
settings: state.settings ? { ...state.settings, [key]: value } : null,
|
||||
}));
|
||||
|
||||
// Invoke the appropriate backend method
|
||||
switch (key) {
|
||||
case "always_on_microphone":
|
||||
await invoke("update_microphone_mode", { alwaysOn: value });
|
||||
break;
|
||||
case "audio_feedback":
|
||||
await invoke("change_audio_feedback_setting", { enabled: value });
|
||||
break;
|
||||
case "push_to_talk":
|
||||
await invoke("change_ptt_setting", { enabled: value });
|
||||
break;
|
||||
case "selected_microphone":
|
||||
const micDeviceName = value === "Default" ? "default" : value;
|
||||
await invoke("set_selected_microphone", { deviceName: micDeviceName });
|
||||
break;
|
||||
case "selected_output_device":
|
||||
const outputDeviceName = value === "Default" ? "default" : value;
|
||||
await invoke("set_selected_output_device", { deviceName: outputDeviceName });
|
||||
break;
|
||||
case "translate_to_english":
|
||||
await invoke("change_translate_to_english_setting", { enabled: value });
|
||||
break;
|
||||
case "selected_language":
|
||||
await invoke("change_selected_language_setting", { language: value });
|
||||
break;
|
||||
case "overlay_position":
|
||||
await invoke("change_overlay_position_setting", { position: value });
|
||||
break;
|
||||
case "debug_mode":
|
||||
await invoke("change_debug_mode_setting", { enabled: value });
|
||||
break;
|
||||
case "custom_words":
|
||||
await invoke("update_custom_words", { words: value });
|
||||
break;
|
||||
case "word_correction_threshold":
|
||||
await invoke("change_word_correction_threshold_setting", { threshold: value });
|
||||
break;
|
||||
case "bindings":
|
||||
case "selected_model":
|
||||
break;
|
||||
default:
|
||||
console.warn(`No handler for setting: ${String(key)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to update setting ${String(key)}:`, error);
|
||||
|
||||
// Rollback on error
|
||||
set((state) => ({
|
||||
settings: state.settings
|
||||
? { ...state.settings, [key]: originalValue }
|
||||
: null,
|
||||
}));
|
||||
} finally {
|
||||
setUpdating(updateKey, false);
|
||||
}
|
||||
},
|
||||
|
||||
// Reset a setting to its default value
|
||||
resetSetting: async (key) => {
|
||||
const defaultValue = DEFAULT_SETTINGS[key];
|
||||
if (defaultValue !== undefined) {
|
||||
await get().updateSetting(key, defaultValue as any);
|
||||
}
|
||||
},
|
||||
|
||||
// Update a specific binding
|
||||
updateBinding: async (id, binding) => {
|
||||
const { settings, setUpdating } = get();
|
||||
const updateKey = `binding_${id}`;
|
||||
const originalBinding = settings?.bindings?.[id]?.current_binding;
|
||||
|
||||
setUpdating(updateKey, true);
|
||||
|
||||
try {
|
||||
// Optimistic update
|
||||
set((state) => ({
|
||||
settings: state.settings
|
||||
? {
|
||||
...state.settings,
|
||||
bindings: {
|
||||
...state.settings.bindings,
|
||||
[id]: {
|
||||
...state.settings.bindings[id],
|
||||
current_binding: binding,
|
||||
},
|
||||
},
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
await invoke("change_binding", { id, binding });
|
||||
} catch (error) {
|
||||
console.error(`Failed to update binding ${id}:`, error);
|
||||
|
||||
// Rollback on error
|
||||
if (originalBinding && get().settings) {
|
||||
set((state) => ({
|
||||
settings: state.settings
|
||||
? {
|
||||
...state.settings,
|
||||
bindings: {
|
||||
...state.settings.bindings,
|
||||
[id]: {
|
||||
...state.settings.bindings[id],
|
||||
current_binding: originalBinding,
|
||||
},
|
||||
},
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
setUpdating(updateKey, false);
|
||||
}
|
||||
},
|
||||
|
||||
// Reset a specific binding
|
||||
resetBinding: async (id) => {
|
||||
const { setUpdating, refreshSettings } = get();
|
||||
const updateKey = `binding_${id}`;
|
||||
|
||||
setUpdating(updateKey, true);
|
||||
|
||||
try {
|
||||
await invoke("reset_binding", { id });
|
||||
await refreshSettings();
|
||||
} catch (error) {
|
||||
console.error(`Failed to reset binding ${id}:`, error);
|
||||
} finally {
|
||||
setUpdating(updateKey, false);
|
||||
}
|
||||
},
|
||||
|
||||
// Initialize everything
|
||||
initialize: async () => {
|
||||
const { refreshSettings, refreshAudioDevices, refreshOutputDevices } = get();
|
||||
await Promise.all([
|
||||
refreshSettings(),
|
||||
refreshAudioDevices(),
|
||||
refreshOutputDevices(),
|
||||
]);
|
||||
},
|
||||
}))
|
||||
);
|
||||
Loading…
Reference in a new issue