mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:21:09 +02:00
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:
parent
0d1d3b9449
commit
079cc39dbc
6 changed files with 334 additions and 568 deletions
|
|
@ -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 1–2 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">🎤</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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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">🎤</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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')}`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue