mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-26 01:14:39 +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
|
* Originally lived as `dreams/recorder.svelte.ts` and
|
||||||
* that components can read to render the mic button state and elapsed time.
|
* `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';
|
export type RecorderStatus = 'idle' | 'requesting' | 'recording' | 'stopping';
|
||||||
|
|
@ -13,7 +34,7 @@ export interface RecordingResult {
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class DreamRecorder {
|
class VoiceRecorder {
|
||||||
status = $state<RecorderStatus>('idle');
|
status = $state<RecorderStatus>('idle');
|
||||||
error = $state<string | null>(null);
|
error = $state<string | null>(null);
|
||||||
elapsedMs = $state(0);
|
elapsedMs = $state(0);
|
||||||
|
|
@ -41,7 +62,7 @@ class DreamRecorder {
|
||||||
async start(options: { force?: boolean } = {}): Promise<void> {
|
async start(options: { force?: boolean } = {}): Promise<void> {
|
||||||
if (this.status !== 'idle') return;
|
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.
|
// over plain http (except localhost), with no permission prompt.
|
||||||
if (!this.isSecureContext) {
|
if (!this.isSecureContext) {
|
||||||
const host = typeof window !== 'undefined' ? window.location.host : '';
|
const host = typeof window !== 'undefined' ? window.location.host : '';
|
||||||
|
|
@ -49,17 +70,17 @@ class DreamRecorder {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Browser API present?
|
|
||||||
if (!this.isAvailable) {
|
if (!this.isAvailable) {
|
||||||
this.error = 'Audio-Aufnahme wird in diesem Browser nicht unterstützt.';
|
this.error = 'Audio-Aufnahme wird in diesem Browser nicht unterstützt.';
|
||||||
return;
|
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.
|
// will silently reject getUserMedia without showing a prompt.
|
||||||
// On macOS this is most often a SYSTEM-level block, not a per-site
|
// On macOS this is most often a SYSTEM-level block, not a
|
||||||
// setting, which is why no lock icon helps. Skip the check if the
|
// per-site setting, which is why no lock icon helps. Skip the
|
||||||
// caller explicitly forces a retry to surface the real error.
|
// check if the caller explicitly forces a retry to surface the
|
||||||
|
// real error.
|
||||||
if (!options.force) {
|
if (!options.force) {
|
||||||
const stickyDenied = await this.#checkStickyDeny();
|
const stickyDenied = await this.#checkStickyDeny();
|
||||||
if (stickyDenied) {
|
if (stickyDenied) {
|
||||||
|
|
@ -186,7 +207,6 @@ class DreamRecorder {
|
||||||
|
|
||||||
async #checkStickyDeny(): Promise<boolean> {
|
async #checkStickyDeny(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Permissions API may not be available everywhere; treat as unknown.
|
|
||||||
const perms = (
|
const perms = (
|
||||||
navigator as Navigator & {
|
navigator as Navigator & {
|
||||||
permissions?: {
|
permissions?: {
|
||||||
|
|
@ -244,7 +264,14 @@ function pickSupportedMimeType(): string | null {
|
||||||
return 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 {
|
export function formatElapsed(ms: number): string {
|
||||||
const totalSec = Math.floor(ms / 1000);
|
const totalSec = Math.floor(ms / 1000);
|
||||||
|
|
@ -11,10 +11,9 @@
|
||||||
useAllDreams,
|
useAllDreams,
|
||||||
} from './queries';
|
} from './queries';
|
||||||
import { dreamsStore } from './stores/dreams.svelte';
|
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 { MOOD_COLORS, MOOD_LABELS, type Dream, type DreamMood, type SleepQuality } from './types';
|
||||||
import type { ViewProps } from '$lib/app-registry';
|
import type { ViewProps } from '$lib/app-registry';
|
||||||
import { requireAuth } from '$lib/auth/require-auth.svelte';
|
|
||||||
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
|
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
|
||||||
import { PencilSimple, PushPin, Trash } from '@mana/shared-icons';
|
import { PencilSimple, PushPin, Trash } from '@mana/shared-icons';
|
||||||
import SymbolsView from './views/SymbolsView.svelte';
|
import SymbolsView from './views/SymbolsView.svelte';
|
||||||
|
|
@ -182,56 +181,14 @@
|
||||||
const MOODS: DreamMood[] = ['angenehm', 'neutral', 'unangenehm', 'albtraum'];
|
const MOODS: DreamMood[] = ['angenehm', 'neutral', 'unangenehm', 'albtraum'];
|
||||||
|
|
||||||
// ── Voice capture ─────────────────────────────────────────
|
// ── Voice capture ─────────────────────────────────────────
|
||||||
let recError = $state<string | null>(null);
|
// All MediaRecorder + auth gating + error handling lives in
|
||||||
|
// <VoiceCaptureBar> in $lib/components/voice/. This module just
|
||||||
async function handleMicClick() {
|
// passes the host-specific bits via props and a callback.
|
||||||
recError = null;
|
async function handleVoiceComplete(blob: Blob, durationMs: number) {
|
||||||
if (dreamRecorder.status === 'recording') {
|
const dream = await dreamsStore.createFromVoice(blob, durationMs, 'de');
|
||||||
try {
|
// Open the dream so the user sees the transcript appear inline
|
||||||
const result = await dreamRecorder.stop();
|
viewMode = 'list';
|
||||||
if (result.durationMs < 500) {
|
startEdit(dream);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -259,39 +216,12 @@
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Voice capture -->
|
<!-- Voice capture -->
|
||||||
<div class="capture-row">
|
<VoiceCaptureBar
|
||||||
<button
|
idleLabel="Traum sprechen"
|
||||||
class="mic-btn"
|
feature="dreams-voice-capture"
|
||||||
class:recording={dreamRecorder.status === 'recording'}
|
reason="Sprach-Aufnahmen werden verschlüsselt in deinem persönlichen Tagebuch gespeichert. Dafür brauchst du ein Mana-Konto."
|
||||||
class:busy={dreamRecorder.status === 'requesting' || dreamRecorder.status === 'stopping'}
|
onComplete={handleVoiceComplete}
|
||||||
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}
|
|
||||||
|
|
||||||
<!-- Quick create -->
|
<!-- Quick create -->
|
||||||
<form onsubmit={(e) => e.preventDefault()} class="quick-add">
|
<form onsubmit={(e) => e.preventDefault()} class="quick-add">
|
||||||
|
|
@ -573,119 +503,7 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Voice capture ─────────────────────────── */
|
/* Voice capture styles live in $lib/components/voice/VoiceCaptureBar.svelte */
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── View Tabs ─────────────────────────────── */
|
/* ── View Tabs ─────────────────────────────── */
|
||||||
.view-tabs {
|
.view-tabs {
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,23 @@
|
||||||
import { db } from '$lib/data/database';
|
import { db } from '$lib/data/database';
|
||||||
import type { ViewProps } from '$lib/app-registry';
|
import type { ViewProps } from '$lib/app-registry';
|
||||||
import type { LocalMemo } from './types';
|
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 { navigate, goBack, params }: ViewProps = $props();
|
||||||
|
|
||||||
let memos = $state<LocalMemo[]>([]);
|
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(() => {
|
$effect(() => {
|
||||||
const sub = liveQuery(async () => {
|
const sub = liveQuery(async () => {
|
||||||
return db
|
return db
|
||||||
|
|
@ -47,6 +59,13 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col gap-3 p-3 sm:p-4">
|
<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">
|
<div class="flex gap-3 text-xs text-white/40">
|
||||||
<span>{memos.length} Memos</span>
|
<span>{memos.length} Memos</span>
|
||||||
<span>{pinned.length} angepinnt</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 { goto } from '$app/navigation';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { memosStore } from '$lib/modules/memoro/stores/memos.svelte';
|
import { memosStore } from '$lib/modules/memoro/stores/memos.svelte';
|
||||||
import { memoRecorder, formatElapsed } from '$lib/modules/memoro/recorder.svelte';
|
import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte';
|
||||||
import { requireAuth } from '$lib/auth/require-auth.svelte';
|
|
||||||
import {
|
import {
|
||||||
filterBySearch,
|
filterBySearch,
|
||||||
filterByTag,
|
filterByTag,
|
||||||
|
|
@ -46,51 +45,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Voice capture ─────────────────────────────────────────
|
// ── Voice capture ─────────────────────────────────────────
|
||||||
let recError = $state<string | null>(null);
|
async function handleVoiceComplete(blob: Blob, durationMs: number) {
|
||||||
|
const memo = await memosStore.createFromVoice(blob, durationMs, 'de');
|
||||||
async function handleMicClick() {
|
goto(`/memoro/${memo.id}`);
|
||||||
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 handlePin(e: Event, id: string, isPinned: boolean) {
|
async function handlePin(e: Event, id: string, isPinned: boolean) {
|
||||||
|
|
@ -144,41 +101,14 @@
|
||||||
<TagIcon size={16} />
|
<TagIcon size={16} />
|
||||||
Tags
|
Tags
|
||||||
</a>
|
</a>
|
||||||
<button
|
<div class="w-64">
|
||||||
onclick={handleMicClick}
|
<VoiceCaptureBar
|
||||||
disabled={memoRecorder.status === 'requesting' || memoRecorder.status === 'stopping'}
|
idleLabel="Memo aufnehmen"
|
||||||
aria-label={memoRecorder.status === 'recording' ? 'Aufnahme beenden' : 'Aufnahme starten'}
|
feature="memoro-voice-capture"
|
||||||
class="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-60"
|
reason="Sprach-Memos werden verschlüsselt gespeichert. Dafür brauchst du ein Mana-Konto."
|
||||||
class:recording={memoRecorder.status === 'recording'}
|
onComplete={handleVoiceComplete}
|
||||||
style:background-color={memoRecorder.status === 'recording'
|
/>
|
||||||
? '#ef4444'
|
</div>
|
||||||
: '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}
|
|
||||||
<button
|
<button
|
||||||
onclick={handleNewMemo}
|
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"
|
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>
|
||||||
</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 -->
|
<!-- Search -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<MagnifyingGlass
|
<MagnifyingGlass
|
||||||
|
|
@ -356,23 +275,3 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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