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:
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;
|
||||
}
|
||||
Reference in new issue
Block a user