/** * Module-level store for the customer's session of recorded clips. * * Lives in memory only — survives across route navigations within a single tab, * doesn't survive a hard reload (and shouldn't, because the customer would have * to re-grant camera permission anyway). * * Browser-only. Importing on the server is a no-op. */ import { useEffect, useState } from 'react'; import type { UploadedClipRef } from '@/lib/humeoApi'; export type RecordedClip = { step: number; takeId: number; mediaType: 'video' | 'audio'; blob: Blob; durationSeconds: number; ext: 'webm' | 'mp4' | 'mov'; needsOptimization?: boolean; }; export type ClipUploadState = | { status: 'idle' } | { status: 'pending'; takeId: number } | { status: 'uploaded'; takeId: number; ref: UploadedClipRef } | { status: 'error'; takeId: number; message: string }; type Listener = () => void; type Snapshot = { clipsByStep: Record; orderedClips: RecordedClip[]; clipUploadsByStep: Record; skippedSteps: Record; socialHandle: string; tableId: string | null; slug: string | null; sessionId: string; }; let clipsByStep: Record = {}; let clipUploadsByStep: Record = {}; let clipUploadPromises: Record }> = {}; let takeIdsByStep: Record = {}; let skippedSteps: Record = {}; let socialHandle = ''; let tableId: string | null = null; let slug: string | null = null; let sessionId = newSessionId(); const listeners = new Set(); function newSessionId() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return `sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; } function emit() { for (const l of listeners) l(); } function buildSnapshot(): Snapshot { return { clipsByStep, orderedClips: Object.values(clipsByStep).sort((a, b) => a.step - b.step), clipUploadsByStep, skippedSteps, socialHandle, tableId, slug, sessionId, }; } export const recordingStore = { setClip(clip: Omit) { const takeId = (takeIdsByStep[clip.step] ?? 0) + 1; takeIdsByStep = { ...takeIdsByStep, [clip.step]: takeId }; const recordedClip = { ...clip, takeId }; clipsByStep = { ...clipsByStep, [clip.step]: recordedClip }; const nextUploads = { ...clipUploadsByStep }; delete nextUploads[clip.step]; clipUploadsByStep = nextUploads; const nextPromises = { ...clipUploadPromises }; delete nextPromises[clip.step]; clipUploadPromises = nextPromises; const nextSkipped = { ...skippedSteps }; delete nextSkipped[clip.step]; skippedSteps = nextSkipped; emit(); return recordedClip; }, startClipUpload(step: number, takeId: number, promise: Promise) { clipUploadPromises = { ...clipUploadPromises, [step]: { takeId, promise } }; clipUploadsByStep = { ...clipUploadsByStep, [step]: { status: 'pending', takeId } }; emit(); promise .then((ref) => { const activeUpload = clipUploadPromises[step]; if (activeUpload?.promise !== promise || activeUpload.takeId !== takeId) return; clipUploadsByStep = { ...clipUploadsByStep, [step]: { status: 'uploaded', takeId, ref } }; emit(); }) .catch((err) => { const activeUpload = clipUploadPromises[step]; if (activeUpload?.promise !== promise || activeUpload.takeId !== takeId) return; clipUploadsByStep = { ...clipUploadsByStep, [step]: { status: 'error', takeId, message: err instanceof Error ? err.message : 'Clip upload failed', }, }; emit(); }); }, getClipUploadPromise(step: number, takeId: number) { const activeUpload = clipUploadPromises[step]; return activeUpload?.takeId === takeId ? activeUpload.promise : null; }, getClipUploadState(step: number, takeId: number): ClipUploadState { const state = clipUploadsByStep[step]; if (!state || state.status === 'idle') return { status: 'idle' }; return state.takeId === takeId ? state : { status: 'idle' }; }, removeClip(step: number) { const next = { ...clipsByStep }; delete next[step]; clipsByStep = next; const nextUploads = { ...clipUploadsByStep }; delete nextUploads[step]; clipUploadsByStep = nextUploads; const nextPromises = { ...clipUploadPromises }; delete nextPromises[step]; clipUploadPromises = nextPromises; emit(); }, skipStep(step: number) { const nextClips = { ...clipsByStep }; delete nextClips[step]; clipsByStep = nextClips; const nextUploads = { ...clipUploadsByStep }; delete nextUploads[step]; clipUploadsByStep = nextUploads; const nextPromises = { ...clipUploadPromises }; delete nextPromises[step]; clipUploadPromises = nextPromises; skippedSteps = { ...skippedSteps, [step]: true }; emit(); }, setMeta(meta: { slug?: string; tableId?: string | null; socialHandle?: string }) { if (meta.slug !== undefined) slug = meta.slug; if (meta.tableId !== undefined) tableId = meta.tableId; if (meta.socialHandle !== undefined) socialHandle = meta.socialHandle; emit(); }, reset() { clipsByStep = {}; clipUploadsByStep = {}; clipUploadPromises = {}; takeIdsByStep = {}; skippedSteps = {}; socialHandle = ''; tableId = null; slug = null; sessionId = newSessionId(); emit(); }, snapshot(): Snapshot { return buildSnapshot(); }, subscribe(listener: Listener) { listeners.add(listener); return () => { listeners.delete(listener); }; }, }; export function useRecordingStore(): Snapshot { const [snap, setSnap] = useState(() => buildSnapshot()); useEffect(() => { const unsubscribe = recordingStore.subscribe(() => setSnap(buildSnapshot())); return () => { unsubscribe(); }; }, []); return snap; }