feat(overlay): replace show_overlay toggle with position selector

This commit is contained in:
Vlad Gerasimov 2025-08-06 07:52:09 +04:00
parent 53874110b1
commit 091b392aa6
10 changed files with 149 additions and 144 deletions

View file

@ -181,7 +181,7 @@ pub fn run() {
shortcut::change_audio_feedback_setting, shortcut::change_audio_feedback_setting,
shortcut::change_translate_to_english_setting, shortcut::change_translate_to_english_setting,
shortcut::change_selected_language_setting, shortcut::change_selected_language_setting,
shortcut::change_show_overlay_setting, shortcut::change_overlay_position_setting,
shortcut::change_debug_mode_setting, shortcut::change_debug_mode_setting,
shortcut::suspend_binding, shortcut::suspend_binding,
shortcut::resume_binding, shortcut::resume_binding,

View file

@ -30,8 +30,8 @@ pub struct AppSettings {
pub translate_to_english: bool, pub translate_to_english: bool,
#[serde(default = "default_selected_language")] #[serde(default = "default_selected_language")]
pub selected_language: String, pub selected_language: String,
#[serde(default = "default_show_overlay")] #[serde(default = "default_overlay_position")]
pub show_overlay: bool, pub overlay_position: String,
#[serde(default = "default_debug_mode")] #[serde(default = "default_debug_mode")]
pub debug_mode: bool, pub debug_mode: bool,
} }
@ -58,9 +58,9 @@ fn default_selected_language() -> String {
"auto".to_string() "auto".to_string()
} }
fn default_show_overlay() -> bool { fn default_overlay_position() -> String {
// Default to true - users expect visual feedback by default // Default to "bottom" - less intrusive position
true "bottom".to_string()
} }
fn default_debug_mode() -> bool { fn default_debug_mode() -> bool {
@ -114,7 +114,7 @@ pub fn get_default_settings() -> AppSettings {
selected_output_device: None, selected_output_device: None,
translate_to_english: false, translate_to_english: false,
selected_language: "auto".to_string(), selected_language: "auto".to_string(),
show_overlay: true, overlay_position: "bottom".to_string(),
debug_mode: false, debug_mode: false,
} }
} }

View file

@ -135,10 +135,14 @@ pub fn change_selected_language_setting(app: AppHandle, language: String) -> Res
} }
#[tauri::command] #[tauri::command]
pub fn change_show_overlay_setting(app: AppHandle, enabled: bool) -> Result<(), String> { pub fn change_overlay_position_setting(app: AppHandle, position: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app); let mut settings = settings::get_settings(&app);
settings.show_overlay = enabled; settings.overlay_position = position;
settings::write_settings(&app, settings); settings::write_settings(&app, settings);
// Update overlay position without recreating window
crate::utils::update_overlay_position(&app);
Ok(()) Ok(())
} }

View file

