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:
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