"use client"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { PlayCanvas, type Phase, } from "@/components/PlayCanvas"; import type { DialogueHistoryItem } from "@/components/DialogueHistoryModal"; import type { GalleryDoc, GalleryScene } from "@/app/[locale]/gallery/page"; import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal"; import { annotateClick } from "@/lib/annotateClient"; import { loadClientTtsConfig } from "@/lib/clientTtsConfig"; import { collectBeatAudioForExport } from "@/lib/exportAudio"; import { saveStory, loadStorySession } from "@/lib/clientStoryPersistence"; import { PRESETS } from "@/lib/presets"; import { STORY_SHARE_STORAGE_KEY, createStoryShareDoc, parseStoryShareDoc, storyShareFilename, } from "@/lib/storyShare"; import { provisionVoice, synthesize } from "@infiplot/tts-client"; import { startSession, requestScene, visionDecide, requestInsertBeat, getTtsProvider, AuthRequiredError, } from "@/lib/engineClient"; import type { Beat, BeatChoice, Character, CharacterVoice, Orientation, Scene, SceneExit, SceneResponse, Session, StartResponse, TtsConfig, TtsProvider, } from "@infiplot/types"; import { coerceOrientation } from "@infiplot/types"; import { track } from "@/lib/analytics"; import { AUTH_ENABLED } from "@/lib/supabase/config"; import { writeResumeSnapshot, consumeResumeSnapshot } from "@/lib/authResume"; import { AuthModal } from "@/components/AuthModal"; import { UserChip } from "@/components/UserChip"; import { useI18n } from "@/lib/i18n/client"; import { useLocalePath } from "@/lib/i18n/hooks"; const MUTED_STORAGE_KEY = "infiplot:muted"; // One-shot snapshot of in-progress game state, written just before an OAuth // full-page redirect (Google/GitHub) so the play page can resume the exact // scene/beat after the round-trip. The redirect unmounts the app and destroys // the in-memory Session (the server is stateless), so without this the play // page re-bootstraps from `?card=…` and restarts the story. OTP login keeps // state in-memory (no redirect) and never writes this. Consumed once on mount. const PLAY_RESUME_KEY = "infiplot:play-resume"; // Serializable form of the action intercepted by a 401. `persistPlayResume` // stashes whichever one is pending into sessionStorage; the deferred-replay // effect re-dispatches it after `restorePlayResume` commits the restored state. type PendingResumeAction = | { kind: "choice"; choice: BeatChoice } | { kind: "freeform"; text: string } | { kind: "background-click"; x: number; y: number }; // Shape written to sessionStorage[PLAY_RESUME_KEY]. `imageOriginalUrl` is the // remote CDN URL (never the blob: URL — those are revoked on unmount and won't // survive the full-page reload); restorePlayResume re-resolves it to a fresh // blob via getOrCreateBlobUrl. type PlayResumeSnapshot = { session: Session; beatId: string; visitedBeats: string[]; orientation: Orientation; imageOriginalUrl: string; pendingAction?: PendingResumeAction; }; // Consecutive silent (no-audio) beats before we surface the BYO-key nudge to a // non-BYO, unmuted player. Set high enough that one transient miss won't trip // it, low enough to catch a scene that's clearly being rate-limited. const SILENCE_NUDGE_THRESHOLD = 3; // Mobile-portrait users get a 9:16 scene image painted for them; everyone else // (desktop, tablet, mobile-landscape) keeps the 16:9 landscape image. Only a // touch device (coarse pointer) held upright counts as "portrait" — a mouse // device is always landscape. Detected once and locked for the whole session. function detectOrientation(): Orientation { if (typeof window === "undefined") return "landscape"; const portrait = window.matchMedia("(orientation: portrait)").matches; const coarse = window.matchMedia("(pointer: coarse)").matches; return portrait && coarse ? "portrait" : "landscape"; } // Runs before the browser paints (so it can correct first-frame state without a // visible flash), but useLayoutEffect warns when called during SSR. PlayInner // only ever renders on the client (/play prerenders the Suspense fallback), yet // fall back to useEffect on the server anyway to keep the warning out. const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; // Cap how long we wait for the browser to download + decode a scene image // before giving up and rendering anyway. Runware's CDN is usually <2s for a // 1792×1024 PNG, but over slow links / VPN / strict corp networks the same // download can stretch to 10-20s. The previous 8s ceiling fired in that // window, and because the rendered has no aspect-ratio occupation, the // layout collapsed to a one-pixel-tall sliver until the bytes actually // finished arriving — "等了很久 → 一根线 → 突然出图" of the original report. // 20s + the aspect-video fallback together remove that failure mode. const IMAGE_PRELOAD_TIMEOUT_MS = 20000; // After blob/preload resolves the still needs to decode the bitmap. // This gate keeps the "transitioning" overlay visible until decode fires, // so the user never sees progressive paint or a blank flash. 3s is generous // (decode is typically <100ms for a locally-held blob). const IMAGE_READY_TIMEOUT_MS = 3000; // ────────────────────────────────────────────────────────────────────── // Two ways an gets its pixels, picked per-URL by shouldProxy(): // // 1. DIRECT (default — no proxy configured): preload the URL with an // Image() + decode() so the HTTP cache is warm and the bitmap decoded // before React commits, then hand the ORIGINAL URL to . This is the // long-standing behavior; deployers who set no env var get exactly this // and are completely unaffected by the proxy machinery below. // // 2. PROXY (opt-in — NEXT_PUBLIC_IMAGE_PROXY_URL set, host allow-listed): // fetch the bytes through the Cloudflare Worker (which adds CORS and // serves over stable HTTP/2), await the FULL body via .blob(), materialize // a blob: URL over that local copy, and hand THAT to . The // never sees a network-backed src, so there's no "字节还在路上" middle // state and no progressive paint. // Why it matters: Chrome's direct fetch of im.runware.ai sometimes hits // ERR_QUIC_PROTOCOL_ERROR mid-stream, leaving partial PNG bytes that // paint row-by-row. The Worker re-fetches server-to-server (no QUIC // fragility) and serves over HTTP/2 — atomic and reliable. Trade-off: // callers MUST revoke the blob URL when swapping it out (revokeBlobUrlFor) // or the bytes leak in the JS heap. // // Data URIs (MOCK_IMAGE mode) are already local; passed through unchanged // on both paths. blobUrlCache is keyed by the ORIGINAL URL either way. // ────────────────────────────────────────────────────────────────────── // Direct-path preload: decode the URL in memory before committing to React // state, so when the mounts the cache is warm and first paint is // instant. Errors / timeouts resolve quietly — better a broken than a // hung play loop. (im.runware.ai sends no CORS header, so we can't fetch() // its bytes here; warming + decoding is the most the direct path can do.) function preloadImage(url: string): Promise { return new Promise((resolve) => { const img = new Image(); let timer: ReturnType; // Single exit: clear the timeout and resolve. resolve() is idempotent, so // whichever path fires first (load+decode, error, timeout) wins. const done = () => { clearTimeout(timer); resolve(); }; // Armed across BOTH network load and decode, so a hung decode still // resolves quietly — better a broken than a stuck play loop. timer = setTimeout(done, IMAGE_PRELOAD_TIMEOUT_MS); img.onload = () => { // .decode() forces the bitmap to be fully decoded before we proceed — // without it, a slow decode could still cause a flash on first paint. img.decode().then(done, done); }; img.onerror = done; img.src = url; }); } // Opt-in Cloudflare Workers proxy (deploy your own — see the link in README). // Inlined by Next.js at build time. Empty / unset → no proxy → every URL takes // the direct path above, exactly as if this feature didn't exist. const IMAGE_PROXY_BASE = ( process.env.NEXT_PUBLIC_IMAGE_PROXY_URL ?? "" ).replace(/\/$/, ""); // Hostnames eligible for the proxy. Default: Runware's CDN only. Deployers who // point IMAGE_BASE_URL at another provider can opt that provider's image host // in via NEXT_PUBLIC_IMAGE_PROXY_ALLOWED_HOSTS (comma-separated). Inlined at // build time. Anything not on this list stays on the direct path. const IMAGE_PROXY_ALLOWED_HOSTS = ( process.env.NEXT_PUBLIC_IMAGE_PROXY_ALLOWED_HOSTS ?? "im.runware.ai" ) .split(",") .map((h) => h.trim().toLowerCase()) .filter(Boolean); // Route a URL through the proxy only when a proxy is configured AND it's a // remote http(s) image on an allow-listed host. data: URIs (MOCK_IMAGE) are // already local; malformed URLs and any other origin fall through to direct. function shouldProxy(originalUrl: string): boolean { if (!IMAGE_PROXY_BASE) return false; if (originalUrl.startsWith("data:")) return false; try { const { protocol, hostname } = new URL(originalUrl); if (protocol !== "https:" && protocol !== "http:") return false; return IMAGE_PROXY_ALLOWED_HOSTS.includes(hostname.toLowerCase()); } catch { return false; } } function proxiedImageUrl(originalUrl: string): string { return `${IMAGE_PROXY_BASE}/?url=${encodeURIComponent(originalUrl)}`; } async function fetchImageAsBlobUrl(url: string): Promise { if (url.startsWith("data:")) return url; // Direct path (default): warm the cache + decode, hand back the original // URL. No fetch() — im.runware.ai has no CORS, so fetch().blob() would throw. if (!shouldProxy(url)) { await preloadImage(url); return url; } // Proxy path (opt-in): fetch through the Worker and materialize a blob: URL. // On error / timeout fall back to the original URL so still tries // (possible progressive paint — same as the direct path, never worse). const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), IMAGE_PRELOAD_TIMEOUT_MS); try { const r = await fetch(proxiedImageUrl(url), { signal: ctrl.signal }); if (!r.ok) return url; const blob = await r.blob(); return URL.createObjectURL(blob); } catch { return url; } finally { clearTimeout(timer); } } // Module-level cache so speculative prefetches and the eventual commit share // the same in-flight fetch — no double-download per scene. Keyed by the // ORIGINAL CDN URL (the blob: URL it resolves to is the value). Persists for // the page's lifetime; entries are explicitly revoked when the scene swaps. const blobUrlCache = new Map>(); function getOrCreateBlobUrl(originalUrl: string): Promise { let p = blobUrlCache.get(originalUrl); if (!p) { p = fetchImageAsBlobUrl(originalUrl); blobUrlCache.set(originalUrl, p); } return p; } function revokeBlobUrlFor(originalUrl: string): void { const p = blobUrlCache.get(originalUrl); if (!p) return; blobUrlCache.delete(originalUrl); p.then((u) => { if (u.startsWith("blob:")) URL.revokeObjectURL(u); }).catch(() => {}); } // ────────────────────────────────────────────────────────────────────── // Prefetch pool — speculative SceneResponses keyed by choice path. // // Key format: "C1" → reached by choosing C1 from current scene. // "C1/C2" → after C1, then C2 (recursive must-pass prefetch). // // When the player picks a change-scene choice, we keep that key's // descendants (re-rooted) and abort the rest. // ────────────────────────────────────────────────────────────────────── const PREFETCH_MAX_DEPTH = 3; type PrefetchEntry = { promise: Promise; abort: AbortController; }; type ScenePathStep = { fromScene: Scene; fromVisitedBeats: string[]; exit: { choiceId: string; label: string; nextSceneSeed: string }; }; function buildDialogueHistory( session: Session | null, ): DialogueHistoryItem[] { if (!session) return []; return session.history.flatMap((entry, sceneIndex) => { const beatsById = new Map(entry.scene.beats.map((b) => [b.id, b])); const visitedBeatIds = entry.visitedBeatIds; return visitedBeatIds.flatMap((beatId, beatIndex) => { const beat = beatsById.get(beatId); if (!beat) return []; const nextVisitedBeatId = visitedBeatIds[beatIndex + 1]; const choice = beat.next.type === "choice" ? beat.next.choices.find((c) => { if (c.effect.kind === "advance-beat") { return c.effect.targetBeatId === nextVisitedBeatId; } return ( beatIndex === visitedBeatIds.length - 1 && entry.exit?.kind === "choice" && c.id === entry.exit.choiceId ); }) : undefined; const freeformAction = beatIndex === visitedBeatIds.length - 1 && entry.exit?.kind === "freeform" ? entry.exit.action : undefined; const body = beat.speaker ? beat.line : beat.narration; const narration = beat.speaker ? beat.narration : undefined; if (!body && !narration && !choice && !freeformAction) return []; return [ { id: `${sceneIndex}:${beatId}:${beatIndex}`, sceneIndex: sceneIndex + 1, speaker: beat.speaker, body, narration, selectedChoice: choice?.label, freeformAction, }, ]; }); }); } function pathKey(steps: ScenePathStep[]): string { return steps.map((s) => s.exit.choiceId).join("/"); } function buildSpeculativeSession( base: Session, steps: ScenePathStep[], ): Session { // Drop base's current (last) entry and re-add each step's `fromScene` with // its exit set. Final result has `history.length = base.length - 1 + steps.length`. const newHistory = [...base.history.slice(0, -1)]; for (const step of steps) { newHistory.push({ scene: step.fromScene, visitedBeatIds: step.fromVisitedBeats, exit: { kind: "choice", choiceId: step.exit.choiceId, label: step.exit.label, nextSceneSeed: step.exit.nextSceneSeed, }, }); } return { ...base, history: newHistory }; } function findAllChangeSceneChoices(scene: Scene): BeatChoice[] { const result: BeatChoice[] = []; const seen = new Set(); for (const b of scene.beats) { if (b.next.type === "choice") { for (const c of b.next.choices) { if (c.effect.kind === "change-scene" && !seen.has(c.id)) { seen.add(c.id); result.push(c); } } } } return result; } function findSoleChangeSceneChoice(scene: Scene): BeatChoice | null { const all = findAllChangeSceneChoices(scene); return all.length === 1 ? all[0]! : null; } function prefetchScenePath( pool: Map, // Resolved-prefetch sink for the gallery export. Every successful resolve // is recorded here keyed by `${parentSceneId}:${choiceId}` so the gallery // can let the player click any choice whose alternate the AI already paid // to generate — even ones that were later abandoned mid-play because the // player took a different branch. Survives `consumeChoice`'s abort sweep: // a prefetch that's already resolved when its parent choice is abandoned // still leaves the result here. resolvedSink: Map, baseSession: Session, steps: ScenePathStep[], depth: number, clientTts: boolean, ): void { if (depth >= PREFETCH_MAX_DEPTH) return; const key = pathKey(steps); if (pool.has(key)) return; const specSession = buildSpeculativeSession(baseSession, steps); const abort = new AbortController(); const prefetchT0 = Date.now(); const promise = (async () => { const data = await requestScene({ session: specSession, clientTts }); if (abort.signal.aborted) throw new DOMException("aborted", "AbortError"); // Record this resolved alternate for the gallery export. Key is // (parent scene id at the choice point) : (choice id). Includes the // CDN imageUrl on the Scene so the gallery has everything it needs to // render without any further info from the engine. const lastStep = steps[steps.length - 1]!; resolvedSink.set(`${lastStep.fromScene.id}:${lastStep.exit.choiceId}`, { ...data.scene, imageUrl: data.imageUrl, }); // Kick off the blob fetch for this URL so when the player eventually // picks this choice, transitioning is a no-op cache lookup instead of a // fresh CDN download. Don't await — let it run in the background; the // transition path awaits the same cached promise via getOrCreateBlobUrl. void getOrCreateBlobUrl(data.imageUrl); // Recursive: if the resulting scene has exactly one change-scene exit, // it is a must-pass node — prefetch its child too. if (depth + 1 < PREFETCH_MAX_DEPTH) { const sole = findSoleChangeSceneChoice(data.scene); if (sole && sole.effect.kind === "change-scene") { const nextStep: ScenePathStep = { fromScene: data.scene, fromVisitedBeats: [data.scene.entryBeatId], exit: { choiceId: sole.id, label: sole.label, nextSceneSeed: sole.effect.nextSceneSeed, }, }; // Carry forward the registry that the parent prefetch result already // settled (it may include characters introduced by the intermediate // scene). Without this, the L2+ prefetch starts from the original // base.characters and a later transition through this survivor would // silently drop voices the player has already heard. const carriedBase: Session = { ...baseSession, characters: data.characters, storyState: data.storyState, }; prefetchScenePath( pool, resolvedSink, carriedBase, [...steps, nextStep], depth + 1, clientTts, ); } } return data; })(); promise.catch((e) => { if ((e as { name?: string }).name === "AbortError") return; const { kind, http_status } = classifyError(e); track("play_error", { source: "prefetch" as const, kind, http_status, orientation: coerceOrientation(baseSession.orientation), connection: getConnectionType(), was_hidden: typeof document !== "undefined" && document.visibilityState === "hidden", scene_index: baseSession.history.length, elapsed_bucket: elapsedBucket(prefetchT0), }); }); pool.set(key, { promise, abort }); } function consumeChoice( pool: Map, choiceId: string, ): PrefetchEntry | undefined { const my = pool.get(choiceId); const survivors = new Map(); for (const [key, entry] of pool) { if (key === choiceId) continue; if (key.startsWith(choiceId + "/")) { survivors.set(key.slice(choiceId.length + 1), entry); } else { entry.abort.abort(); } } pool.clear(); for (const [k, e] of survivors) pool.set(k, e); return my; } function clearPool(pool: Map): void { for (const e of pool.values()) e.abort.abort(); pool.clear(); } // ────────────────────────────────────────────────────────────────────── // BYO voice resolution (client-direct Xiaomi TTS). // // In BYO mode the server skips all TTS (clientTts:true), so the browser must // obtain each speaker's reference audio itself. `cache` is keyed by character // NAME and persists for the whole session, so a voice locked in on a // character's first speaking beat stays identical across every later scene — // even though /api/scene returns its characters without `.voice`. Storing the // in-flight Promise (not the resolved value) dedupes the burst of concurrent // beats by the same speaker into ONE voicedesign call, which matters because // Xiaomi rate-limits voicedesign hard. // ────────────────────────────────────────────────────────────────────── async function resolveByoVoice( cache: Map>, cfg: TtsConfig, speaker: Character, ): Promise { const cached = cache.get(speaker.name); if (cached) return cached; // Prebaked cards ship baked reference audio — reuse it directly (cross-key // synth with the user's key works), keeping the prebaked voice identical. if (speaker.voice) { const ready = Promise.resolve(speaker.voice); cache.set(speaker.name, ready); return ready; } if (!speaker.voiceDescription) return null; const p = provisionVoice(cfg, speaker.voiceDescription, speaker.name); cache.set(speaker.name, p); try { return await p; } catch (e) { cache.delete(speaker.name); // failed provision — let a later beat retry throw e; } } // ── Error observability helpers ──────────────────────────────────────── type ErrorSource = "scene" | "start" | "vision" | "insert_beat" | "freeform" | "prefetch"; function classifyError( e: unknown, res?: Response, ): { kind: "network" | "timeout" | "http_5xx" | "http_4xx" | "abort" | "unknown"; http_status: number } { if (res) { const s = res.status; if (s >= 500) return { kind: "http_5xx", http_status: s }; if (s >= 400) return { kind: "http_4xx", http_status: s }; } if (e instanceof Error) { if (e.name === "AbortError") return { kind: "abort", http_status: 0 }; if (e instanceof TypeError && /fetch|network/i.test(e.message)) return { kind: "network", http_status: 0 }; if (/timeout/i.test(e.message)) return { kind: "timeout", http_status: 0 }; const httpMatch = e.message.match(/^HTTP (\d+)$/); if (httpMatch) { const s = Number(httpMatch[1]); if (s >= 500) return { kind: "http_5xx", http_status: s }; if (s >= 400) return { kind: "http_4xx", http_status: s }; } } return { kind: "unknown", http_status: 0 }; } function elapsedBucket(startMs: number): "<5s" | "5-30s" | "30-60s" | "60-120s" | "120s+" { const s = (Date.now() - startMs) / 1000; if (s < 5) return "<5s"; if (s < 30) return "5-30s"; if (s < 60) return "30-60s"; if (s < 120) return "60-120s"; return "120s+"; } function getConnectionType(): "4g" | "3g" | "2g" | "slow-2g" | "unknown" { const nav = typeof navigator !== "undefined" ? navigator : undefined; const conn = (nav as { connection?: { effectiveType?: string } } | undefined)?.connection; const et = conn?.effectiveType; if (et === "4g" || et === "3g" || et === "2g" || et === "slow-2g") return et; return "unknown"; } // ────────────────────────────────────────────────────────────────────── // Component // ────────────────────────────────────────────────────────────────────── function PlayInner() { const router = useRouter(); const params = useSearchParams(); const { t, locale } = useI18n(); const lp = useLocalePath(); const [phase, setPhase] = useState("loading-first"); const [session, setSession] = useState(null); const [currentScene, setCurrentScene] = useState(null); const [currentBeatId, setCurrentBeatId] = useState(null); const [imageUrl, setImageUrl] = useState(null); const [beatAudioMap, setBeatAudioMap] = useState>({}); // Lazy-initialize 优先级:本局选择(homepage 的「语音配音」存到 sessionStorage:infiplot:custom) // > 上次会话的粘性偏好(localStorage:infiplot:muted) > 默认非静音。 // 这样首页选了「关闭」开始游戏,进来就是静音;选「开启」就不是静音;进入 play 页后用户自己 // 切换 静音/有声 时再用 localStorage 持久化,下一局开新游戏 sessionStorage 选择会再覆盖。 const [muted, setMuted] = useState(() => { if (typeof window === "undefined") return false; try { const stored = window.sessionStorage.getItem("infiplot:custom"); if (stored) { const parsed = JSON.parse(stored) as { audioEnabled?: boolean }; if (typeof parsed.audioEnabled === "boolean") { return !parsed.audioEnabled; } } return window.localStorage.getItem(MUTED_STORAGE_KEY) === "1"; } catch { return false; } }); const [pendingClick, setPendingClick] = useState<{ x: number; y: number; } | null>(null); const [error, setError] = useState(null); const [presentation, setPresentation] = useState(false); // Session-locked image orientation (see detectOrientation). "portrait" makes // the whole play surface render full-bleed vertical on phones. const [orientation, setOrientation] = useState("landscape"); const [lastExitLabel, setLastExitLabel] = useState(null); // Consecutive server-side TTS misses (null audio / failed /api/beat-audio). // Climbs when the shared server key is rate-limited by MiMo — the exact pain // BYO fixes — so the play page can nudge non-BYO users to add their own key. // Reset to 0 on any successful synth. Only the server path touches it. const [silenceStrikes, setSilenceStrikes] = useState(0); // Once the player dismisses the silence nudge, keep it gone for this session. const [nudgeDismissed, setNudgeDismissed] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [visionClickEnabled, setVisionClickEnabled] = useState(true); const [authModalOpen, setAuthModalOpen] = useState(false); const authResolveRef = useRef<(() => void) | null>(null); // Serializable description of the action that hit the 401 (choice / freeform // text / background-click coords), captured alongside the retry closure. An // OAuth round-trip destroys the closure, but this survives in sessionStorage // so the exact action can be replayed after game state is restored. const pendingResumeActionRef = useRef(null); // Set by restorePlayResume when a snapshot carries a pending action; a // dedicated effect dispatches it once the restored state has committed // (phase "ready", session + scene present), then clears it. Mirrors the // homepage's autoStartPending resume pattern. const [pendingReplayAction, setPendingReplayAction] = useState(null); // Bumped by the OAuth-resume fallback to retrigger the bootstrap effect after // relinquishing its `startedRef` slot (snapshot consumed but user not signed // in → run normal card/preset/custom bootstrap instead of leaving a blank // loading screen). const [retryBootstrap, setRetryBootstrap] = useState(0); // Top-of-screen progress toast for the gallery / story export pipeline. // null when idle; { done, total, label } while collecting beat audio. const [exportProgress, setExportProgress] = useState< { done: number; total: number; label: string } | null >(null); // `retry` re-runs the action that hit the 401, replayed by AuthModal.onSuccess // after the user signs in. Omitted by callers whose path can't actually 401 // (initial load already gated on the homepage, recorded replay is local). // `action` is the serializable twin of `retry`: same intent, but survives an // OAuth full-page redirect via sessionStorage so it can be replayed after // game state is restored (the retry closure itself is destroyed on unmount). const handleAuthError = useCallback( ( e: unknown, retry?: () => void, action?: PendingResumeAction, ): boolean => { if (e instanceof AuthRequiredError) { authResolveRef.current = retry ?? null; pendingResumeActionRef.current = action ?? null; setAuthModalOpen(true); return true; } return false; }, [], ); // Snapshot the in-progress game just before an OAuth full-page redirect so // the play page can resume the exact scene/beat on return. Reads only refs // (stable across renders), so an empty dep list is safe. Mirrors the // homepage's persistPendingStart + quota-fallback degradation. const persistPlayResume = useCallback((): void => { const sess = sessionRef.current; const beat = currentBeatRef.current; const imageOriginalUrl = lastImageOriginalUrlRef.current; if (!sess || !beat || !imageOriginalUrl) return; const snap: PlayResumeSnapshot = { session: sess, beatId: beat.id, visitedBeats: [...visitedBeatsRef.current], orientation: coerceOrientation(sess.orientation), imageOriginalUrl, pendingAction: pendingResumeActionRef.current ?? undefined, }; // Quota-safe write: the only heavy field is the user-uploaded style ref // (~100KB data URL), which only affects the Painter on FUTURE scenes, not // the resumed scene — so stripping it degrades gracefully. Voices are // deliberately kept (continuity > rare quota miss; a typical session of // remote-image URLs + a few ~160KB voice refs fits under the 5MB cap). writeResumeSnapshot(PLAY_RESUME_KEY, snap, [ // Fallback: drop the style-reference data URL from the session. { ...snap, session: { ...sess, styleReferenceImage: undefined } }, ]); }, []); // Restore an in-progress game from a PLAY_RESUME_KEY snapshot after an OAuth // round-trip. Re-resolves the remote image URL to a fresh blob (the old blob // was revoked on unmount), repopulates the runtime refs the handlers read, // and hands any pending action to the deferred-replay effect. Throws on a // corrupt snapshot so the caller can fall back to normal bootstrap. const restorePlayResume = useCallback( async (snap: PlayResumeSnapshot): Promise => { const last = snap.session.history[snap.session.history.length - 1]; if (!last?.scene) throw new Error("resume snapshot missing current scene"); setOrientation(snap.orientation); visitedBeatsRef.current = [...snap.visitedBeats]; lastImageOriginalUrlRef.current = snap.imageOriginalUrl; setSession(snap.session); setCurrentScene(last.scene); setCurrentBeatId(snap.beatId); const blobUrl = await getOrCreateBlobUrl(snap.imageOriginalUrl); const ready = waitForImageReady(); setImageUrl(blobUrl); await ready; setPhase("ready"); track("scene_reached", { scene_index: snap.session.history.length }); if (snap.pendingAction) setPendingReplayAction(snap.pendingAction); }, [], ); const startedRef = useRef(false); const poolRef = useRef>(new Map()); // Accumulator for resolved prefetches across the whole session — every // `prefetchScenePath` resolution writes here, keyed by parent-scene + choice. // Survives `consumeChoice`'s pool sweep (an already-resolved promise is not // un-resolved by aborting its controller), so abandoned alternates remain // available for the gallery export. Cleared only on unmount. const resolvedPrefetchesRef = useRef>(new Map()); // Lazy per-beat audio fetches keyed by beat.id. Aborted when the scene // changes so stale in-flight requests can't poison the new scene's map // (beat ids like "b1" are scene-local and would collide across scenes). const beatAudioAbortRef = useRef>(new Map()); // Mirrors `muted` so the closure-stable fetchBeatAudio (deps []) can gate on // it. Muting stops TTS *synthesis*, not just playback — TTS is the only sound // source, so synthesizing audio the user can't hear just burns quota. // 首页「语音配音 关闭」会把 muted 初值置为 true(见上方 useState 初始化), // 不再单独维护 audioEnabledRef —— 单一来源避免两个 flag 漂移。 const mutedRef = useRef(muted); const phaseRef = useRef(phase); // Resolved bring-your-own Xiaomi TTS config (region preset + key), read once // from localStorage. When non-null, the browser provisions + synths voices // directly against Xiaomi — the key never touches our server — and every // start/scene/insert-beat request carries clientTts:true so the engine skips // server-side TTS. null = user hasn't opted in (server default / silent). const [byoTtsConfig, setByoTtsConfig] = useState(() => loadClientTtsConfig(), ); const byoTtsRef = useRef(byoTtsConfig); // Server TTS provider (probed once at mount via /api/tts-provider). Used by // fetchBeatAudio to decide which voice fields to send: when the server runs // StepFun, omit the ~220KB Xiaomi `voice` and send stepfunVoiceId / // voiceDescription instead (saves Fast Origin Transfer bandwidth). null = // probe failed or server has no TTS; fetchBeatAudio then sends defensively // and the server normalizes. Ignored entirely in BYO mode (byoTtsRef wins). const [serverTtsProvider, setServerTtsProvider] = useState(null); const serverTtsProviderRef = useRef(null); // BYO voice cache (see resolveByoVoice). Keyed by character name; persists // across scenes so each speaker is provisioned at most once per session. const provisionedVoicesRef = useRef>>( new Map(), ); // Mirrors for use inside async handlers (closure-stable) const sessionRef = useRef(null); const currentSceneRef = useRef(null); const currentBeatRef = useRef(null); const visitedBeatsRef = useRef([]); const replaySourceRef = useRef(null); const replayIndexRef = useRef(-1); const replayActiveRef = useRef(false); const exportingStoryRef = useRef(false); const exportingGalleryRef = useRef(false); const prebakedAudioRef = useRef>({}); // Original (CDN) URL of the currently-rendered scene image. Used as the key // to revoke its blob: URL when the scene swaps. We track the ORIGINAL URL, // not the blob URL, because blobUrlCache is keyed by original URL. const lastImageOriginalUrlRef = useRef(null); // Image-ready gate: keeps the "transitioning" overlay visible until the // actual element has decoded its bitmap, so the user never sees // progressive paint or a blank flash between scenes. const imageReadyResolverRef = useRef<(() => void) | null>(null); function waitForImageReady(): Promise { return new Promise((resolve) => { let settled = false; const done = () => { if (settled) return; settled = true; imageReadyResolverRef.current = null; resolve(); }; imageReadyResolverRef.current = done; setTimeout(done, IMAGE_READY_TIMEOUT_MS); }); } const handleImageReady = useCallback(() => { imageReadyResolverRef.current?.(); }, []); const currentBeat = useMemo(() => { if (!currentScene || !currentBeatId) return null; return currentScene.beats.find((b) => b.id === currentBeatId) ?? null; }, [currentScene, currentBeatId]); const dialogueHistory = useMemo( () => buildDialogueHistory(session), [session], ); const audioSrc = (currentBeat ? beatAudioMap[currentBeat.id] : undefined) ?? null; useEffect(() => { sessionRef.current = session; }, [session]); // Autosave bookkeeping. We persist on a stable FINGERPRINT of the durable, // session-level state — committed-scene count + playerName — not the raw // `session` reference, which churns on every beat advance (visitedBeatIds). // - lastSavedFingerprintRef holds the fingerprint of the last SUCCESSFUL save. // On failure it's cleared so the next session change retries: a // fire-and-forget that silently failed (IndexedDB transiently unavailable) // must not strand the scene unsaved. // - saveChainRef serializes writes so a slow save for scene N can't land after // a faster save for N+1 and persist a stale, shorter session. const lastSavedFingerprintRef = useRef(""); const saveChainRef = useRef>(Promise.resolve()); // Persist to the browser-local store when the durable state changes (Req 2.1). // Fingerprint = committed-scene count + last-scene beat count + playerName: // - scene count grows on a normal scene commit; // - last-scene beat count grows on an insert-beat (freeform / background-click // appends a beat to the current scene WITHOUT changing history.length), which // is real generated narrative that must persist — keying on length alone // would silently drop it; // - playerName captures a late rename. // Within-scene *visited* progress (visitedBeatIds) is deliberately NOT in the // fingerprint, so merely advancing through existing beats doesn't re-save. The // resume path primes the fingerprint so loading a story stays a pure read (no // re-save / rev bump / list reorder). No debounce — the write is issued on the // committing render, so navigating home right after a change can't drop it (the // IndexedDB put is already in flight, serialized, not cancelled by unmount). // Fire-and-forget: never blocks. useEffect(() => { // Never persist a replayed shared story into the user's own library — it // isn't theirs and its id can collide with (and clobber) a real local save. // Guard on replaySourceRef (set unconditionally on import, cleared by // detachRecordedReplay when the user takes over) — NOT replayActiveRef, which // means "more recorded scenes remain" and is false for a single-scene share, // so that share would otherwise slip through and overwrite a real save. if (!session || replaySourceRef.current) return; const history = session.history ?? []; if (history.length < 1) return; const lastBeatCount = history[history.length - 1]?.scene?.beats?.length ?? 0; const fingerprint = `${history.length}:${lastBeatCount}:${session.playerName ?? ""}`; if (fingerprint === lastSavedFingerprintRef.current) return; lastSavedFingerprintRef.current = fingerprint; // optimistic; rolled back on failure const snapshot = session; saveChainRef.current = saveChainRef.current .then(async () => { const r = await saveStory(snapshot); // Roll back only if no newer save has superseded us, so the next session // change retries this content instead of the failure being permanent. if (!r.ok && lastSavedFingerprintRef.current === fingerprint) { lastSavedFingerprintRef.current = ""; } }) // Defensive: saveStory is contracted never to throw, but if a future edit // to this callback ever does, an unhandled rejection here would poison the // chain and freeze ALL subsequent saves. Swallow to keep the chain alive. .catch(() => {}); }, [session]); useEffect(() => { currentSceneRef.current = currentScene; }, [currentScene]); useEffect(() => { currentBeatRef.current = currentBeat; }, [currentBeat]); useEffect(() => { mutedRef.current = muted; }, [muted]); useEffect(() => { phaseRef.current = phase; }, [phase]); useEffect(() => { serverTtsProviderRef.current = serverTtsProvider; }, [serverTtsProvider]); useEffect(() => { setVisionClickEnabled(readStoredVisionClick()); }, []); // Probe the server's TTS provider ONCE at mount. Non-BYO users need this so // fetchBeatAudio can skip the ~220KB Xiaomi reference audio when the server // runs StepFun. BYO users never read this ref (byoTtsRef takes precedence), // but the probe is harmless and cheap, so we run it unconditionally and let // getTtsProvider short-circuit for BYO. AuthRequiredError is handled by the // bootstrap flow's handleAuthError; other errors degrade to null silently. useEffect(() => { let cancelled = false; getTtsProvider() .then((p) => { if (!cancelled) setServerTtsProvider(p); }) .catch((e) => { if (!cancelled && e instanceof AuthRequiredError) { // Defer to the bootstrap effect's auth modal — leave provider null. return; } // Non-auth errors already logged in getTtsProvider; null = unknown. }); return () => { cancelled = true; }; }, []); function trackPlayError(source: ErrorSource, e: unknown, startMs: number, res?: Response) { const { kind, http_status } = classifyError(e, res); track("play_error", { source, kind, http_status, orientation, connection: getConnectionType(), was_hidden: document.visibilityState === "hidden", scene_index: session?.history.length ?? 0, elapsed_bucket: elapsedBucket(startMs), }); } // Coarse liveness ping for active-time analytics. /play is a single SPA // route, so page views alone read as ~0 duration; a 30s heartbeat (only // while the tab is visible) gives Umami the timestamps to derive real // engaged time. Content-free — no payload. The interval is never even // scheduled unless the tracker is configured, so it's zero work when off. useEffect(() => { if (!process.env.NEXT_PUBLIC_UMAMI_SRC || !process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID) { return; } const id = window.setInterval(() => { if (document.visibilityState === "visible") track("play_heartbeat"); }, 30_000); return () => window.clearInterval(id); }, []); useEffect(() => { function onVisChange() { if (document.visibilityState === "hidden") { const p = phaseRef.current; track("play_visibility_lost", { phase: p, had_pending_fetch: p !== "ready", }); } } document.addEventListener("visibilitychange", onVisChange); return () => document.removeEventListener("visibilitychange", onVisChange); }, []); // Whenever currentBeatId changes, append it to visited (skip consecutive dups) useEffect(() => { if (!currentBeatId) return; if (visitedBeatsRef.current.at(-1) === currentBeatId) return; visitedBeatsRef.current = [...visitedBeatsRef.current, currentBeatId]; setSession((s) => { if (!s) return s; return { ...s, history: s.history.map((h, i, arr) => i === arr.length - 1 ? { ...h, visitedBeatIds: [...visitedBeatsRef.current] } : h, ), }; }); }, [currentBeatId]); // ── Lazy per-beat audio fetch ──────────────────────────────────────── // Returns silently on any failure — the UI never waits for audio, so a // null result just means that beat plays without voice. // Sends only the speaker's voice + the line to speak — NOT the whole // session — so the per-beat payload stays small even with many characters // (each voice.referenceAudioBase64 is ~160KB). const fetchBeatAudio = useCallback( async ( sess: Session, beat: { id: string; speaker?: string; line?: string; lineDelivery?: string }, ): Promise => { if (mutedRef.current) return; // 静音 → 不合成 TTS(避免无谓的调用与花费)。 // 「首页选关闭」也走这条路:bootstrap 时 muted 已被初始化为 true。 if (!beat.speaker || !beat.line) return; // Reuse pre-baked audio from a `.infiplot` import before any synth — // free, instant, and identical to what the original player heard. const curSceneId = currentSceneRef.current?.id; if (curSceneId) { const baked = prebakedAudioRef.current[`${curSceneId}:${beat.id}`]; if (baked) { setBeatAudioMap((m) => (m[beat.id] === baked ? m : { ...m, [beat.id]: baked })); return; } } const speaker = sess.characters.find((c) => c.name === beat.speaker); if (!speaker) return; const byo = byoTtsRef.current; const serverProvider = serverTtsProviderRef.current; // What we need to synthesize depends on the path: // - BYO (xiaomi): baked voice OR voiceDescription to provision locally. // - Server stepfun: stepfunVoiceId or voiceDescription — no Xiaomi // `voice` needed (saves the ~220KB reference-audio FOT). // - Server xiaomi / unknown (probe pending): accept ANY synthesizable // source. The null case covers the race where getTtsProvider hasn't // resolved before the first beat fetch fires — without this widening // a stepfun-only speaker (no Xiaomi voice) would be silently dropped. // The server resolves + normalizes regardless of which fields arrive. if (byo) { if (!speaker.voice && !speaker.voiceDescription) return; } else if (serverProvider === "stepfun") { if (!speaker.stepfunVoiceId && !speaker.voiceDescription) return; } else { if (!speaker.voice && !speaker.stepfunVoiceId && !speaker.voiceDescription) return; } if (beatAudioAbortRef.current.has(beat.id)) return; const abort = new AbortController(); beatAudioAbortRef.current.set(beat.id, abort); try { let audioUrl: string | null = null; if (byo) { // Client-direct: provision (once per speaker, cached) + synth against // Xiaomi with the user's own key — the key never touches our server. const voice = await resolveByoVoice( provisionedVoicesRef.current, byo, speaker, ); if (!voice || abort.signal.aborted) return; const out = await synthesize( byo, voice, beat.line, beat.lineDelivery, abort.signal, ); audioUrl = `data:${out.mimeType};base64,${out.audioBase64}`; } else { // Server-side synth: shape the body by the probed provider so we don't // waste Fast Origin Transfer bandwidth on the ~220KB Xiaomi reference // audio when the server actually runs StepFun. // - stepfun → stepfunVoiceId + voiceDescription + characterName // (all lightweight; the server synths directly with the id). // - xiaomi / unknown → voice (the ~220KB reference audio the server // needs to clone), PLUS the lightweight fallback fields so the // server can still normalize on a provider mismatch (e.g. a prebaked // card holding a Xiaomi voice while the server runs StepFun). const isStepfunServer = serverProvider === "stepfun"; const res = await fetch("/api/beat-audio", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ beat: { id: beat.id, line: beat.line, lineDelivery: beat.lineDelivery }, ...(isStepfunServer ? { stepfunVoiceId: speaker.stepfunVoiceId, voiceDescription: speaker.voiceDescription, characterName: speaker.name, } : { voice: speaker.voice, // Defensive fallback fields (lightweight) — let the server // re-provision if speaker.voice.provider ≠ server provider. stepfunVoiceId: speaker.stepfunVoiceId, voiceDescription: speaker.voiceDescription, characterName: speaker.name, }), }), signal: abort.signal, }); if (res.status === 204) { setSilenceStrikes((n) => Math.min(n + 1, 99)); return; } if (!res.ok) { setSilenceStrikes((n) => Math.min(n + 1, 99)); return; } const blob = await res.blob(); // Defensive: a 200 with an empty body (proxy/CDN truncation, // framework edge cases) would create a silent blob URL and wrongly // reset the silence counter. Treat empty as a miss so the nudge // still surfaces when the shared key is being rate-limited. if (blob.size === 0) { setSilenceStrikes((n) => Math.min(n + 1, 99)); return; } audioUrl = URL.createObjectURL(blob); setSilenceStrikes(0); } // Skip the state write if we've been aborted between the await and // here — beat ids are scene-local, so a late arrival from a prior // scene would otherwise overwrite the current scene's audio under the // same id. if (audioUrl && !abort.signal.aborted) { setBeatAudioMap((m) => ({ ...m, [beat.id]: audioUrl })); } else if (audioUrl?.startsWith("blob:")) { // Aborted between synth and store — revoke the blob URL we just // created so it doesn't leak. (Scene-change and mute transitions // revoke stored URLs separately; this only covers this race.) URL.revokeObjectURL(audioUrl); } } catch { // aborted (scene change / mute) — silent fallback, NOT a strike. // Network failure / server 5xx / shared-key rate-limit that surfaces // as a thrown error on the server path DOES count — otherwise the // silence nudge would never fire for those cases (the explicit 204/ // !ok/empty-blob branches above only cover responses, not throws). // BYO throws are the user's own key quota, not the shared-key pain // the nudge addresses, so they don't count. if (!abort.signal.aborted && !byo) { setSilenceStrikes((n) => Math.min(n + 1, 99)); } } finally { // Only clear the slot if it's still ours. An aborted prior fetch // running its finally late could otherwise delete the controller of a // new fetch that took the same beat id, leaving the new one // unabortable on the next scene change. if (beatAudioAbortRef.current.get(beat.id) === abort) { beatAudioAbortRef.current.delete(beat.id); } } }, [], ); function cancelBeatAudioFetches(): void { for (const c of beatAudioAbortRef.current.values()) c.abort(); beatAudioAbortRef.current.clear(); } // Fire one /api/beat-audio request per speaking beat in the current scene. // Reads refs (not props) so it stays closure-stable and can be re-run on // un-mute as well as on scene change. const prefetchSceneAudio = useCallback(() => { const scene = currentSceneRef.current; const sess = sessionRef.current; if (!scene || !sess) return; for (const b of scene.beats) { if (b.speaker && b.line) void fetchBeatAudio(sess, b); } }, [fetchBeatAudio]); // (Re)synthesize each time the scene changes. Cancel any in-flight requests // from the prior scene first — beat ids are scene-local ("b1" repeats across // scenes) so a late arrival would land under the wrong beat otherwise. useEffect(() => { cancelBeatAudioFetches(); setBeatAudioMap((prev) => { for (const url of Object.values(prev)) { if (url.startsWith("blob:")) URL.revokeObjectURL(url); } return {}; }); prefetchSceneAudio(); }, [currentScene?.id, prefetchSceneAudio]); // ── Mute persistence (read is via the useState lazy initializer above) ─ const toggleMuted = useCallback(() => { track("tts_toggle", { muted: !mutedRef.current }); setMuted((prev) => { const next = !prev; try { window.localStorage.setItem(MUTED_STORAGE_KEY, next ? "1" : "0"); } catch { // ignore } return next; }); }, []); // Muting stops synthesis, not just playback: abort in-flight requests when // muting. When un-muting, re-synthesize the current scene — fetchBeatAudio // skips synthesis while muted, so a scene entered muted has no audio to play // back otherwise. (Clearing the map re-synthesizes already-fetched beats on a // mid-scene un-mute, but that's bounded to one scene and a rare toggle.) // // Gate on actual mute *transitions*: on mount this effect would otherwise // fire alongside the scene effect above (both call prefetchSceneAudio), // doubling the initial /api/beat-audio batch — the first set is dispatched // only to be aborted mid-flight, burning TTS quota. const prevMutedRef = useRef(muted); useEffect(() => { const prev = prevMutedRef.current; prevMutedRef.current = muted; if (prev === muted) return; cancelBeatAudioFetches(); if (muted) return; setBeatAudioMap((prev) => { for (const url of Object.values(prev)) { if (url.startsWith("blob:")) URL.revokeObjectURL(url); } return {}; }); prefetchSceneAudio(); }, [muted, prefetchSceneAudio]); const handleSettingsSaved = useCallback( (settings: { playerName: string; visionClickEnabled: boolean; ttsConfigured: boolean }) => { setVisionClickEnabled(settings.visionClickEnabled); const nextPlayerName = settings.playerName || undefined; setSession((prev) => prev ? { ...prev, playerName: nextPlayerName } : prev); const cfg = settings.ttsConfigured ? loadClientTtsConfig() : null; byoTtsRef.current = cfg; setByoTtsConfig(cfg); if (cfg) { // Switching to BYO: any server-path audio in flight is now stale, // and the silence nudge is no longer relevant. Abort + clear, then // re-synth the current scene with the user's own key. setSilenceStrikes(0); cancelBeatAudioFetches(); setBeatAudioMap((prev) => { for (const url of Object.values(prev)) { if (url.startsWith("blob:")) URL.revokeObjectURL(url); } return {}; }); prefetchSceneAudio(); } }, [prefetchSceneAudio], ); function detachRecordedReplay(): void { replayActiveRef.current = false; replaySourceRef.current = null; replayIndexRef.current = -1; clearPool(poolRef.current); } // ── Export to interactive gallery (PPT-style replay) ───────────────── // Drop all but the (keepCount) most-recent gallery exports from localStorage, // ordered by their stored createdAt. Called right before writing a new // export so the cap is enforced strictly (≤ keepCount + 1 transiently → ≤ N // once write completes). Corrupt entries (un-parseable / no createdAt) sort // last and get evicted first. // // Audio lives in a sidecar key `infiplot:gallery::audio` so the main // doc JSON.parse on gallery load doesn't block the main thread with several // MB of base64. The sidecar key inherits its doc's age — paired by id, not // its own createdAt (it never has one) — and is evicted alongside its doc. const trimGalleryExports = useCallback((keepCount: number) => { try { const prefix = "infiplot:gallery:"; const audioSuffix = ":audio"; const docs: Map = new Map(); const sidecars: Map = new Map(); for (let i = 0; i < window.localStorage.length; i++) { const k = window.localStorage.key(i); if (!k || !k.startsWith(prefix)) continue; if (k.endsWith(audioSuffix)) { const id = k.slice(prefix.length, -audioSuffix.length); sidecars.set(id, k); continue; } const id = k.slice(prefix.length); let createdAt = 0; try { const raw = window.localStorage.getItem(k); if (raw) { const parsed = JSON.parse(raw) as { createdAt?: number }; createdAt = parsed.createdAt ?? 0; } } catch { createdAt = 0; } docs.set(id, { key: k, createdAt }); } const ordered = [...docs.entries()].sort( (a, b) => b[1].createdAt - a[1].createdAt, ); for (const [id, { key }] of ordered.slice(keepCount)) { window.localStorage.removeItem(key); const sc = sidecars.get(id); if (sc) window.localStorage.removeItem(sc); sidecars.delete(id); } // Orphan sidecars (their doc was already gone) get cleaned up too. for (const sc of sidecars.values()) { if (!docs.has(sc.slice(prefix.length, -audioSuffix.length))) { window.localStorage.removeItem(sc); } } } catch { // best-effort — quota or disabled storage shouldn't block the export } }, []); // Strips the live Session to a small GalleryDoc — only scene images + // dialogue text + recorded choices, no voice base64 / portraits / style // reference (those are tens-to-hundreds of KB each). Writes it to // localStorage under a one-shot id and opens /gallery# in a new tab // so the play session keeps running. // // Beat audio is collected synchronously here (reusing the per-scene // beatAudioMap when possible, BYO / server TTS for the rest) and stashed // in a sidecar localStorage key so the gallery's first paint isn't // bottlenecked on JSON.parse-ing several MB of base64. const handleExportGallery = useCallback(async () => { const s = sessionRef.current; if (!s || exportingGalleryRef.current) return; exportingGalleryRef.current = true; const scenes: GalleryScene[] = s.history .map((h) => ({ id: h.scene.id, imageUrl: h.scene.imageUrl ?? "", sceneKey: h.scene.sceneKey, orientation: h.scene.orientation, beats: h.scene.beats, entryBeatId: h.scene.entryBeatId, visitedBeatIds: h.visitedBeatIds, exit: h.exit, })) .filter((sc) => sc.imageUrl); if (scenes.length === 0) { exportingGalleryRef.current = false; return; } // Alternates: ${parentSceneId}:${choiceId} → reachable scene. Two sources, // merged with main-path winning ties (it always agrees with prefetch when // prefetch was actually used, so the override is a no-op in the common case; // it differs only when the player took a cold path and the prefetch had // resolved to something the engine later regenerated): // 1. Every resolved prefetch (including alternates the player never took) // 2. Main path: every history step's choice exit → the next visited scene const alternates: Record = {}; for (const [key, scene] of resolvedPrefetchesRef.current) { if (!scene.imageUrl) continue; alternates[key] = { id: scene.id, imageUrl: scene.imageUrl, sceneKey: scene.sceneKey, orientation: scene.orientation, beats: scene.beats, entryBeatId: scene.entryBeatId, }; } for (let i = 0; i < s.history.length - 1; i++) { const h = s.history[i]!; const nextH = s.history[i + 1]!; if ( h.exit?.kind === "choice" && h.scene.id && nextH.scene.imageUrl ) { alternates[`${h.scene.id}:${h.exit.choiceId}`] = { id: nextH.scene.id, imageUrl: nextH.scene.imageUrl, sceneKey: nextH.scene.sceneKey, orientation: nextH.scene.orientation, beats: nextH.scene.beats, entryBeatId: nextH.scene.entryBeatId, }; } } // Character portraits — names + CDN URLs only. The big voice base64s are // intentionally dropped (the gallery only needs the portraits for download). const characters = s.characters .filter((c) => c.basePortraitUrl) .map((c) => ({ name: c.name, basePortraitUrl: c.basePortraitUrl as string, })); const id = `${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 8)}`; let audioByBeatId: Record = {}; try { setExportProgress({ done: 0, total: 0, label: t("play.exportProgress.preparingVoice") }); audioByBeatId = await collectBeatAudioForExport({ session: s, beatAudioMap, currentSceneId: currentSceneRef.current?.id ?? null, byoTts: byoTtsRef.current, byoVoiceCache: provisionedVoicesRef.current, prebakedAudio: prebakedAudioRef.current, onProgress: (done, total) => setExportProgress({ done, total, label: t("play.exportProgress.preparingVoice") }), }); } catch { // best-effort — even if the collector throws, the gallery without audio // is still usable; we keep going rather than block the export. } finally { setExportProgress(null); } const doc: GalleryDoc = { v: audioByBeatId && Object.keys(audioByBeatId).length > 0 ? 3 : 2, id, createdAt: Date.now(), orientation: coerceOrientation(s.orientation), scenes, alternates, characters, }; // Cap retained gallery exports at the most recent 2. Drop everything // older BEFORE writing the new doc so we never transiently exceed the cap // (and so a near-quota localStorage has headroom for the new entry). trimGalleryExports(1); const docStr = JSON.stringify(doc); try { window.localStorage.setItem(`infiplot:gallery:${id}`, docStr); } catch { // localStorage full or disabled — silently bail; the player keeps playing. exportingGalleryRef.current = false; return; } const audioCount = Object.keys(audioByBeatId).length; if (audioCount > 0) { try { window.localStorage.setItem( `infiplot:gallery:${id}:audio`, JSON.stringify(audioByBeatId), ); } catch { // Sidecar too big for quota — gallery still opens without sound. } } track("gallery_export", { scene_count: scenes.length, audio_count: audioCount }); window.open(`/gallery#id=${id}`, "_blank", "noopener"); exportingGalleryRef.current = false; }, [beatAudioMap, trimGalleryExports]); const handleExportStory = useCallback(async () => { const s = sessionRef.current; if (!s || s.history.length === 0 || exportingStoryRef.current) return; exportingStoryRef.current = true; const sceneIndex = Math.max(0, s.history.length - 1); let audioByBeatId: Record = {}; try { setExportProgress({ done: 0, total: 0, label: t("play.exportProgress.preparingVoice") }); audioByBeatId = await collectBeatAudioForExport({ session: s, beatAudioMap, currentSceneId: currentSceneRef.current?.id ?? null, byoTts: byoTtsRef.current, byoVoiceCache: provisionedVoicesRef.current, prebakedAudio: prebakedAudioRef.current, onProgress: (done, total) => setExportProgress({ done, total, label: t("play.exportProgress.preparingVoice") }), }); } catch { // best-effort — share the doc silent if collecting audio failed } finally { setExportProgress(null); } const doc = createStoryShareDoc( s, { sceneIndex, beatId: currentBeatRef.current?.id ?? s.history[sceneIndex]?.scene.entryBeatId, }, Object.keys(audioByBeatId).length > 0 ? audioByBeatId : undefined, ); try { const r = await fetch("/api/story-pack", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ docStr: JSON.stringify(doc) }), }); if (!r.ok) { const j = (await r.json().catch(() => ({}))) as { error?: string }; window.alert(j.error ?? t("play.shareErrors.packFailed")); return; } const blob = await r.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = storyShareFilename(doc); a.rel = "noopener"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 2000); } catch { window.alert(t("play.shareErrors.packFailed")); } finally { exportingStoryRef.current = false; } }, [beatAudioMap, t]); // ── Presentation mode toggle ───────────────────────────────────────── const togglePresentation = useCallback(async () => { const entering = !presentation; track("fullscreen_toggle", { on: entering }); if (entering) { try { if (!document.fullscreenElement) { await document.documentElement.requestFullscreen(); } } catch { // ignore — fall through to chrome-less mode anyway } setPresentation(true); } else { try { if (document.fullscreenElement) await document.exitFullscreen(); } catch { // ignore } setPresentation(false); } }, [presentation]); useEffect(() => { function onKey(e: KeyboardEvent) { const tag = (e.target as HTMLElement)?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement)?.isContentEditable) return; if (e.key === "f" || e.key === "F") { if (e.metaKey || e.ctrlKey || e.altKey) return; e.preventDefault(); void togglePresentation(); } else if (e.key === "Escape" && presentation) { setPresentation(false); } } function onFullscreenChange() { if (!document.fullscreenElement && presentation) setPresentation(false); } window.addEventListener("keydown", onKey); document.addEventListener("fullscreenchange", onFullscreenChange); return () => { window.removeEventListener("keydown", onKey); document.removeEventListener("fullscreenchange", onFullscreenChange); }; }, [togglePresentation, presentation]); // Lock the visible orientation BEFORE the first paint, so portrait phones // never flash the landscape loading chrome. The state inits to "landscape" // for SSR-safety; this corrects it pre-paint (no-op re-render on landscape // devices). The bootstrap effect below re-derives the same value for the // /api/start payload. useIsomorphicLayoutEffect(() => { setOrientation(detectOrientation()); }, [params]); // ── Bootstrap: start session ───────────────────────────────────────── useEffect(() => { if (startedRef.current) return; startedRef.current = true; // ── OAuth resume ──────────────────────────────────────────────── // Returning from a Google/GitHub round-trip? The full-page redirect // destroyed the in-memory Session; if we stashed a snapshot just before // navigating away (persistPlayResume via AuthModal.onBeforeOAuth) and the // user is now signed in, restore the exact scene/beat instead of // re-bootstrapping from `?card=…` (which would restart the story). OTP // login never writes a snapshot — its onSuccess retry keeps state // in-memory. // // Peek before awaiting: when there's no snapshot (the common case — // normal card/preset/custom entry), fall straight through to the // bootstrap below. Only when a snapshot exists do we enter the async // gate, which itself removes the entry. This keeps the no-snapshot path // off the retryBootstrap re-trigger loop entirely. if ( AUTH_ENABLED && sessionStorage.getItem(PLAY_RESUME_KEY) !== null ) { void (async () => { const snap = await consumeResumeSnapshot( PLAY_RESUME_KEY, ); if (!snap) { // Snapshot existed but user isn't signed in / payload corrupt → // consumeResumeSnapshot already removed it. Relinquish the slot so // the normal bootstrap below re-runs on the next effect cycle. startedRef.current = false; setRetryBootstrap((n) => n + 1); return; } try { await restorePlayResume(snap); } catch { // Corrupt snapshot / network — relinquish and bootstrap normally. startedRef.current = false; setRetryBootstrap((n) => n + 1); } })(); return; } // 三条进入路径: // ?card= → 首页精选卡,直接从 /home/firstact/{name}.json // 静态文件加载(已在构建期 prebake,免一切引擎调用) // ?preset= → 内置 PRESETS(仍走 /api/start 现场生成) // ?custom=1 → 用户自定义 prompt,sessionStorage 取 ws/sg // 后走 /api/start 现场生成 // ?share=1 → 首页上传的剧情分享 JSON,从第一幕开始本地回放 // ?storyId= → 加载已保存的剧情(从 localStorage) const cardName = params.get("card"); const presetId = params.get("preset"); const isCustom = params.get("custom") === "1"; const isShare = params.get("share") === "1"; const storyId = params.get("storyId"); if (isShare) { (async () => { const t0 = Date.now(); try { const raw = sessionStorage.getItem(STORY_SHARE_STORAGE_KEY); if (!raw) throw new Error(t("play.shareErrors.notFound")); const doc = parseStoryShareDoc(JSON.parse(raw)); const imported = doc.session; const first = imported.history[0]; if (!first) throw new Error(t("play.shareErrors.invalid")); if (!first.scene.imageUrl) throw new Error(t("play.shareErrors.noImage")); const sessionOrientation = first.scene.orientation ?? imported.orientation ?? detectOrientation(); setOrientation(sessionOrientation); const blobUrl = await getOrCreateBlobUrl(first.scene.imageUrl); lastImageOriginalUrlRef.current = first.scene.imageUrl; const initialStoryState = first.storyStateAfter ?? imported.storyState; if (!initialStoryState) throw new Error(t("play.shareErrors.noMemory")); const initial: Session = { ...imported, history: [ { ...first, visitedBeatIds: [first.scene.entryBeatId], exit: undefined, }, ], storyState: initialStoryState, orientation: sessionOrientation, }; replaySourceRef.current = imported; replayIndexRef.current = 0; replayActiveRef.current = imported.history.length > 1; visitedBeatsRef.current = [first.scene.entryBeatId]; if (doc.audioByBeatId) { prebakedAudioRef.current = { ...doc.audioByBeatId }; const seed: Record = {}; for (const beat of first.scene.beats) { const k = `${first.scene.id}:${beat.id}`; const v = doc.audioByBeatId[k]; if (v) seed[beat.id] = v; } if (Object.keys(seed).length > 0) setBeatAudioMap(seed); } setSession(initial); setCurrentScene(first.scene); setCurrentBeatId(first.scene.entryBeatId); const ready = waitForImageReady(); setImageUrl(blobUrl); await ready; setPhase("ready"); track("scene_reached", { scene_index: 1 }); } catch (e) { if (!handleAuthError(e)) { trackPlayError("start", e, t0); setError(e instanceof Error ? e.message : String(e)); } } })(); return; } let livePayload: { worldSetting: string; styleGuide: string; styleReferenceImage?: string; orientation?: Orientation; playerName?: string; language?: string; } | null = null; if (!cardName) { if (presetId) { const p = PRESETS.find((x) => x.id === presetId); if (p) livePayload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide, playerName: readStoredPlayerName() || undefined, language: locale }; } else if (isCustom) { const stored = sessionStorage.getItem("infiplot:custom"); if (stored) { try { const parsed = JSON.parse(stored) as { worldSetting: string; styleGuide: string; audioEnabled?: boolean; styleReferenceImage?: string; playerName?: string; }; livePayload = { worldSetting: parsed.worldSetting, styleGuide: parsed.styleGuide, styleReferenceImage: parsed.styleReferenceImage || undefined, playerName: parsed.playerName || undefined, language: locale, }; // audioEnabled 已在 useState 初始化时反向投射到 muted;这里无需再额外存。 } catch { livePayload = null; } } } } // Lock orientation for the whole session. Both prebaked-card and live paths // now respect device orientation — portrait prebaked assets live under // firstact-portrait/ and firstscene-portrait/. const sessionOrientation: Orientation = detectOrientation(); if (livePayload) livePayload.orientation = sessionOrientation; // sessionLanguage flows into Session.language regardless of which start // path was taken (prebaked card skips /api/start, so the language has to // be tagged onto the local Session build for /api/scene calls). const sessionLanguage: string = locale; if (!cardName && !livePayload && !storyId) { router.replace(lp("/")); return; } // ── Load saved story path ── if (storyId) { (async () => { // Browser-local store (IndexedDB) is async; load inside the IIFE. const loadedSession = await loadStorySession(storyId); if (!loadedSession) { setError(t("play.savedStoryNotFound")); return; } // Resume at the player's last position. Walk from the newest scene back // to the first and resume at the latest one that actually has a rendered // image: the final scene → correct position; if the very last scene // failed to image (committed without one), a small rewind beats a blank // canvas (Req 3.3). If NO scene has an image the story can't render — // surface savedStoryCorrupted instead of landing on getOrCreateBlobUrl(""). const history = loadedSession.history; let resumeEntry = history[history.length - 1]; for (let i = history.length - 1; i >= 0; i--) { if (history[i]?.scene?.imageUrl) { resumeEntry = history[i]; break; } } const resumeScene = resumeEntry?.scene; if (!resumeScene?.imageUrl) { setError(t("play.savedStoryCorrupted")); return; } // Pure read: prime the autosave fingerprint so loading doesn't re-save / // bump rev / reorder the list. Must match the effect's fingerprint shape // exactly (scene count + last-scene beat count + playerName) or the first // render would re-persist. { const lastBeatCount = history[history.length - 1]?.scene?.beats?.length ?? 0; lastSavedFingerprintRef.current = `${history.length}:${lastBeatCount}:${loadedSession.playerName ?? ""}`; } try { const blobUrl = await getOrCreateBlobUrl(resumeScene.imageUrl); lastImageOriginalUrlRef.current = resumeScene.imageUrl; setSession(loadedSession); setCurrentScene(resumeScene); setCurrentBeatId(resumeScene.entryBeatId); setImageUrl(blobUrl); visitedBeatsRef.current = [resumeScene.entryBeatId]; setOrientation(coerceOrientation(loadedSession.orientation)); setPhase("ready"); track("scene_reached", { scene_index: loadedSession.history.length }); } catch (e) { setError(String(e)); } })(); return; } type PrebakedFirstAct = StartResponse & { worldSetting: string; styleGuide: string; // Live /api/start path tags this on after the response (prebaked card // JSONs never have one — they were rendered at build time without any // user-uploaded reference). Carried into Session so /api/scene's painter // anchors the same style image on every subsequent scene. styleReferenceImage?: string; cardName?: string; cardTitle?: string; cardGender?: string; }; const baseDir = sessionOrientation === "portrait" ? "firstact-portrait" : "firstact"; const localeSuffix = locale !== "zh-CN" ? `-${locale}` : ""; const firstactDir = `${baseDir}${localeSuffix}`; const startT0 = Date.now(); const fetchStart: Promise = cardName ? fetch(`/home/${firstactDir}/${encodeURIComponent(cardName)}.json`).then( async (r) => { if (r.ok) return (await r.json()) as PrebakedFirstAct; // Fallback chain: locale-specific → zh-CN portrait → zh-CN landscape if (localeSuffix) { const zhFb = await fetch(`/home/${baseDir}/${encodeURIComponent(cardName)}.json`); if (zhFb.ok) return (await zhFb.json()) as PrebakedFirstAct; } if (sessionOrientation === "portrait") { console.warn(`[play] portrait firstact missing for ${cardName} (HTTP ${r.status}), falling back to landscape`); const fbDir = localeSuffix ? `firstact-${locale}` : "firstact"; const fb = await fetch(`/home/${fbDir}/${encodeURIComponent(cardName)}.json`); if (fb.ok) { const fallback = (await fb.json()) as PrebakedFirstAct; return { ...fallback, scene: { ...fallback.scene, orientation: "landscape" as const } }; } if (localeSuffix) { const zhLandscape = await fetch(`/home/firstact/${encodeURIComponent(cardName)}.json`); if (zhLandscape.ok) { const fallback = (await zhLandscape.json()) as PrebakedFirstAct; return { ...fallback, scene: { ...fallback.scene, orientation: "landscape" as const } }; } } } throw new Error(t("home.errors.cardNotFound", { cardName })); }, ) : (async () => { const data = await startSession({ ...livePayload!, clientTts: !!byoTtsRef.current, }); // startSession doesn't echo ws/sg back — splice in what we sent. // styleReferenceImage is similarly not in StartResponse; tag it on so // the session we build below carries it for every scene call. return { ...data, worldSetting: livePayload!.worldSetting, styleGuide: livePayload!.styleGuide, styleReferenceImage: livePayload!.styleReferenceImage, }; })(); fetchStart .then(async (data) => { const blobUrl = await getOrCreateBlobUrl(data.imageUrl); lastImageOriginalUrlRef.current = data.imageUrl; const initial: Session = { id: data.sessionId, createdAt: Date.now(), worldSetting: data.worldSetting, styleGuide: data.styleGuide, history: [ { scene: data.scene, visitedBeatIds: [data.scene.entryBeatId], storyStateAfter: data.storyState, }, ], characters: data.characters, storyState: data.storyState, styleReferenceImage: data.styleReferenceImage, orientation: data.scene.orientation ?? sessionOrientation, playerName: livePayload?.playerName || readStoredPlayerName() || undefined, language: sessionLanguage, }; visitedBeatsRef.current = [data.scene.entryBeatId]; setSession(initial); setCurrentScene(data.scene); setCurrentBeatId(data.scene.entryBeatId); const ready = waitForImageReady(); setImageUrl(blobUrl); await ready; setPhase("ready"); track("scene_reached", { scene_index: initial.history.length }); }) .catch((e) => { if (!handleAuthError(e)) { trackPlayError("start", e, startT0); setError(String(e)); } }); }, [params, router, retryBootstrap, restorePlayResume]); // ── Deferred replay of the action that hit 401 (OAuth resume) ───────── // After restorePlayResume commits the restored session/scene/beat, dispatch // the pending action so the player lands exactly where they were headed // (seamless continuation). Runs once the restored state is interactive, // then clears the slot. Mirrors the homepage's autoStartPending pattern. useEffect(() => { if (!pendingReplayAction) return; if (phase !== "ready" || !session || !currentScene) return; const action = pendingReplayAction; setPendingReplayAction(null); if (action.kind === "choice") { onSelectChoice(action.choice); } else if (action.kind === "freeform") { void onFreeformInput(action.text); } else { void onBackgroundClick({ x: action.x, y: action.y }); } // onSelectChoice/onFreeformInput/onBackgroundClick are stable inner // functions keyed off the restored state; listing them would re-fire on // every render, so we intentionally scope deps to the readiness gate. // eslint-disable-next-line react-hooks/exhaustive-deps }, [pendingReplayAction, phase, session, currentScene]); // ── Prefetch on scene entry: L1 + recursive L2/L3 for must-pass ────── useEffect(() => { const s = session; const scene = currentScene; if (!s || !scene) return; if (isRecordedReplayLockedAt(currentBeat)) return; const exits = findAllChangeSceneChoices(scene); for (const choice of exits) { if (choice.effect.kind !== "change-scene") continue; const step: ScenePathStep = { fromScene: scene, // Snapshot of visited beats at prefetch start. Slight drift is OK. fromVisitedBeats: [...visitedBeatsRef.current], exit: { choiceId: choice.id, label: choice.label, nextSceneSeed: choice.effect.nextSceneSeed, }, }; prefetchScenePath( poolRef.current, resolvedPrefetchesRef.current, s, [step], 0, !!byoTtsRef.current, ); } }, [currentScene?.id, session?.id]); // Abort all in-flight speculative prefetches when the page unmounts, so we // stop paying for background scene/image generation. Empty deps → fires only // on unmount; it must NOT run on scene transitions, which rely on // consumeChoice keeping the re-rooted survivor prefetches alive. // Also revoke any surviving blob: URLs so their bytes can be GC'd — the // module-level blobUrlCache outlives the component but its entries should // not survive the page navigation that unmounts us. useEffect(() => { const pool = poolRef.current; const beatAborts = beatAudioAbortRef.current; return () => { clearPool(pool); for (const c of beatAborts.values()) c.abort(); beatAborts.clear(); for (const [originalUrl] of blobUrlCache) { revokeBlobUrlFor(originalUrl); } }; }, []); // ── Handlers ────────────────────────────────────────────────────────── function onAdvance() { if (phase !== "ready") return; const beat = currentBeatRef.current; if (!beat || beat.next.type !== "continue") return; setCurrentBeatId(beat.next.nextBeatId); } async function performSceneTransition( source: PrefetchEntry | Promise, exit: SceneExit, visitedForCurrent: string[], exitLabel: string, retry?: () => void, action?: PendingResumeAction, ) { const sceneT0 = Date.now(); setPhase("transitioning"); setPendingClick(null); try { const result = await ("promise" in source ? source.promise : source); const base = sessionRef.current; if (!base) throw new Error("Session lost mid-transition"); // Pull full image bytes into a local blob: URL before committing. For // prefetched scenes the speculative getOrCreateBlobUrl in // prefetchScenePath already has this in flight (often resolved), so // this is a near-instant cache lookup. For cold transitions we eat the // CDN download / preload time under the "transitioning" overlay. Proxy // path: the then gets a fully-local blob (no progressive paint); // direct path (default): the preloaded original URL. const blobUrl = await getOrCreateBlobUrl(result.imageUrl); // Revoke the previous scene's blob (no longer rendered) to release JS // heap. New scene's original URL takes its place as "current". const priorOriginal = lastImageOriginalUrlRef.current; if (priorOriginal && priorOriginal !== result.imageUrl) { revokeBlobUrlFor(priorOriginal); } lastImageOriginalUrlRef.current = result.imageUrl; const closedHistory = base.history.map((h, i, arr) => i === arr.length - 1 ? { ...h, visitedBeatIds: visitedForCurrent, exit } : h, ); const newSession: Session = { ...base, history: [ ...closedHistory, { scene: result.scene, visitedBeatIds: [result.scene.entryBeatId], storyStateAfter: result.storyState, }, ], characters: result.characters, storyState: result.storyState, }; visitedBeatsRef.current = [result.scene.entryBeatId]; setSession(newSession); setCurrentScene(result.scene); setCurrentBeatId(result.scene.entryBeatId); const ready = waitForImageReady(); setImageUrl(blobUrl); setLastExitLabel(exitLabel); await ready; setPhase("ready"); track("scene_reached", { scene_index: newSession.history.length }); } catch (e) { if ((e as { name?: string }).name === "AbortError") { setPhase("ready"); return; } if (!handleAuthError(e, retry, action)) { trackPlayError("scene", e, sceneT0); setError(String(e)); } setPhase("ready"); } } function tryRecordedSceneTransition( choice: BeatChoice, exit: SceneExit, visitedForCurrent: string[], ): boolean { const source = replaySourceRef.current; const idx = replayIndexRef.current; if (!source || idx < 0 || !isRecordedReplayLockedAt(currentBeatRef.current)) { return false; } const recorded = source.history[idx]; const next = source.history[idx + 1]; if ( !recorded || !next || recorded.exit?.kind !== "choice" || recorded.exit.choiceId !== choice.id ) { detachRecordedReplay(); return false; } void (async () => { const replayT0 = Date.now(); setPhase("transitioning"); setPendingClick(null); try { if (!next.scene.imageUrl) throw new Error(t("play.shareErrors.noNextImage")); const blobUrl = await getOrCreateBlobUrl(next.scene.imageUrl); const priorOriginal = lastImageOriginalUrlRef.current; if (priorOriginal && priorOriginal !== next.scene.imageUrl) { revokeBlobUrlFor(priorOriginal); } lastImageOriginalUrlRef.current = next.scene.imageUrl; const base = sessionRef.current; if (!base) throw new Error("Session lost mid-replay"); const closedHistory = base.history.map((h, i, arr) => i === arr.length - 1 ? { ...h, visitedBeatIds: visitedForCurrent, exit } : h, ); const nextIndex = idx + 1; const nextSession: Session = { ...base, history: [ ...closedHistory, { ...next, visitedBeatIds: [next.scene.entryBeatId], exit: undefined, }, ], characters: source.characters, storyState: next.storyStateAfter ?? base.storyState, orientation: next.scene.orientation ?? base.orientation, }; replayIndexRef.current = nextIndex; replayActiveRef.current = true; visitedBeatsRef.current = [next.scene.entryBeatId]; setSession(nextSession); setCurrentScene(next.scene); setCurrentBeatId(next.scene.entryBeatId); const ready = waitForImageReady(); setImageUrl(blobUrl); setLastExitLabel(choice.label); await ready; setPhase("ready"); track("scene_reached", { scene_index: nextSession.history.length }); } catch (e) { if (!handleAuthError(e)) { trackPlayError("scene", e, replayT0); setError(e instanceof Error ? e.message : String(e)); } setPhase("ready"); } })(); return true; } function recordedAllowedChoiceIds(beat: Beat | null): Set | null { if (!replaySourceRef.current || !beat || beat.next.type !== "choice") return null; const source = replaySourceRef.current; const recorded = source?.history[replayIndexRef.current]; if (!recorded) return new Set(); const visited = recorded.visitedBeatIds; const beatIdx = visited.indexOf(beat.id); if (beatIdx < 0) return null; const nextVisited = beatIdx >= 0 ? visited[beatIdx + 1] : undefined; const allowed = new Set(); if (nextVisited) { for (const choice of beat.next.choices) { if ( choice.effect.kind === "advance-beat" && choice.effect.targetBeatId === nextVisited ) { allowed.add(choice.id); } } return allowed; } if ( beatIdx === visited.length - 1 && recorded.exit?.kind === "choice" && source.history[replayIndexRef.current + 1] ) { allowed.add(recorded.exit.choiceId); return allowed; } return null; } function isRecordedReplayLockedAt(beat: Beat | null): boolean { if (!replaySourceRef.current || !beat) return false; const recorded = replaySourceRef.current.history[replayIndexRef.current]; if (!recorded) return false; const beatIdx = recorded.visitedBeatIds.indexOf(beat.id); if (beatIdx < 0) return false; return Boolean( recorded.visitedBeatIds[beatIdx + 1] || ( beatIdx === recorded.visitedBeatIds.length - 1 && recorded.exit?.kind === "choice" && replaySourceRef.current.history[replayIndexRef.current + 1] ), ); } function isDisabledByRecordedReplay(choice: BeatChoice): boolean { const allowed = recordedAllowedChoiceIds(currentBeatRef.current); return allowed !== null && !allowed.has(choice.id); } function onSelectChoice(choice: BeatChoice) { if (phase !== "ready" || !session || !currentScene) return; if (isDisabledByRecordedReplay(choice)) return; const beatNext = currentBeatRef.current?.next; const choiceIndex = beatNext?.type === "choice" ? beatNext.choices.findIndex((c) => c.id === choice.id) : -1; if (choiceIndex >= 0) { track("choice_select", { scene_index: session.history.length, choice_index: choiceIndex, kind: choice.effect.kind, }); } if (choice.effect.kind === "advance-beat") { if (replayActiveRef.current && currentBeatRef.current) { const source = replaySourceRef.current; const idx = replayIndexRef.current; const recorded = source?.history[idx]; const recordedVisited = recorded?.visitedBeatIds ?? []; const beatIdx = recordedVisited.indexOf(currentBeatRef.current.id); const recordedNext = beatIdx >= 0 ? recordedVisited[beatIdx + 1] : undefined; if (recordedNext && recordedNext !== choice.effect.targetBeatId) { detachRecordedReplay(); } } else if ( replaySourceRef.current && !isRecordedReplayLockedAt(currentBeatRef.current) ) { detachRecordedReplay(); } // Pure local jump. No network. No pool changes. setCurrentBeatId(choice.effect.targetBeatId); return; } const visited = [...visitedBeatsRef.current]; const exit: SceneExit = { kind: "choice", choiceId: choice.id, label: choice.label, nextSceneSeed: choice.effect.nextSceneSeed, }; if (tryRecordedSceneTransition(choice, exit, visited)) return; if (replaySourceRef.current) detachRecordedReplay(); const cached = consumeChoice(poolRef.current, choice.id); if (cached) { void performSceneTransition( cached, exit, visited, choice.label, () => onSelectChoice(choice), { kind: "choice", choice }, ); return; } // Cold path — start a fresh fetch const step: ScenePathStep = { fromScene: currentScene, fromVisitedBeats: visited, exit: { choiceId: choice.id, label: choice.label, nextSceneSeed: choice.effect.nextSceneSeed, }, }; const specSession = buildSpeculativeSession(session, [step]); clearPool(poolRef.current); const promise = (async () => { const data = await requestScene({ session: specSession, clientTts: !!byoTtsRef.current, }); return data; })(); void performSceneTransition( promise, exit, visited, choice.label, () => onSelectChoice(choice), { kind: "choice", choice }, ); } async function onFreeformInput(text: string) { if (phase !== "ready" || !session || !currentScene) return; // Detach if we're still replaying a shared story (gate on replaySourceRef, // not replayActiveRef — the latter is false for a single-scene share, which // would otherwise leave us "stuck" in replay and block autosave forever). if (replaySourceRef.current) detachRecordedReplay(); track("freeform_input", { scene_index: session.history.length, text_length: text.length, }); const freeformT0 = Date.now(); setPhase("vision-thinking"); try { // Always generate a new scene for freeform text input — the player // typed something, so they expect the story to move forward. const visited = [...visitedBeatsRef.current]; const exit: SceneExit = { kind: "freeform", action: text, }; clearPool(poolRef.current); const specSession: Session = { ...session, history: session.history.map((h, i, arr) => i === arr.length - 1 ? { ...h, visitedBeatIds: visited, exit } : h, ), }; const promise = (async () => { const data = await requestScene({ session: specSession, clientTts: !!byoTtsRef.current, }); return data; })(); setPendingClick(null); void performSceneTransition( promise, exit, visited, text, () => onFreeformInput(text), { kind: "freeform", text }, ); } catch (e) { if (!handleAuthError(e, () => onFreeformInput(text), { kind: "freeform", text })) { trackPlayError("freeform", e, freeformT0); setError(String(e)); } setPhase("ready"); } } async function onBackgroundClick(click: { x: number; y: number }) { if (phase !== "ready" || !session || !currentScene || !imageUrl) return; // Gate on replaySourceRef, not replayActiveRef (false for a single-scene // share) — see onFreeformInput for the rationale. if (replaySourceRef.current) detachRecordedReplay(); const visionT0 = Date.now(); setPhase("vision-thinking"); setPendingClick(click); try { const annotatedImageBase64 = await annotateClick(imageUrl, click); const decision = await visionDecide({ session, annotatedImageBase64, }); track("vision_click", { result: decision.classify }); if (decision.classify === "insert-beat") { setPhase("inserting-beat"); const { partial, extraBeats, characters: insertChars } = await requestInsertBeat({ session, freeformAction: decision.intent.freeformAction, clientTts: !!byoTtsRef.current, }); const fromBeatId = currentBeatRef.current?.id ?? currentScene.entryBeatId; const allPartials = [partial, ...(extraBeats ?? [])]; const newBeats: Beat[] = []; const newBeatIds: string[] = []; for (const [i, p] of allPartials.entries()) { const id = `b_ins_${Date.now()}_${Math.random().toString(36).slice(2, 6)}_${i}`; newBeatIds.push(id); newBeats.push({ id, narration: p.narration, speaker: p.speaker, line: p.line, lineDelivery: p.lineDelivery, next: { type: "continue", nextBeatId: "" }, }); } // Chain beats: each points to the next; last one loops back to original beat for (let i = 0; i < newBeats.length - 1; i++) { newBeats[i]!.next = { type: "continue", nextBeatId: newBeatIds[i + 1]! }; } newBeats[newBeats.length - 1]!.next = { type: "continue", nextBeatId: fromBeatId }; const patched: Scene = { ...currentScene, beats: [...currentScene.beats, ...newBeats], }; const nextVisited = [...visitedBeatsRef.current, ...newBeatIds]; visitedBeatsRef.current = nextVisited; const nextSession: Session = { ...session, history: session.history.map((h, i, arr) => i === arr.length - 1 ? { ...h, scene: patched, visitedBeatIds: nextVisited } : h, ), characters: insertChars, }; setSession(nextSession); setCurrentScene(patched); setCurrentBeatId(newBeatIds[0]!); for (const nb of newBeats) { if (nb.speaker && nb.line) { void fetchBeatAudio(nextSession, { id: nb.id, speaker: nb.speaker, line: nb.line, lineDelivery: nb.lineDelivery, }); } } setLastExitLabel(decision.intent.freeformAction); setPhase("ready"); setPendingClick(null); } else { const exit: SceneExit = { kind: "freeform", action: decision.intent.freeformAction, }; const visited = [...visitedBeatsRef.current]; const base = sessionRef.current; if (!base) { setPhase("ready"); setPendingClick(null); return; } const specSession: Session = { ...base, history: base.history.map((h, i, arr) => i === arr.length - 1 ? { ...h, visitedBeatIds: visited, exit } : h, ), }; clearPool(poolRef.current); const promise = (async () => { const data = await requestScene({ session: specSession, clientTts: !!byoTtsRef.current, }); return data; })(); await performSceneTransition( promise, exit, visited, decision.intent.freeformAction, () => onBackgroundClick(click), { kind: "background-click", x: click.x, y: click.y }, ); } } catch (e) { if (!handleAuthError(e, () => onBackgroundClick(click), { kind: "background-click", x: click.x, y: click.y })) { trackPlayError("vision", e, visionT0); setError(String(e)); } setPendingClick(null); setPhase("ready"); } } // ── Render ──────────────────────────────────────────────────────────── const replayAllowedChoiceIds = recordedAllowedChoiceIds(currentBeat); const disabledReplayChoiceIds = replayAllowedChoiceIds && currentBeat?.next.type === "choice" ? currentBeat.next.choices .filter((choice) => !replayAllowedChoiceIds.has(choice.id)) .map((choice) => choice.id) : []; const replayLocked = isRecordedReplayLockedAt(currentBeat); if (error) { return (

