diff --git a/src/lib/components/ToastViewport.svelte b/src/lib/components/ToastViewport.svelte new file mode 100644 index 0000000..d5aea97 --- /dev/null +++ b/src/lib/components/ToastViewport.svelte @@ -0,0 +1,129 @@ + + +
+ {#each toasts.items as toast (toast.id)} +
+
+
{toast.title}
+ {#if toast.body} +
{toast.body}
+ {/if} +
+ +
+ {/each} +
+ + diff --git a/src/lib/pages/DictationPage.svelte b/src/lib/pages/DictationPage.svelte index dfee3eb..50c3a64 100644 --- a/src/lib/pages/DictationPage.svelte +++ b/src/lib/pages/DictationPage.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from "svelte"; import { Channel, invoke } from "@tauri-apps/api/core"; import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js"; + 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"; @@ -342,6 +343,10 @@ timerInterval = setInterval(updateTimer, 1000); } 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(); diff --git a/src/lib/stores/toasts.svelte.js b/src/lib/stores/toasts.svelte.js new file mode 100644 index 0000000..260750a --- /dev/null +++ b/src/lib/stores/toasts.svelte.js @@ -0,0 +1,83 @@ +// Minimal toast store. Roll-our-own (no svelte-french-toast etc) so Kon +// stays lean. Toasts auto-dismiss after `duration` ms unless duration is 0 +// (sticky) or unless the user clicks the close button. +// +// Severity → colour: +// info → moss (informational, default 4s) +// success → moss (operation succeeded, default 3s) +// warn → signal (degraded but not failed, default 6s) +// error → ember (operation failed, sticky until dismissed) +// +// Usage from a component: +// import { toasts } from '$lib/stores/toasts.svelte.js'; +// toasts.error('Could not start recording', 'Selected microphone disappeared.'); +// toasts.success('Saved'); + +let nextId = 1; + +function defaultDuration(severity) { + switch (severity) { + case 'success': return 3000; + case 'info': return 4000; + case 'warn': return 6000; + case 'error': return 0; // sticky + default: return 4000; + } +} + +function createToastsStore() { + // $state requires a class field or top-level let in Svelte 5; we expose + // `items` via a getter on the singleton. + let items = $state([]); + + function show(severity, title, body) { + const id = nextId++; + const duration = defaultDuration(severity); + const toast = { id, severity, title, body: body ?? '', duration }; + items.push(toast); + if (duration > 0) { + setTimeout(() => dismiss(id), duration); + } + return id; + } + + function dismiss(id) { + const ix = items.findIndex(t => t.id === id); + if (ix >= 0) items.splice(ix, 1); + } + + function dismissAll() { + items.length = 0; + } + + return { + get items() { return items; }, + info: (title, body) => show('info', title, body), + success: (title, body) => show('success', title, body), + warn: (title, body) => show('warn', title, body), + error: (title, body) => show('error', title, body), + dismiss, + dismissAll, + }; +} + +export const toasts = createToastsStore(); + +// Helper: wrap a Tauri invoke and toast on failure. Returns the result on +// success, throws on failure (so callers that need to handle the error +// can still try/catch). +// +// import { invoke } from '@tauri-apps/api/core'; +// import { invokeWithToast } from '$lib/stores/toasts.svelte.js'; +// const result = await invokeWithToast('start_native_capture', { deviceName }); +// +// Optional `errorTitle` overrides the default ("Action failed"). +export async function invokeWithToast(invokeFn, command, args, errorTitle) { + try { + return await invokeFn(command, args); + } catch (err) { + const msg = typeof err === 'string' ? err : (err?.message ?? String(err)); + toasts.error(errorTitle ?? 'Action failed', msg); + throw err; + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 41995ec..5157cb8 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -5,14 +5,17 @@ import Sidebar from "$lib/Sidebar.svelte"; import TaskSidebar from "$lib/components/TaskSidebar.svelte"; import Titlebar from "$lib/components/Titlebar.svelte"; + import ToastViewport from "$lib/components/ToastViewport.svelte"; import { page, settings, saveSettings } from "$lib/stores/page.svelte.js"; import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js"; + import { hasTauriRuntime } from "$lib/utils/runtime.js"; import { page as sveltePage } from "$app/stores"; let { children } = $props(); const prefs = getPreferences(); + const tauriRuntimeAvailable = hasTauriRuntime(); // Detect secondary windows (float, viewer) — they use +layout@.svelte @@ -49,6 +52,10 @@ let hotkeyBackend = $state("unknown"); // "evdev" | "tauri" | "unavailable" async function initHotkeyBackend() { + if (!tauriRuntimeAvailable) { + hotkeyBackend = "unavailable"; + return; + } try { const isWayland = await invoke("is_wayland_session"); if (isWayland) { @@ -73,6 +80,7 @@ } async function registerGlobalHotkey(hotkey) { + if (!tauriRuntimeAvailable) return; if (hotkeyBackend === "unknown") return; // not yet initialised try { @@ -106,6 +114,7 @@ // Listen for evdev hotkey events from the Rust backend let unlistenEvdev = null; async function setupEvdevListener() { + if (!tauriRuntimeAvailable) return; const { listen } = await import("@tauri-apps/api/event"); unlistenEvdev = await listen("kon:hotkey-pressed", () => { if (page.current !== "dictation") page.current = "dictation"; @@ -161,6 +170,11 @@ handleResize(); window.addEventListener("resize", handleResize); + if (!tauriRuntimeAvailable) { + hotkeyBackend = "unavailable"; + return; + } + // Detect and initialise the correct hotkey backend await initHotkeyBackend(); @@ -177,6 +191,9 @@ onDestroy(() => { window.removeEventListener("resize", handleResize); + if (!tauriRuntimeAvailable) { + return; + } if (hotkeyBackend === "evdev") { invoke("stop_evdev_hotkey").catch(() => {}); } else if (hotkeyBackend === "tauri" && registeredHotkey) { @@ -220,3 +237,8 @@ {/if} + + +