feat(preview): floating transcription overlay with listening→live→cleanup→final phases
Ported the best bits of OpenWhispr's TranscriptionPreviewOverlay into Kon's window conventions. Off by default — toggle in Settings → Output → "Floating preview when Kon is unfocused". Opens only when the main window isn't focused at the start of a recording, so it never adds noise when the user can already see the transcript in the main surface. Phase state machine (src/routes/preview/+page.svelte): - listening — pulsing dot, no text yet - live — animated bars + streaming raw Whisper output - cleanup — accent bars while the LLM cleanup pass runs - final — checkmark + formatted text + 4s auto-hide Data plumbing: raw segment text is captured before post_process_segments in live.rs (new raw_text field on LiveResultMessage) and in transcription.rs (new raw_text in the transcription-result payload). DictationPage forwards raw_text to the overlay via Tauri global events — preview-listening on start, preview-append per chunk, preview-cleanup before the LLM pass, preview-final with the formatted text, preview-hide when a run produced no transcript (empty recording / cancel). Window is always_on_top, skip_taskbar, focused=false so it never steals focus from whatever the user is dictating into. open_preview_window shows an existing hidden preview or builds it fresh; close_preview_window hides without destroying so the next open is instant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,11 @@ pub struct LiveResultMessage {
|
|||||||
pub language: String,
|
pub language: String,
|
||||||
pub inference_ms: u64,
|
pub inference_ms: u64,
|
||||||
pub segments: Vec<Segment>,
|
pub segments: Vec<Segment>,
|
||||||
|
/// Concatenated text BEFORE post-processing (no filler removal, no
|
||||||
|
/// British conversion, no LLM cleanup). Used by the transcription
|
||||||
|
/// preview overlay so the user can see raw Whisper output as it
|
||||||
|
/// streams in.
|
||||||
|
pub raw_text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -616,6 +621,14 @@ fn poll_inference(
|
|||||||
Ok(Ok(timed)) => {
|
Ok(Ok(timed)) => {
|
||||||
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
||||||
trim_overlap_segments(&mut segments, task.trim_before_secs);
|
trim_overlap_segments(&mut segments, task.trim_before_secs);
|
||||||
|
// Capture raw text BEFORE any post-processing so the preview
|
||||||
|
// overlay can show what Whisper actually returned.
|
||||||
|
let raw_text = segments
|
||||||
|
.iter()
|
||||||
|
.map(|segment| segment.text.trim())
|
||||||
|
.filter(|segment| !segment.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
post_process_segments(
|
post_process_segments(
|
||||||
&mut segments,
|
&mut segments,
|
||||||
&PostProcessOptions {
|
&PostProcessOptions {
|
||||||
@@ -645,6 +658,7 @@ fn poll_inference(
|
|||||||
language: timed.transcript.language().to_string(),
|
language: timed.transcript.language().to_string(),
|
||||||
inference_ms: timed.inference_ms,
|
inference_ms: timed.inference_ms,
|
||||||
segments,
|
segments,
|
||||||
|
raw_text,
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
remember_recent_segments(recent_segments, &delivered_segments, chunk_start_secs);
|
remember_recent_segments(recent_segments, &delivered_segments, chunk_start_secs);
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ pub async fn transcribe_pcm(
|
|||||||
let dictionary_terms = profile_terms.clone();
|
let dictionary_terms = profile_terms.clone();
|
||||||
|
|
||||||
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
||||||
|
let raw_text = join_segment_text(&segments);
|
||||||
post_process_segments(
|
post_process_segments(
|
||||||
&mut segments,
|
&mut segments,
|
||||||
&PostProcessOptions {
|
&PostProcessOptions {
|
||||||
@@ -209,6 +210,7 @@ pub async fn transcribe_pcm(
|
|||||||
"duration": timed.transcript.duration(),
|
"duration": timed.transcript.duration(),
|
||||||
"chunk_id": chunk_id,
|
"chunk_id": chunk_id,
|
||||||
"inference_ms": timed.inference_ms,
|
"inference_ms": timed.inference_ms,
|
||||||
|
"raw_text": raw_text,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("Failed to emit result: {e}"))?;
|
.map_err(|e| format!("Failed to emit result: {e}"))?;
|
||||||
@@ -216,6 +218,15 @@ pub async fn transcribe_pcm(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn join_segment_text(segments: &[Segment]) -> String {
|
||||||
|
segments
|
||||||
|
.iter()
|
||||||
|
.map(|segment| segment.text.trim())
|
||||||
|
.filter(|segment| !segment.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
/// Transcribe an audio file by path. Decodes, resamples to 16kHz, runs Whisper.
|
/// Transcribe an audio file by path. Decodes, resamples to 16kHz, runs Whisper.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn transcribe_file(
|
pub async fn transcribe_file(
|
||||||
@@ -279,6 +290,7 @@ pub async fn transcribe_file(
|
|||||||
let dictionary_terms = profile_terms.clone();
|
let dictionary_terms = profile_terms.clone();
|
||||||
|
|
||||||
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
||||||
|
let raw_text = join_segment_text(&segments);
|
||||||
post_process_segments(
|
post_process_segments(
|
||||||
&mut segments,
|
&mut segments,
|
||||||
&PostProcessOptions {
|
&PostProcessOptions {
|
||||||
@@ -298,6 +310,7 @@ pub async fn transcribe_file(
|
|||||||
"language": timed.transcript.language(),
|
"language": timed.transcript.language(),
|
||||||
"duration": timed.transcript.duration(),
|
"duration": timed.transcript.duration(),
|
||||||
"inference_ms": timed.inference_ms,
|
"inference_ms": timed.inference_ms,
|
||||||
|
"raw_text": raw_text,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +357,7 @@ pub async fn transcribe_pcm_parakeet(
|
|||||||
let dictionary_terms = profile_terms.clone();
|
let dictionary_terms = profile_terms.clone();
|
||||||
|
|
||||||
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
||||||
|
let raw_text = join_segment_text(&segments);
|
||||||
post_process_segments(
|
post_process_segments(
|
||||||
&mut segments,
|
&mut segments,
|
||||||
&PostProcessOptions {
|
&PostProcessOptions {
|
||||||
@@ -365,6 +379,7 @@ pub async fn transcribe_pcm_parakeet(
|
|||||||
"duration": timed.transcript.duration(),
|
"duration": timed.transcript.duration(),
|
||||||
"chunk_id": chunk_id,
|
"chunk_id": chunk_id,
|
||||||
"inference_ms": timed.inference_ms,
|
"inference_ms": timed.inference_ms,
|
||||||
|
"raw_text": raw_text,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("Failed to emit result: {e}"))?;
|
.map_err(|e| format!("Failed to emit result: {e}"))?;
|
||||||
|
|||||||
@@ -41,6 +41,58 @@ pub async fn open_task_window(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the always-on-top transcription preview window. Idempotent — an
|
||||||
|
/// existing window is shown and focused; otherwise a new one is built.
|
||||||
|
/// The preview is passive: it subscribes to the `transcription-result`
|
||||||
|
/// event and the cross-window `preview-*` events that DictationPage fires
|
||||||
|
/// as it moves through the phases of a dictation run.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_preview_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
if let Some(window) = app.get_webview_window("transcription-preview") {
|
||||||
|
window.show().map_err(|e| e.to_string())?;
|
||||||
|
// Intentionally NOT set_focus — the preview window is meant to
|
||||||
|
// appear next to whatever the user is typing into; stealing focus
|
||||||
|
// defeats the whole point.
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let use_native_decorations = cfg!(target_os = "linux");
|
||||||
|
|
||||||
|
let mut builder = WebviewWindowBuilder::new(
|
||||||
|
&app,
|
||||||
|
"transcription-preview",
|
||||||
|
WebviewUrl::App("/preview".into()),
|
||||||
|
)
|
||||||
|
.title("Kon — Preview")
|
||||||
|
.inner_size(420.0, 200.0)
|
||||||
|
.min_inner_size(360.0, 140.0)
|
||||||
|
.max_inner_size(520.0, 360.0)
|
||||||
|
.always_on_top(true)
|
||||||
|
.skip_taskbar(true)
|
||||||
|
.focused(false)
|
||||||
|
.decorations(use_native_decorations)
|
||||||
|
.resizable(true);
|
||||||
|
|
||||||
|
if let Some(script) = app.try_state::<PreferencesScript>() {
|
||||||
|
if !script.0.is_empty() {
|
||||||
|
builder = builder.initialization_script(&script.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.build().map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hide the transcription preview window without destroying it so the next
|
||||||
|
/// open is instant. Returns Ok even when no preview window exists.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn close_preview_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
if let Some(window) = app.get_webview_window("transcription-preview") {
|
||||||
|
window.hide().map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the transcript viewer window.
|
/// Open the transcript viewer window.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_viewer_window(app: tauri::AppHandle) -> Result<(), String> {
|
pub async fn open_viewer_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
|||||||
@@ -305,6 +305,8 @@ pub fn run() {
|
|||||||
// Windows
|
// Windows
|
||||||
commands::windows::open_task_window,
|
commands::windows::open_task_window,
|
||||||
commands::windows::open_viewer_window,
|
commands::windows::open_viewer_window,
|
||||||
|
commands::windows::open_preview_window,
|
||||||
|
commands::windows::close_preview_window,
|
||||||
// Clipboard
|
// Clipboard
|
||||||
commands::clipboard::copy_to_clipboard,
|
commands::clipboard::copy_to_clipboard,
|
||||||
// Paste (auto-insert at cursor)
|
// Paste (auto-insert at cursor)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||||
|
import { emit } from "@tauri-apps/api/event";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
|
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
|
||||||
import { profilesStore } from "$lib/stores/profiles.svelte.ts";
|
import { profilesStore } from "$lib/stores/profiles.svelte.ts";
|
||||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||||
@@ -154,6 +156,12 @@
|
|||||||
}
|
}
|
||||||
if (result.chunkId != null) processedChunks.add(result.chunkId);
|
if (result.chunkId != null) processedChunks.add(result.chunkId);
|
||||||
|
|
||||||
|
// Forward raw Whisper output to the preview overlay (if it's enabled
|
||||||
|
// and opened). `raw_text` was captured in live.rs before post-processing.
|
||||||
|
if (result.rawText && settings.transcriptionPreview) {
|
||||||
|
emit("preview-append", { text: result.rawText }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
const text = result.segments.map((segment) => segment.text).join(" ").trim();
|
const text = result.segments.map((segment) => segment.text).join(" ").trim();
|
||||||
if (text) {
|
if (text) {
|
||||||
if (insertPos >= 0) {
|
if (insertPos >= 0) {
|
||||||
@@ -348,6 +356,19 @@
|
|||||||
page.status = "Recording...";
|
page.status = "Recording...";
|
||||||
page.statusColor = "#e87171";
|
page.statusColor = "#e87171";
|
||||||
timerInterval = setInterval(updateTimer, 1000);
|
timerInterval = setInterval(updateTimer, 1000);
|
||||||
|
|
||||||
|
// Preview overlay: if the user opted in, reset any leftover state
|
||||||
|
// from a prior run and open the window when the main window is not
|
||||||
|
// focused (i.e. the user is dictating into some other app).
|
||||||
|
if (settings.transcriptionPreview && tauriRuntimeAvailable) {
|
||||||
|
emit("preview-listening").catch(() => {});
|
||||||
|
try {
|
||||||
|
const focused = await getCurrentWindow().isFocused();
|
||||||
|
if (!focused) {
|
||||||
|
invoke("open_preview_window").catch(() => {});
|
||||||
|
}
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = typeof err === "string" ? err : err?.message || "Microphone error";
|
error = typeof err === "string" ? err : err?.message || "Microphone error";
|
||||||
// Surface the failure as a sticky error toast so it does not get
|
// Surface the failure as a sticky error toast so it does not get
|
||||||
@@ -473,11 +494,17 @@
|
|||||||
|
|
||||||
async function finaliseTranscription(audioPath = null) {
|
async function finaliseTranscription(audioPath = null) {
|
||||||
if (transcript.trim()) {
|
if (transcript.trim()) {
|
||||||
|
if (settings.transcriptionPreview && settings.aiTier !== "off" && settings.formatMode !== "Raw") {
|
||||||
|
emit("preview-cleanup").catch(() => {});
|
||||||
|
}
|
||||||
const cleanedTranscript = await cleanupTranscriptIfEnabled(transcript);
|
const cleanedTranscript = await cleanupTranscriptIfEnabled(transcript);
|
||||||
if (cleanedTranscript !== transcript) {
|
if (cleanedTranscript !== transcript) {
|
||||||
transcript = cleanedTranscript;
|
transcript = cleanedTranscript;
|
||||||
replaceSegmentsWithCleanedText(cleanedTranscript);
|
replaceSegmentsWithCleanedText(cleanedTranscript);
|
||||||
}
|
}
|
||||||
|
if (settings.transcriptionPreview) {
|
||||||
|
emit("preview-final", { text: transcript }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.autoPaste) {
|
if (settings.autoPaste) {
|
||||||
try {
|
try {
|
||||||
@@ -535,6 +562,10 @@
|
|||||||
|
|
||||||
saved = true;
|
saved = true;
|
||||||
setTimeout(() => { saved = false; extractedCount = 0; }, 4000);
|
setTimeout(() => { saved = false; extractedCount = 0; }, 4000);
|
||||||
|
} else if (settings.transcriptionPreview) {
|
||||||
|
// No transcript to surface — dismiss the overlay rather than leaving
|
||||||
|
// it stuck in "listening".
|
||||||
|
emit("preview-hide").catch(() => {});
|
||||||
}
|
}
|
||||||
page.status = "Ready";
|
page.status = "Ready";
|
||||||
page.statusColor = "#7ec89a";
|
page.statusColor = "#7ec89a";
|
||||||
|
|||||||
@@ -1518,6 +1518,11 @@
|
|||||||
label="Auto-paste into focused window"
|
label="Auto-paste into focused window"
|
||||||
description={pasteBackendsDescription}
|
description={pasteBackendsDescription}
|
||||||
/>
|
/>
|
||||||
|
<Toggle
|
||||||
|
bind:checked={settings.transcriptionPreview}
|
||||||
|
label="Floating preview when Kon is unfocused"
|
||||||
|
description="Shows a small always-on-top window with the raw transcription as you dictate, then the final formatted text. Only opens when the main window is unfocused or hidden."
|
||||||
|
/>
|
||||||
<Toggle
|
<Toggle
|
||||||
bind:checked={settings.meetingAutoCapture}
|
bind:checked={settings.meetingAutoCapture}
|
||||||
label="Remind me when a meeting starts"
|
label="Remind me when a meeting starts"
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const defaults: SettingsState = {
|
|||||||
britishEnglish: true,
|
britishEnglish: true,
|
||||||
autoCopy: true,
|
autoCopy: true,
|
||||||
autoPaste: false,
|
autoPaste: false,
|
||||||
|
transcriptionPreview: false,
|
||||||
meetingAutoCapture: false,
|
meetingAutoCapture: false,
|
||||||
meetingAutoCaptureApps: ["zoom", "teams"],
|
meetingAutoCaptureApps: ["zoom", "teams"],
|
||||||
includeTimestamps: true,
|
includeTimestamps: true,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface SettingsState {
|
|||||||
britishEnglish: boolean;
|
britishEnglish: boolean;
|
||||||
autoCopy: boolean;
|
autoCopy: boolean;
|
||||||
autoPaste: boolean;
|
autoPaste: boolean;
|
||||||
|
transcriptionPreview: boolean;
|
||||||
meetingAutoCapture: boolean;
|
meetingAutoCapture: boolean;
|
||||||
meetingAutoCaptureApps: string[];
|
meetingAutoCaptureApps: string[];
|
||||||
includeTimestamps: boolean;
|
includeTimestamps: boolean;
|
||||||
|
|||||||
68
src/routes/preview/+layout@.svelte
Normal file
68
src/routes/preview/+layout@.svelte
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// @ts-nocheck
|
||||||
|
import "../../app.css";
|
||||||
|
import { onDestroy, onMount } from "svelte";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { settings } from "$lib/stores/page.svelte.js";
|
||||||
|
import {
|
||||||
|
getPreferences,
|
||||||
|
updatePreferences,
|
||||||
|
applyExternalPreferences,
|
||||||
|
PREFERENCES_CHANGED_EVENT,
|
||||||
|
} from "$lib/stores/preferences.svelte.js";
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
let unlistenPrefs = null;
|
||||||
|
|
||||||
|
const prefs = getPreferences();
|
||||||
|
|
||||||
|
// Keep transcript-editor theme sync trick: legacy settings → preferences
|
||||||
|
$effect(() => {
|
||||||
|
const legacyTheme = settings.theme;
|
||||||
|
const mapped = legacyTheme === "Light" ? "light" : legacyTheme === "Dark" ? "dark" : "system";
|
||||||
|
if (prefs.theme !== mapped) {
|
||||||
|
updatePreferences({ theme: mapped });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("storage", (event) => {
|
||||||
|
if (event.key === "kon_settings" && event.newValue) {
|
||||||
|
try { Object.assign(settings, JSON.parse(event.newValue)); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
let ownLabel = null;
|
||||||
|
try { ownLabel = getCurrentWindow().label; } catch {}
|
||||||
|
unlistenPrefs = await listen(PREFERENCES_CHANGED_EVENT, (event) => {
|
||||||
|
const payload = event?.payload;
|
||||||
|
if (!payload || payload.source === ownLabel) return;
|
||||||
|
applyExternalPreferences(payload.prefs);
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (unlistenPrefs) unlistenPrefs();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape closes the preview without destroying it — the next dictation
|
||||||
|
// reopens it instantly via open_preview_window.
|
||||||
|
function handleKeydown(event) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
getCurrentWindow().hide().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div class="h-screen w-screen overflow-hidden grain border border-border shadow-xl flex flex-col">
|
||||||
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
214
src/routes/preview/+page.svelte
Normal file
214
src/routes/preview/+page.svelte
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// @ts-nocheck
|
||||||
|
import { onDestroy, onMount, tick } from "svelte";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { Copy, Check, X } from "lucide-svelte";
|
||||||
|
|
||||||
|
// Phase state machine:
|
||||||
|
// listening → live → cleanup → final → (auto-hide)
|
||||||
|
type Phase = "listening" | "live" | "cleanup" | "final";
|
||||||
|
let phase = $state<Phase>("listening");
|
||||||
|
let rawText = $state("");
|
||||||
|
let finalText = $state("");
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
|
let scrollEl: HTMLDivElement | null = null;
|
||||||
|
const unlisteners: Array<() => void> = [];
|
||||||
|
let autoHideTimer: number | null = null;
|
||||||
|
let copyResetTimer: number | null = null;
|
||||||
|
|
||||||
|
const AUTO_HIDE_MS = 4000;
|
||||||
|
const COPY_RESET_MS = 1400;
|
||||||
|
|
||||||
|
function clearAutoHide() {
|
||||||
|
if (autoHideTimer !== null) {
|
||||||
|
window.clearTimeout(autoHideTimer);
|
||||||
|
autoHideTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAutoHide() {
|
||||||
|
clearAutoHide();
|
||||||
|
autoHideTimer = window.setTimeout(() => {
|
||||||
|
getCurrentWindow().hide().catch(() => {});
|
||||||
|
}, AUTO_HIDE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollToBottom() {
|
||||||
|
await tick();
|
||||||
|
if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyActiveText() {
|
||||||
|
const text = phase === "final" ? finalText : rawText;
|
||||||
|
if (!text.trim()) return;
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
ok = true;
|
||||||
|
} catch {
|
||||||
|
ok = await invoke("copy_to_clipboard", { text }).then(() => true).catch(() => false);
|
||||||
|
}
|
||||||
|
if (!ok) return;
|
||||||
|
copied = true;
|
||||||
|
if (copyResetTimer !== null) window.clearTimeout(copyResetTimer);
|
||||||
|
copyResetTimer = window.setTimeout(() => { copied = false; }, COPY_RESET_MS);
|
||||||
|
// Re-arm auto-hide so the user's copy action doesn't get cut short.
|
||||||
|
if (phase === "final") scheduleAutoHide();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
clearAutoHide();
|
||||||
|
getCurrentWindow().hide().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// preview-listening: recording just started, no text yet.
|
||||||
|
unlisteners.push(
|
||||||
|
await listen("preview-listening", () => {
|
||||||
|
clearAutoHide();
|
||||||
|
phase = "listening";
|
||||||
|
rawText = "";
|
||||||
|
finalText = "";
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// preview-append: main forwards each live chunk's raw_text. We also
|
||||||
|
// accept the full payload from single-shot transcriptions as append.
|
||||||
|
unlisteners.push(
|
||||||
|
await listen<{ text: string; replace?: boolean }>("preview-append", (event) => {
|
||||||
|
const chunk = (event.payload?.text ?? "").trim();
|
||||||
|
if (!chunk) return;
|
||||||
|
if (event.payload?.replace) {
|
||||||
|
rawText = chunk;
|
||||||
|
} else if (rawText.length === 0) {
|
||||||
|
rawText = chunk;
|
||||||
|
} else {
|
||||||
|
rawText = `${rawText} ${chunk}`;
|
||||||
|
}
|
||||||
|
phase = "live";
|
||||||
|
scrollToBottom();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// preview-cleanup: main window is about to run the LLM cleanup pass.
|
||||||
|
unlisteners.push(
|
||||||
|
await listen("preview-cleanup", () => {
|
||||||
|
phase = "cleanup";
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// preview-final: cleanup done (or skipped). Show formatted text and
|
||||||
|
// start the 4s auto-hide countdown.
|
||||||
|
unlisteners.push(
|
||||||
|
await listen<{ text: string }>("preview-final", (event) => {
|
||||||
|
finalText = (event.payload?.text ?? "").trim();
|
||||||
|
phase = "final";
|
||||||
|
scrollToBottom();
|
||||||
|
scheduleAutoHide();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// preview-hide: main asks us to dismiss immediately (recording
|
||||||
|
// cancelled, or the user re-focused the main window mid-stream).
|
||||||
|
unlisteners.push(
|
||||||
|
await listen("preview-hide", () => {
|
||||||
|
dismiss();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
clearAutoHide();
|
||||||
|
if (copyResetTimer !== null) window.clearTimeout(copyResetTimer);
|
||||||
|
for (const off of unlisteners) off();
|
||||||
|
});
|
||||||
|
|
||||||
|
let activeText = $derived(phase === "final" ? finalText : rawText);
|
||||||
|
let phaseLabel = $derived(
|
||||||
|
phase === "listening" ? "Listening"
|
||||||
|
: phase === "live" ? "Raw"
|
||||||
|
: phase === "cleanup" ? "Cleaning up"
|
||||||
|
: "Final",
|
||||||
|
);
|
||||||
|
let borderColorClass = $derived(
|
||||||
|
phase === "final" ? "border-success"
|
||||||
|
: phase === "cleanup" ? "border-accent"
|
||||||
|
: "border-border",
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="h-full w-full flex flex-col bg-bg text-text p-3 gap-2 border-l-2 {borderColorClass}"
|
||||||
|
style="transition: border-color var(--duration-ui) ease-out"
|
||||||
|
data-tauri-drag-region
|
||||||
|
>
|
||||||
|
<header class="flex items-center justify-between gap-2 text-[11px] text-text-secondary" data-tauri-drag-region>
|
||||||
|
<div class="flex items-center gap-2" data-tauri-drag-region>
|
||||||
|
{#if phase === "listening"}
|
||||||
|
<span class="inline-block w-[8px] h-[8px] rounded-full bg-text-tertiary animate-pulse"></span>
|
||||||
|
{:else if phase === "live"}
|
||||||
|
<span class="inline-flex items-end gap-[2px] h-[10px]">
|
||||||
|
<span class="w-[3px] bg-accent rounded-sm animate-bars-1"></span>
|
||||||
|
<span class="w-[3px] bg-accent rounded-sm animate-bars-2"></span>
|
||||||
|
<span class="w-[3px] bg-accent rounded-sm animate-bars-3"></span>
|
||||||
|
</span>
|
||||||
|
{:else if phase === "cleanup"}
|
||||||
|
<span class="inline-flex items-end gap-[2px] h-[10px]">
|
||||||
|
<span class="w-[3px] bg-accent/80 rounded-sm animate-bars-1"></span>
|
||||||
|
<span class="w-[3px] bg-accent/80 rounded-sm animate-bars-2"></span>
|
||||||
|
<span class="w-[3px] bg-accent/80 rounded-sm animate-bars-3"></span>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<Check size={12} class="text-success" />
|
||||||
|
{/if}
|
||||||
|
<span class="uppercase tracking-wider font-medium">{phaseLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
class="p-1 rounded hover:bg-hover text-text-tertiary hover:text-text disabled:opacity-40"
|
||||||
|
onclick={copyActiveText}
|
||||||
|
disabled={!activeText.trim()}
|
||||||
|
title="Copy"
|
||||||
|
>
|
||||||
|
{#if copied}
|
||||||
|
<Check size={14} />
|
||||||
|
{:else}
|
||||||
|
<Copy size={14} />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1 rounded hover:bg-hover text-text-tertiary hover:text-text"
|
||||||
|
onclick={dismiss}
|
||||||
|
title="Dismiss"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={scrollEl}
|
||||||
|
class="flex-1 min-h-0 overflow-y-auto text-[14px] leading-relaxed whitespace-pre-wrap break-words"
|
||||||
|
>
|
||||||
|
{#if activeText.trim()}
|
||||||
|
{activeText}
|
||||||
|
{:else}
|
||||||
|
<span class="text-text-tertiary italic">
|
||||||
|
{phase === "listening" ? "Waiting for speech…" : ""}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes bars-1 { 0%,100% { height: 40%; } 50% { height: 100%; } }
|
||||||
|
@keyframes bars-2 { 0%,100% { height: 70%; } 50% { height: 30%; } }
|
||||||
|
@keyframes bars-3 { 0%,100% { height: 50%; } 50% { height: 90%; } }
|
||||||
|
:global(.animate-bars-1) { animation: bars-1 0.9s ease-in-out infinite; }
|
||||||
|
:global(.animate-bars-2) { animation: bars-2 0.9s ease-in-out infinite 0.15s; }
|
||||||
|
:global(.animate-bars-3) { animation: bars-3 0.9s ease-in-out infinite 0.3s; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user