Grabby-Voice-Classic / src /lib /recordingStore.ts
moonlantern1's picture
Build Foodstar user journal flow
d99b1af
/**
* 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<number, RecordedClip>;
orderedClips: RecordedClip[];
clipUploadsByStep: Record<number, ClipUploadState>;
skippedSteps: Record<number, true>;
socialHandle: string;
instagramHandle: string;
tiktokHandle: string;
placeName: string;
dishName: string;
tableId: string | null;
slug: string | null;
sessionId: string;
};
let clipsByStep: Record<number, RecordedClip> = {};
let clipUploadsByStep: Record<number, ClipUploadState> = {};
let clipUploadPromises: Record<number, { takeId: number; promise: Promise<UploadedClipRef> }> = {};
let takeIdsByStep: Record<number, number> = {};
let skippedSteps: Record<number, true> = {};
let socialHandle = '';
let instagramHandle = '';
let tiktokHandle = '';
let placeName = '';
let dishName = '';
let tableId: string | null = null;
let slug: string | null = null;
let sessionId = newSessionId();
const listeners = new Set<Listener>();
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,
instagramHandle,
tiktokHandle,
placeName,
dishName,
tableId,
slug,
sessionId,
};
}
export const recordingStore = {
setClip(clip: Omit<RecordedClip, 'takeId'>) {
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<UploadedClipRef>) {
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;
instagramHandle?: string;
tiktokHandle?: string;
placeName?: string;
dishName?: string;
}) {
if (meta.slug !== undefined) slug = meta.slug;
if (meta.tableId !== undefined) tableId = meta.tableId;
if (meta.socialHandle !== undefined) socialHandle = meta.socialHandle;
if (meta.instagramHandle !== undefined) instagramHandle = meta.instagramHandle;
if (meta.tiktokHandle !== undefined) tiktokHandle = meta.tiktokHandle;
if (meta.placeName !== undefined) placeName = meta.placeName;
if (meta.dishName !== undefined) dishName = meta.dishName;
emit();
},
reset() {
clipsByStep = {};
clipUploadsByStep = {};
clipUploadPromises = {};
takeIdsByStep = {};
skippedSteps = {};
socialHandle = '';
instagramHandle = '';
tiktokHandle = '';
placeName = '';
dishName = '';
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<Snapshot>(() => buildSnapshot());
useEffect(() => {
const unsubscribe = recordingStore.subscribe(() => setSnap(buildSnapshot()));
return () => {
unsubscribe();
};
}, []);
return snap;
}