From 079cc39dbc517c06a13e5bca582f828af06566ea Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 8 Apr 2026 15:51:22 +0200 Subject: [PATCH] refactor(mana/web): extract shared for module voice capture Dreams and Memoro had two literal copies of the MediaRecorder boilerplate plus parallel mic-button markup, error UI, and requireAuth gating. Lift the recorder + bar into $lib/components/voice and add it to the memoro workbench ListView (which had no mic at all). New voice-capture features just drop in with idleLabel/feature/reason/onComplete. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/voice/VoiceCaptureBar.svelte | 248 ++++++++++++++++++ .../voice}/recorder.svelte.ts | 51 +++- .../src/lib/modules/dreams/ListView.svelte | 214 ++------------- .../src/lib/modules/memoro/ListView.svelte | 19 ++ .../src/lib/modules/memoro/recorder.svelte.ts | 245 ----------------- .../web/src/routes/(app)/memoro/+page.svelte | 125 +-------- 6 files changed, 334 insertions(+), 568 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/components/voice/VoiceCaptureBar.svelte rename apps/mana/apps/web/src/lib/{modules/dreams => components/voice}/recorder.svelte.ts (78%) delete mode 100644 apps/mana/apps/web/src/lib/modules/memoro/recorder.svelte.ts diff --git a/apps/mana/apps/web/src/lib/components/voice/VoiceCaptureBar.svelte b/apps/mana/apps/web/src/lib/components/voice/VoiceCaptureBar.svelte new file mode 100644 index 000000000..eaac07dc6 --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/voice/VoiceCaptureBar.svelte @@ -0,0 +1,248 @@ + + + +
+ + {#if voiceRecorder.status === 'recording'} + + {/if} +
+ +{#if localError} +
+

{localError}

+ +
+{/if} + + diff --git a/apps/mana/apps/web/src/lib/modules/dreams/recorder.svelte.ts b/apps/mana/apps/web/src/lib/components/voice/recorder.svelte.ts similarity index 78% rename from apps/mana/apps/web/src/lib/modules/dreams/recorder.svelte.ts rename to apps/mana/apps/web/src/lib/components/voice/recorder.svelte.ts index e2fd6f35f..824123cd1 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/recorder.svelte.ts +++ b/apps/mana/apps/web/src/lib/components/voice/recorder.svelte.ts @@ -1,8 +1,29 @@ /** - * Browser audio recorder for the Dreams voice-capture feature. + * Shared browser audio recorder for all voice-capture features. * - * Uses MediaRecorder under the hood. Exposes a small reactive state object - * that components can read to render the mic button state and elapsed time. + * Originally lived as `dreams/recorder.svelte.ts` and + * `memoro/recorder.svelte.ts` — two literal copies that diverged only + * by the class name and a few comments. Extracted to one shared + * singleton + state machine so: + * + * - New voice-capture features (e.g. notes voice memos, todo voice + * quick-add, chat voice messages) just import this and drop a + * `` into their UI without copy-pasting 200 LOC + * of MediaRecorder boilerplate. + * - There is exactly ONE recording at a time across the whole app, + * which matches the physical reality (one mic, one MediaStream). + * The state machine enforces it explicitly instead of relying on + * `getUserMedia()` to fail at the second simultaneous call. + * - Bug fixes (sticky-deny detection, error message wording, secure + * context check, …) live in one place. The 2026-04-08 mic-button + * investigation surfaced three orthogonal issues + * (Permissions-Policy header, mount-time notification request, + * dev SW caching) — all of which would have had to be debugged + * twice with the old per-module recorders. + * + * Use it via `` in `$lib/components/voice/`. Direct + * use is also supported for advanced cases (analysers, custom UI), + * but most call sites only need the bar. */ export type RecorderStatus = 'idle' | 'requesting' | 'recording' | 'stopping'; @@ -13,7 +34,7 @@ export interface RecordingResult { mimeType: string; } -class DreamRecorder { +class VoiceRecorder { status = $state('idle'); error = $state(null); elapsedMs = $state(0); @@ -41,7 +62,7 @@ class DreamRecorder { async start(options: { force?: boolean } = {}): Promise { if (this.status !== 'idle') return; - // 1. Secure context check — getUserMedia is silently unavailable + // Secure context check — getUserMedia is silently unavailable // over plain http (except localhost), with no permission prompt. if (!this.isSecureContext) { const host = typeof window !== 'undefined' ? window.location.host : ''; @@ -49,17 +70,17 @@ class DreamRecorder { return; } - // 2. Browser API present? if (!this.isAvailable) { this.error = 'Audio-Aufnahme wird in diesem Browser nicht unterstützt.'; return; } - // 3. Sticky deny check — Permissions API tells us if the browser + // Sticky deny check — Permissions API tells us if the browser // will silently reject getUserMedia without showing a prompt. - // On macOS this is most often a SYSTEM-level block, not a per-site - // setting, which is why no lock icon helps. Skip the check if the - // caller explicitly forces a retry to surface the real error. + // On macOS this is most often a SYSTEM-level block, not a + // per-site setting, which is why no lock icon helps. Skip the + // check if the caller explicitly forces a retry to surface the + // real error. if (!options.force) { const stickyDenied = await this.#checkStickyDeny(); if (stickyDenied) { @@ -186,7 +207,6 @@ class DreamRecorder { async #checkStickyDeny(): Promise { try { - // Permissions API may not be available everywhere; treat as unknown. const perms = ( navigator as Navigator & { permissions?: { @@ -244,7 +264,14 @@ function pickSupportedMimeType(): string | null { return null; } -export const dreamRecorder = new DreamRecorder(); +/** + * Single shared recorder. The browser physically only allows one + * active recording at a time anyway (one mic, one MediaStream); the + * singleton makes that constraint explicit and visible to UI code so + * a second module can render its mic button as disabled while another + * module is still recording. + */ +export const voiceRecorder = new VoiceRecorder(); export function formatElapsed(ms: number): string { const totalSec = Math.floor(ms / 1000); diff --git a/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte b/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte index 1895166eb..8285b6ad6 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte @@ -11,10 +11,9 @@ useAllDreams, } from './queries'; import { dreamsStore } from './stores/dreams.svelte'; - import { dreamRecorder, formatElapsed } from './recorder.svelte'; + import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte'; import { MOOD_COLORS, MOOD_LABELS, type Dream, type DreamMood, type SleepQuality } from './types'; import type { ViewProps } from '$lib/app-registry'; - import { requireAuth } from '$lib/auth/require-auth.svelte'; import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui'; import { PencilSimple, PushPin, Trash } from '@mana/shared-icons'; import SymbolsView from './views/SymbolsView.svelte'; @@ -182,56 +181,14 @@ const MOODS: DreamMood[] = ['angenehm', 'neutral', 'unangenehm', 'albtraum']; // ── Voice capture ───────────────────────────────────────── - let recError = $state(null); - - async function handleMicClick() { - recError = null; - if (dreamRecorder.status === 'recording') { - try { - const result = await dreamRecorder.stop(); - if (result.durationMs < 500) { - recError = 'Aufnahme war zu kurz.'; - return; - } - const dream = await dreamsStore.createFromVoice(result.blob, result.durationMs, 'de'); - // Open the dream so the user sees the transcript appear inline - viewMode = 'list'; - startEdit(dream); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - if (msg !== 'cancelled') recError = msg; - } - } else if (dreamRecorder.status === 'idle') { - // Voice recording writes to the encrypted `dreams` table — without - // an account the vault is locked and the very last step (the - // dexie write) would throw VaultLockedError after the user has - // already invested time recording and waiting for transcription. - // Gate the entry point so guests see a friendly login prompt - // BEFORE the mic permission request. - const ok = await requireAuth({ - feature: 'dreams-voice-capture', - reason: - 'Sprach-Aufnahmen werden verschlüsselt in deinem persönlichen Tagebuch gespeichert. Dafür brauchst du ein Mana-Konto.', - }); - if (!ok) return; - - await dreamRecorder.start(); - if (dreamRecorder.error) { - recError = dreamRecorder.error; - } - } - } - - async function forceRetryMic() { - recError = null; - await dreamRecorder.start({ force: true }); - if (dreamRecorder.error) { - recError = dreamRecorder.error; - } - } - - function cancelRecording() { - dreamRecorder.cancel(); + // All MediaRecorder + auth gating + error handling lives in + // in $lib/components/voice/. This module just + // passes the host-specific bits via props and a callback. + async function handleVoiceComplete(blob: Blob, durationMs: number) { + const dream = await dreamsStore.createFromVoice(blob, durationMs, 'de'); + // Open the dream so the user sees the transcript appear inline + viewMode = 'list'; + startEdit(dream); } @@ -259,39 +216,12 @@ /> {:else} -
- - {#if dreamRecorder.status === 'recording'} - - {/if} -
- {#if recError} -
-

{recError}

- -
- {/if} +
e.preventDefault()} class="quick-add"> @@ -573,119 +503,7 @@ height: 100%; } - /* ── Voice capture ─────────────────────────── */ - .capture-row { - display: flex; - align-items: center; - gap: 0.375rem; - } - - .mic-btn { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - padding: 0.625rem 0.875rem; - border-radius: 0.5rem; - border: 1px solid rgba(99, 102, 241, 0.2); - background: rgba(99, 102, 241, 0.04); - color: #6366f1; - font-size: 0.8125rem; - font-weight: 500; - cursor: pointer; - transition: all 0.15s; - } - .mic-btn:hover:not(:disabled) { - background: rgba(99, 102, 241, 0.08); - border-color: #6366f1; - } - .mic-btn:disabled { - opacity: 0.6; - cursor: wait; - } - .mic-btn.recording { - background: rgba(239, 68, 68, 0.08); - border-color: rgba(239, 68, 68, 0.4); - color: #ef4444; - animation: rec-pulse 1.5s ease-in-out infinite; - } - @keyframes rec-pulse { - 0%, - 100% { - background: rgba(239, 68, 68, 0.08); - } - 50% { - background: rgba(239, 68, 68, 0.16); - } - } - - .mic-icon { - font-size: 1rem; - } - .mic-stop { - display: inline-block; - width: 10px; - height: 10px; - background: #ef4444; - border-radius: 2px; - } - .mic-time { - font-variant-numeric: tabular-nums; - } - - .mic-cancel { - width: 32px; - height: 32px; - border-radius: 0.375rem; - border: 1px solid rgba(0, 0, 0, 0.08); - background: transparent; - color: #9ca3af; - font-size: 1.125rem; - line-height: 1; - cursor: pointer; - } - .mic-cancel:hover { - color: #ef4444; - border-color: #ef4444; - } - :global(.dark) .mic-cancel { - border-color: rgba(255, 255, 255, 0.1); - } - - .rec-error { - display: flex; - flex-direction: column; - gap: 0.5rem; - padding: 0.625rem 0.75rem; - border-radius: 0.375rem; - background: rgba(239, 68, 68, 0.06); - border: 1px solid rgba(239, 68, 68, 0.2); - } - .rec-error p { - font-size: 0.6875rem; - color: #b91c1c; - margin: 0; - white-space: pre-line; - line-height: 1.5; - } - :global(.dark) .rec-error p { - color: #fca5a5; - } - .rec-retry { - align-self: flex-start; - padding: 0.25rem 0.625rem; - border-radius: 0.25rem; - border: 1px solid rgba(239, 68, 68, 0.3); - background: transparent; - color: #ef4444; - font-size: 0.6875rem; - font-weight: 500; - cursor: pointer; - } - .rec-retry:hover { - background: rgba(239, 68, 68, 0.08); - } + /* Voice capture styles live in $lib/components/voice/VoiceCaptureBar.svelte */ /* ── View Tabs ─────────────────────────────── */ .view-tabs { diff --git a/apps/mana/apps/web/src/lib/modules/memoro/ListView.svelte b/apps/mana/apps/web/src/lib/modules/memoro/ListView.svelte index 126642474..77e4007e1 100644 --- a/apps/mana/apps/web/src/lib/modules/memoro/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/memoro/ListView.svelte @@ -7,11 +7,23 @@ import { db } from '$lib/data/database'; import type { ViewProps } from '$lib/app-registry'; import type { LocalMemo } from './types'; + import { memosStore } from './stores/memos.svelte'; + import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte'; let { navigate, goBack, params }: ViewProps = $props(); let memos = $state([]); + async function handleVoiceComplete(blob: Blob, durationMs: number) { + const memo = await memosStore.createFromVoice(blob, durationMs, 'de'); + // Open the new memo so the user sees the transcription land + navigate('detail', { + memoId: memo.id, + _siblingIds: sorted.map((m) => m.id), + _siblingKey: 'memoId', + }); + } + $effect(() => { const sub = liveQuery(async () => { return db @@ -47,6 +59,13 @@
+ +
{memos.length} Memos {pinned.length} angepinnt diff --git a/apps/mana/apps/web/src/lib/modules/memoro/recorder.svelte.ts b/apps/mana/apps/web/src/lib/modules/memoro/recorder.svelte.ts deleted file mode 100644 index 9e99728b8..000000000 --- a/apps/mana/apps/web/src/lib/modules/memoro/recorder.svelte.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * Browser audio recorder for the Memoro voice-capture feature. - * - * Uses MediaRecorder under the hood. Exposes a small reactive state object - * that components can read to render the mic button state and elapsed time. - */ - -export type RecorderStatus = 'idle' | 'requesting' | 'recording' | 'stopping'; - -export interface RecordingResult { - blob: Blob; - durationMs: number; - mimeType: string; -} - -class MemoRecorder { - status = $state('idle'); - error = $state(null); - elapsedMs = $state(0); - - #mediaRecorder: MediaRecorder | null = null; - #stream: MediaStream | null = null; - #chunks: Blob[] = []; - #startedAt = 0; - #tickHandle: ReturnType | null = null; - #resolve: ((result: RecordingResult) => void) | null = null; - #reject: ((reason: Error) => void) | null = null; - - get isAvailable(): boolean { - return ( - typeof navigator !== 'undefined' && - !!navigator.mediaDevices?.getUserMedia && - typeof MediaRecorder !== 'undefined' - ); - } - - get isSecureContext(): boolean { - return typeof window !== 'undefined' && window.isSecureContext === true; - } - - async start(options: { force?: boolean } = {}): Promise { - if (this.status !== 'idle') return; - - if (!this.isSecureContext) { - const host = typeof window !== 'undefined' ? window.location.host : ''; - this.error = `Mikrofon-Zugriff braucht eine sichere Verbindung. Öffne die App über https:// oder http://localhost statt http://${host}.`; - return; - } - - if (!this.isAvailable) { - this.error = 'Audio-Aufnahme wird in diesem Browser nicht unterstützt.'; - return; - } - - if (!options.force) { - const stickyDenied = await this.#checkStickyDeny(); - if (stickyDenied) { - this.error = this.#stickyDenyMessage(); - return; - } - } - - this.error = null; - this.status = 'requesting'; - - try { - this.#stream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - }, - }); - } catch (e) { - this.error = this.#explainError(e); - this.status = 'idle'; - return; - } - - const mimeType = pickSupportedMimeType(); - try { - this.#mediaRecorder = new MediaRecorder(this.#stream, mimeType ? { mimeType } : {}); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - this.error = `MediaRecorder konnte nicht gestartet werden: ${msg}`; - this.#cleanupStream(); - this.status = 'idle'; - return; - } - - this.#chunks = []; - this.#mediaRecorder.ondataavailable = (event) => { - if (event.data && event.data.size > 0) this.#chunks.push(event.data); - }; - this.#mediaRecorder.onerror = (event: Event) => { - const err = (event as Event & { error?: Error }).error; - this.#failWith(err ?? new Error('MediaRecorder error')); - }; - this.#mediaRecorder.onstop = () => { - const durationMs = this.elapsedMs; - const type = this.#mediaRecorder?.mimeType || mimeType || 'audio/webm'; - const blob = new Blob(this.#chunks, { type }); - this.#cleanupStream(); - this.#cleanupTimer(); - this.status = 'idle'; - this.elapsedMs = 0; - const resolve = this.#resolve; - this.#resolve = null; - this.#reject = null; - resolve?.({ blob, durationMs, mimeType: type }); - }; - - this.#startedAt = Date.now(); - this.elapsedMs = 0; - this.#tickHandle = setInterval(() => { - this.elapsedMs = Date.now() - this.#startedAt; - }, 100); - this.#mediaRecorder.start(); - this.status = 'recording'; - } - - stop(): Promise { - if (this.status !== 'recording' || !this.#mediaRecorder) { - return Promise.reject(new Error('Not recording')); - } - this.status = 'stopping'; - return new Promise((resolve, reject) => { - this.#resolve = resolve; - this.#reject = reject; - this.#mediaRecorder?.stop(); - }); - } - - cancel(): void { - if (this.status === 'idle') return; - this.#cleanupStream(); - this.#cleanupTimer(); - this.#mediaRecorder = null; - this.#chunks = []; - this.elapsedMs = 0; - this.status = 'idle'; - const reject = this.#reject; - this.#resolve = null; - this.#reject = null; - reject?.(new Error('cancelled')); - } - - #failWith(err: Error) { - this.error = err.message; - this.#cleanupStream(); - this.#cleanupTimer(); - this.status = 'idle'; - this.elapsedMs = 0; - const reject = this.#reject; - this.#resolve = null; - this.#reject = null; - reject?.(err); - } - - #stickyDenyMessage(): string { - const isMac = - typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform || ''); - if (isMac) { - return [ - 'Mikrofon-Zugriff blockiert. Auf macOS hat das fast immer eine von zwei Ursachen:', - '1) System-Einstellungen → Datenschutz & Sicherheit → Mikrofon: dein Browser muss in der Liste aktiviert sein. Wenn er fehlt oder deaktiviert ist, schalte ihn ein und starte den Browser komplett neu (Cmd+Q, nicht nur Tab schließen).', - '2) Browser-Einstellung: chrome://settings/content/microphone (Chrome) oder about:preferences#privacy (Firefox) → "localhost" darf nicht in der Block-Liste stehen.', - 'Tipp: Klicke auf "Trotzdem versuchen" um den exakten Browser-Fehler zu sehen.', - ].join('\n'); - } - return [ - 'Mikrofon-Zugriff blockiert. Mögliche Ursachen:', - '1) Browser-Einstellungen → Mikrofon → "localhost" darf nicht blockiert sein.', - '2) System-Einstellungen → Datenschutz → Mikrofon → Browser muss erlaubt sein.', - 'Tipp: Klicke auf "Trotzdem versuchen" um den exakten Browser-Fehler zu sehen.', - ].join('\n'); - } - - async #checkStickyDeny(): Promise { - try { - const perms = ( - navigator as Navigator & { - permissions?: { - query: (descriptor: { name: string }) => Promise<{ state: string }>; - }; - } - ).permissions; - if (!perms?.query) return false; - const status = await perms.query({ name: 'microphone' }); - return status.state === 'denied'; - } catch { - return false; - } - } - - #explainError(e: unknown): string { - const err = e instanceof Error ? e : new Error(String(e)); - const name = err.name || ''; - const msg = err.message || ''; - - if (name === 'NotAllowedError' || /denied|permission/i.test(msg)) { - return 'Mikrofon-Zugriff wurde verweigert. Klicke in der Adressleiste auf das Schloss-Symbol und erlaube den Zugriff.'; - } - if (name === 'NotFoundError' || /not.?found|no.?device/i.test(msg)) { - return 'Kein Mikrofon gefunden. Schließe ein Mikrofon an oder prüfe deine System-Einstellungen.'; - } - if (name === 'NotReadableError' || /in use|busy/i.test(msg)) { - return 'Mikrofon ist gerade von einer anderen Anwendung belegt.'; - } - if (name === 'SecurityError') { - return 'Mikrofon-Zugriff vom Browser blockiert (Sicherheitsrichtlinie).'; - } - return `Mikrofon konnte nicht geöffnet werden: ${msg || name || 'Unbekannter Fehler'}`; - } - - #cleanupStream() { - this.#stream?.getTracks().forEach((t) => t.stop()); - this.#stream = null; - } - - #cleanupTimer() { - if (this.#tickHandle !== null) { - clearInterval(this.#tickHandle); - this.#tickHandle = null; - } - } -} - -function pickSupportedMimeType(): string | null { - if (typeof MediaRecorder === 'undefined') return null; - const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/mp4']; - for (const c of candidates) { - if (MediaRecorder.isTypeSupported(c)) return c; - } - return null; -} - -export const memoRecorder = new MemoRecorder(); - -export function formatElapsed(ms: number): string { - const totalSec = Math.floor(ms / 1000); - const min = Math.floor(totalSec / 60); - const sec = totalSec % 60; - return `${min}:${sec.toString().padStart(2, '0')}`; -} diff --git a/apps/mana/apps/web/src/routes/(app)/memoro/+page.svelte b/apps/mana/apps/web/src/routes/(app)/memoro/+page.svelte index d604f78a4..cd604cde9 100644 --- a/apps/mana/apps/web/src/routes/(app)/memoro/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/memoro/+page.svelte @@ -2,8 +2,7 @@ import { goto } from '$app/navigation'; import { getContext } from 'svelte'; import { memosStore } from '$lib/modules/memoro/stores/memos.svelte'; - import { memoRecorder, formatElapsed } from '$lib/modules/memoro/recorder.svelte'; - import { requireAuth } from '$lib/auth/require-auth.svelte'; + import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte'; import { filterBySearch, filterByTag, @@ -46,51 +45,9 @@ } // ── Voice capture ───────────────────────────────────────── - let recError = $state(null); - - async function handleMicClick() { - recError = null; - if (memoRecorder.status === 'recording') { - try { - const result = await memoRecorder.stop(); - if (result.durationMs < 500) { - recError = 'Aufnahme war zu kurz.'; - return; - } - const memo = await memosStore.createFromVoice(result.blob, result.durationMs, 'de'); - goto(`/memoro/${memo.id}`); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - if (msg !== 'cancelled') recError = msg; - } - } else if (memoRecorder.status === 'idle') { - // Memos write to the encrypted `memos` table — gate guests - // before the mic permission request, otherwise they record - // audio + wait for transcription only to crash on the - // VaultLockedError at the very last step. - const ok = await requireAuth({ - feature: 'memoro-voice-capture', - reason: 'Sprach-Memos werden verschlüsselt gespeichert. Dafür brauchst du ein Mana-Konto.', - }); - if (!ok) return; - - await memoRecorder.start(); - if (memoRecorder.error) { - recError = memoRecorder.error; - } - } - } - - async function forceRetryMic() { - recError = null; - await memoRecorder.start({ force: true }); - if (memoRecorder.error) { - recError = memoRecorder.error; - } - } - - function cancelRecording() { - memoRecorder.cancel(); + async function handleVoiceComplete(blob: Blob, durationMs: number) { + const memo = await memosStore.createFromVoice(blob, durationMs, 'de'); + goto(`/memoro/${memo.id}`); } async function handlePin(e: Event, id: string, isPinned: boolean) { @@ -144,41 +101,14 @@ Tags - - {#if memoRecorder.status === 'recording'} - - {/if} +
+ +
- {#if recError} -
-

{recError}

- -
- {/if} -
- -