@ -338,53 +338,66 @@ fn play_audio_file(
/* OVERLAY MANAGEMENT */ /* OVERLAY MANAGEMENT */
/* ──────────────────────────────────────────────────────────────── */ /* ──────────────────────────────────────────────────────────────── */
/// Creates the recording overlay window and keeps it hidden by default const OVERLAY_WIDTH: f64 = 172.0;
pub fn create_recording_overlay(app_handle: &AppHandle) { const OVERLAY_HEIGHT: f64 = 36.0;
// Get work area dimensions for positioning (respects taskbars, docks, etc.) const OVERLAY_OFFSET: f64 = 46.0;
fn calculate_overlay_position(app_handle: &AppHandle) -> Option<(f64, f64)> {
if let Ok(monitors) = app_handle.primary_monitor() { if let Ok(monitors) = app_handle.primary_monitor() {
if let Some(monitor) = monitors { if let Some(monitor) = monitors {
const OVERLAY_WIDTH: f64 = 172.0;
const OVERLAY_HEIGHT: f64 = 36.0;
const OVERLAY_TOP_OFFSET: f64 = 46.0;
let work_area = monitor.work_area(); let work_area = monitor.work_area();
let scale = monitor.scale_factor(); let scale = monitor.scale_factor();
let work_area_width = work_area.size.width as f64 / scale; let work_area_width = work_area.size.width as f64 / scale;
let work_area_height = work_area.size.height as f64 / scale;
let work_area_x = work_area.position.x as f64 / scale; let work_area_x = work_area.position.x as f64 / scale;
let work_area_y = work_area.position.y as f64 / scale; let work_area_y = work_area.position.y as f64 / scale;
// Position at top center of work area let settings = settings::get_settings(app_handle);
let x = work_area_x + (work_area_width - OVERLAY_WIDTH) / 2.0; let overlay_position = settings.overlay_position.as_str();
let y = work_area_y + OVERLAY_TOP_OFFSET;
match WebviewWindowBuilder::new( let x = work_area_x + (work_area_width - OVERLAY_WIDTH) / 2.0;
app_handle, let y = match overlay_position {
"recording_overlay", "top" => work_area_y + OVERLAY_OFFSET,
tauri::WebviewUrl::App("src/overlay/index.html".into()), "bottom" => work_area_y + work_area_height - OVERLAY_HEIGHT - OVERLAY_OFFSET,
) _ => work_area_y + work_area_height - OVERLAY_HEIGHT - OVERLAY_OFFSET,
.title("Recording") };
.position(x, y)
.resizable(false) return Some((x, y));
.inner_size(OVERLAY_WIDTH, OVERLAY_HEIGHT) }
.shadow(false) }
.maximizable(false) None
.minimizable(false) }
.closable(false)
.accept_first_mouse(true) /// Creates the recording overlay window and keeps it hidden by default
.decorations(false) pub fn create_recording_overlay(app_handle: &AppHandle) {
.always_on_top(true) if let Some((x, y)) = calculate_overlay_position(app_handle) {
.skip_taskbar(true) match WebviewWindowBuilder::new(
.transparent(true) app_handle,
.focused(false) "recording_overlay",
.visible(false) // Start hidden tauri::WebviewUrl::App("src/overlay/index.html".into()),
.build() )
{ .title("Recording")
Ok(_window) => { .position(x, y)
debug!("Recording overlay window created successfully (hidden)"); .resizable(false)
} .inner_size(OVERLAY_WIDTH, OVERLAY_HEIGHT)
Err(e) => { .shadow(false)
debug!("Failed to create recording overlay window: {}", e); .maximizable(false)
} .minimizable(false)
.closable(false)
.accept_first_mouse(true)
.decorations(false)
.always_on_top(true)
.skip_taskbar(true)
.transparent(true)
.focused(false)
.visible(false)
.build()
{
Ok(_window) => {
debug!("Recording overlay window created successfully (hidden)");
}
Err(e) => {
debug!("Failed to create recording overlay window: {}", e);
} }
} }
} }
@ -392,9 +405,9 @@ pub fn create_recording_overlay(app_handle: &AppHandle) {
/// Shows the recording overlay window with fade-in animation /// Shows the recording overlay window with fade-in animation
pub fn show_recording_overlay(app_handle: &AppHandle) { pub fn show_recording_overlay(app_handle: &AppHandle) {
// Check if show_overlay is enabled in settings // Check if overlay should be shown based on position setting
let settings = settings::get_settings(app_handle); let settings = settings::get_settings(app_handle);
if !settings.show_overlay { if settings.overlay_position == "none" {
return; return;
} }
@ -407,9 +420,9 @@ pub fn show_recording_overlay(app_handle: &AppHandle) {
/// Shows the transcribing overlay window /// Shows the transcribing overlay window
pub fn show_transcribing_overlay(app_handle: &AppHandle) { pub fn show_transcribing_overlay(app_handle: &AppHandle) {
// Check if show_overlay is enabled in settings // Check if overlay should be shown based on position setting
let settings = settings::get_settings(app_handle); let settings = settings::get_settings(app_handle);
if !settings.show_overlay { if settings.overlay_position == "none" {
return; return;
} }
@ -420,10 +433,19 @@ pub fn show_transcribing_overlay(app_handle: &AppHandle) {
} }
} }
/// Updates the overlay window position based on current settings
pub fn update_overlay_position(app_handle: &AppHandle) {
if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") {
if let Some((x, y)) = calculate_overlay_position(app_handle) {
let _ = overlay_window.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y }));
}
}
}
/// Hides the recording overlay window with fade-out animation /// Hides the recording overlay window with fade-out animation
pub fn hide_recording_overlay(app_handle: &AppHandle) { pub fn hide_recording_overlay(app_handle: &AppHandle) {
// Check if show_overlay is enabled in settings - if disabled, the overlay shouldn't be shown anyway // Always hide the overlay regardless of settings - if setting was changed while recording,
// but we still want to hide it in case the setting was changed while recording // we still want to hide it properly
if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") {
// Emit event to trigger fade-out animation // Emit event to trigger fade-out animation
let _ = overlay_window.emit("hide-overlay", ()); let _ = overlay_window.emit("hide-overlay", ());

View file

@ -55,6 +55,11 @@ export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = ({
console.log("Microphone reset to default"); console.log("Microphone reset to default");
}; };
const microphoneOptions = audioDevices.map(device => ({
value: device.name,
label: device.name
}));
return ( return (
<SettingContainer <SettingContainer
title="Microphone" title="Microphone"
@ -64,12 +69,12 @@ export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = ({
> >
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Dropdown <Dropdown
devices={audioDevices} options={microphoneOptions}
selectedDevice={selectedMicrophone} selectedValue={selectedMicrophone}
onSelect={handleMicrophoneSelect} onSelect={handleMicrophoneSelect}
placeholder={isLoading ? "Loading..." : "Select microphone..."} placeholder={isLoading ? "Loading..." : "Select microphone..."}
disabled={isUpdating("selected_microphone") || isLoading} disabled={isUpdating("selected_microphone") || isLoading}
refreshDevices={refreshAudioDevices} onRefresh={refreshAudioDevices}
/> />
<button <button
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150" className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"

View file

@ -38,6 +38,11 @@ export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = ({
console.log("Output device reset to default"); console.log("Output device reset to default");
}; };
const outputDeviceOptions = outputDevices.map(device => ({
value: device.name,
label: device.name
}));
return ( return (
<SettingContainer <SettingContainer
title="Output Device" title="Output Device"
@ -47,12 +52,12 @@ export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = ({
> >
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Dropdown <Dropdown
devices={outputDevices} options={outputDeviceOptions}
selectedDevice={selectedOutputDevice} selectedValue={selectedOutputDevice}
onSelect={handleOutputDeviceSelect} onSelect={handleOutputDeviceSelect}
placeholder={isLoading ? "Loading..." : "Select output device..."} placeholder={isLoading ? "Loading..." : "Select output device..."}
disabled={isUpdating("selected_output_device") || isLoading} disabled={isUpdating("selected_output_device") || isLoading}
refreshDevices={refreshOutputDevices} onRefresh={refreshOutputDevices}
/> />
<button <button
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150" className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"

View file

@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { ToggleSwitch } from "../ui/ToggleSwitch"; import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings"; import { useSettings } from "../../hooks/useSettings";
interface ShowOverlayProps { interface ShowOverlayProps {
@ -7,23 +8,33 @@ interface ShowOverlayProps {
grouped?: boolean; grouped?: boolean;
} }
const overlayOptions = [
{ value: "none", label: "Do not show" },
{ value: "bottom", label: "On bottom" },
{ value: "top", label: "On top" }
];
export const ShowOverlay: React.FC<ShowOverlayProps> = ({ export const ShowOverlay: React.FC<ShowOverlayProps> = ({
descriptionMode = "tooltip", descriptionMode = "tooltip",
grouped = false, grouped = false
}) => { }) => {
const { getSetting, updateSetting, isUpdating } = useSettings(); const { getSetting, updateSetting, isUpdating } = useSettings();
const showOverlayEnabled = getSetting("show_overlay") ?? true; const selectedPosition = getSetting("overlay_position") || "bottom";
return ( return (
<ToggleSwitch <SettingContainer
checked={showOverlayEnabled} title="Show Overlay"
onChange={(enabled) => updateSetting("show_overlay", enabled)}
isUpdating={isUpdating("show_overlay")}
label="Show Overlay"
description="Display visual feedback overlay during recording and transcription" description="Display visual feedback overlay during recording and transcription"
descriptionMode={descriptionMode} descriptionMode={descriptionMode}
grouped={grouped} grouped={grouped}
/> >
<Dropdown
options={overlayOptions}
selectedValue={selectedPosition}
onSelect={(value) => updateSetting("overlay_position", value)}
disabled={isUpdating("overlay_position")}
/>
</SettingContainer>
); );
}; };

View file

@ -1,71 +1,50 @@
import React, { useState, useRef, useEffect } from "react"; import React, { useState, useRef, useEffect } from "react";
import { AudioDevice } from "../../lib/types";
export interface DropdownOption {
value: string;
label: string;
}
interface DropdownProps { interface DropdownProps {
devices: AudioDevice[]; options: DropdownOption[];
selectedDevice: string | null; selectedValue: string | null;
onSelect: (deviceName: string) => void; onSelect: (value: string) => void;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
refreshDevices?: () => void; onRefresh?: () => void;
} }
export const Dropdown: React.FC<DropdownProps> = ({ export const Dropdown: React.FC<DropdownProps> = ({
devices, options,
selectedDevice, selectedValue,
onSelect, onSelect,
placeholder = "Select a microphone...", placeholder = "Select an option...",
disabled = false, disabled = false,
refreshDevices, onRefresh,
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if ( if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false); setIsOpen(false);
} }
}; };
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
return () => { return () => document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("mousedown", handleClickOutside);
};
}, []); }, []);
// Find the selected device name with proper default handling const selectedOption = options.find(option => option.value === selectedValue);
const selectedDeviceName = selectedDevice
? (() => {
// First try exact match
let device = devices.find((d) => d.name === selectedDevice);
// If no exact match and selected is "default" or "Default", try both variants const handleSelect = (value: string) => {
if (!device && selectedDevice.toLowerCase() === "default") { onSelect(value);
device = devices.find((d) => d.name.toLowerCase() === "default");
}
return device?.name || "Unknown Device";
})()
: null;
const handleSelect = (deviceName: string) => {
onSelect(deviceName);
setIsOpen(false); setIsOpen(false);
}; };
const handleToggle = () => { const handleToggle = () => {
if (disabled) return; if (disabled) return;
if (!isOpen && onRefresh) onRefresh();
// Refresh devices when opening the dropdown
if (!isOpen && refreshDevices) {
refreshDevices();
}
setIsOpen(!isOpen); setIsOpen(!isOpen);
}; };
@ -74,52 +53,31 @@ export const Dropdown: React.FC<DropdownProps> = ({
<button <button
type="button" type="button"
className={`px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded min-w-[200px] text-left flex items-center justify-between transition-all duration-150 ${ className={`px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded min-w-[200px] text-left flex items-center justify-between transition-all duration-150 ${
disabled disabled ? "opacity-50 cursor-not-allowed" : "hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary"
? "opacity-50 cursor-not-allowed"
: "hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary"
}`} }`}
onClick={handleToggle} onClick={handleToggle}
disabled={disabled} disabled={disabled}
> >
<span className="truncate">{selectedDeviceName || placeholder}</span> <span className="truncate">{selectedOption?.label || placeholder}</span>
<svg <svg className={`w-4 h-4 ml-2 transition-transform duration-200 ${isOpen ? "transform rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={`w-4 h-4 ml-2 transition-transform duration-200 ${ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
isOpen ? "transform rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</button> </button>
{isOpen && !disabled && ( {isOpen && !disabled && (
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-mid-gray/80 rounded shadow-lg z-50 max-h-60 overflow-y-auto"> <div className="absolute top-full left-0 right-0 mt-1 bg-background border border-mid-gray/80 rounded shadow-lg z-50 max-h-60 overflow-y-auto">
{devices.length === 0 ? ( {options.length === 0 ? (
<div className="px-2 py-1 text-sm text-mid-gray"> <div className="px-2 py-1 text-sm text-mid-gray">No options found</div>
No microphones found
</div>
) : ( ) : (
devices.map((device) => ( options.map((option) => (
<button <button
key={device.index} key={option.value}
type="button" type="button"
className={`w-full px-2 py-1 text-sm text-left hover:bg-logo-primary/10 transition-colors duration-150 ${ className={`w-full px-2 py-1 text-sm text-left hover:bg-logo-primary/10 transition-colors duration-150 ${
selectedDevice === device.name selectedValue === option.value ? "bg-logo-primary/20 text-logo-primary font-semibold" : ""
? "bg-logo-primary/20 text-logo-primary font-semibold"
: ""
}`} }`}
onClick={() => handleSelect(device.name)} onClick={() => handleSelect(option.value)}
> >
<div className="flex items-center justify-between"> <span className="truncate">{option.label}</span>
<span className="truncate">{device.name}</span>
</div>
</button> </button>
)) ))
)} )}

View file

@ -199,8 +199,8 @@ export const useSettings = (): UseSettingsReturn => {
language: value, language: value,
}); });
break; break;
case "show_overlay": case "overlay_position":
await invoke("change_show_overlay_setting", { enabled: value }); await invoke("change_overlay_position_setting", { position: value });
break; break;
case "debug_mode": case "debug_mode":
await invoke("change_debug_mode_setting", { enabled: value }); await invoke("change_debug_mode_setting", { enabled: value });
@ -249,7 +249,7 @@ export const useSettings = (): UseSettingsReturn => {
selected_output_device: "Default", selected_output_device: "Default",
translate_to_english: false, translate_to_english: false,
selected_language: "auto", selected_language: "auto",
show_overlay: true, overlay_position: "bottom",
debug_mode: false, debug_mode: false,
}; };

View file

@ -29,7 +29,7 @@ export const SettingsSchema = z.object({
selected_output_device: z.string().nullable().optional(), selected_output_device: z.string().nullable().optional(),
translate_to_english: z.boolean(), translate_to_english: z.boolean(),
selected_language: z.string(), selected_language: z.string(),
show_overlay: z.boolean(), overlay_position: z.string(),
debug_mode: z.boolean(), debug_mode: z.boolean(),
}); });