import { ref, type Ref } from 'vue' import { saveRecording, packFrames, type Recording } from './useStorage' export type RecorderState = 'idle' | 'recording' | 'saving' const SOI = new Uint8Array([0xff, 0xd8]) const EOI = new Uint8Array([0xff, 0xd9]) function indexOf(haystack: Uint8Array, needle: Uint8Array, from = 0): number { outer: for (let i = from; i <= haystack.length - needle.length; i++) { for (let j = 0; j < needle.length; j++) { if (haystack[i + j] !== needle[j]) continue outer } return i } return -1 } async function* parseMjpegByMarkers( response: Response, ): AsyncGenerator<{ blob: Blob; timestamp: number }> { if (!response.body) throw new Error('Response body is null') const reader = response.body.getReader() let buffer = new Uint8Array(0) const startTime = Date.now() try { while (true) { const { done, value } = await reader.read() if (done) break const newBuf = new Uint8Array(buffer.length + value.length) newBuf.set(buffer) newBuf.set(value, buffer.length) buffer = newBuf while (true) { const soi = indexOf(buffer, SOI) if (soi === -1) break const eoi = indexOf(buffer, EOI, soi + 2) if (eoi === -1) break const jpegData = buffer.subarray(soi, eoi + 2) if (jpegData.length > 100) { yield { blob: new Blob([jpegData], { type: 'image/jpeg' }), timestamp: Date.now() - startTime } } buffer = buffer.subarray(eoi + 2) } } } finally { reader.releaseLock() } } export function useRecorder() { const recorderState: Ref = ref('idle') const elapsedMs = ref(0) const frameCount = ref(0) const recorderError: Ref = ref(null) let frames: { timestamp: number; blob: Blob }[] = [] let startTime = 0 let timerInterval: ReturnType | null = null let abortController: AbortController | null = null let running = false function reset() { running = false if (abortController) { abortController.abort() abortController = null } if (timerInterval) { clearInterval(timerInterval) timerInterval = null } frames = [] frameCount.value = 0 elapsedMs.value = 0 recorderState.value = 'idle' } async function startRecording(streamUrl: string) { reset() startTime = Date.now() recorderState.value = 'recording' recorderError.value = null running = true timerInterval = setInterval(() => { if (running) elapsedMs.value = Date.now() - startTime }, 100) abortController = new AbortController() try { const response = await fetch(streamUrl, { signal: abortController.signal, cache: 'no-store', }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } for await (const frame of parseMjpegByMarkers(response)) { if (!running) break frames.push({ timestamp: frame.timestamp, blob: frame.blob }) frameCount.value = frames.length } } catch (e: any) { if (e.name === 'AbortError') return recorderError.value = e.message || String(e) // Don't reset state here — user may still want to stop and see the error } } async function stopRecording(): Promise { if (recorderState.value !== 'recording') { if (recorderState.value === 'saving') return null reset() return null } recorderState.value = 'saving' running = false if (abortController) { abortController.abort() abortController = null } if (timerInterval) { clearInterval(timerInterval) timerInterval = null } elapsedMs.value = Date.now() - startTime const savedFrames = [...frames] frames = [] // Safety: force back to idle after 5s even if IndexedDB hangs const safetyTimer = setTimeout(() => { if (recorderState.value === 'saving') { recorderError.value = 'Save timed out' recorderState.value = 'idle' } }, 5000) if (savedFrames.length === 0) { clearTimeout(safetyTimer) recorderError.value = 'No frames captured — check that the stream is accessible' recorderState.value = 'idle' return null } const recording: Recording = { id: crypto.randomUUID(), name: `Recording ${new Date().toLocaleString()}`, createdAt: new Date(), duration: elapsedMs.value, frameCount: savedFrames.length, data: packFrames(savedFrames), } try { await saveRecording(recording) recorderError.value = null return recording } catch (e: any) { recorderError.value = `Save failed: ${e.message || e}` return null } finally { clearTimeout(safetyTimer) recorderState.value = 'idle' } } function cancelRecording() { reset() recorderError.value = null } function formatElapsed(ms: number): string { const totalSec = Math.floor(Math.max(0, ms) / 1000) const min = Math.floor(totalSec / 60) const sec = totalSec % 60 const centis = Math.floor((Math.max(0, ms) % 1000) / 10) return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(centis).padStart(2, '0')}` } return { recorderState, elapsedMs, frameCount, recorderError, startRecording, stopRecording, cancelRecording, formatElapsed, } }