feat: add global recording shortcut

This commit is contained in:
Evandro committed 2026-06-11 01:05:50 -03:00
1 parent 1f6ea8e5db
commit 2aaad73652
3 files changed
+268 -196

No files matched your search

+3
View File
@@ -4,6 +4,7 @@ pub mod error;
pub mod injection; pub mod injection;
pub mod models; pub mod models;
pub mod settings; pub mod settings;
pub mod shortcuts;
pub mod transcription; pub mod transcription;
pub mod tray; pub mod tray;
pub mod ui_commands; pub mod ui_commands;
@@ -14,8 +15,10 @@ use app_state::ManagedAppState;
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(shortcuts::plugin())
.setup(|app| { .setup(|app| {
tray::setup(app)?; tray::setup(app)?;
shortcuts::setup(app)?;
Ok(()) Ok(())
}) })
.manage(ManagedAppState::default()) .manage(ManagedAppState::default())
+20
View File
@@ -0,0 +1,20 @@
use tauri::{App, Emitter, Runtime};
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
pub const TOGGLE_RECORDING_EVENT: &str = "transcript://toggle-recording";
pub fn plugin<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, _shortcut, event| {
if event.state() == ShortcutState::Pressed {
let _ = app.emit(TOGGLE_RECORDING_EVENT, ());
}
})
.build()
}
pub fn setup(app: &App) -> Result<(), Box<dyn std::error::Error>> {
let shortcut = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::Space);
app.global_shortcut().register(shortcut)?;
Ok(())
}
+245 -196
View File
@@ -1,251 +1,300 @@
import { invoke as tauriInvoke } from '@tauri-apps/api/core'; import { invoke as tauriInvoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
export type RecordingStatus = 'idle' | 'recording'; export type RecordingStatus = "idle" | "recording";
export interface AppSettings { export interface AppSettings {
transcription: { transcription: {
mode: 'localFirst' | 'apiOnly'; mode: "localFirst" | "apiOnly";
modelId: string; modelId: string;
language: string | null; language: string | null;
openaiCompatible: { openaiCompatible: {
endpoint: string; endpoint: string;
apiKeyConfigured: boolean; apiKeyConfigured: boolean;
model: string; model: string;
}; };
}; };
insertion: { insertion: {
method: 'clipboardPaste' | 'keyTyping' | 'manualCopy'; method: "clipboardPaste" | "keyTyping" | "manualCopy";
restoreClipboard: boolean; restoreClipboard: boolean;
waylandManualFallback: boolean; waylandManualFallback: boolean;
}; };
storage: { storage: {
keepAudio: boolean; keepAudio: boolean;
audioFormat: 'wav' | 'ogg' | 'mp3'; audioFormat: "wav" | "ogg" | "mp3";
}; };
} }
export interface ModelWithStatus { export interface ModelWithStatus {
model: { model: {
id: string; id: string;
displayName: string; displayName: string;
repository: string; repository: string;
filename: string; filename: string;
quantization: string; quantization: string;
sizeBytes: number; sizeBytes: number;
languages: string[]; languages: string[];
recommendedForSimplePc: boolean; recommendedForSimplePc: boolean;
}; };
status: 'notInstalled' | 'ready' | { error: string }; status: "notInstalled" | "ready" | { error: string };
} }
export interface TranscriptDraft { export interface TranscriptDraft {
text: string; text: string;
lastMessage: string | null; lastMessage: string | null;
} }
export interface AppSnapshot { export interface AppSnapshot {
settings: AppSettings; settings: AppSettings;
recordingStatus: RecordingStatus; recordingStatus: RecordingStatus;
transcript: TranscriptDraft; transcript: TranscriptDraft;
models: ModelWithStatus[]; models: ModelWithStatus[];
} }
export interface NativeClient { export interface NativeClient {
invoke<T>(command: string, args?: Record<string, unknown>): Promise<T>; invoke<T>(command: string, args?: Record<string, unknown>): Promise<T>;
} }
const fallbackSnapshot: AppSnapshot = { const fallbackSnapshot: AppSnapshot = {
recordingStatus: 'idle', recordingStatus: "idle",
transcript: { transcript: {
text: '', text: "",
lastMessage: 'Modo pré-visualização: comandos nativos disponíveis dentro do Tauri.', lastMessage:
}, "Modo pré-visualização: comandos nativos disponíveis dentro do Tauri.",
settings: { },
transcription: { settings: {
mode: 'localFirst', transcription: {
modelId: 'parakeet-tdt-0.6b-v3-q8_0', mode: "localFirst",
language: null, modelId: "parakeet-tdt-0.6b-v3-q8_0",
openaiCompatible: { language: null,
endpoint: 'https://api.openai.com/v1/audio/transcriptions', openaiCompatible: {
apiKeyConfigured: false, endpoint: "https://api.openai.com/v1/audio/transcriptions",
model: 'whisper-1', apiKeyConfigured: false,
}, model: "whisper-1",
}, },
insertion: { },
method: 'clipboardPaste', insertion: {
restoreClipboard: true, method: "clipboardPaste",
waylandManualFallback: true, restoreClipboard: true,
}, waylandManualFallback: true,
storage: { },
keepAudio: false, storage: {
audioFormat: 'wav', keepAudio: false,
}, audioFormat: "wav",
}, },
models: [ },
{ models: [
model: { {
id: 'parakeet-tdt-0.6b-v3-q8_0', model: {
displayName: 'Parakeet TDT 0.6B v3 Q8', id: "parakeet-tdt-0.6b-v3-q8_0",
repository: 'cstr/parakeet-tdt-0.6b-v3-GGUF', displayName: "Parakeet TDT 0.6B v3 Q8",
filename: 'parakeet-tdt-0.6b-v3-q8_0.gguf', repository: "cstr/parakeet-tdt-0.6b-v3-GGUF",
quantization: 'Q8_0', filename: "parakeet-tdt-0.6b-v3-q8_0.gguf",
sizeBytes: 745_121_600, quantization: "Q8_0",
languages: ['pt', 'en', 'es', 'fr', 'de'], sizeBytes: 745_121_600,
recommendedForSimplePc: true, languages: ["pt", "en", "es", "fr", "de"],
}, recommendedForSimplePc: true,
status: 'notInstalled', },
}, status: "notInstalled",
], },
],
}; };
function createNativeClient(): NativeClient { function createNativeClient(): NativeClient {
return { return {
invoke: <T>(command: string, args?: Record<string, unknown>) => tauriInvoke<T>(command, args), invoke: <T>(command: string, args?: Record<string, unknown>) =>
}; tauriInvoke<T>(command, args),
};
} }
function element<K extends keyof HTMLElementTagNameMap>( function element<K extends keyof HTMLElementTagNameMap>(
tag: K, tag: K,
className?: string, className?: string,
text?: string, text?: string,
): HTMLElementTagNameMap[K] { ): HTMLElementTagNameMap[K] {
const node = document.createElement(tag); const node = document.createElement(tag);
if (className) node.className = className; if (className) node.className = className;
if (text !== undefined) node.textContent = text; if (text !== undefined) node.textContent = text;
return node; return node;
} }
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
const mib = bytes / 1024 / 1024; const mib = bytes / 1024 / 1024;
return `${mib.toFixed(0)} MiB`; return `${mib.toFixed(0)} MiB`;
} }
async function safeSnapshot(client: NativeClient): Promise<AppSnapshot> { async function safeSnapshot(client: NativeClient): Promise<AppSnapshot> {
try { try {
return await client.invoke<AppSnapshot>('get_app_snapshot'); return await client.invoke<AppSnapshot>("get_app_snapshot");
} catch { } catch {
return fallbackSnapshot; return fallbackSnapshot;
} }
} }
export function createTranscriptionApp( export function createTranscriptionApp(
mount: HTMLElement, mount: HTMLElement,
client: NativeClient = createNativeClient(), client: NativeClient = createNativeClient(),
): void { ): void {
mount.textContent = ''; mount.textContent = "";
let snapshot = fallbackSnapshot; let snapshot = fallbackSnapshot;
const shell = element('section', 'modal-shell'); const shell = element("section", "modal-shell");
shell.setAttribute('aria-labelledby', 'app-title'); shell.setAttribute("aria-labelledby", "app-title");
const topbar = element('header', 'topbar'); const topbar = element("header", "topbar");
const titleGroup = element('div', 'title-group'); const titleGroup = element("div", "title-group");
const eyebrow = element('p', 'eyebrow', 'Transcript Desktop'); const eyebrow = element("p", "eyebrow", "Transcript Desktop");
const title = element('h1', undefined, 'Transcrição'); const title = element("h1", undefined, "Transcrição");
title.id = 'app-title'; title.id = "app-title";
const subtitle = element('p', 'subtitle'); const subtitle = element("p", "subtitle");
titleGroup.append(eyebrow, title, subtitle); titleGroup.append(eyebrow, title, subtitle);
const status = element('span', 'status-pill'); const status = element("span", "status-pill");
topbar.append(titleGroup, status); topbar.append(titleGroup, status);
const editorLabel = element('label', 'editor-label', 'Texto transcrito'); const editorLabel = element("label", "editor-label", "Texto transcrito");
editorLabel.setAttribute('for', 'transcript-editor'); editorLabel.setAttribute("for", "transcript-editor");
const editor = element('textarea', 'transcript-editor') as HTMLTextAreaElement; const editor = element(
editor.id = 'transcript-editor'; "textarea",
editor.placeholder = 'A transcrição aparecerá aqui para revisão antes da inserção.'; "transcript-editor",
editor.rows = 10; ) as HTMLTextAreaElement;
editor.id = "transcript-editor";
editor.placeholder =
"A transcrição aparecerá aqui para revisão antes da inserção.";
editor.rows = 10;
const message = element('p', 'message'); const message = element("p", "message");
message.setAttribute('role', 'status'); message.setAttribute("role", "status");
const actions = element('div', 'actions'); const actions = element("div", "actions");
const recordButton = element('button', 'primary-button', 'Iniciar gravação') as HTMLButtonElement; const recordButton = element(
const continueButton = element('button', 'secondary-button', '+ Continuar') as HTMLButtonElement; "button",
const copyButton = element('button', 'secondary-button', 'Copiar') as HTMLButtonElement; "primary-button",
const insertButton = element('button', 'secondary-button', 'Inserir no app') as HTMLButtonElement; "Iniciar gravação",
const saveButton = element('button', 'ghost-button', 'Salvar texto') as HTMLButtonElement; ) as HTMLButtonElement;
actions.append(recordButton, continueButton, copyButton, insertButton, saveButton); const continueButton = element(
"button",
"secondary-button",
"+ Continuar",
) as HTMLButtonElement;
const copyButton = element(
"button",
"secondary-button",
"Copiar",
) as HTMLButtonElement;
const insertButton = element(
"button",
"secondary-button",
"Inserir no app",
) as HTMLButtonElement;
const saveButton = element(
"button",
"ghost-button",
"Salvar texto",
) as HTMLButtonElement;
actions.append(
recordButton,
continueButton,
copyButton,
insertButton,
saveButton,
);
const modelPanel = element('aside', 'model-panel'); const modelPanel = element("aside", "model-panel");
const modelTitle = element('h2', undefined, 'Modelo offline'); const modelTitle = element("h2", undefined, "Modelo offline");
const modelDetails = element('p'); const modelDetails = element("p");
modelPanel.append(modelTitle, modelDetails); modelPanel.append(modelTitle, modelDetails);
shell.append(topbar, editorLabel, editor, message, actions, modelPanel); shell.append(topbar, editorLabel, editor, message, actions, modelPanel);
mount.append(shell); mount.append(shell);
const render = (next: AppSnapshot): void => { const render = (next: AppSnapshot): void => {
snapshot = next; snapshot = next;
const isRecording = snapshot.recordingStatus === 'recording'; const isRecording = snapshot.recordingStatus === "recording";
status.textContent = isRecording ? 'Gravando' : 'Pronto'; status.textContent = isRecording ? "Gravando" : "Pronto";
status.dataset.state = snapshot.recordingStatus; status.dataset.state = snapshot.recordingStatus;
subtitle.textContent = `${snapshot.settings.transcription.mode === 'localFirst' ? 'Offline primeiro' : 'API'} · idioma ${snapshot.settings.transcription.language ?? 'automático'}`; subtitle.textContent = `${snapshot.settings.transcription.mode === "localFirst" ? "Offline primeiro" : "API"} · idioma ${snapshot.settings.transcription.language ?? "automático"}`;
editor.value = snapshot.transcript.text; editor.value = snapshot.transcript.text;
message.textContent = snapshot.transcript.lastMessage ?? 'Revise o texto antes de inserir.'; message.textContent =
recordButton.textContent = isRecording ? 'Parar e transcrever' : 'Iniciar gravação'; snapshot.transcript.lastMessage ?? "Revise o texto antes de inserir.";
recordButton.textContent = isRecording
? "Parar e transcrever"
: "Iniciar gravação";
const firstModel = snapshot.models[0]?.model; const firstModel = snapshot.models[0]?.model;
modelDetails.textContent = firstModel modelDetails.textContent = firstModel
? `${firstModel.displayName} · ${firstModel.quantization} · ${formatBytes(firstModel.sizeBytes)} · ${firstModel.repository}` ? `${firstModel.displayName} · ${firstModel.quantization} · ${formatBytes(firstModel.sizeBytes)} · ${firstModel.repository}`
: 'Nenhum modelo offline homologado encontrado.'; : "Nenhum modelo offline homologado encontrado.";
}; };
const refresh = async (): Promise<void> => { const refresh = async (): Promise<void> => {
render(await safeSnapshot(client)); render(await safeSnapshot(client));
}; };
recordButton.addEventListener('click', () => { const showError = (error: unknown): void => {
void (async () => { message.textContent = error instanceof Error ? error.message : String(error);
const command = snapshot.recordingStatus === 'recording' };
? 'stop_recording_and_transcribe'
: 'start_recording';
render(await client.invoke<AppSnapshot>(command));
})().catch((error: unknown) => {
message.textContent = error instanceof Error ? error.message : String(error);
});
});
continueButton.addEventListener('click', () => { const toggleRecording = async (): Promise<void> => {
void client.invoke<AppSnapshot>('start_recording').then(render).catch((error: unknown) => { const command =
message.textContent = error instanceof Error ? error.message : String(error); snapshot.recordingStatus === "recording"
}); ? "stop_recording_and_transcribe"
}); : "start_recording";
render(await client.invoke<AppSnapshot>(command));
};
copyButton.addEventListener('click', () => { recordButton.addEventListener("click", () => {
void navigator.clipboard void toggleRecording().catch(showError);
.writeText(editor.value) });
.then(() => {
message.textContent = 'Texto copiado para a área de transferência.';
})
.catch(() => {
message.textContent = 'Não foi possível copiar automaticamente. Selecione o texto manualmente.';
});
});
insertButton.addEventListener('click', () => { continueButton.addEventListener("click", () => {
void client void client
.invoke<{ message: string }>('request_insert_text', { text: editor.value }) .invoke<AppSnapshot>("start_recording")
.then((result) => { .then(render)
message.textContent = result.message; .catch(showError);
}) });
.catch((error: unknown) => {
message.textContent = error instanceof Error ? error.message : String(error);
});
});
saveButton.addEventListener('click', () => { copyButton.addEventListener("click", () => {
const blob = new Blob([editor.value], { type: 'text/plain;charset=utf-8' }); void navigator.clipboard
const url = URL.createObjectURL(blob); .writeText(editor.value)
const link = document.createElement('a'); .then(() => {
link.href = url; message.textContent = "Texto copiado para a área de transferência.";
link.download = 'transcricao.txt'; })
link.click(); .catch(() => {
URL.revokeObjectURL(url); message.textContent =
message.textContent = 'Arquivo de texto preparado para download.'; "Não foi possível copiar automaticamente. Selecione o texto manualmente.";
}); });
});
void refresh(); insertButton.addEventListener("click", () => {
void client
.invoke<{ message: string }>("request_insert_text", {
text: editor.value,
})
.then((result) => {
message.textContent = result.message;
})
.catch(showError);
});
saveButton.addEventListener("click", () => {
const blob = new Blob([editor.value], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "transcricao.txt";
link.click();
URL.revokeObjectURL(url);
message.textContent = "Arquivo de texto preparado para download.";
});
void listen("transcript://toggle-recording", () => {
void toggleRecording().catch(showError);
}).catch(() => {
// Browser preview and tests do not expose the Tauri event bridge.
});
void refresh();
} }