Phase 8 of the rebrand cascade. Every rendered string is now Lumotia;
no Magnotia surface visible in the UI.
Sweep replaced \bmagnotia\b -> lumotia and \bMagnotia\b -> Lumotia
across all .svelte / .ts / .js / .css / .html / .json (excluding
package-lock.json which regenerates, target/, build/, node_modules/).
Surfaces touched:
- src/app.css — design-token comment header and .magnotia-rh-* CSS
resize-handle class selectors (also the consuming elements in
components/ResizeHandles.svelte and src/routes/*/+layout.svelte).
- src/lib/i18n/locales/{en,de,es}.json — brand name in translations.
- src/lib/i18n/index.ts — header comment.
- src/lib/Sidebar.svelte and most pages under src/lib/pages/ +
src/lib/components/ — title bars, document titles, default
filenames (lumotia-YYYY-MM-DD.* etc), toast strings, error
messages, dialog headers.
- src/routes/+layout.svelte, +page.svelte, viewer/, float/, preview/.
- src/app.html page <title>.
- src/lib/utils/settingsMigrations.ts — fallback toast copy.
- src/design-system/{colors_and_type.css,SKILL.md,README.md,
ui_kits/{Sidebar.jsx,index.html}} — design-tokens, doc strings,
preview wordmark in the kit.
- package.json — name + description.
NOT touched (deferred / immutable):
- package-lock.json — regenerates on next npm install.
- The two migration-call sites in stores reference the legacy magnotia
keys deliberately; restored after the sweep clobbered them.
- docs/, README.md, HANDOVER.md — Phase 9 scope.
npm run check: 0 errors / 0 warnings (3958 files).
cargo test --workspace: 339 pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1122 lines
40 KiB
Svelte
1122 lines
40 KiB
Svelte
<script lang="ts">
|
|
// @ts-nocheck
|
|
import { onMount, onDestroy } from "svelte";
|
|
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 { markGenerating, markGenerationDone } from "$lib/stores/llmStatus.svelte.js";
|
|
import { playStartCue, playStopCue, playCompleteCue } from "$lib/utils/sounds.js";
|
|
import { profilesStore } from "$lib/stores/profiles.svelte.ts";
|
|
import { toasts } from "$lib/stores/toasts.svelte.js";
|
|
import Card from "$lib/components/Card.svelte";
|
|
import ModelDownloader from "$lib/components/ModelDownloader.svelte";
|
|
import { exportTranscript } from "$lib/utils/export.js";
|
|
import { extractTasks } from "$lib/utils/taskExtractor.js";
|
|
import { pad } from "$lib/utils/time.js";
|
|
import { FEEDBACK_TIMEOUT_MS } from "$lib/utils/constants.js";
|
|
import { Mic, Loader2, SquareCheck, AlertTriangle } from 'lucide-svelte';
|
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
|
|
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
|
import { bionicReading } from '$lib/actions/bionicReading.js';
|
|
import { measurePreWrap } from '$lib/utils/textMeasure.js';
|
|
import { transcriptPretextFont, transcriptPretextLineHeight } from '$lib/utils/accessibilityTypography.js';
|
|
import { hasTauriRuntime } from '$lib/utils/runtime.js';
|
|
const prefs = getPreferences();
|
|
const tauriRuntimeAvailable = hasTauriRuntime();
|
|
const browserPreviewMessage = "You're viewing Lumotia in a normal browser. Local transcription only works in the Tauri desktop app window.";
|
|
|
|
let transcript = $state("");
|
|
let segments = $state([]);
|
|
let timerInterval = $state(null);
|
|
let startTime = $state(0);
|
|
let modelReady = $state(false);
|
|
let modelLoading = $state(false);
|
|
let needsDownload = $state(false);
|
|
let error = $state("");
|
|
let showExportMenu = $state(false);
|
|
let transcribing = $state(false);
|
|
let saved = $state(false);
|
|
let extractedCount = $state(0);
|
|
let aiProcessing = $state(false);
|
|
let aiStatus = $state("");
|
|
let runtimeCapabilities = $state(null);
|
|
let sessionId = $state(null);
|
|
let drainingSessionId = $state(null);
|
|
let liveWarning = $state("");
|
|
let lastResultAt = $state(0);
|
|
let lastLiveActivityAt = $state(0);
|
|
let resultChannel = null;
|
|
let statusChannel = null;
|
|
|
|
// Cursor-based insertion
|
|
let textareaEl = $state(null);
|
|
let insertPos = $state(-1); // -1 = append mode, >= 0 = insert at position
|
|
|
|
// Template
|
|
let activeTemplate = $state("");
|
|
let showTemplateMenu = $state(false);
|
|
|
|
// Deduplication: track which chunk IDs have been processed
|
|
let processedChunks = new Set();
|
|
|
|
// Global hotkey listener
|
|
let hotkeyHandler = () => toggleRecording();
|
|
|
|
onMount(async () => {
|
|
window.addEventListener("lumotia:toggle-recording", hotkeyHandler);
|
|
|
|
if (!tauriRuntimeAvailable) {
|
|
error = browserPreviewMessage;
|
|
return;
|
|
}
|
|
|
|
await checkModelState();
|
|
await ensureLlmModelLoaded();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
window.removeEventListener("lumotia:toggle-recording", hotkeyHandler);
|
|
clearInterval(timerInterval);
|
|
if (page.recording) {
|
|
page.recording = false;
|
|
page.status = "Ready";
|
|
page.statusColor = "#7ec89a";
|
|
}
|
|
cleanup();
|
|
});
|
|
|
|
$effect(() => {
|
|
settings.engine;
|
|
settings.modelSize;
|
|
if (!tauriRuntimeAvailable) return;
|
|
queueMicrotask(() => {
|
|
if (!page.recording) {
|
|
void checkModelState();
|
|
}
|
|
});
|
|
});
|
|
|
|
function whisperModelId(size) {
|
|
const map = {
|
|
Tiny: "whisper-tiny-en",
|
|
Base: "whisper-base-en",
|
|
Small: "whisper-small-en",
|
|
Medium: "whisper-medium-en",
|
|
};
|
|
return map[size] || "whisper-base-en";
|
|
}
|
|
|
|
function selectedModelId() {
|
|
return settings.engine === "parakeet"
|
|
? "parakeet-ctc-0.6b-int8"
|
|
: whisperModelId(settings.modelSize);
|
|
}
|
|
|
|
function currentEngineCapabilities() {
|
|
return runtimeCapabilities?.engines?.find((engine) => engine.id === settings.engine) || null;
|
|
}
|
|
|
|
function currentModelCapabilities() {
|
|
return currentEngineCapabilities()?.models?.find((model) => model.id === selectedModelId()) || null;
|
|
}
|
|
|
|
function currentModelIsEnglishOnly() {
|
|
return currentModelCapabilities()?.languageSupport?.kind === "english-only";
|
|
}
|
|
|
|
function effectiveLanguage() {
|
|
return currentModelIsEnglishOnly() ? "en" : settings.language;
|
|
}
|
|
|
|
async function refreshRuntimeCapabilities() {
|
|
runtimeCapabilities = await invoke("get_runtime_capabilities");
|
|
}
|
|
|
|
function matchesLiveSession(candidateSessionId) {
|
|
return candidateSessionId != null
|
|
&& (candidateSessionId === sessionId || candidateSessionId === drainingSessionId);
|
|
}
|
|
|
|
function handleLiveResult(result) {
|
|
if (!result || !matchesLiveSession(result.sessionId) || !result.segments) {
|
|
return;
|
|
}
|
|
|
|
lastResultAt = Date.now();
|
|
lastLiveActivityAt = lastResultAt;
|
|
if (textareaEl) {
|
|
if (page.recording && userScrolledUp) {
|
|
shouldPreserveScrollAnchor = true;
|
|
} else if (page.recording) {
|
|
shouldStickToBottom = true;
|
|
}
|
|
}
|
|
|
|
if (result.chunkId != null && processedChunks.has(result.chunkId)) {
|
|
return;
|
|
}
|
|
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();
|
|
if (text) {
|
|
if (insertPos >= 0) {
|
|
const before = transcript.slice(0, insertPos);
|
|
const after = transcript.slice(insertPos);
|
|
const spaceBefore = before && !before.endsWith(" ") && !before.endsWith("\n") ? " " : "";
|
|
const spaceAfter = after && !after.startsWith(" ") && !after.startsWith("\n") ? " " : "";
|
|
transcript = before + spaceBefore + text + spaceAfter + after;
|
|
insertPos += spaceBefore.length + text.length + spaceAfter.length;
|
|
requestAnimationFrame(() => {
|
|
if (textareaEl) {
|
|
textareaEl.selectionStart = insertPos;
|
|
textareaEl.selectionEnd = insertPos;
|
|
}
|
|
});
|
|
} else {
|
|
transcript = transcript ? `${transcript} ${text}` : text;
|
|
}
|
|
}
|
|
|
|
const absoluteSegments = result.segments.map((segment) => ({
|
|
...segment,
|
|
start: segment.start + result.chunkStartSecs,
|
|
end: segment.end + result.chunkStartSecs,
|
|
}));
|
|
segments = [...segments, ...absoluteSegments];
|
|
}
|
|
|
|
function handleLiveStatus(status) {
|
|
if (!status || !matchesLiveSession(status.sessionId)) return;
|
|
|
|
lastLiveActivityAt = Date.now();
|
|
|
|
if (status.type === "overload" || status.type === "warning") {
|
|
liveWarning = status.message || "Lumotia is dropping older audio to stay responsive.";
|
|
return;
|
|
}
|
|
|
|
if (status.type === "error") {
|
|
error = status.message || "Live transcription failed";
|
|
transcriptionFailed = true;
|
|
page.status = "Error";
|
|
page.statusColor = "#e87171";
|
|
return;
|
|
}
|
|
|
|
if (status.type === "finished" && status.droppedAudioMs > 0) {
|
|
liveWarning = `Dropped ${Math.round(status.droppedAudioMs / 1000)}s of older audio to keep up in real time.`;
|
|
}
|
|
}
|
|
|
|
async function checkModelState() {
|
|
if (!tauriRuntimeAvailable) {
|
|
modelReady = false;
|
|
needsDownload = false;
|
|
return;
|
|
}
|
|
try {
|
|
await refreshRuntimeCapabilities();
|
|
const currentModel = currentModelCapabilities();
|
|
modelReady = !!currentModel?.loaded;
|
|
needsDownload = currentModel ? !currentModel.downloaded : true;
|
|
if (currentModelIsEnglishOnly() && settings.language !== "en") {
|
|
settings.language = "en";
|
|
}
|
|
} catch (err) {
|
|
error = typeof err === "string" ? err : err.message || "Failed to check model";
|
|
}
|
|
}
|
|
|
|
async function ensureLlmModelLoaded() {
|
|
if (!tauriRuntimeAvailable || settings.aiTier === "off" || !settings.llmModelId) return;
|
|
try {
|
|
const status = await invoke("check_llm_model", { modelId: settings.llmModelId });
|
|
if (status?.downloaded && !status.loaded) {
|
|
await invoke("load_llm_model", {
|
|
modelId: settings.llmModelId,
|
|
// Sequential-GPU guard (brief item A.1 #28): frees whisper
|
|
// before loading the LLM on tight-VRAM setups. "parallel"
|
|
// keeps both resident (default, safe on multi-GB cards).
|
|
concurrent: settings.aiGpuConcurrency === "parallel",
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.warn("ensureLlmModelLoaded failed", err);
|
|
}
|
|
}
|
|
|
|
async function loadModel() {
|
|
if (!tauriRuntimeAvailable) {
|
|
error = browserPreviewMessage;
|
|
return;
|
|
}
|
|
modelLoading = true;
|
|
page.status = "Loading model...";
|
|
page.statusColor = "#e8c86e";
|
|
error = "";
|
|
|
|
try {
|
|
// concurrent flag mirrors settings.aiGpuConcurrency (brief A.1 #28).
|
|
const concurrent = settings.aiGpuConcurrency === "parallel";
|
|
if (settings.engine === "parakeet") {
|
|
await invoke("load_parakeet_model", { name: "ctc-int8", concurrent });
|
|
} else {
|
|
await invoke("load_model", {
|
|
size: settings.modelSize.toLowerCase(),
|
|
concurrent,
|
|
});
|
|
}
|
|
await refreshRuntimeCapabilities();
|
|
modelReady = true;
|
|
modelLoading = false;
|
|
page.status = "Ready";
|
|
page.statusColor = "#7ec89a";
|
|
} catch (err) {
|
|
error = typeof err === "string" ? err : err.message || "Failed to load model";
|
|
modelLoading = false;
|
|
page.status = "Error";
|
|
page.statusColor = "#e87171";
|
|
}
|
|
}
|
|
|
|
function onModelDownloaded() {
|
|
needsDownload = false;
|
|
void loadModel();
|
|
}
|
|
|
|
async function toggleRecording() {
|
|
if (page.recording) {
|
|
await stopRecording();
|
|
} else {
|
|
await startRecording();
|
|
}
|
|
}
|
|
|
|
async function startRecording() {
|
|
error = "";
|
|
liveWarning = "";
|
|
saved = false;
|
|
transcriptionFailed = false;
|
|
if (!tauriRuntimeAvailable) {
|
|
error = browserPreviewMessage;
|
|
page.status = "Desktop app required";
|
|
page.statusColor = "#e8c86e";
|
|
return;
|
|
}
|
|
if (!modelReady) {
|
|
if (needsDownload) return;
|
|
await loadModel();
|
|
if (!modelReady) return;
|
|
}
|
|
|
|
// Capture cursor position for insert mode
|
|
if (textareaEl && transcript.length > 0) {
|
|
insertPos = textareaEl.selectionStart ?? transcript.length;
|
|
} else {
|
|
insertPos = -1; // Append mode for empty textarea
|
|
}
|
|
|
|
try {
|
|
sessionId = null;
|
|
drainingSessionId = null;
|
|
lastResultAt = 0;
|
|
lastLiveActivityAt = 0;
|
|
processedChunks.clear();
|
|
|
|
// Only clear transcript if not in insert mode (fresh recording)
|
|
if (insertPos === -1) {
|
|
transcript = "";
|
|
segments = [];
|
|
previousMeasuredContentHeight = 0;
|
|
}
|
|
|
|
resultChannel = new Channel((message) => handleLiveResult(message));
|
|
statusChannel = new Channel((message) => handleLiveStatus(message));
|
|
|
|
const response = await invoke("start_live_transcription_session", {
|
|
config: {
|
|
engine: settings.engine,
|
|
modelId: selectedModelId(),
|
|
language: effectiveLanguage(),
|
|
// Backend (Task 13) falls back to profile.initial_prompt when this
|
|
// is empty. Vocabulary now lives on the active profile, not on the
|
|
// old page.activeProfile localStorage bag.
|
|
initialPrompt: "",
|
|
profileId: profilesStore.activeProfileId,
|
|
saveAudio: settings.saveAudio,
|
|
outputFolder: settings.outputFolder || null,
|
|
removeFillers: settings.removeFillers,
|
|
britishEnglish: settings.britishEnglish,
|
|
antiHallucination: settings.antiHallucination,
|
|
formatMode: settings.formatMode,
|
|
// Honour Settings → Audio → Microphone choice. Empty = auto-select.
|
|
microphoneDevice: settings.microphoneDevice || null,
|
|
},
|
|
resultChannel,
|
|
statusChannel,
|
|
});
|
|
sessionId = response.sessionId;
|
|
|
|
startTime = Date.now();
|
|
page.recording = true;
|
|
page.status = "Recording...";
|
|
page.statusColor = "#e87171";
|
|
timerInterval = setInterval(updateTimer, 1000);
|
|
|
|
// Start cue (brief item #20). Off by default; volume user-set.
|
|
playStartCue(settings.soundCueVolume, settings.soundCues);
|
|
|
|
// 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) {
|
|
error = typeof err === "string" ? err : err?.message || "Microphone error";
|
|
// Surface the failure as a sticky error toast so it does not get
|
|
// swallowed if the inline `error` panel is not visible.
|
|
// (Day 3 of the upgrade plan)
|
|
toasts.error("Could not start recording", error);
|
|
page.status = "Error";
|
|
page.statusColor = "#e87171";
|
|
cleanup();
|
|
}
|
|
}
|
|
|
|
async function stopRecording() {
|
|
clearInterval(timerInterval);
|
|
page.recording = false;
|
|
transcribing = true;
|
|
page.status = "Finalising...";
|
|
page.statusColor = "#e8c86e";
|
|
|
|
// Stop cue (brief item #20). "Recording over, finalising now."
|
|
playStopCue(settings.soundCueVolume, settings.soundCues);
|
|
|
|
try {
|
|
const activityBeforeStop = lastLiveActivityAt;
|
|
drainingSessionId = sessionId;
|
|
const response = sessionId
|
|
? await invoke("stop_live_transcription_session", { sessionId })
|
|
: null;
|
|
await waitForResultDrain(activityBeforeStop);
|
|
cleanup();
|
|
|
|
if (processedChunks.size === 0 && !transcript.trim()) {
|
|
page.status = "Ready";
|
|
page.statusColor = "#7ec89a";
|
|
} else {
|
|
await finaliseTranscription(response?.audioPath || null);
|
|
}
|
|
} catch (err) {
|
|
error = typeof err === "string" ? err : err?.message || "Failed to stop live transcription";
|
|
page.status = "Error";
|
|
page.statusColor = "#e87171";
|
|
cleanup();
|
|
} finally {
|
|
transcribing = false;
|
|
}
|
|
}
|
|
|
|
function cleanup() {
|
|
sessionId = null;
|
|
drainingSessionId = null;
|
|
resultChannel = null;
|
|
statusChannel = null;
|
|
}
|
|
|
|
function replaceSegmentsWithCleanedText(cleanedText) {
|
|
if (!cleanedText.trim()) return;
|
|
if (segments.length === 0) {
|
|
segments = [{
|
|
start: 0,
|
|
end: Math.max((Date.now() - startTime) / 1000, 0),
|
|
text: cleanedText,
|
|
}];
|
|
return;
|
|
}
|
|
segments = [{
|
|
start: segments[0].start,
|
|
end: segments[segments.length - 1].end,
|
|
text: cleanedText,
|
|
}];
|
|
}
|
|
|
|
async function cleanupTranscriptIfEnabled(text) {
|
|
if (!text.trim()) return text;
|
|
if (settings.aiTier === "off" || settings.formatMode === "Raw") return text;
|
|
|
|
const llmLoaded = await invoke("get_llm_status").catch(() => false);
|
|
if (!llmLoaded) return text;
|
|
|
|
// Flip the sidebar LLM status chip to "generating" so the user can
|
|
// see the cleanup pass in flight (brief item #31). Always paired
|
|
// with markGenerationDone below — success or failure.
|
|
markGenerating("Cleaning up");
|
|
try {
|
|
const cleaned = await invoke("cleanup_transcript_text_cmd", {
|
|
transcript: text,
|
|
profileId: profilesStore.activeProfileId,
|
|
preset: settings.llmPromptPreset,
|
|
});
|
|
markGenerationDone(true);
|
|
return cleaned?.trim() ? cleaned.trim() : text;
|
|
} catch (err) {
|
|
console.warn("LLM cleanup failed, keeping existing transcript", err);
|
|
markGenerationDone(false, typeof err === "string" ? err : err?.message ?? null);
|
|
return text;
|
|
}
|
|
}
|
|
|
|
async function extractTasksForTranscript(text) {
|
|
const llmLoaded = await invoke("get_llm_status").catch(() => false);
|
|
if (settings.aiTier === "tasks" && llmLoaded) {
|
|
markGenerating("Extracting tasks");
|
|
try {
|
|
const items = await invoke("extract_tasks_from_transcript_cmd", {
|
|
transcript: text,
|
|
profileId: profilesStore.activeProfileId,
|
|
});
|
|
markGenerationDone(true);
|
|
return items.map((taskText) => ({ text: taskText }));
|
|
} catch (err) {
|
|
console.warn("LLM extract_tasks failed, falling back to regex", err);
|
|
markGenerationDone(false, typeof err === "string" ? err : err?.message ?? null);
|
|
}
|
|
}
|
|
|
|
return extractTasks(text);
|
|
}
|
|
|
|
async function waitForResultDrain(previousActivityAt = 0) {
|
|
const firstMessageDeadline = Date.now() + 400;
|
|
while (Date.now() < firstMessageDeadline) {
|
|
if (lastLiveActivityAt > previousActivityAt) {
|
|
break;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
|
|
if (lastLiveActivityAt <= previousActivityAt) {
|
|
return;
|
|
}
|
|
|
|
const idleDeadline = Date.now() + 1500;
|
|
while (Date.now() < idleDeadline) {
|
|
if (Date.now() - lastLiveActivityAt >= 200) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
}
|
|
|
|
async function finaliseTranscription(audioPath = null) {
|
|
if (transcript.trim()) {
|
|
if (settings.transcriptionPreview && settings.aiTier !== "off" && settings.formatMode !== "Raw") {
|
|
emit("preview-cleanup").catch(() => {});
|
|
}
|
|
const cleanedTranscript = await cleanupTranscriptIfEnabled(transcript);
|
|
if (cleanedTranscript !== transcript) {
|
|
transcript = cleanedTranscript;
|
|
replaceSegmentsWithCleanedText(cleanedTranscript);
|
|
}
|
|
if (settings.transcriptionPreview) {
|
|
emit("preview-final", { text: transcript }).catch(() => {});
|
|
}
|
|
|
|
if (settings.autoPaste) {
|
|
try {
|
|
const outcome = await invoke("paste_text", { text: transcript });
|
|
if (!outcome?.pasted && outcome?.message) {
|
|
toasts.warn(
|
|
"Auto-paste unavailable",
|
|
`${outcome.message}${outcome.copied ? ". Transcript is on your clipboard." : ""}`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
await navigator.clipboard
|
|
.writeText(transcript)
|
|
.catch(() => invoke("copy_to_clipboard", { text: transcript }).catch(() => {}));
|
|
toasts.warn("Auto-paste failed", String(err));
|
|
}
|
|
} else if (settings.autoCopy) {
|
|
navigator.clipboard.writeText(transcript).catch(() => {
|
|
invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
|
|
});
|
|
}
|
|
|
|
const historyId = crypto.randomUUID();
|
|
addToHistory({
|
|
id: historyId,
|
|
date: new Date().toLocaleString("en-GB"),
|
|
source: "live",
|
|
profileId: profilesStore.activeProfileId,
|
|
preview: transcript.slice(0, 120),
|
|
text: transcript,
|
|
segments: segments,
|
|
duration: (Date.now() - startTime) / 1000,
|
|
engine: settings.engine,
|
|
modelId: selectedModelId(),
|
|
language: effectiveLanguage(),
|
|
template: activeTemplate || undefined,
|
|
audioPath,
|
|
});
|
|
|
|
// Extract tasks from transcript
|
|
const extracted = await extractTasksForTranscript(transcript);
|
|
extractedCount = extracted.length;
|
|
for (const item of extracted) {
|
|
addTask({
|
|
text: item.text,
|
|
bucket: "inbox",
|
|
sourceTranscriptId: historyId,
|
|
});
|
|
}
|
|
|
|
// Auto-open task sidebar if tasks were extracted
|
|
if (extracted.length > 0) {
|
|
page.taskSidebarOpen = true;
|
|
}
|
|
|
|
// Complete cue (brief item #20) — major-third arpeggio. Plays
|
|
// after the full finalise flow so the user hears "done"
|
|
// aligned with the history entry + paste landing.
|
|
playCompleteCue(settings.soundCueVolume, settings.soundCues);
|
|
|
|
saved = true;
|
|
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.statusColor = "#7ec89a";
|
|
insertPos = -1;
|
|
}
|
|
|
|
function updateTimer() {
|
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
page.timerText = `${pad(Math.floor(elapsed / 60))}:${pad(elapsed % 60)}`;
|
|
}
|
|
|
|
function copyAll() {
|
|
if (transcript) {
|
|
navigator.clipboard.writeText(transcript).catch(() => {
|
|
invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
|
|
});
|
|
}
|
|
}
|
|
|
|
function clearTranscript() {
|
|
transcript = "";
|
|
segments = [];
|
|
previousMeasuredContentHeight = 0;
|
|
shouldPreserveScrollAnchor = false;
|
|
shouldStickToBottom = false;
|
|
page.timerText = "00:00";
|
|
error = "";
|
|
liveWarning = "";
|
|
saved = false;
|
|
insertPos = -1;
|
|
activeTemplate = "";
|
|
}
|
|
|
|
async function handleExport(format) {
|
|
if (!transcript) return;
|
|
showExportMenu = false;
|
|
const content = exportTranscript(transcript, segments, format);
|
|
const extMap = { txt: "txt", vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" };
|
|
const ext = extMap[format] || "txt";
|
|
const defaultPath = `lumotia-${new Date().toISOString().slice(0, 10)}.${ext}`;
|
|
|
|
if (hasTauriRuntime()) {
|
|
try {
|
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
const path = await save({
|
|
title: `Export ${format.toUpperCase()} transcript`,
|
|
defaultPath,
|
|
filters: [{ name: format.toUpperCase(), extensions: [ext] }],
|
|
});
|
|
if (!path) return;
|
|
await invoke("write_text_file_cmd", { path, contents: content });
|
|
toasts.success(`Exported ${format.toUpperCase()} transcript`);
|
|
return;
|
|
} catch (err) {
|
|
console.error("dictation export failed", err);
|
|
error = `Export failed: ${typeof err === "string" ? err : err.message || err}`;
|
|
return;
|
|
}
|
|
}
|
|
|
|
const blob = new Blob([content], { type: "text/plain" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = defaultPath;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function applyTemplate(template) {
|
|
activeTemplate = template.name;
|
|
showTemplateMenu = false;
|
|
transcript = template.sections.map((s) => `## ${s}\n\n`).join("\n");
|
|
segments = [];
|
|
previousMeasuredContentHeight = 0;
|
|
// Position cursor at first section body
|
|
requestAnimationFrame(() => {
|
|
if (textareaEl) {
|
|
const firstNewline = transcript.indexOf("\n\n") + 2;
|
|
textareaEl.selectionStart = firstNewline;
|
|
textareaEl.selectionEnd = firstNewline;
|
|
textareaEl.focus();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function manualExtractTasks() {
|
|
if (!transcript.trim() || aiProcessing) return;
|
|
aiProcessing = true;
|
|
aiStatus = "Extracting tasks...";
|
|
try {
|
|
const extracted = await extractTasksForTranscript(transcript);
|
|
for (const item of extracted) {
|
|
addTask({ text: item.text, bucket: "inbox" });
|
|
}
|
|
aiStatus = `${extracted.length} task${extracted.length === 1 ? '' : 's'} extracted`;
|
|
} catch (err) {
|
|
aiStatus = typeof err === "string" ? err : "Extraction failed";
|
|
} finally {
|
|
aiProcessing = false;
|
|
setTimeout(() => { aiStatus = ""; }, 4000);
|
|
}
|
|
}
|
|
|
|
function saveTypedText() {
|
|
if (!transcript.trim()) return;
|
|
addToHistory({
|
|
id: crypto.randomUUID(),
|
|
date: new Date().toLocaleString("en-GB"),
|
|
source: "typed",
|
|
profileId: profilesStore.activeProfileId,
|
|
preview: transcript.slice(0, 120),
|
|
text: transcript,
|
|
segments: [],
|
|
duration: 0,
|
|
language: settings.language,
|
|
});
|
|
saved = true;
|
|
setTimeout(() => { saved = false; }, FEEDBACK_TIMEOUT_MS);
|
|
}
|
|
|
|
let taskCount = $derived(tasks.filter((t) => !t.done).length);
|
|
let hasTranscript = $derived(transcript.trim().length > 0);
|
|
|
|
let wordCount = $derived.by(() => {
|
|
const trimmed = transcript.trim();
|
|
return trimmed ? trimmed.split(/\s+/).length : 0;
|
|
});
|
|
|
|
// Pretext content height measurement (DOM-free).
|
|
// Used for scroll-to-bottom affordance during live transcription.
|
|
let textareaWidth = $state(0);
|
|
let userScrolledUp = $state(false);
|
|
let previousMeasuredContentHeight = $state(0);
|
|
let shouldPreserveScrollAnchor = $state(false);
|
|
let shouldStickToBottom = $state(false);
|
|
|
|
// Build font string matching .font-transcript CSS
|
|
let transcriptFont = $derived(transcriptPretextFont(prefs.accessibility));
|
|
let transcriptLineHeight = $derived(transcriptPretextLineHeight(prefs.accessibility));
|
|
|
|
let contentHeight = $derived.by(() => {
|
|
if (!transcript.trim() || textareaWidth <= 0) return 0;
|
|
// Subtract padding (p-6 = 24px each side)
|
|
const innerWidth = textareaWidth - 48;
|
|
if (innerWidth <= 0) return 0;
|
|
return measurePreWrap(transcript, transcriptFont, innerWidth, transcriptLineHeight).height;
|
|
});
|
|
|
|
function onTextareaScroll(e) {
|
|
const el = e.target;
|
|
// "scrolled up" = more than 1 line from the bottom
|
|
userScrolledUp = (el.scrollHeight - el.scrollTop - el.clientHeight) > transcriptLineHeight;
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
if (textareaEl) {
|
|
textareaEl.scrollTop = textareaEl.scrollHeight;
|
|
userScrolledUp = false;
|
|
}
|
|
}
|
|
|
|
// Track textarea width for Pretext layout calculations
|
|
$effect(() => {
|
|
if (!textareaEl) return;
|
|
const ro = new ResizeObserver(entries => {
|
|
for (const entry of entries) textareaWidth = entry.contentRect.width;
|
|
});
|
|
ro.observe(textareaEl);
|
|
return () => ro.disconnect();
|
|
});
|
|
|
|
$effect(() => {
|
|
const measuredHeight = contentHeight;
|
|
const previousHeight = previousMeasuredContentHeight;
|
|
|
|
if (!textareaEl) {
|
|
previousMeasuredContentHeight = measuredHeight;
|
|
return;
|
|
}
|
|
|
|
if (shouldPreserveScrollAnchor && measuredHeight > previousHeight) {
|
|
const delta = measuredHeight - previousHeight;
|
|
requestAnimationFrame(() => {
|
|
if (textareaEl) {
|
|
textareaEl.scrollTop += delta;
|
|
}
|
|
});
|
|
shouldPreserveScrollAnchor = false;
|
|
} else if (shouldStickToBottom) {
|
|
requestAnimationFrame(() => {
|
|
if (textareaEl) {
|
|
textareaEl.scrollTop = textareaEl.scrollHeight;
|
|
}
|
|
});
|
|
shouldStickToBottom = false;
|
|
}
|
|
|
|
previousMeasuredContentHeight = measuredHeight;
|
|
});
|
|
|
|
let showScrollHint = $derived(page.recording && userScrolledUp && contentHeight > 0);
|
|
|
|
let reduceMotion = $derived(
|
|
prefs.accessibility.reduceMotion === 'on'
|
|
|| (prefs.accessibility.reduceMotion === 'system'
|
|
&& typeof window !== 'undefined'
|
|
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
);
|
|
|
|
let transcriptionFailed = $state(false);
|
|
|
|
// Screen-reader announcer for recording state transitions.
|
|
// Announces both start and stop, then clears so the live region does not
|
|
// hold stale text. prevRecording is a plain let (not $state) because it is
|
|
// bookkeeping and must not re-trigger the effect.
|
|
let recordingAnnouncement = $state("");
|
|
let prevRecording = false;
|
|
$effect(() => {
|
|
if (page.recording && !prevRecording) {
|
|
recordingAnnouncement = "Recording started";
|
|
} else if (!page.recording && prevRecording) {
|
|
recordingAnnouncement = "Recording stopped";
|
|
setTimeout(() => { recordingAnnouncement = ""; }, 1200);
|
|
}
|
|
prevRecording = page.recording;
|
|
});
|
|
</script>
|
|
|
|
<div class="flex flex-col h-full animate-fade-in">
|
|
<div aria-live="assertive" aria-atomic="true" class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);">
|
|
{recordingAnnouncement}
|
|
</div>
|
|
{#if needsDownload}
|
|
<ModelDownloader
|
|
engine={settings.engine}
|
|
modelSize={settings.modelSize.toLowerCase()}
|
|
onComplete={onModelDownloaded}
|
|
/>
|
|
{:else}
|
|
<!-- Control strip -->
|
|
<div class="flex items-center gap-3 px-5 h-[56px] border-b border-border-subtle flex-shrink-0">
|
|
<!-- Record button -->
|
|
<button
|
|
class="relative flex items-center justify-center w-[48px] h-[48px] min-w-[48px] flex-shrink-0 rounded-full text-white
|
|
{page.recording
|
|
? 'bg-danger animate-pulse-warm'
|
|
: modelLoading
|
|
? 'bg-warning opacity-60 cursor-wait'
|
|
: !tauriRuntimeAvailable
|
|
? 'bg-bg-elevated text-text-tertiary cursor-not-allowed'
|
|
: 'bg-accent hover:bg-accent-hover shadow-[var(--shadow-accent-lg)]'}"
|
|
onclick={toggleRecording}
|
|
disabled={modelLoading || !tauriRuntimeAvailable}
|
|
aria-label={page.recording ? "Stop recording" : "Start recording"}
|
|
>
|
|
{#if page.recording}
|
|
<span class="w-[16px] h-[16px] rounded-[3px] bg-white"></span>
|
|
{:else if modelLoading}
|
|
<Loader2 size={18} class="animate-spin" aria-hidden="true" />
|
|
{:else}
|
|
<span class="w-[16px] h-[16px] rounded-full bg-white/90"></span>
|
|
{/if}
|
|
</button>
|
|
<span class="text-[12px] font-medium flex-shrink-0 {page.recording ? 'text-danger' : 'text-text-secondary'}">
|
|
{page.recording ? 'Stop' : modelLoading ? 'Loading' : 'Record'}
|
|
</span>
|
|
|
|
<!-- Waveform placeholder (Phase 2: live visualiser) -->
|
|
<div class="flex-1 min-h-[32px] rounded-md border border-border-subtle bg-bg-elevated/40 flex items-center px-3">
|
|
{#if page.recording}
|
|
<span class="flex items-end gap-[3px] h-[18px]">
|
|
{#each [0.4, 0.8, 0.5, 1, 0.6, 0.9, 0.4, 0.7, 0.5, 0.8, 0.3, 0.6] as h, i}
|
|
{#if reduceMotion}
|
|
<span
|
|
class="w-[2px] rounded-full bg-danger"
|
|
style="height: 50%"
|
|
></span>
|
|
{:else}
|
|
<span
|
|
class="w-[2px] rounded-full bg-danger/70 animate-pulse-soft"
|
|
style="height: {h * 100}%; animation-delay: {i * 60}ms"
|
|
></span>
|
|
{/if}
|
|
{/each}
|
|
</span>
|
|
{:else}
|
|
<span class="text-[12px] text-text-secondary">
|
|
{#if modelLoading}
|
|
Loading model...
|
|
{:else if !tauriRuntimeAvailable}
|
|
Desktop app required for local transcription
|
|
{:else if saved}
|
|
<span class="text-success animate-fade-in">
|
|
Saved{#if extractedCount > 0} · {extractedCount} task{extractedCount === 1 ? '' : 's'} extracted{/if}
|
|
</span>
|
|
{:else}
|
|
Press record or <kbd class="px-1 py-0.5 rounded bg-bg-elevated text-[12px] text-text-secondary border border-border-subtle">{settings.globalHotkey}</kbd>
|
|
{/if}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Timer -->
|
|
<span class="text-[20px] font-bold text-text tabular-nums tracking-tight leading-none flex-shrink-0" style="font-variant-numeric: tabular-nums;">
|
|
{page.timerText}
|
|
</span>
|
|
|
|
<!-- Status indicator -->
|
|
<span class="text-[12px] text-text-secondary flex-shrink-0 min-w-[60px] text-right">
|
|
{#if page.recording}
|
|
<span class="inline-flex items-center gap-1.5">
|
|
<span class="w-[6px] h-[6px] rounded-full bg-danger animate-pulse-soft"></span>
|
|
<span class="font-medium text-danger tracking-wide">REC</span>
|
|
</span>
|
|
{:else if modelLoading}
|
|
<span class="text-warning">Loading</span>
|
|
{:else}
|
|
<span class="text-success">Ready</span>
|
|
{/if}
|
|
</span>
|
|
|
|
<!-- Separator -->
|
|
<span class="w-px h-4 bg-border-subtle flex-shrink-0"></span>
|
|
|
|
<!-- Task sidebar toggle -->
|
|
<button
|
|
class="relative px-2 py-1.5 rounded-lg flex-shrink-0
|
|
{page.taskSidebarOpen ? 'bg-accent/10' : 'hover:bg-hover'}"
|
|
onclick={() => page.taskSidebarOpen = !page.taskSidebarOpen}
|
|
aria-label="Toggle task sidebar"
|
|
>
|
|
<span class="flex items-center gap-1.5">
|
|
<SquareCheck size={16} class={page.taskSidebarOpen ? 'text-accent' : 'text-text-tertiary'} aria-hidden="true" />
|
|
<span class="text-[12px] {page.taskSidebarOpen ? 'text-accent' : 'text-text-tertiary'}">Tasks</span>
|
|
</span>
|
|
{#if taskCount > 0}
|
|
<span class="absolute -top-0.5 -right-0.5 text-[12px] px-1 py-0 rounded-full bg-accent text-white font-medium min-w-[14px] text-center leading-[14px]">
|
|
{taskCount}
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Toolbar -->
|
|
<div class="flex items-center justify-end gap-1 px-5 py-2 border-b border-border-subtle flex-shrink-0">
|
|
<!-- Template selector -->
|
|
{#if templates.length > 0}
|
|
<div class="relative">
|
|
<button
|
|
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
|
|
onclick={() => { showTemplateMenu = !showTemplateMenu; showExportMenu = false; }}
|
|
>Template</button>
|
|
{#if showTemplateMenu}
|
|
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-10 animate-fade-in min-w-[160px]">
|
|
{#each templates as template}
|
|
<button
|
|
class="w-full text-left btn-md text-text-secondary hover:bg-hover hover:text-text
|
|
{activeTemplate === template.name ? 'text-accent' : ''}"
|
|
onclick={() => applyTemplate(template)}
|
|
>{template.name}</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
<button
|
|
class="btn-md rounded-lg flex-shrink-0 whitespace-nowrap {aiProcessing ? 'text-warning' : 'text-accent hover:bg-accent/10 hover:text-accent font-medium'}"
|
|
onclick={manualExtractTasks}
|
|
disabled={aiProcessing || !transcript.trim()}
|
|
>{aiProcessing ? "Extracting..." : "Extract Tasks"}</button>
|
|
{#if hasTranscript || page.recording}
|
|
<div class="flex items-center gap-1 animate-fade-in">
|
|
<span class="w-px h-4 bg-border-subtle flex-shrink-0"></span>
|
|
<button
|
|
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap
|
|
{!transcript.trim() ? 'opacity-40 cursor-default' : ''}"
|
|
onclick={saveTypedText}
|
|
disabled={!transcript.trim()}
|
|
>Save</button>
|
|
<button
|
|
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
|
|
onclick={copyAll}
|
|
>Copy</button>
|
|
<button
|
|
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
|
|
onclick={clearTranscript}
|
|
>Clear</button>
|
|
<div class="relative flex-shrink-0">
|
|
<button
|
|
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text whitespace-nowrap"
|
|
onclick={() => { showExportMenu = !showExportMenu; showTemplateMenu = false; }}
|
|
>Export</button>
|
|
{#if showExportMenu}
|
|
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-10 animate-fade-in min-w-[120px]">
|
|
{#each [["txt", "Plain Text"], ["md", "Markdown"], ["csv", "CSV"], ["html", "HTML"], ["srt", "SRT Subtitles"], ["vtt", "WebVTT"]] as [fmt, label]}
|
|
<button
|
|
class="w-full text-left btn-md text-text-secondary hover:bg-hover hover:text-text"
|
|
onclick={() => handleExport(fmt)}
|
|
>{label}</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Template indicator -->
|
|
{#if activeTemplate}
|
|
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
|
|
<div class="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-accent-subtle border border-accent/20">
|
|
<span class="text-[12px] text-accent font-medium">Template: {activeTemplate}</span>
|
|
<span class="text-[12px] text-text-secondary">Click a section, then record to fill it</span>
|
|
<div class="flex-1"></div>
|
|
<button class="text-[12px] text-text-secondary hover:text-text" onclick={() => activeTemplate = ""}>Remove</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Error -->
|
|
{#if error}
|
|
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
|
|
<div class="px-4 py-2 rounded-lg bg-danger/10 border border-danger/20 text-[12px] text-danger">
|
|
{error}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if liveWarning && !error}
|
|
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
|
|
<div class="px-4 py-2 rounded-lg bg-warning/10 border border-warning/20 text-[12px] text-warning">
|
|
{liveWarning}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Transcript area -->
|
|
<div class="flex-1 px-5 pt-3 pb-3 min-h-0" style="--text-transcript: {prefs.accessibility.transcriptSize}px; font-size: var(--text-transcript)">
|
|
<Card classes="h-full flex flex-col">
|
|
{#if transcriptionFailed && !transcript.trim() && !page.recording}
|
|
<div class="flex-1 flex items-center justify-center">
|
|
<EmptyState icon={AlertTriangle} message="Something went wrong with that transcription. Your audio is saved, try again when you're ready." />
|
|
</div>
|
|
{:else if !transcript.trim() && !page.recording && !transcribing}
|
|
<div class="flex-1 flex items-center justify-center">
|
|
<EmptyState icon={Mic} headline="Talk now, think later." message={`Press record, or ${settings.globalHotkey}.`} />
|
|
</div>
|
|
<textarea
|
|
bind:this={textareaEl}
|
|
class="font-transcript flex-1 w-full bg-transparent text-text p-6
|
|
resize-none focus:outline-none placeholder:text-text-tertiary min-h-0 hidden"
|
|
placeholder={activeTemplate ? "Click a section above, then press record..." : "Your words will appear here..."}
|
|
bind:value={transcript}
|
|
onclick={() => { showExportMenu = false; showTemplateMenu = false; }}
|
|
data-no-transition
|
|
aria-label="Transcript"
|
|
aria-live="polite"
|
|
></textarea>
|
|
{:else}
|
|
<div class="relative flex-1 min-h-0">
|
|
<textarea
|
|
bind:this={textareaEl}
|
|
class="font-transcript h-full w-full bg-transparent text-text p-6
|
|
resize-none focus:outline-none placeholder:text-text-tertiary"
|
|
placeholder={activeTemplate ? "Click a section above, then press record..." : "Your words will appear here..."}
|
|
bind:value={transcript}
|
|
onscroll={onTextareaScroll}
|
|
onclick={() => { showExportMenu = false; showTemplateMenu = false; }}
|
|
data-no-transition
|
|
aria-label="Transcript"
|
|
aria-live="polite"
|
|
></textarea>
|
|
{#if showScrollHint}
|
|
<button
|
|
class="absolute bottom-3 right-6 px-3 py-1.5 rounded-full bg-accent text-white text-[12px] font-medium shadow-md hover:bg-accent-hover animate-fade-in"
|
|
onclick={scrollToBottom}
|
|
aria-label="Scroll to latest"
|
|
style="transition-duration: var(--duration-ui)"
|
|
>
|
|
↓ New text
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Status footer (inside transcript card) -->
|
|
<div class="flex items-center justify-between px-6 pb-3 pt-1 flex-shrink-0">
|
|
<span class="text-[12px] text-text-secondary">
|
|
{#if wordCount > 0}
|
|
{wordCount} {wordCount === 1 ? 'word' : 'words'}
|
|
{:else}
|
|
0 words
|
|
{/if}
|
|
{#if insertPos >= 0 && page.recording}
|
|
· <span class="text-accent">inserting at cursor</span>
|
|
{/if}
|
|
</span>
|
|
<div class="flex items-center gap-2">
|
|
{#if aiStatus}
|
|
<span class="text-[12px] text-accent animate-fade-in">{aiStatus}</span>
|
|
<span class="text-[12px] text-text-secondary">·</span>
|
|
{/if}
|
|
<span class="text-[12px] text-text-secondary">
|
|
{settings.formatMode} · {page.activeProfile === "None" ? "No profile" : page.activeProfile}
|
|
</span>
|
|
{#if transcript.trim()}
|
|
<SpeakerButton text={transcript} label="Read transcript aloud" size={12} />
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
{/if}
|
|
</div>
|