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_translate_to_english_setting,
shortcut::change_selected_language_setting,
shortcut::change_show_overlay_setting,
shortcut::change_overlay_position_setting,
shortcut::change_debug_mode_setting,
shortcut::suspend_binding,
shortcut::resume_binding,

View file

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

View file

@ -135,10 +135,14 @@ pub fn change_selected_language_setting(app: AppHandle, language: String) -> Res
}
#[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);
settings.show_overlay = enabled;
settings.overlay_position = position;
settings::write_settings(&app, settings);
// Update overlay position without recreating window
crate::utils::update_overlay_position(&app);
Ok(())
}

View file

@ -338,53 +338,66 @@ fn play_audio_file(
/* OVERLAY MANAGEMENT */
/* ──────────────────────────────────────────────────────────────── */
/// Creates the recording overlay window and keeps it hidden by default
pub fn create_recording_overlay(app_handle: &AppHandle) {
// Get work area dimensions for positioning (respects taskbars, docks, etc.)
const OVERLAY_WIDTH: f64 = 172.0;
const OVERLAY_HEIGHT: f64 = 36.0;
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 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 scale = monitor.scale_factor();
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_y = work_area.position.y as f64 / scale;
// Position at top center of work area
let x = work_area_x + (work_area_width - OVERLAY_WIDTH) / 2.0;
let y = work_area_y + OVERLAY_TOP_OFFSET;
let settings = settings::get_settings(app_handle);
let overlay_position = settings.overlay_position.as_str();
match WebviewWindowBuilder::new(
app_handle,
"recording_overlay",
tauri::WebviewUrl::App("src/overlay/index.html".into()),
)
.title("Recording")
.position(x, y)
.resizable(false)
.inner_size(OVERLAY_WIDTH, OVERLAY_HEIGHT)
.shadow(false)
.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) // Start hidden
.build()
{
Ok(_window) => {
debug!("Recording overlay window created successfully (hidden)");
}
Err(e) => {
debug!("Failed to create recording overlay window: {}", e);
}
let x = work_area_x + (work_area_width - OVERLAY_WIDTH) / 2.0;
let y = match overlay_position {
"top" => work_area_y + OVERLAY_OFFSET,
"bottom" => work_area_y + work_area_height - OVERLAY_HEIGHT - OVERLAY_OFFSET,
_ => work_area_y + work_area_height - OVERLAY_HEIGHT - OVERLAY_OFFSET,
};
return Some((x, y));
}
}
None
}
/// Creates the recording overlay window and keeps it hidden by default
pub fn create_recording_overlay(app_handle: &AppHandle) {
if let Some((x, y)) = calculate_overlay_position(app_handle) {
match WebviewWindowBuilder::new(
app_handle,
"recording_overlay",
tauri::WebviewUrl::App("src/overlay/index.html".into()),
)
.title("Recording")
.position(x, y)
.resizable(false)
.inner_size(OVERLAY_WIDTH, OVERLAY_HEIGHT)
.shadow(false)
.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
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);
if !settings.show_overlay {
if settings.overlay_position == "none" {
return;
}
@ -407,9 +420,9 @@ pub fn show_recording_overlay(app_handle: &AppHandle) {
/// Shows the transcribing overlay window
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);
if !settings.show_overlay {
if settings.overlay_position == "none" {
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
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
// but we still want to hide it in case the setting was changed while recording
// 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", ());

View file

@ -55,6 +55,11 @@ export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = ({
console.log("Microphone reset to default");
};
const microphoneOptions = audioDevices.map(device => ({
value: device.name,
label: device.name
}));
return (
<SettingContainer
title="Microphone"
@ -64,12 +69,12 @@ export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = ({
>
<div className="flex items-center space-x-1">
<Dropdown
devices={audioDevices}
selectedDevice={selectedMicrophone}
options={microphoneOptions}
selectedValue={selectedMicrophone}
onSelect={handleMicrophoneSelect}
placeholder={isLoading ? "Loading..." : "Select microphone..."}
disabled={isUpdating("selected_microphone") || isLoading}
refreshDevices={refreshAudioDevices}
onRefresh={refreshAudioDevices}
/>
<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"

View file

@ -38,6 +38,11 @@ export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = ({
console.log("Output device reset to default");
};
const outputDeviceOptions = outputDevices.map(device => ({
value: device.name,
label: device.name
}));
return (
<SettingContainer
title="Output Device"
@ -47,12 +52,12 @@ export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = ({
>
<div className="flex items-center space-x-1">
<Dropdown
devices={outputDevices}
selectedDevice={selectedOutputDevice}
options={outputDeviceOptions}
selectedValue={selectedOutputDevice}
onSelect={handleOutputDeviceSelect}
placeholder={isLoading ? "Loading..." : "Select output device..."}
disabled={isUpdating("selected_output_device") || isLoading}
refreshDevices={refreshOutputDevices}
onRefresh={refreshOutputDevices}
/>
<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"

View file

@ -1,5 +1,6 @@
import React from "react";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings";
interface ShowOverlayProps {
@ -7,23 +8,33 @@ interface ShowOverlayProps {
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> = ({
descriptionMode = "tooltip",
grouped = false,
grouped = false
}) => {
const { getSetting, updateSetting, isUpdating } = useSettings();
const showOverlayEnabled = getSetting("show_overlay") ?? true;
const selectedPosition = getSetting("overlay_position") || "bottom";
return (
<ToggleSwitch
checked={showOverlayEnabled}
onChange={(enabled) => updateSetting("show_overlay", enabled)}
isUpdating={isUpdating("show_overlay")}
label="Show Overlay"
<SettingContainer
title="Show Overlay"
description="Display visual feedback overlay during recording and transcription"
descriptionMode={descriptionMode}
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 { AudioDevice } from "../../lib/types";
export interface DropdownOption {
value: string;
label: string;
}
interface DropdownProps {
devices: AudioDevice[];
selectedDevice: string | null;
onSelect: (deviceName: string) => void;
options: DropdownOption[];
selectedValue: string | null;
onSelect: (value: string) => void;
placeholder?: string;
disabled?: boolean;
refreshDevices?: () => void;
onRefresh?: () => void;
}
export const Dropdown: React.FC<DropdownProps> = ({
devices,
selectedDevice,
options,
selectedValue,
onSelect,
placeholder = "Select a microphone...",
placeholder = "Select an option...",
disabled = false,
refreshDevices,
onRefresh,
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Find the selected device name with proper default handling
const selectedDeviceName = selectedDevice
? (() => {
// First try exact match
let device = devices.find((d) => d.name === selectedDevice);
const selectedOption = options.find(option => option.value === selectedValue);
// If no exact match and selected is "default" or "Default", try both variants
if (!device && selectedDevice.toLowerCase() === "default") {
device = devices.find((d) => d.name.toLowerCase() === "default");
}
return device?.name || "Unknown Device";
})()
: null;
const handleSelect = (deviceName: string) => {
onSelect(deviceName);
const handleSelect = (value: string) => {
onSelect(value);
setIsOpen(false);
};
const handleToggle = () => {
if (disabled) return;
// Refresh devices when opening the dropdown
if (!isOpen && refreshDevices) {
refreshDevices();
}
if (!isOpen && onRefresh) onRefresh();
setIsOpen(!isOpen);
};
@ -74,52 +53,31 @@ export const Dropdown: React.FC<DropdownProps> = ({
<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 ${
disabled
? "opacity-50 cursor-not-allowed"
: "hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary"
disabled ? "opacity-50 cursor-not-allowed" : "hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary"
}`}
onClick={handleToggle}
disabled={disabled}
>
<span className="truncate">{selectedDeviceName || placeholder}</span>
<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"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
<span className="truncate">{selectedOption?.label || placeholder}</span>
<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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{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">
{devices.length === 0 ? (
<div className="px-2 py-1 text-sm text-mid-gray">
No microphones found
</div>
{options.length === 0 ? (
<div className="px-2 py-1 text-sm text-mid-gray">No options found</div>
) : (
devices.map((device) => (
options.map((option) => (
<button
key={device.index}
key={option.value}
type="button"
className={`w-full px-2 py-1 text-sm text-left hover:bg-logo-primary/10 transition-colors duration-150 ${
selectedDevice === device.name
? "bg-logo-primary/20 text-logo-primary font-semibold"
: ""
selectedValue === option.value ? "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">{device.name}</span>
</div>
<span className="truncate">{option.label}</span>
</button>
))
)}

View file

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

View file

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