Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 26 additions & 23 deletions src-tauri/src/actions.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
use crate::apple_intelligence;
use crate::audio_feedback::{play_feedback_sound, play_feedback_sound_blocking, SoundType};
use crate::global_controller::GlobalController;
use crate::managers::audio::AudioRecordingManager;
use crate::managers::history::HistoryManager;
use crate::managers::transcription::TranscriptionManager;
use crate::settings::{get_settings, AppSettings, APPLE_INTELLIGENCE_PROVIDER_ID};
use crate::shortcut;
use crate::tray::{change_tray_icon, TrayIconState};
use crate::utils::{self, show_recording_overlay, show_transcribing_overlay};
use crate::utils;
use crate::ManagedToggleState;
use ferrous_opencc::{config::BuiltinConfig, OpenCC};
use log::{debug, error};
Expand Down Expand Up @@ -218,14 +218,18 @@ impl ShortcutAction for TranscribeAction {
let start_time = Instant::now();
debug!("TranscribeAction::start called for binding: {}", binding_id);

// Acquire global lock - block if already busy
let controller = app.state::<Arc<GlobalController>>();
if !controller.begin() {
debug!("TranscribeAction::start blocked - already busy");
return;
}

// Load model in the background
let tm = app.state::<Arc<TranscriptionManager>>();
tm.initiate_model_load();

let binding_id = binding_id.to_string();
change_tray_icon(app, TrayIconState::Recording);
show_recording_overlay(app);

let rm = app.state::<Arc<AudioRecordingManager>>();

// Get the microphone mode to determine audio feedback timing
Expand Down Expand Up @@ -275,6 +279,9 @@ impl ShortcutAction for TranscribeAction {
if recording_started {
// Dynamically register the cancel shortcut in a separate task to avoid deadlock
shortcut::register_cancel_shortcut(app);
} else {
// Recording failed - release the lock
controller.complete();
}

debug!(
Expand All @@ -284,19 +291,24 @@ impl ShortcutAction for TranscribeAction {
}

fn stop(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) {
// Unregister the cancel shortcut when transcription stops
shortcut::unregister_cancel_shortcut(app);

let stop_time = Instant::now();
debug!("TranscribeAction::stop called for binding: {}", binding_id);

// Transition to Processing phase - only proceed if we were Recording
let controller = app.state::<Arc<GlobalController>>();
if !controller.advance() {
debug!("TranscribeAction::stop ignored - not in Recording phase");
return;
}

// Unregister the cancel shortcut
shortcut::unregister_cancel_shortcut(app);

let ah = app.clone();
let rm = Arc::clone(&app.state::<Arc<AudioRecordingManager>>());
let tm = Arc::clone(&app.state::<Arc<TranscriptionManager>>());
let hm = Arc::clone(&app.state::<Arc<HistoryManager>>());

change_tray_icon(app, TrayIconState::Transcribing);
show_transcribing_overlay(app);
let ctrl = Arc::clone(&controller);

// Unmute before playing audio feedback so the stop sound is audible
rm.remove_mute();
Expand Down Expand Up @@ -394,36 +406,27 @@ impl ShortcutAction for TranscribeAction {
),
Err(e) => error!("Failed to paste transcription: {}", e),
}
// Hide the overlay after transcription is complete
utils::hide_recording_overlay(&ah_clone);
change_tray_icon(&ah_clone, TrayIconState::Idle);
})
.unwrap_or_else(|e| {
error!("Failed to run paste on main thread: {:?}", e);
utils::hide_recording_overlay(&ah);
change_tray_icon(&ah, TrayIconState::Idle);
});
} else {
utils::hide_recording_overlay(&ah);
change_tray_icon(&ah, TrayIconState::Idle);
}
}
Err(err) => {
debug!("Global Shortcut Transcription error: {}", err);
utils::hide_recording_overlay(&ah);
change_tray_icon(&ah, TrayIconState::Idle);
}
}
} else {
debug!("No samples retrieved from recording stop");
utils::hide_recording_overlay(&ah);
change_tray_icon(&ah, TrayIconState::Idle);
}

// Clear toggle state now that transcription is complete
if let Ok(mut states) = ah.state::<ManagedToggleState>().lock() {
states.active_toggles.insert(binding_id, false);
}

// Release the global lock
ctrl.complete();
});

