add Jon's feature.

This commit is contained in:
CJ Pais 2025-05-12 14:09:47 -07:00
parent b5eb5b0431
commit cff0969b36
11 changed files with 134 additions and 54 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 B

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,11 +1,11 @@
use crate::managers::audio::AudioRecordingManager;
use crate::managers::transcription::TranscriptionManager;
use crate::utils;
use crate::utils::change_tray_icon;
use crate::utils::TrayIconState;
use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::sync::Arc;
use tauri::image::Image;
use tauri::tray::TrayIcon;
use tauri::AppHandle;
use tauri::Manager;
@ -13,6 +13,7 @@ use tauri::Manager;
pub type PressAction = fn(app: &AppHandle, shortcut_str: &str);
pub type ReleaseAction = fn(app: &AppHandle, shortcut_str: &str);
// TODO refactor to start and stop
pub struct ActionSet {
pub press: PressAction,
pub release: ReleaseAction,
@ -20,45 +21,22 @@ pub struct ActionSet {
// Handler Functions
fn transcribe_pressed_action(app: &AppHandle, _shortcut_str: &str) {
let tray = app.state::<TrayIcon>();
tray.set_icon(Some(
Image::from_path(
app.path()
.resolve(
"resources/tray_recording.png",
tauri::path::BaseDirectory::Resource,
)
.expect("failed to resolve"),
)
.expect("failed to set icon"),
));
change_tray_icon(app, TrayIconState::Recording);
let rm = app.state::<Arc<AudioRecordingManager>>();
rm.try_start_recording("transcribe");
}
fn transcribe_released_action(app: &AppHandle, _shortcut_str: &str) {
let tray = app.state::<TrayIcon>();
tray.set_icon(Some(
Image::from_path(
app.path()
.resolve(
"resources/tray_64x64.png",
tauri::path::BaseDirectory::Resource,
)
.expect("failed to resolve"),
)
.expect("failed to set icon"),
));
let ah = app.clone();
let rm = Arc::clone(&app.state::<Arc<AudioRecordingManager>>());
let tm = Arc::clone(&app.state::<Arc<TranscriptionManager>>());
change_tray_icon(app, TrayIconState::Idle);
tauri::async_runtime::spawn(async move {
if let Some(samples) = rm.stop_recording("transcribe") {
match tm.transcribe(samples) {
// Not .await, as transcribe is synchronous
Ok(transcription) => {
println!("Global Shortcut Transcription: {}", transcription);
utils::paste(transcription, ah);

View file

@ -6,13 +6,22 @@ mod utils;
use managers::audio::AudioRecordingManager;
use managers::transcription::TranscriptionManager;
use std::sync::Arc;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tauri::image::Image;
use tauri::menu::{Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::Manager;
use tauri_plugin_autostart::MacosLauncher;
#[derive(Default)]
struct ShortcutToggleStates {
// Map: shortcut_binding_id -> is_active
active_toggles: HashMap<String, bool>,
}
type ManagedToggleState = Mutex<ShortcutToggleStates>;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
env_logger::init();
@ -27,13 +36,14 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.manage(Mutex::new(ShortcutToggleStates::default()))
.setup(move |app| {
let settings_i = MenuItem::with_id(app, "settings", "Settings", true, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&settings_i, &quit_i])?;
let tray = TrayIconBuilder::new()
.icon(Image::from_path(app.path().resolve(
"resources/tray_64x64.png",
"resources/tray_idle.png",
tauri::path::BaseDirectory::Resource,
)?)?)
.menu(&menu)
@ -86,7 +96,8 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
shortcut::change_binding,
shortcut::reset_binding
shortcut::reset_binding,
shortcut::change_ptt_setting
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View file

@ -54,6 +54,7 @@ impl TranscriptionManager {
params.set_print_progress(false);
params.set_print_realtime(false);
params.set_print_timestamps(false);
params.set_suppress_blank(true);
params.set_suppress_non_speech_tokens(true);
state

View file

@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Arc};
use tauri::{App, AppHandle, Runtime};
use tauri_plugin_store::{Store, StoreExt};
use std::collections::HashMap;
use tauri::{App, AppHandle};
use tauri_plugin_store::StoreExt;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ShortcutBinding {
@ -16,6 +16,7 @@ pub struct ShortcutBinding {
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AppSettings {
pub bindings: HashMap<String, ShortcutBinding>,
pub push_to_talk: bool,
}
pub const SETTINGS_STORE_PATH: &str = "settings_store.json";
@ -32,18 +33,21 @@ pub fn get_default_settings() -> AppSettings {
current_binding: "alt+space".to_string(),
},
);
bindings.insert(
"test".to_string(),
ShortcutBinding {
id: "test".to_string(),
name: "Test".to_string(),
description: "This is a test binding.".to_string(),
default_binding: "ctrl+d".to_string(),
current_binding: "ctrl+d".to_string(),
},
);
// bindings.insert(
// "test".to_string(),
// ShortcutBinding {
// id: "test".to_string(),
// name: "Test".to_string(),
// description: "This is a test binding.".to_string(),
// default_binding: "ctrl+d".to_string(),
// current_binding: "ctrl+d".to_string(),
// },
// );
AppSettings { bindings }
AppSettings {
bindings,
push_to_talk: true,
}
}
pub fn load_or_create_app_settings(app: &App) -> AppSettings {

View file

@ -1,11 +1,12 @@
use serde::Serialize;
use tauri::{App, AppHandle};
use tauri::{App, AppHandle, Manager};
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::{Shortcut, ShortcutState};
use crate::actions::ACTION_MAP;
use crate::settings;
use crate::settings::ShortcutBinding;
use crate::settings::{self, get_settings};
use crate::ManagedToggleState;
pub fn init_shortcuts(app: &App) {
let settings = settings::load_or_create_app_settings(app);
@ -89,6 +90,19 @@ pub fn reset_binding(app: AppHandle, id: String) -> Result<BindingResponse, Stri
return change_binding(app, id, binding.default_binding);
}
#[tauri::command]
pub fn change_ptt_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
// TODO if the setting is currently false, we probably want to
// cancel any ongoing recordings or actions
settings.push_to_talk = enabled;
settings::write_settings(&app, settings);
Ok(())
}
fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
// Parse shortcut and return error if it fails
let shortcut = match binding.current_binding.parse::<Shortcut>() {
@ -100,15 +114,34 @@ fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), S
let binding_id_for_closure = binding.id.clone();
app.global_shortcut()
.on_shortcut(shortcut, move |handler_app, scut, event| {
.on_shortcut(shortcut, move |ah, scut, event| {
if scut == &shortcut {
let shortcut_string = scut.into_string();
let settings = get_settings(ah);
if let Some(action_set) = ACTION_MAP.get(&binding_id_for_closure) {
if event.state == ShortcutState::Pressed {
(action_set.press)(handler_app, &shortcut_string);
} else if event.state == ShortcutState::Released {
(action_set.release)(handler_app, &shortcut_string);
if settings.push_to_talk {
if event.state == ShortcutState::Pressed {
(action_set.press)(ah, &shortcut_string);
} else if event.state == ShortcutState::Released {
(action_set.release)(ah, &shortcut_string);
}
} else {
if event.state == ShortcutState::Pressed {
let toggle_state_manager = ah.state::<ManagedToggleState>();
let mut states = toggle_state_manager.lock().expect("Failed to lock toggle state manager");
let is_currently_active = states.active_toggles.entry(binding_id_for_closure.clone()).or_insert(false);
if *is_currently_active {
(action_set.release)(ah, &shortcut_string);
*is_currently_active = false; // Update state to inactive
} else {
(action_set.press)(ah, &shortcut_string);
*is_currently_active = true; // Update state to active
}
}
}
} else {
println!(

View file

@ -1,7 +1,10 @@
use rdev::{simulate, EventType, Key, SimulateError};
use std::thread;
use std::time;
use tauri::image::Image;
use tauri::tray::TrayIcon;
use tauri::AppHandle;
use tauri::Manager;
use tauri_plugin_clipboard_manager::ClipboardExt;
fn try_send_event(event: &EventType) {
@ -43,3 +46,26 @@ pub fn paste(text: String, app_handle: AppHandle) {
// restore the clipboard
clipboard.write_text(&clipboard_content).unwrap();
}
pub enum TrayIconState {
Idle,
Recording,
}
pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) {
let tray = app.state::<TrayIcon>();
let icon_path = match icon {
TrayIconState::Idle => "resources/tray_idle.png",
TrayIconState::Recording => "resources/tray_recording.png",
};
let _ = tray.set_icon(Some(
Image::from_path(
app.path()
.resolve(icon_path, tauri::path::BaseDirectory::Resource)
.expect("failed to resolve"),
)
.expect("failed to set icon"),
));
}

View file

@ -12,6 +12,7 @@ import ResetIcon from "../icons/ResetIcon";
export const KeyboardShortcuts: React.FC = () => {
const [bindings, setBindings] = React.useState<ShortcutBindingsMap>({});
const [pttEnabled, setPttEnabled] = React.useState<boolean>(false);
const [keyPressed, setKeyPressed] = useState<string[]>([]);
const [recordedKeys, setRecordedKeys] = useState<string[]>([]);
const [editingShortcutId, setEditingShortcutId] = useState<string | null>(
@ -27,6 +28,7 @@ export const KeyboardShortcuts: React.FC = () => {
r.get("settings").then((s) => {
const settings = SettingsSchema.parse(s);
setBindings(settings.bindings);
setPttEnabled(settings.push_to_talk);
});
});
}, []);
@ -146,6 +148,30 @@ export const KeyboardShortcuts: React.FC = () => {
return (
<div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg border border-mid-gray/20 ">
<div className="max-w-2/3">
<h3 className="text-sm font-medium ">Push To Talk</h3>
<p className="text-sm">Hold to record, release to stop</p>
</div>
<label className="inline-flex items-center cursor-pointer">
<input
type="checkbox"
value=""
className="sr-only peer"
checked={pttEnabled}
onChange={(e) => {
console.log("change ptt setting", e.target.checked);
const newValue = e.target.checked;
setPttEnabled(newValue);
invoke("change_ptt_setting", {
enabled: newValue,
});
}}
/>
<div className="relative w-11 h-6 bg-mid-gray/20 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-logo-primary rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-logo-primary"></div>
</label>
</div>
{Object.entries(bindings).map(([id, binding]) => (
<div
key={id}

View file

@ -5,8 +5,8 @@ import { KeyboardShortcuts } from "./KeyboardShortcuts";
export const Settings: React.FC = () => {
return (
<div className="max-w-3xl w-full mx-auto space-y-4">
<div className="">
<h2 className="text-xl font-semibold mb-2">Keyboard Shortcuts</h2>
<div className="mt-8">
{/* <h2 className="text-xl font-semibold mb-2">Keyboard Shortcuts</h2> */}
<KeyboardShortcuts />
</div>
</div>

View file

@ -15,6 +15,7 @@ export const ShortcutBindingsMapSchema = z.record(
export const SettingsSchema = z.object({
bindings: ShortcutBindingsMapSchema,
push_to_talk: z.boolean(),
});
export const BindingResponseSchema = z.object({