{t("play.error.title")}

{error}

{t("play.error.back")}
); } // Mobile portrait renders full-bleed by default — it sidesteps the iOS // Safari Fullscreen API (unsupported on iPhone) with a CSS full-viewport // layout instead. Desktop "presentation" mode shares the same immersive // canvas, toggled via the F key. const immersive = presentation || orientation === "portrait"; if (immersive) { return (
setSettingsOpen(true)} onImageReady={handleImageReady} fullViewport dialogueHistory={dialogueHistory} disabledChoiceIds={disabledReplayChoiceIds} freeformDisabled={replayLocked} /> {orientation === "portrait" && (
)} {settingsOpen && ( setSettingsOpen(false)} onSaved={handleSettingsSaved} footerNote={t("play.settingsFooter")} /> )} {authModalOpen && ( { setAuthModalOpen(false); // User dismissed login — drop the retry, don't re-run the action. authResolveRef.current = null; pendingResumeActionRef.current = null; }} onSuccess={() => { setAuthModalOpen(false); const retry = authResolveRef.current; authResolveRef.current = null; pendingResumeActionRef.current = null; retry?.(); }} onBeforeOAuth={persistPlayResume} /> )}
); } const sceneCount = session?.history.length ?? 0; const beatCount = visitedBeatsRef.current.length; // Surface the BYO-key nudge only to an unmuted, non-BYO player whose last few // beats came back silent (shared key rate-limited) — the exact pain BYO fixes. // Dismissible for the session. const showSilenceNudge = phase === "ready" && !muted && !byoTtsConfig && !nudgeDismissed && silenceStrikes >= SILENCE_NUDGE_THRESHOLD; return (
{exportProgress && (
{exportProgress.label} {exportProgress.total > 0 && ( {exportProgress.done}/{exportProgress.total} )}
)}
InfiPlot
{t("play.counter.scene", { n: String(sceneCount).padStart(3, "0") })} · {t("play.counter.beat", { n: String(beatCount).padStart(3, "0") })}
setSettingsOpen(true)} onImageReady={handleImageReady} dialogueHistory={dialogueHistory} disabledChoiceIds={disabledReplayChoiceIds} freeformDisabled={replayLocked} aboveCanvas={ } belowCanvas={ session && session.history.length > 0 ? ( <> ) : null } aboveCanvasLeft={ <> {/* Silence nudge — a compact pill right beside the mute toggle. Triggers when the shared server key keeps coming back silent, which usually means it's rate-limited; nudges the player to enter their own API Key for a more stable experience. Clicking opens the settings modal in place; the × dismisses it for the session. */} {showSilenceNudge && ( )} } />
{phase === "loading-first" && (

{t("play.loading.loadingFirst")}

)} {phase === "ready" && lastExitLabel && (

{t("play.previousStep")} {lastExitLabel}

)}
{settingsOpen && ( setSettingsOpen(false)} onSaved={handleSettingsSaved} footerNote={t("play.settingsFooter")} /> )} {authModalOpen && ( { setAuthModalOpen(false); // User dismissed login — drop the retry, don't re-run the action. authResolveRef.current = null; pendingResumeActionRef.current = null; }} onSuccess={() => { setAuthModalOpen(false); const retry = authResolveRef.current; authResolveRef.current = null; pendingResumeActionRef.current = null; retry?.(); }} onBeforeOAuth={persistPlayResume} /> )}
); } export default function PlayPage() { return ( } > ); }