debug!(
Expand Down
83 changes: 83 additions & 0 deletions src-tauri/src/global_controller.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//! Global lock with declarative UI sync.

use std::sync::Mutex;
use tauri::AppHandle;

use crate::overlay::{hide_recording_overlay, show_recording_overlay, show_transcribing_overlay};
use crate::tray::{change_tray_icon, TrayIconState};

#[derive(Debug, Clone, PartialEq)]
pub enum GlobalPhase {
Idle,
Recording,
Processing,
}

pub struct GlobalController {
phase: Mutex<GlobalPhase>,
app: AppHandle,
}

impl GlobalController {
pub fn new(app: AppHandle) -> Self {
Self {
phase: Mutex::new(GlobalPhase::Idle),
app,
}
}

fn sync_ui(&self, phase: &GlobalPhase) {
match phase {
GlobalPhase::Idle => {
change_tray_icon(&self.app, TrayIconState::Idle);
hide_recording_overlay(&self.app);
}
GlobalPhase::Recording => {
change_tray_icon(&self.app, TrayIconState::Recording);
show_recording_overlay(&self.app);
}
GlobalPhase::Processing => {
change_tray_icon(&self.app, TrayIconState::Transcribing);
show_transcribing_overlay(&self.app);
}
}
}

pub fn begin(&self) -> bool {
let mut phase = self.phase.lock().unwrap();
if *phase == GlobalPhase::Idle {
*phase = GlobalPhase::Recording;
self.sync_ui(&phase);
true
} else {
false
}
}

pub fn advance(&self) -> bool {
let mut phase = self.phase.lock().unwrap();
if *phase == GlobalPhase::Recording {
*phase = GlobalPhase::Processing;
self.sync_ui(&phase);
true
} else {
false
}
}

pub fn complete(&self) {
let mut phase = self.phase.lock().unwrap();
*phase = GlobalPhase::Idle;
self.sync_ui(&phase);
}

pub fn is_busy(&self) -> bool {
*self.phase.lock().unwrap() != GlobalPhase::Idle
}

/// Re-sync UI to current phase (e.g., after theme change)
pub fn refresh_ui(&self) {
let phase = self.phase.lock().unwrap();
self.sync_ui(&phase);
}
}
10 changes: 8 additions & 2 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod audio_feedback;
pub mod audio_toolkit;
mod clipboard;
mod commands;
mod global_controller;
mod helpers;
mod input;
mod llm_client;
Expand Down Expand Up @@ -39,6 +40,7 @@ use tauri::{AppHandle, Manager};
use tauri_plugin_autostart::{MacosLauncher, ManagerExt};
use tauri_plugin_log::{Builder as LogBuilder, RotationStrategy, Target, TargetKind};

use crate::global_controller::GlobalController;
use crate::settings::get_settings;

// Global atomic to store the file log level filter
Expand Down Expand Up @@ -134,6 +136,9 @@ fn initialize_core_logic(app_handle: &AppHandle) {
app_handle.manage(transcription_manager.clone());
app_handle.manage(history_manager.clone());

// Global lock - prevents concurrent operations
app_handle.manage(Arc::new(GlobalController::new(app_handle.clone())));

// Initialize the shortcuts
shortcut::init_shortcuts(app_handle);

Expand Down Expand Up @@ -413,8 +418,9 @@ pub fn run() {
}
tauri::WindowEvent::ThemeChanged(theme) => {
log::info!("Theme changed to: {:?}", theme);
// Update tray icon to match new theme, maintaining idle state
utils::change_tray_icon(&window.app_handle(), utils::TrayIconState::Idle);
// Re-sync UI to current phase with new theme
let controller = window.app_handle().state::<Arc<GlobalController>>();
controller.refresh_ui();
}
_ => {}
})
Expand Down
12 changes: 2 additions & 10 deletions src-tauri/src/overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,19 +262,11 @@ pub fn update_overlay_position(app_handle: &AppHandle) {
}
}

/// Hides the recording overlay window with fade-out animation
/// Hides the recording overlay window
pub fn hide_recording_overlay(app_handle: &AppHandle) {
// Always hide the overlay regardless of settings - if setting was changed while recording,
// we still want to hide it properly
if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") {
// Emit event to trigger fade-out animation
let _ = overlay_window.emit("hide-overlay", ());
// Hide the window after a short delay to allow animation to complete
let window_clone = overlay_window.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(300));
let _ = window_clone.hide();
});
let _ = overlay_window.hide();
}
}

Expand Down
8 changes: 4 additions & 4 deletions src-tauri/src/shortcut/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::sync::Arc;
use tauri::{AppHandle, Manager};

use crate::actions::ACTION_MAP;
use crate::managers::audio::AudioRecordingManager;
use crate::global_controller::GlobalController;
use crate::settings::get_settings;
use crate::ManagedToggleState;

Expand Down Expand Up @@ -41,10 +41,10 @@ pub fn handle_shortcut_event(
return;
};

// Cancel binding: only fires when recording and key is pressed
// Cancel binding: only fires when busy and key is pressed
if binding_id == "cancel" {
let audio_manager = app.state::<Arc<AudioRecordingManager>>();
if audio_manager.is_recording() && is_pressed {
let controller = app.state::<Arc<GlobalController>>();
if controller.is_busy() && is_pressed {
action.start(app, binding_id, hotkey_string);
}
return;
Expand Down
9 changes: 5 additions & 4 deletions src-tauri/src/utils.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::global_controller::GlobalController;
use crate::managers::audio::AudioRecordingManager;
use crate::managers::transcription::TranscriptionManager;
use crate::shortcut;
Expand Down Expand Up @@ -33,14 +34,14 @@ pub fn cancel_current_operation(app: &AppHandle) {
let audio_manager = app.state::<Arc<AudioRecordingManager>>();
audio_manager.cancel_recording();

// Update tray icon and hide overlay
change_tray_icon(app, crate::tray::TrayIconState::Idle);
hide_recording_overlay(app);

// Unload model if immediate unload is enabled
let tm = app.state::<Arc<TranscriptionManager>>();
tm.maybe_unload_immediately("cancellation");

// Release the global lock
let controller = app.state::<Arc<GlobalController>>();
controller.complete();

info!("Operation cancellation completed - returned to idle state");
}

Expand Down