refactor(mana/web): extract shared <VoiceCaptureBar> 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 <VoiceCaptureBar> with idleLabel/feature/reason/onComplete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-08 15:51:22 +02:00
parent 0d1d3b9449
commit 079cc39dbc
6 changed files with 334 additions and 568 deletions

View file

@ -0,0 +1,248 @@
<!--
Shared "Voice Capture" UI bar.
Drop this into any module's ListView to add a single-button voice
recorder. The bar handles the entire flow:
1. Gate the click on requireAuth() — guests see the global
"Konto erforderlich" modal before the mic permission request.
2. Start the shared singleton voiceRecorder (handles secure-context
check, sticky-deny detection, MediaRecorder lifecycle).
3. Render the four states: idle / requesting / recording / stopping.
4. On stop, call back into the host module via `onComplete(blob, ms)`
so the host can transcribe + encrypt + persist however it likes.
5. Surface errors with the "Trotzdem versuchen" force-retry button
for the macOS sticky-deny scenario.
The host module supplies:
- `idleLabel` — what the button says when idle (e.g. "Traum sprechen")
- `feature` — stable id for analytics (e.g. "dreams-voice-capture")
- `reason` — text for the requireAuth modal
- `onComplete` — async callback that receives the recorded blob
The host module does NOT need to:
- import or instantiate a recorder
- copy/paste 200 LOC of MediaRecorder boilerplate
- own any local error state
- handle requireAuth gating
- care about secure context, sticky deny, or browser quirks
-->
<script lang="ts">
import { voiceRecorder, formatElapsed } from './recorder.svelte';
import { requireAuth } from '$lib/auth/require-auth.svelte';
interface Props {
/** Label shown on the mic button when no recording is active. */
idleLabel: string;
/** Stable feature identifier for analytics + auth gate telemetry.
* Lowercase, hyphenated. Example: 'dreams-voice-capture'. */
feature: string;
/** Human-readable reason shown in the requireAuth modal if the
* user is not logged in. Should explain *why* this specific
* feature needs an account in 12 sentences. */
reason: string;
/** Called when a recording is successfully stopped and the user
* has waited at least the minimum duration. The host module is
* responsible for transcribing / persisting the blob. */
onComplete: (blob: Blob, durationMs: number) => Promise<void> | void;
/** Minimum recording duration in milliseconds. Recordings shorter
* than this surface a "too short" error and don't fire onComplete.
* Default 500 ms. */
minDurationMs?: number;
}
let { idleLabel, feature, reason, onComplete, minDurationMs = 500 }: Props = $props();
let localError = $state<string | null>(null);
async function handleClick() {
localError = null;
if (voiceRecorder.status === 'recording') {
try {
const result = await voiceRecorder.stop();
if (result.durationMs < minDurationMs) {
localError = 'Aufnahme war zu kurz.';
return;
}
await onComplete(result.blob, result.durationMs);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg !== 'cancelled') localError = msg;
}
return;
}
if (voiceRecorder.status !== 'idle') return;
// Auth gate before the mic permission request — see
// $lib/auth/require-auth.svelte.ts for the full design rationale.
const ok = await requireAuth({ feature, reason });
if (!ok) return;
await voiceRecorder.start();
if (voiceRecorder.error) {
localError = voiceRecorder.error;
}
}
async function forceRetry() {
localError = null;
await voiceRecorder.start({ force: true });
if (voiceRecorder.error) {
localError = voiceRecorder.error;
}
}
function cancel() {
voiceRecorder.cancel();
}
</script>
<div class="capture-row">
<button
class="mic-btn"
class:recording={voiceRecorder.status === 'recording'}
class:busy={voiceRecorder.status === 'requesting' || voiceRecorder.status === 'stopping'}
onclick={handleClick}
disabled={voiceRecorder.status === 'requesting' || voiceRecorder.status === 'stopping'}
aria-label={voiceRecorder.status === 'recording' ? 'Aufnahme beenden' : 'Aufnahme starten'}
>
{#if voiceRecorder.status === 'recording'}
<span class="mic-stop"></span>
<span class="mic-time">{formatElapsed(voiceRecorder.elapsedMs)}</span>
{:else if voiceRecorder.status === 'requesting'}
<span class="mic-icon"></span>
<span class="mic-time">Mikro öffnen…</span>
{:else if voiceRecorder.status === 'stopping'}
<span class="mic-icon"></span>
<span class="mic-time">Verarbeite…</span>
{:else}
<span class="mic-icon">&#x1f3a4;</span>
<span class="mic-time">{idleLabel}</span>
{/if}
</button>
{#if voiceRecorder.status === 'recording'}
<button class="mic-cancel" onclick={cancel} title="Aufnahme verwerfen">×</button>
{/if}
</div>
{#if localError}
<div class="rec-error">
<p>{localError}</p>
<button class="rec-retry" onclick={forceRetry}>Trotzdem versuchen</button>
</div>
{/if}
<style>
.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% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3);
}
50% {
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
}
}
.mic-icon {
font-size: 1rem;
line-height: 1;
}
.mic-stop {
display: inline-block;
width: 10px;
height: 10px;
background: currentColor;
border-radius: 1px;
}
.mic-time {
font-size: 0.8125rem;
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;
margin-top: 0.5rem;
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);
}
</style>

View file

