diff --git a/app/api/gallery-unpack/route.ts b/app/api/gallery-unpack/route.ts index a2cfa78..8e1703c 100644 --- a/app/api/gallery-unpack/route.ts +++ b/app/api/gallery-unpack/route.ts @@ -4,8 +4,8 @@ export const runtime = "nodejs"; // Cap a bit above pack's MAX_DOC_BYTES — ciphertext adds the 16-byte GCM tag // and the 17-byte header; some slack accommodates near-cap docs without -// rejecting them at unpack time. -const MAX_FILE_BYTES = 6_000_000; +// rejecting them at unpack time. Bumped to fit pre-baked beat audio. +const MAX_FILE_BYTES = 13_000_000; // Decrypt a `.infiplot` share file back to its doc JSON string. Returns the // plaintext as a JSON field (not raw bytes) so the client can chain it through diff --git a/app/gallery/page.tsx b/app/gallery/page.tsx index d79361f..b37556c 100644 --- a/app/gallery/page.tsx +++ b/app/gallery/page.tsx @@ -57,8 +57,11 @@ export type GalleryScene = { }; export type GalleryDoc = { - /** v1 = scenes only (initial export). v2 = + alternates + characters. */ - v: 1 | 2; + /** v1 = scenes only (initial export). v2 = + alternates + characters. + * v3 = + beat audio (stored in a sidecar localStorage key so the main + * doc stays small and the first paint isn't blocked by JSON.parse-ing + * several MB of base64). */ + v: 1 | 2 | 3; id: string; createdAt: number; orientation: Orientation; @@ -71,13 +74,18 @@ export type GalleryDoc = { }; const STORAGE_PREFIX = "infiplot:gallery:"; +const AUDIO_SUFFIX = ":audio"; +const MUTED_STORAGE_KEY = "infiplot:gallery:muted"; function readDoc(id: string): GalleryDoc | null { try { const raw = window.localStorage.getItem(STORAGE_PREFIX + id); if (!raw) return null; const parsed = JSON.parse(raw) as GalleryDoc; - if ((parsed.v !== 1 && parsed.v !== 2) || !Array.isArray(parsed.scenes)) { + if ( + (parsed.v !== 1 && parsed.v !== 2 && parsed.v !== 3) || + !Array.isArray(parsed.scenes) + ) { return null; } return parsed; @@ -86,6 +94,23 @@ function readDoc(id: string): GalleryDoc | null { } } +function readSidecarAudio(id: string): Record { + try { + const raw = window.localStorage.getItem( + STORAGE_PREFIX + id + AUDIO_SUFFIX, + ); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(parsed)) { + if (typeof v === "string" && v.startsWith("data:")) out[k] = v; + } + return out; + } catch { + return {}; + } +} + function detectOrientation(): Orientation { if (typeof window === "undefined") return "landscape"; const portrait = window.matchMedia("(orientation: portrait)").matches; @@ -352,6 +377,8 @@ function Slide({ beatId, orientation, alternates, + audioByBeatId, + muted, dialogueOpen, setDialogueOpen, onAdvanceBeat, @@ -361,6 +388,8 @@ function Slide({ beatId: string; orientation: Orientation; alternates: Record; + audioByBeatId: Record; + muted: boolean; dialogueOpen: boolean; setDialogueOpen: (b: boolean) => void; onAdvanceBeat: (nextBeatId: string) => void; @@ -372,6 +401,24 @@ function Slide({ const beat = findBeat(scene, beatId) ?? findBeat(scene, scene.entryBeatId); + const audioSrc = + beat && scene.id && !muted + ? (audioByBeatId[`${scene.id}:${beat.id}`] ?? null) + : null; + const audioRef = useRef(null); + useEffect(() => { + const el = audioRef.current; + if (!el) return; + if (!audioSrc) { + el.pause(); + return; + } + el.currentTime = 0; + void el.play().catch(() => { + // Browsers can refuse autoplay until user interacts — silent fail is fine. + }); + }, [audioSrc]); + const choices: BeatChoice[] = beat?.next.type === "choice" ? (beat.next as { type: "choice"; choices: BeatChoice[] }).choices @@ -533,6 +580,16 @@ function Slide({ onClose={() => setDialogueOpen(false)} /> )} + + {audioSrc && ( +