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.