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 0ba47be89..2702398fb 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte @@ -208,6 +208,14 @@ } } + async function forceRetryMic() { + recError = null; + await dreamRecorder.start({ force: true }); + if (dreamRecorder.error) { + recError = dreamRecorder.error; + } + } + function cancelRecording() { dreamRecorder.cancel(); } @@ -265,7 +273,10 @@ {/if} {#if recError} -

{recError}

+
+

{recError}

+ +
{/if} @@ -629,10 +640,37 @@ } .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: #ef4444; + color: #b91c1c; margin: 0; - padding: 0 0.25rem; + 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); } /* ── View Tabs ─────────────────────────────── */ diff --git a/apps/mana/apps/web/src/lib/modules/dreams/recorder.svelte.ts b/apps/mana/apps/web/src/lib/modules/dreams/recorder.svelte.ts index 8988fbbc1..e2fd6f35f 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/recorder.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/dreams/recorder.svelte.ts @@ -38,7 +38,7 @@ class DreamRecorder { return typeof window !== 'undefined' && window.isSecureContext === true; } - async start(): Promise { + async start(options: { force?: boolean } = {}): Promise { if (this.status !== 'idle') return; // 1. Secure context check — getUserMedia is silently unavailable @@ -55,14 +55,17 @@ class DreamRecorder { return; } - // 3. Sticky deny check — Permissions API tells us if the user - // previously denied access. The browser will silently reject - // getUserMedia without showing a prompt in that case. - const stickyDenied = await this.#checkStickyDeny(); - if (stickyDenied) { - this.error = - 'Mikrofon-Zugriff wurde für diese Seite blockiert. Klicke in der Adressleiste auf das Schloss-Symbol → Mikrofon → Erlauben, dann lade die Seite neu.'; - return; + // 3. 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. + if (!options.force) { + const stickyDenied = await this.#checkStickyDeny(); + if (stickyDenied) { + this.error = this.#stickyDenyMessage(); + return; + } } this.error = null; @@ -162,6 +165,25 @@ class DreamRecorder { 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 { // Permissions API may not be available everywhere; treat as unknown.