fix(dreams): explain why the mic prompt isn't appearing

The voice capture used to surface a generic "Mikrofon-Zugriff
verweigert" whenever getUserMedia rejected, even though the actual
cause was usually one of three distinct, fixable conditions:

- Insecure context (http://192.168.x.x:5173 instead of localhost or
  https) — getUserMedia is silently unavailable, no prompt
- Sticky deny — user previously refused once, browser remembers and
  rejects without ever asking again
- Hardware: no microphone, busy mic, security policy

Now the recorder:

1. Checks window.isSecureContext first and tells the user to switch
   to https or localhost, naming the offending host
2. Queries the Permissions API for "microphone" before calling
   getUserMedia. If state is "denied", shows step-by-step recovery
   instructions (lock icon → mic → allow → reload) instead of pretending
   the user actively denied just now
3. Maps NotAllowedError / NotFoundError / NotReadableError /
   SecurityError to specific German messages, with the raw error as a
   fallback so the rest is still debuggable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 15:55:05 +02:00
parent 63a6f62529
commit a6828a16c2

View file

@ -34,13 +34,37 @@ class DreamRecorder {
);
}
get isSecureContext(): boolean {
return typeof window !== 'undefined' && window.isSecureContext === true;
}
async start(): Promise<void> {
if (this.status !== 'idle') return;
// 1. 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 : '';
this.error = `Mikrofon-Zugriff braucht eine sichere Verbindung. Öffne die App über https:// oder http://localhost statt http://${host}.`;
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 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;
}
this.error = null;
this.status = 'requesting';
@ -53,10 +77,7 @@ class DreamRecorder {
},
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.error = msg.includes('Permission')
? 'Mikrofon-Zugriff wurde verweigert.'
: `Mikrofon konnte nicht geöffnet werden: ${msg}`;
this.error = this.#explainError(e);
this.status = 'idle';
return;
}
@ -141,6 +162,44 @@ class DreamRecorder {
reject?.(err);
}
async #checkStickyDeny(): Promise<boolean> {
try {
// Permissions API may not be available everywhere; treat as unknown.
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;