Add custom sound and control notif volume (#214)
* Add custom sound and control notif volume * Modularize the picker and slider, consolidate the playback and loading * Move custom sounds to advanced * pairs of audio * custom sound handling --------- Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
parent
d5a970042a
commit
a286fe5143
20 changed files with 394 additions and 149 deletions
BIN
src-tauri/resources/pop_start.wav
Normal file
BIN
src-tauri/resources/pop_start.wav
Normal file
Binary file not shown.
BIN
src-tauri/resources/pop_stop.wav
Normal file
BIN
src-tauri/resources/pop_stop.wav
Normal file
Binary file not shown.
|
|
@ -1,4 +1,4 @@
|
|||
use crate::audio_feedback::{play_recording_start_sound, play_recording_stop_sound};
|
||||
use crate::audio_feedback::{SoundType, play_feedback_sound};
|
||||
use crate::managers::audio::AudioRecordingManager;
|
||||
use crate::managers::history::HistoryManager;
|
||||
use crate::managers::transcription::TranscriptionManager;
|
||||
|
|
@ -46,7 +46,7 @@ impl ShortcutAction for TranscribeAction {
|
|||
if is_always_on {
|
||||
// Always-on mode: Play audio feedback immediately
|
||||
debug!("Always-on mode: Playing audio feedback immediately");
|
||||
play_recording_start_sound(app);
|
||||
play_feedback_sound(app, SoundType::Start);
|
||||
let recording_started = rm.try_start_recording(&binding_id);
|
||||
debug!("Recording started: {}", recording_started);
|
||||
} else {
|
||||
|
|
@ -61,7 +61,7 @@ impl ShortcutAction for TranscribeAction {
|
|||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
debug!("Playing delayed audio feedback");
|
||||
play_recording_start_sound(&app_clone);
|
||||
play_feedback_sound(&app_clone, SoundType::Start);
|
||||
});
|
||||
} else {
|
||||
debug!("Failed to start recording");
|
||||
|
|
@ -87,7 +87,7 @@ impl ShortcutAction for TranscribeAction {
|
|||
show_transcribing_overlay(app);
|
||||
|
||||
// Play audio feedback for recording stop
|
||||
play_recording_stop_sound(app);
|
||||
play_feedback_sound(app, SoundType::Stop);
|
||||
|
||||
let binding_id = binding_id.to_string(); // Clone binding_id for the async task
|
||||
|
||||
|
|
|
|||
|
|
@ -6,25 +6,19 @@ use std::io::BufReader;
|
|||
use std::thread;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
/// Plays an audio resource from the resources directory.
|
||||
/// Checks if audio feedback is enabled in settings before playing.
|
||||
pub fn play_sound(app: &AppHandle, resource_path: &str) {
|
||||
// Check if audio feedback is enabled
|
||||
let settings = settings::get_settings(app);
|
||||
if !settings.audio_feedback {
|
||||
return;
|
||||
}
|
||||
pub enum SoundType {
|
||||
Start,
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Plays an audio resource from the specified directory.
|
||||
fn play_sound(app: &AppHandle, resource_path: &str, base_dir: tauri::path::BaseDirectory) {
|
||||
let app_handle = app.clone();
|
||||
let resource_path = resource_path.to_string();
|
||||
let volume = settings::get_settings(app).audio_feedback_volume;
|
||||
|
||||
// Spawn a new thread to play the audio without blocking the main thread
|
||||
thread::spawn(move || {
|
||||
// Get the path to the audio file in resources
|
||||
let audio_path = match app_handle
|
||||
.path()
|
||||
.resolve(&resource_path, tauri::path::BaseDirectory::Resource)
|
||||
{
|
||||
let audio_path = match app_handle.path().resolve(&resource_path, base_dir) {
|
||||
Ok(path) => path.to_path_buf(),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
|
|
@ -35,38 +29,67 @@ pub fn play_sound(app: &AppHandle, resource_path: &str) {
|
|||
}
|
||||
};
|
||||
|
||||
// Get the selected output device from settings
|
||||
let settings = settings::get_settings(&app_handle);
|
||||
let selected_device = settings.selected_output_device.clone();
|
||||
|
||||
// Try to play the audio file
|
||||
if let Err(e) = play_audio_file(&audio_path, selected_device) {
|
||||
if let Err(e) = play_audio_file(&audio_path, selected_device, volume) {
|
||||
eprintln!("Failed to play sound '{}': {}", resource_path, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Convenience function to play the recording start sound
|
||||
pub fn play_recording_start_sound(app: &AppHandle) {
|
||||
play_sound(app, "resources/rec_start.wav");
|
||||
fn get_sound_path(app: &AppHandle, sound_type: SoundType) -> String {
|
||||
let settings = settings::get_settings(app);
|
||||
match sound_type {
|
||||
SoundType::Start => match settings.sound_theme {
|
||||
crate::settings::SoundTheme::Custom => "custom_start.wav".to_string(),
|
||||
_ => settings.sound_theme.to_start_path(),
|
||||
},
|
||||
SoundType::Stop => match settings.sound_theme {
|
||||
crate::settings::SoundTheme::Custom => "custom_stop.wav".to_string(),
|
||||
_ => settings.sound_theme.to_stop_path(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience function to play the recording stop sound
|
||||
pub fn play_recording_stop_sound(app: &AppHandle) {
|
||||
play_sound(app, "resources/rec_stop.wav");
|
||||
pub fn play_feedback_sound(app: &AppHandle, sound_type: SoundType) {
|
||||
// Only play if audio feedback is enabled
|
||||
let settings = settings::get_settings(app);
|
||||
if !settings.audio_feedback {
|
||||
return;
|
||||
}
|
||||
|
||||
let sound_file = get_sound_path(app, sound_type);
|
||||
let base_dir = if settings.sound_theme == crate::settings::SoundTheme::Custom {
|
||||
tauri::path::BaseDirectory::AppData
|
||||
} else {
|
||||
tauri::path::BaseDirectory::Resource
|
||||
};
|
||||
play_sound(app, &sound_file, base_dir);
|
||||
}
|
||||
|
||||
pub fn play_test_sound(app: &AppHandle, sound_type: SoundType) {
|
||||
// Always play test sound, regardless of audio_feedback setting
|
||||
let settings = settings::get_settings(app);
|
||||
let sound_file = get_sound_path(app, sound_type);
|
||||
let base_dir = if settings.sound_theme == crate::settings::SoundTheme::Custom {
|
||||
tauri::path::BaseDirectory::AppData
|
||||
} else {
|
||||
tauri::path::BaseDirectory::Resource
|
||||
};
|
||||
play_sound(app, &sound_file, base_dir);
|
||||
}
|
||||
|
||||
fn play_audio_file(
|
||||
path: &std::path::Path,
|
||||
selected_device: Option<String>,
|
||||
volume: f32,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let stream_builder = if let Some(device_name) = selected_device {
|
||||
if device_name == "Default" {
|
||||
println!("Using default device");
|
||||
// Use default device
|
||||
OutputStreamBuilder::from_default_device()?
|
||||
} else {
|
||||
// Try to find the device by name
|
||||
let host = crate::audio_toolkit::get_cpal_host();
|
||||
let devices = host.output_devices()?;
|
||||
|
||||
|
|
@ -88,18 +111,17 @@ fn play_audio_file(
|
|||
}
|
||||
} else {
|
||||
println!("Using default device");
|
||||
// Use default device
|
||||
OutputStreamBuilder::from_default_device()?
|
||||
};
|
||||
|
||||
let stream_handle = stream_builder.open_stream()?;
|
||||
let mixer = stream_handle.mixer();
|
||||
|
||||
// Load the audio file
|
||||
let file = File::open(path)?;
|
||||
let buf_reader = BufReader::new(file);
|
||||
|
||||
let sink = rodio::play(mixer, buf_reader)?;
|
||||
sink.set_volume(volume);
|
||||
sink.sleep_until_end();
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::audio_feedback;
|
||||
use crate::audio_toolkit::audio::{list_input_devices, list_output_devices};
|
||||
use crate::managers::audio::{AudioRecordingManager, MicrophoneMode};
|
||||
use crate::settings::{get_settings, write_settings};
|
||||
|
|
@ -5,6 +6,29 @@ use serde::{Deserialize, Serialize};
|
|||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CustomSounds {
|
||||
start: bool,
|
||||
stop: bool,
|
||||
}
|
||||
|
||||
fn custom_sound_exists(app: &AppHandle, sound_type: &str) -> bool {
|
||||
app.path()
|
||||
.resolve(
|
||||
format!("custom_{}.wav", sound_type),
|
||||
tauri::path::BaseDirectory::AppData,
|
||||
)
|
||||
.map_or(false, |path| path.exists())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_custom_sounds(app: AppHandle) -> CustomSounds {
|
||||
CustomSounds {
|
||||
start: custom_sound_exists(&app, "start"),
|
||||
stop: custom_sound_exists(&app, "stop"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AudioDevice {
|
||||
pub index: String,
|
||||
|
|
@ -122,3 +146,16 @@ pub fn get_selected_output_device(app: AppHandle) -> Result<String, String> {
|
|||
.selected_output_device
|
||||
.unwrap_or_else(|| "default".to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn play_test_sound(app: AppHandle, sound_type: String) {
|
||||
let sound = match sound_type.as_str() {
|
||||
"start" => audio_feedback::SoundType::Start,
|
||||
"stop" => audio_feedback::SoundType::Stop,
|
||||
_ => {
|
||||
eprintln!("Unknown sound type: {}", sound_type);
|
||||
return;
|
||||
}
|
||||
};
|
||||
audio_feedback::play_test_sound(&app, sound);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@ pub fn run() {
|
|||
shortcut::reset_binding,
|
||||
shortcut::change_ptt_setting,
|
||||
shortcut::change_audio_feedback_setting,
|
||||
shortcut::change_audio_feedback_volume_setting,
|
||||
shortcut::change_sound_theme_setting,
|
||||
shortcut::change_start_hidden_setting,
|
||||
shortcut::change_autostart_setting,
|
||||
shortcut::change_translate_to_english_setting,
|
||||
|
|
@ -245,6 +247,8 @@ pub fn run() {
|
|||
commands::audio::get_available_output_devices,
|
||||
commands::audio::set_selected_output_device,
|
||||
commands::audio::get_selected_output_device,
|
||||
commands::audio::play_test_sound,
|
||||
commands::audio::check_custom_sounds,
|
||||
commands::transcription::set_model_unload_timeout,
|
||||
commands::transcription::get_model_load_status,
|
||||
commands::transcription::unload_model_manually,
|
||||
|
|
|
|||
|
|
@ -80,12 +80,42 @@ impl ModelUnloadTimeout {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SoundTheme {
|
||||
Marimba,
|
||||
Pop,
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl SoundTheme {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
SoundTheme::Marimba => "marimba",
|
||||
SoundTheme::Pop => "pop",
|
||||
SoundTheme::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_start_path(&self) -> String {
|
||||
format!("resources/{}_start.wav", self.as_str())
|
||||
}
|
||||
|
||||
pub fn to_stop_path(&self) -> String {
|
||||
format!("resources/{}_stop.wav", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/* still handy for composing the initial JSON in the store ------------- */
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AppSettings {
|
||||
pub bindings: HashMap<String, ShortcutBinding>,
|
||||
pub push_to_talk: bool,
|
||||
pub audio_feedback: bool,
|
||||
#[serde(default = "default_audio_feedback_volume")]
|
||||
pub audio_feedback_volume: f32,
|
||||
#[serde(default = "default_sound_theme")]
|
||||
pub sound_theme: SoundTheme,
|
||||
#[serde(default = "default_start_hidden")]
|
||||
pub start_hidden: bool,
|
||||
#[serde(default = "default_autostart_enabled")]
|
||||
|
|
@ -161,6 +191,14 @@ fn default_history_limit() -> usize {
|
|||
5
|
||||
}
|
||||
|
||||
fn default_audio_feedback_volume() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn default_sound_theme() -> SoundTheme {
|
||||
SoundTheme::Marimba
|
||||
}
|
||||
|
||||
pub const SETTINGS_STORE_PATH: &str = "settings_store.json";
|
||||
|
||||
pub fn get_default_settings() -> AppSettings {
|
||||
|
|
@ -189,6 +227,8 @@ pub fn get_default_settings() -> AppSettings {
|
|||
bindings,
|
||||
push_to_talk: true,
|
||||
audio_feedback: false,
|
||||
audio_feedback_volume: default_audio_feedback_volume(),
|
||||
sound_theme: default_sound_theme(),
|
||||
start_hidden: default_start_hidden(),
|
||||
autostart_enabled: default_autostart_enabled(),
|
||||
selected_model: "".to_string(),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use tauri_plugin_global_shortcut::{Shortcut, ShortcutState};
|
|||
|
||||
use crate::actions::ACTION_MAP;
|
||||
use crate::settings::ShortcutBinding;
|
||||
use crate::settings::{self, get_settings, OverlayPosition, PasteMethod};
|
||||
use crate::settings::{self, get_settings, OverlayPosition, PasteMethod, SoundTheme};
|
||||
use crate::ManagedToggleState;
|
||||
|
||||
pub fn init_shortcuts(app: &App) {
|
||||
|
|
@ -119,6 +119,31 @@ pub fn change_audio_feedback_setting(app: AppHandle, enabled: bool) -> Result<()
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn change_audio_feedback_volume_setting(app: AppHandle, volume: f32) -> Result<(), String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
settings.audio_feedback_volume = volume;
|
||||
settings::write_settings(&app, settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn change_sound_theme_setting(app: AppHandle, theme: String) -> Result<(), String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
let parsed = match theme.as_str() {
|
||||
"marimba" => SoundTheme::Marimba,
|
||||
"pop" => SoundTheme::Pop,
|
||||
"custom" => SoundTheme::Custom,
|
||||
other => {
|
||||
eprintln!("Invalid sound theme '{}', defaulting to marimba", other);
|
||||
SoundTheme::Marimba
|
||||
}
|
||||
};
|
||||
settings.sound_theme = parsed;
|
||||
settings::write_settings(&app, settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn change_translate_to_english_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
|
|
|
|||
|
|
@ -1,30 +1,31 @@
|
|||
import React from "react";
|
||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import { VolumeSlider } from "./VolumeSlider";
|
||||
import { SoundPicker } from "./SoundPicker";
|
||||
|
||||
interface AudioFeedbackProps {
|
||||
descriptionMode?: "inline" | "tooltip";
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
export const AudioFeedback: React.FC<AudioFeedbackProps> = React.memo(({
|
||||
descriptionMode = "tooltip",
|
||||
grouped = false,
|
||||
}) => {
|
||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||
|
||||
const audioFeedbackEnabled = getSetting("audio_feedback") || false;
|
||||
export const AudioFeedback: React.FC<AudioFeedbackProps> = React.memo(
|
||||
({ descriptionMode = "tooltip", grouped = false }) => {
|
||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||
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}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,12 +5,17 @@ import { SettingsGroup } from "../ui/SettingsGroup";
|
|||
import { HistoryLimit } from "./HistoryLimit";
|
||||
import { PasteMethodSetting } from "./PasteMethod";
|
||||
import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
|
||||
import { SoundPicker } from "./SoundPicker";
|
||||
|
||||
export const DebugSettings: React.FC = () => {
|
||||
return (
|
||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
||||
<SettingsGroup title="Debug">
|
||||
<PasteMethodSetting descriptionMode="tooltip" grouped={true} />
|
||||
<SoundPicker
|
||||
label="Sound Theme"
|
||||
description="Choose a sound theme for recording start and stop feedback"
|
||||
/>
|
||||
<WordCorrectionThreshold descriptionMode="tooltip" grouped={true} />
|
||||
<AppDataDirectory descriptionMode="tooltip" grouped={true} />
|
||||
<HistoryLimit descriptionMode="tooltip" grouped={true} />
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { OutputDeviceSelector } from "./OutputDeviceSelector";
|
|||
import { PushToTalk } from "./PushToTalk";
|
||||
import { AudioFeedback } from "./AudioFeedback";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import { VolumeSlider } from "./VolumeSlider";
|
||||
|
||||
export const GeneralSettings: React.FC = () => {
|
||||
const { audioFeedbackEnabled } = useSettings();
|
||||
|
|
@ -25,6 +26,7 @@ export const GeneralSettings: React.FC = () => {
|
|||
grouped={true}
|
||||
disabled={!audioFeedbackEnabled}
|
||||
/>
|
||||
<VolumeSlider disabled={!audioFeedbackEnabled} />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||
import { AudioPlayer } from "../ui/AudioPlayer";
|
||||
import { ClipboardCopy, Star, Check, Trash2, Loader2 } from "lucide-react";
|
||||
import { Copy, Star, Check, Trash2 } from "lucide-react";
|
||||
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
|
|
@ -86,12 +86,12 @@ export const HistorySettings: React.FC = () => {
|
|||
|
||||
const deleteAudioEntry = async (id: number) => {
|
||||
try {
|
||||
await invoke("delete_history_entry", {id});
|
||||
await invoke("delete_history_entry", { id });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete audio entry:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -189,7 +189,7 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
|||
{showCopied ? (
|
||||
<Check width={16} height={16} />
|
||||
) : (
|
||||
<ClipboardCopy width={16} height={16} />
|
||||
<Copy width={16} height={16} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -212,7 +212,7 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
|||
className="text-text/50 hover:text-logo-primary transition-colors cursor-pointer"
|
||||
title="Delete entry"
|
||||
>
|
||||
<Trash2 width={16} height={16}/>
|
||||
<Trash2 width={16} height={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
67
src/components/settings/SoundPicker.tsx
Normal file
67
src/components/settings/SoundPicker.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import React from "react";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Dropdown, DropdownOption } from "../ui/Dropdown";
|
||||
import { PlayIcon } from "lucide-react";
|
||||
import { SettingContainer } from "../ui/SettingContainer";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
|
||||
interface SoundPickerProps {
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const SoundPicker: React.FC<SoundPickerProps> = ({
|
||||
label,
|
||||
description,
|
||||
}) => {
|
||||
const { getSetting, updateSetting } = useSettings();
|
||||
const playTestSound = useSettingsStore((state) => state.playTestSound);
|
||||
const customSounds = useSettingsStore((state) => state.customSounds);
|
||||
|
||||
const selectedTheme = getSetting("sound_theme") ?? "marimba";
|
||||
|
||||
const options: DropdownOption[] = [
|
||||
{ value: "marimba", label: "Marimba" },
|
||||
{ value: "pop", label: "Pop" },
|
||||
];
|
||||
|
||||
// Only add Custom option if both custom sound files exist
|
||||
if (customSounds.start && customSounds.stop) {
|
||||
options.push({ value: "custom", label: "Custom" });
|
||||
}
|
||||
|
||||
const handlePlayBothSounds = async () => {
|
||||
await playTestSound("start");
|
||||
// Wait before playing stop sound
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
await playTestSound("stop");
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingContainer
|
||||
title={label}
|
||||
description={description}
|
||||
grouped
|
||||
layout="horizontal"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dropdown
|
||||
selectedValue={selectedTheme}
|
||||
onSelect={(value) =>
|
||||
updateSetting("sound_theme", value as "marimba" | "pop" | "custom")
|
||||
}
|
||||
options={options}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handlePlayBothSounds}
|
||||
title="Preview sound theme (plays start then stop)"
|
||||
>
|
||||
<PlayIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</SettingContainer>
|
||||
);
|
||||
};
|
||||
28
src/components/settings/VolumeSlider.tsx
Normal file
28
src/components/settings/VolumeSlider.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from "react";
|
||||
import { Slider } from "../ui/Slider";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
|
||||
export const VolumeSlider: React.FC<{ disabled?: boolean }> = ({
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { getSetting, updateSetting } = useSettings();
|
||||
const audioFeedbackVolume = getSetting("audio_feedback_volume") ?? 0.5;
|
||||
|
||||
return (
|
||||
<Slider
|
||||
value={audioFeedbackVolume}
|
||||
onChange={(value: number) =>
|
||||
updateSetting("audio_feedback_volume", value)
|
||||
}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
label="Volume"
|
||||
description="Adjust the volume of audio feedback sounds"
|
||||
descriptionMode="tooltip"
|
||||
grouped
|
||||
formatValue={(value) => `${Math.round(value * 100)}%`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from "react";
|
|||
export interface DropdownOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
|
|
@ -97,8 +98,9 @@ export const Dropdown: React.FC<DropdownProps> = ({
|
|||
selectedValue === option.value
|
||||
? "bg-logo-primary/20 font-semibold"
|
||||
: ""
|
||||
}`}
|
||||
} ${option.disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export const Slider: React.FC<SliderProps> = ({
|
|||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
layout="horizontal"
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center space-x-1 h-6">
|
||||
|
|
@ -54,11 +55,11 @@ export const Slider: React.FC<SliderProps> = ({
|
|||
disabled={disabled}
|
||||
className="flex-grow h-2 rounded-lg appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-logo-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #FAA2CA 0%, #FAA2CA ${
|
||||
background: `linear-gradient(to right, var(--color-background-ui) ${
|
||||
((value - min) / (max - min)) * 100
|
||||
}%, rgba(128, 128, 128, 0.2) ${
|
||||
((value - min) / (max - min)) * 100
|
||||
}%, rgba(128, 128, 128, 0.2) 100%)`,
|
||||
}%)`,
|
||||
}}
|
||||
/>
|
||||
{showValue && (
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ export const SettingsSchema = z.object({
|
|||
bindings: ShortcutBindingsMapSchema,
|
||||
push_to_talk: z.boolean(),
|
||||
audio_feedback: z.boolean(),
|
||||
audio_feedback_volume: z.number().optional().default(1.0),
|
||||
sound_theme: z
|
||||
.enum(["marimba", "pop", "custom"])
|
||||
.optional()
|
||||
.default("marimba"),
|
||||
start_hidden: z.boolean().optional().default(false),
|
||||
autostart_enabled: z.boolean().optional().default(false),
|
||||
selected_model: z.string(),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ interface SettingsStore {
|
|||
isUpdating: Record<string, boolean>;
|
||||
audioDevices: AudioDevice[];
|
||||
outputDevices: AudioDevice[];
|
||||
customSounds: { start: boolean; stop: boolean };
|
||||
|
||||
// Actions
|
||||
initialize: () => Promise<void>;
|
||||
|
|
@ -24,6 +25,8 @@ interface SettingsStore {
|
|||
resetBinding: (id: string) => Promise<void>;
|
||||
getSetting: <K extends keyof Settings>(key: K) => Settings[K] | undefined;
|
||||
isUpdatingKey: (key: string) => boolean;
|
||||
playTestSound: (soundType: "start" | "stop") => Promise<void>;
|
||||
checkCustomSounds: () => Promise<void>;
|
||||
|
||||
// Internal state setters
|
||||
setSettings: (settings: Settings | null) => void;
|
||||
|
|
@ -31,11 +34,14 @@ interface SettingsStore {
|
|||
setUpdating: (key: string, updating: boolean) => void;
|
||||
setAudioDevices: (devices: AudioDevice[]) => void;
|
||||
setOutputDevices: (devices: AudioDevice[]) => void;
|
||||
setCustomSounds: (sounds: { start: boolean; stop: boolean }) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Partial<Settings> = {
|
||||
always_on_microphone: false,
|
||||
audio_feedback: true,
|
||||
audio_feedback_volume: 1.0,
|
||||
sound_theme: "marimba",
|
||||
start_hidden: false,
|
||||
autostart_enabled: false,
|
||||
push_to_talk: false,
|
||||
|
|
@ -55,6 +61,46 @@ const DEFAULT_AUDIO_DEVICE: AudioDevice = {
|
|||
is_default: true,
|
||||
};
|
||||
|
||||
const settingUpdaters: {
|
||||
[K in keyof Settings]?: (value: Settings[K]) => Promise<unknown>;
|
||||
} = {
|
||||
always_on_microphone: (value) =>
|
||||
invoke("update_microphone_mode", { alwaysOn: value }),
|
||||
audio_feedback: (value) =>
|
||||
invoke("change_audio_feedback_setting", { enabled: value }),
|
||||
audio_feedback_volume: (value) =>
|
||||
invoke("change_audio_feedback_volume_setting", { volume: value }),
|
||||
sound_theme: (value) =>
|
||||
invoke("change_sound_theme_setting", { theme: value }),
|
||||
start_hidden: (value) =>
|
||||
invoke("change_start_hidden_setting", { enabled: value }),
|
||||
autostart_enabled: (value) =>
|
||||
invoke("change_autostart_setting", { enabled: value }),
|
||||
push_to_talk: (value) => invoke("change_ptt_setting", { enabled: value }),
|
||||
selected_microphone: (value) =>
|
||||
invoke("set_selected_microphone", {
|
||||
deviceName: value === "Default" ? "default" : value,
|
||||
}),
|
||||
selected_output_device: (value) =>
|
||||
invoke("set_selected_output_device", {
|
||||
deviceName: value === "Default" ? "default" : value,
|
||||
}),
|
||||
translate_to_english: (value) =>
|
||||
invoke("change_translate_to_english_setting", { enabled: value }),
|
||||
selected_language: (value) =>
|
||||
invoke("change_selected_language_setting", { language: value }),
|
||||
overlay_position: (value) =>
|
||||
invoke("change_overlay_position_setting", { position: value }),
|
||||
debug_mode: (value) =>
|
||||
invoke("change_debug_mode_setting", { enabled: value }),
|
||||
custom_words: (value) => invoke("update_custom_words", { words: value }),
|
||||
word_correction_threshold: (value) =>
|
||||
invoke("change_word_correction_threshold_setting", { threshold: value }),
|
||||
paste_method: (value) =>
|
||||
invoke("change_paste_method_setting", { method: value }),
|
||||
history_limit: (value) => invoke("update_history_limit", { limit: value }),
|
||||
};
|
||||
|
||||
export const useSettingsStore = create<SettingsStore>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
settings: null,
|
||||
|
|
@ -62,6 +108,7 @@ export const useSettingsStore = create<SettingsStore>()(
|
|||
isUpdating: {},
|
||||
audioDevices: [],
|
||||
outputDevices: [],
|
||||
customSounds: { start: false, stop: false },
|
||||
|
||||
// Internal setters
|
||||
setSettings: (settings) => set({ settings }),
|
||||
|
|
@ -72,6 +119,7 @@ export const useSettingsStore = create<SettingsStore>()(
|
|||
})),
|
||||
setAudioDevices: (audioDevices) => set({ audioDevices }),
|
||||
setOutputDevices: (outputDevices) => set({ outputDevices }),
|
||||
setCustomSounds: (customSounds) => set({ customSounds }),
|
||||
|
||||
// Getters
|
||||
getSetting: (key) => get().settings?.[key],
|
||||
|
|
@ -154,99 +202,52 @@ export const useSettingsStore = create<SettingsStore>()(
|
|||
}
|
||||
},
|
||||
|
||||
// Play a test sound
|
||||
playTestSound: async (soundType: "start" | "stop") => {
|
||||
try {
|
||||
await invoke("play_test_sound", { soundType });
|
||||
} catch (error) {
|
||||
console.error(`Failed to play test sound (${soundType}):`, error);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
checkCustomSounds: async () => {
|
||||
try {
|
||||
const sounds = await invoke("check_custom_sounds");
|
||||
get().setCustomSounds(sounds as { start: boolean; stop: boolean });
|
||||
} catch (error) {
|
||||
console.error("Failed to check custom sounds:", error);
|
||||
}
|
||||
},
|
||||
|
||||
// Update a specific setting
|
||||
updateSetting: async <K extends keyof Settings>(
|
||||
key: K,
|
||||
value: Settings[K],
|
||||
) => {
|
||||
const { settings, setUpdating, refreshSettings } = get();
|
||||
const { settings, setUpdating } = 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 "start_hidden":
|
||||
await invoke("change_start_hidden_setting", { enabled: value });
|
||||
break;
|
||||
case "autostart_enabled":
|
||||
await invoke("change_autostart_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 "paste_method":
|
||||
await invoke("change_paste_method_setting", { method: value });
|
||||
break;
|
||||
case "history_limit":
|
||||
await invoke("update_history_limit", { limit: value });
|
||||
break;
|
||||
case "bindings":
|
||||
case "selected_model":
|
||||
break;
|
||||
default:
|
||||
console.warn(`No handler for setting: ${String(key)}`);
|
||||
const updater = settingUpdaters[key];
|
||||
if (updater) {
|
||||
await updater(value);
|
||||
} else if (key !== "bindings" && key !== "selected_model") {
|
||||
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,
|
||||
}));
|
||||
if (settings) {
|
||||
set({ settings: { ...settings, [key]: originalValue } });
|
||||
}
|
||||
} finally {
|
||||
setUpdating(updateKey, false);
|
||||
}
|
||||
|
|
@ -273,15 +274,15 @@ export const useSettingsStore = create<SettingsStore>()(
|
|||
set((state) => ({
|
||||
settings: state.settings
|
||||
? {
|
||||
...state.settings,
|
||||
bindings: {
|
||||
...state.settings.bindings,
|
||||
[id]: {
|
||||
...state.settings.bindings[id],
|
||||
current_binding: binding,
|
||||
...state.settings,
|
||||
bindings: {
|
||||
...state.settings.bindings,
|
||||
[id]: {
|
||||
...state.settings.bindings[id],
|
||||
current_binding: binding,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
|
|
@ -294,15 +295,15 @@ export const useSettingsStore = create<SettingsStore>()(
|
|||
set((state) => ({
|
||||
settings: state.settings
|
||||
? {
|
||||
...state.settings,
|
||||
bindings: {
|
||||
...state.settings.bindings,
|
||||
[id]: {
|
||||
...state.settings.bindings[id],
|
||||
current_binding: originalBinding,
|
||||
...state.settings,
|
||||
bindings: {
|
||||
...state.settings.bindings,
|
||||
[id]: {
|
||||
...state.settings.bindings[id],
|
||||
current_binding: originalBinding,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
|
@ -330,12 +331,17 @@ export const useSettingsStore = create<SettingsStore>()(
|
|||
|
||||
// Initialize everything
|
||||
initialize: async () => {
|
||||
const { refreshSettings, refreshAudioDevices, refreshOutputDevices } =
|
||||
get();
|
||||
const {
|
||||
refreshSettings,
|
||||
refreshAudioDevices,
|
||||
refreshOutputDevices,
|
||||
checkCustomSounds,
|
||||
} = get();
|
||||
await Promise.all([
|
||||
refreshSettings(),
|
||||
refreshAudioDevices(),
|
||||
refreshOutputDevices(),
|
||||
checkCustomSounds(),
|
||||
]);
|
||||
},
|
||||
})),
|
||||
|
|
|
|||
Loading…
Reference in a new issue