feat(overlay): replace show_overlay toggle with position selector
This commit is contained in:
parent
53874110b1
commit
091b392aa6
10 changed files with 149 additions and 144 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", ());
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue