feat: Aufnahme und lokaler Speicher für Sprachnotizen (T7 L5 Task 17)

Recorder (webm/opus oder mp4, 24 kbit/s, Warnung bei 150 s, Stopp bei
180 s oder im Hintergrund; Mikrofon und Wake-Lock werden in jedem Fall
freigegeben, auch bei Fehlern und ausgestecktem Mikrofon) und
IndexedDB v6 mit Store pending-voice (Index ownerUserId); volle
Speicherquote ergibt VoiceStorageFullError ohne Datenverlust; Abmelden
löscht nur die eigenen Notizen.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 10:47:45 +02:00
1 parent 1dc39f7658
commit ce472164b5
6 files changed
+829 -11

No files matched your search

@@ -0,0 +1,100 @@
import { getDb, type StoredVoiceNote } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
export type { StoredVoiceNote } from "@/lib/offline/db";
/*
* Voice notes recorded on this device. The audio stays here until the sync
* uploads it and is deleted on success, on discard and when the account signs
* out (db.clearQueueOf). It is never logged and never leaves the device
* before the user's upload.
*/
/** The device had no room left for another recording. */
export class VoiceStorageFullError extends Error {
constructor() {
super("Auf dem Gerät ist kein Platz mehr für die Aufnahme.");
this.name = "VoiceStorageFullError";
}
}
export type NewVoiceNote = Omit<
StoredVoiceNote,
"id" | "createdAt" | "uploadIdempotencyKey" | "state"
>;
let persistAsked = false;
/** Asks once per page for storage the browser will not clear on its own. Best effort. */
async function askForPersistentStorage(): Promise<void> {
if (persistAsked) {
return;
}
persistAsked = true;
try {
await navigator.storage?.persist?.();
} catch {
// Without persistent storage the note is still saved; the browser may clear it under pressure.
}
}
function isQuotaError(error: unknown): boolean {
return error instanceof DOMException && error.name === "QuotaExceededError";
}
export async function queueVoiceNote(input: NewVoiceNote): Promise<StoredVoiceNote> {
await askForPersistentStorage();
const note: StoredVoiceNote = {
...input,
id: newIdempotencyKey(),
createdAt: new Date().toISOString(),
uploadIdempotencyKey: newIdempotencyKey(),
state: "recorded",
};
const db = await getDb();
try {
await db.add("pending-voice", note);
} catch (error) {
if (isQuotaError(error)) {
throw new VoiceStorageFullError();
}
throw error;
}
return note;
}
/** The signed-in user's notes, oldest first. Another account's notes never appear. */
export async function listPendingVoice(currentUserId: string): Promise<StoredVoiceNote[]> {
const db = await getDb();
const notes = await db.getAllFromIndex("pending-voice", "ownerUserId", currentUserId);
return notes.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
/** How many notes of other accounts wait on this device - shown as a number only. */
export async function countForeignVoice(currentUserId: string): Promise<number> {
const db = await getDb();
const all = await db.getAllKeys("pending-voice");
const own = await db.countFromIndex("pending-voice", "ownerUserId", currentUserId);
return all.length - own;
}
/** Deletes one note, but only if it belongs to `ownerUserId`. */
export async function discardPendingVoice(id: string, ownerUserId: string): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-voice", "readwrite");
const note = await tx.store.get(id);
if (note?.ownerUserId === ownerUserId) {
await tx.store.delete(id);
}
await tx.done;
}
/** Deletes every note one account recorded on this device; returns how many. */
export async function discardAllPendingVoice(ownerUserId: string): Promise<number> {
const db = await getDb();
const tx = db.transaction("pending-voice", "readwrite");
const ids = await tx.store.index("ownerUserId").getAllKeys(ownerUserId);
await Promise.all(ids.map((id) => tx.store.delete(id)));
await tx.done;
return ids.length;
}