@ -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
* `<VoiceCaptureBar>` 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 `<VoiceCaptureBar>` 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<RecorderStatus>('idle');
error = $state<string | null>(null);
elapsedMs = $state(0);
@ -41,7 +62,7 @@ class DreamRecorder {
async start(options: { force?: boolean } = {}): Promise<void> {
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<boolean> {
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);

View file

@ -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<string | null>(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
// <VoiceCaptureBar> 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);
}
</script>
@ -259,39 +216,12 @@
/>
{:else}
<!-- Voice capture -->
<div class="capture-row">
<button
class="mic-btn"
class:recording={dreamRecorder.status === 'recording'}
class:busy={dreamRecorder.status === 'requesting' || dreamRecorder.status === 'stopping'}
onclick={handleMicClick}
disabled={dreamRecorder.status === 'requesting' || dreamRecorder.status === 'stopping'}
aria-label={dreamRecorder.status === 'recording' ? 'Aufnahme beenden' : 'Aufnahme starten'}
>
{#if dreamRecorder.status === 'recording'}
<span class="mic-stop"></span>
<span class="mic-time">{formatElapsed(dreamRecorder.elapsedMs)}</span>
{:else if dreamRecorder.status === 'requesting'}
<span class="mic-icon"></span>
<span class="mic-time">Mikro öffnen…</span>
{:else if dreamRecorder.status === 'stopping'}
<span class="mic-icon"></span>
<span class="mic-time">Verarbeite…</span>
{:else}
<span class="mic-icon">&#x1f3a4;</span>
<span class="mic-time">Traum sprechen</span>
{/if}
</button>
{#if dreamRecorder.status === 'recording'}
<button class="mic-cancel" onclick={cancelRecording} title="Aufnahme verwerfen"> × </button>
{/if}
</div>
{#if recError}
<div class="rec-error">
<p>{recError}</p>
<button class="rec-retry" onclick={forceRetryMic}>Trotzdem versuchen</button>
</div>
{/if}
<VoiceCaptureBar
idleLabel="Traum sprechen"
feature="dreams-voice-capture"
reason="Sprach-Aufnahmen werden verschlüsselt in deinem persönlichen Tagebuch gespeichert. Dafür brauchst du ein Mana-Konto."
onComplete={handleVoiceComplete}
/>
<!-- Quick create -->
<form onsubmit={(e) => 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 {

View file

@ -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<LocalMemo[]>([]);
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 @@
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
<VoiceCaptureBar
idleLabel="Memo sprechen"
feature="memoro-voice-capture"
reason="Sprach-Memos werden verschlüsselt gespeichert. Dafür brauchst du ein Mana-Konto."
onComplete={handleVoiceComplete}
/>
<div class="flex gap-3 text-xs text-white/40">
<span>{memos.length} Memos</span>
<span>{pinned.length} angepinnt</span>

View file

@ -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<RecorderStatus>('idle');
error = $state<string | null>(null);
elapsedMs = $state(0);
#mediaRecorder: MediaRecorder | null = null;
#stream: MediaStream | null = null;
#chunks: Blob[] = [];
#startedAt = 0;
#tickHandle: ReturnType<typeof setInterval> | 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<void> {
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<RecordingResult> {
if (this.status !== 'recording' || !this.#mediaRecorder) {
return Promise.reject(new Error('Not recording'));
}
this.status = 'stopping';
return new Promise<RecordingResult>((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<boolean> {
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')}`;
}

View file

@ -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<string | null>(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 @@
<TagIcon size={16} />
Tags
</a>
<button
onclick={handleMicClick}
disabled={memoRecorder.status === 'requesting' || memoRecorder.status === 'stopping'}
aria-label={memoRecorder.status === 'recording' ? 'Aufnahme beenden' : 'Aufnahme starten'}
class="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-60"
class:recording={memoRecorder.status === 'recording'}
style:background-color={memoRecorder.status === 'recording'
? '#ef4444'
: 'hsl(var(--muted))'}
style:color={memoRecorder.status === 'recording' ? 'white' : 'hsl(var(--foreground))'}
>
{#if memoRecorder.status === 'recording'}
<span class="rec-dot"></span>
{formatElapsed(memoRecorder.elapsedMs)}
{:else if memoRecorder.status === 'requesting'}
<Microphone size={18} />
Mikro öffnen…
{:else if memoRecorder.status === 'stopping'}
<Microphone size={18} />
Verarbeite…
{:else}
<Microphone size={18} />
Aufnehmen
{/if}
</button>
{#if memoRecorder.status === 'recording'}
<button
onclick={cancelRecording}
title="Aufnahme verwerfen"
aria-label="Aufnahme verwerfen"
class="rounded-lg border border-[hsl(var(--border))] px-3 py-2 text-sm text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
>
×
</button>
{/if}
<div class="w-64">
<VoiceCaptureBar
idleLabel="Memo aufnehmen"
feature="memoro-voice-capture"
reason="Sprach-Memos werden verschlüsselt gespeichert. Dafür brauchst du ein Mana-Konto."
onComplete={handleVoiceComplete}
/>
</div>
<button
onclick={handleNewMemo}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
@ -189,17 +119,6 @@
</div>
</div>
{#if recError}
<div
class="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-600 dark:text-red-300"
>
<p class="whitespace-pre-line">{recError}</p>
<button onclick={forceRetryMic} class="mt-2 text-xs font-medium underline hover:no-underline">
Trotzdem versuchen
</button>
</div>
{/if}
<!-- Search -->
<div class="relative">
<MagnifyingGlass
@ -356,23 +275,3 @@
</a>
</div>
</div>
<style>
.rec-dot {
display: inline-block;
width: 0.625rem;
height: 0.625rem;
border-radius: 9999px;
background: white;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
</style>