/** * IndexedDB Recording Persistence * Handles saving recording chunks to IndexedDB for crash recovery */ const DB_NAME = 'SpeakrRecordings'; const DB_VERSION = 1; const STORE_NAME = 'activeRecording'; let dbInstance = null; /** * Helper to promisify IDBRequest */ const promisifyRequest = (request) => { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); }; /** * Initialize IndexedDB */ export const initDB = () => { return new Promise((resolve, reject) => { if (dbInstance) { resolve(dbInstance); return; } const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => { console.error('[RecordingDB] Failed to open database:', request.error); reject(request.error); }; request.onsuccess = () => { dbInstance = request.result; console.log('[RecordingDB] Database opened successfully'); resolve(dbInstance); }; request.onupgradeneeded = (event) => { const db = event.target.result; // Create object store for active recording if (!db.objectStoreNames.contains(STORE_NAME)) { const objectStore = db.createObjectStore(STORE_NAME, { keyPath: 'id' }); objectStore.createIndex('timestamp', 'timestamp', { unique: false }); console.log('[RecordingDB] Object store created'); } }; }); }; /** * Save recording metadata and initialize session */ export const startRecordingSession = async (recordingData) => { try { const db = await initDB(); const transaction = db.transaction([STORE_NAME], 'readwrite'); const objectStore = transaction.objectStore(STORE_NAME); const session = { id: 'current', timestamp: Date.now(), startTime: new Date().toISOString(), mode: recordingData.mode, notes: recordingData.notes || '', tags: recordingData.tags || [], asrOptions: recordingData.asrOptions || {}, chunks: [], mimeType: recordingData.mimeType || 'audio/webm', duration: 0 }; await promisifyRequest(objectStore.put(session)); console.log('[RecordingDB] Recording session started:', session.id); return session; } catch (error) { console.error('[RecordingDB] Failed to start session:', error); throw error; } }; /** * Save a recording chunk to IndexedDB */ export const saveChunk = async (chunkBlob, chunkIndex) => { try { // Do async prep work BEFORE creating transaction to avoid auto-close const db = await initDB(); const arrayBuffer = await chunkBlob.arrayBuffer(); // Now create transaction and do all DB operations quickly const transaction = db.transaction([STORE_NAME], 'readwrite'); const objectStore = transaction.objectStore(STORE_NAME); // Get current session const session = await promisifyRequest(objectStore.get('current')); if (!session) { console.warn('[RecordingDB] No active session found'); return; } // Add chunk to session session.chunks.push({ index: chunkIndex, data: arrayBuffer, size: chunkBlob.size, timestamp: Date.now() }); // Update session - must happen before transaction auto-closes await promisifyRequest(objectStore.put(session)); // Chunk saved silently to avoid spam (happens every 5 seconds) } catch (error) { console.error('[RecordingDB] Failed to save chunk:', error); // Don't throw - recording should continue even if persistence fails } }; /** * Update recording metadata (notes, duration, etc.) */ export const updateRecordingMetadata = async (updates) => { try { const db = await initDB(); const transaction = db.transaction([STORE_NAME], 'readwrite'); const objectStore = transaction.objectStore(STORE_NAME); const session = await promisifyRequest(objectStore.get('current')); if (!session) { console.warn('[RecordingDB] No active session to update'); return; } // Merge updates Object.assign(session, updates); await promisifyRequest(objectStore.put(session)); // Metadata updated silently to avoid spam (happens every 5 seconds) } catch (error) { console.error('[RecordingDB] Failed to update metadata:', error); } }; /** * Check if there's a recoverable recording */ export const checkForRecoverableRecording = async () => { try { const db = await initDB(); const transaction = db.transaction([STORE_NAME], 'readonly'); const objectStore = transaction.objectStore(STORE_NAME); const session = await promisifyRequest(objectStore.get('current')); if (!session || !session.chunks || session.chunks.length === 0) { return null; } // Calculate total size const totalSize = session.chunks.reduce((sum, chunk) => sum + chunk.size, 0); // Calculate approximate duration (1 second chunks) const duration = session.chunks.length; console.log('[RecordingDB] Found recoverable recording:', { chunks: session.chunks.length, size: totalSize, duration: duration, startTime: session.startTime }); return { ...session, totalSize, duration: duration }; } catch (error) { console.error('[RecordingDB] Failed to check for recoverable recording:', error); return null; } }; /** * Recover recording from IndexedDB */ export const recoverRecording = async () => { try { const db = await initDB(); const transaction = db.transaction([STORE_NAME], 'readonly'); const objectStore = transaction.objectStore(STORE_NAME); const session = await promisifyRequest(objectStore.get('current')); if (!session || !session.chunks || session.chunks.length === 0) { console.warn('[RecordingDB] No recording to recover'); return null; } // Convert chunks back to Blobs const chunks = session.chunks.map(chunk => { return new Blob([chunk.data], { type: session.mimeType }); }); console.log(`[RecordingDB] Recovered ${chunks.length} chunks`); return { chunks, metadata: { mode: session.mode, notes: session.notes, tags: session.tags, asrOptions: session.asrOptions, mimeType: session.mimeType, duration: session.chunks.length, startTime: session.startTime } }; } catch (error) { console.error('[RecordingDB] Failed to recover recording:', error); return null; } }; /** * Clear recording session (after successful upload or discard) */ export const clearRecordingSession = async () => { try { const db = await initDB(); const transaction = db.transaction([STORE_NAME], 'readwrite'); const objectStore = transaction.objectStore(STORE_NAME); await promisifyRequest(objectStore.delete('current')); console.log('[RecordingDB] Recording session cleared'); } catch (error) { console.error('[RecordingDB] Failed to clear session:', error); } }; /** * Get database size information */ export const getDatabaseSize = async () => { try { if (!navigator.storage || !navigator.storage.estimate) { return null; } const estimate = await navigator.storage.estimate(); return { usage: estimate.usage, quota: estimate.quota, percentage: ((estimate.usage / estimate.quota) * 100).toFixed(2) }; } catch (error) { console.error('[RecordingDB] Failed to get database size:', error); return null; } };