/** * IndexedDB wrapper — singleton connection, never closed during app lifetime. * * All frames packed into a single Blob per recording to avoid * multi-blob transaction issues. * * Binary format inside `data`: * [4B BE uint32 frameCount] * Per frame: [4B BE uint32 timestamp] [4B BE uint32 jpegLen] [JPEG bytes] */ export interface Frame { timestamp: number blob: Blob } export interface Recording { id: string name: string createdAt: Date duration: number frameCount: number data: Blob } const DB_NAME = 'mjpeg-viewer' const DB_VERSION = 2 const STORE_NAME = 'recordings' // ---- Singleton connection ---- let db: IDBDatabase | null = null let dbPromise: Promise | null = null function getDB(): Promise { if (db) return Promise.resolve(db) if (dbPromise) return dbPromise dbPromise = new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION) request.onupgradeneeded = () => { const d = request.result if (d.objectStoreNames.contains(STORE_NAME)) { d.deleteObjectStore(STORE_NAME) } d.createObjectStore(STORE_NAME, { keyPath: 'id' }) } request.onsuccess = () => { db = request.result // If connection is lost (browser closes it), reset db.onclose = () => { db = null dbPromise = null } resolve(db) } request.onerror = () => { dbPromise = null reject(request.error) } request.onblocked = () => { // A previous connection is still open — this shouldn't happen // with singleton pattern, but if it does, reject to trigger retry dbPromise = null reject(new Error('Database blocked by another connection')) } }) return dbPromise } // ---- Binary pack / unpack ---- export function packFrames(frames: { timestamp: number; blob: Blob }[]): Blob { const headerSize = 4 + frames.length * 8 const header = new ArrayBuffer(headerSize) const view = new DataView(header) view.setUint32(0, frames.length) let offset = 4 const parts: BlobPart[] = [header] for (const f of frames) { view.setUint32(offset, f.timestamp) view.setUint32(offset + 4, f.blob.size) offset += 8 parts.push(f.blob) } return new Blob(parts, { type: 'application/octet-stream' }) } export async function unpackFrames(data: Blob): Promise { const buf = await data.arrayBuffer() const view = new DataView(buf) const frameCount = view.getUint32(0) const frames: Frame[] = [] let offset = 4 for (let i = 0; i < frameCount; i++) { const timestamp = view.getUint32(offset) const jpegLen = view.getUint32(offset + 4) offset += 8 frames.push({ timestamp, blob: new Blob([buf.slice(offset, offset + jpegLen)], { type: 'image/jpeg' }), }) offset += jpegLen } return frames } // ---- CRUD ---- export async function saveRecording(recording: Recording): Promise { const d = await getDB() return new Promise((resolve, reject) => { const tx = d.transaction(STORE_NAME, 'readwrite') const store = tx.objectStore(STORE_NAME) store.put({ ...recording, createdAt: recording.createdAt.toISOString(), }) tx.oncomplete = () => resolve() tx.onerror = () => reject(tx.error) tx.onabort = () => reject(new Error('Transaction aborted')) }) } export async function getAllRecordings(): Promise { const d = await getDB() return new Promise((resolve, reject) => { const tx = d.transaction(STORE_NAME, 'readonly') const store = tx.objectStore(STORE_NAME) const request = store.getAll() request.onsuccess = () => { const results = (request.result || []).map((r: any) => ({ ...r, createdAt: new Date(r.createdAt), })) results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) resolve(results) } request.onerror = () => reject(request.error) }) } export async function deleteRecording(id: string): Promise { const d = await getDB() return new Promise((resolve, reject) => { const tx = d.transaction(STORE_NAME, 'readwrite') const store = tx.objectStore(STORE_NAME) store.delete(id) tx.oncomplete = () => resolve() tx.onerror = () => reject(tx.error) }) }