da191dd7a2
手机竖屏 (orientation === 'portrait') 和桌面按 F 全屏 (presentation) 都会走 PlayInner 的 immersive 渲染分支,但该分支 加入时只带了 SettingsModal、漏掉了 AuthModal。导致这两条路径下 若 API 返回 401 触发 setAuthModalOpen(true),登录框不会被挂载, 用户无法登录继续游戏。 预设故事卡片入口 (onCardClick) 不做跳转前登录校验,未登录用户进 /play 后点选项即触发 401,在手机上复现该 bug。 补上与非 immersive 分支完全一致的 AuthModal 块,复用现有 authResolveRef 重试机制,登录成功后自动重放被拦截的请求。
2488 lines
95 KiB
TypeScript
2488 lines
95 KiB
TypeScript
"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/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 { 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,
|
||
classifyFreeform,
|
||
requestInsertBeat,
|
||
AuthRequiredError,
|
||
} from "@/lib/engineClient";
|
||
import type {
|
||
Beat,
|
||
BeatChoice,
|
||
Character,
|
||
CharacterVoice,
|
||
Orientation,
|
||
Scene,
|
||
SceneExit,
|
||
SceneResponse,
|
||
Session,
|
||
StartResponse,
|
||
TtsConfig,
|
||
} from "@infiplot/types";
|
||
import { track } from "@/lib/analytics";
|
||
import { AUTH_ENABLED } from "@/lib/supabase/config";
|
||
import { AuthModal } from "@/components/AuthModal";
|
||
import { UserChip } from "@/components/UserChip";
|
||
|
||
const MUTED_STORAGE_KEY = "infiplot:muted";
|
||
|
||
// 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 <img> 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 <img> aspect-video fallback together remove that failure mode.
|
||
const IMAGE_PRELOAD_TIMEOUT_MS = 20000;
|
||
|
||
// After blob/preload resolves the <img> 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 <img> 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 <img>. 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 <img>. The <img>
|
||
// 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 <img> mounts the cache is warm and first paint is
|
||
// instant. Errors / timeouts resolve quietly — better a broken <img> 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<void> {
|
||
return new Promise<void>((resolve) => {
|
||
const img = new Image();
|
||
let timer: ReturnType<typeof setTimeout>;
|
||
// 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 <img> 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<string> {
|
||
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 <img> 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<string, Promise<string>>();
|
||
|
||
function getOrCreateBlobUrl(originalUrl: string): Promise<string> {
|
||
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<SceneResponse>;
|
||
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<string>();
|
||
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<string, PrefetchEntry>,
|
||
// 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<string, Scene>,
|
||
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: baseSession.orientation ?? "landscape",
|
||
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<string, PrefetchEntry>,
|
||
choiceId: string,
|
||
): PrefetchEntry | undefined {
|
||
const my = pool.get(choiceId);
|
||
const survivors = new Map<string, PrefetchEntry>();
|
||
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<string, PrefetchEntry>): 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<string, Promise<CharacterVoice>>,
|
||
cfg: TtsConfig,
|
||
speaker: Character,
|
||
): Promise<CharacterVoice | null> {
|
||
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 [phase, setPhase] = useState<Phase>("loading-first");
|
||
const [session, setSession] = useState<Session | null>(null);
|
||
const [currentScene, setCurrentScene] = useState<Scene | null>(null);
|
||
const [currentBeatId, setCurrentBeatId] = useState<string | null>(null);
|
||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||
const [beatAudioMap, setBeatAudioMap] = useState<Record<string, string>>({});
|
||
// Lazy-initialize 优先级:本局选择(homepage 的「语音配音」存到 sessionStorage:infiplot:custom)
|
||
// > 上次会话的粘性偏好(localStorage:infiplot:muted) > 默认非静音。
|
||
// 这样首页选了「关闭」开始游戏,进来就是静音;选「开启」就不是静音;进入 play 页后用户自己
|
||
// 切换 静音/有声 时再用 localStorage 持久化,下一局开新游戏 sessionStorage 选择会再覆盖。
|
||
const [muted, setMuted] = useState<boolean>(() => {
|
||
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<string | null>(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<Orientation>("landscape");
|
||
const [lastExitLabel, setLastExitLabel] = useState<string | null>(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);
|
||
// 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).
|
||
const handleAuthError = useCallback(
|
||
(e: unknown, retry?: () => void): boolean => {
|
||
if (e instanceof AuthRequiredError) {
|
||
authResolveRef.current = retry ?? null;
|
||
setAuthModalOpen(true);
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
[],
|
||
);
|
||
|
||
const startedRef = useRef(false);
|
||
const poolRef = useRef<Map<string, PrefetchEntry>>(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<Map<string, Scene>>(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<Map<string, AbortController>>(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<boolean>(muted);
|
||
const phaseRef = useRef<Phase>(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<TtsConfig | null>(() =>
|
||
loadClientTtsConfig(),
|
||
);
|
||
const byoTtsRef = useRef<TtsConfig | null>(byoTtsConfig);
|
||
// 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<Map<string, Promise<CharacterVoice>>>(
|
||
new Map(),
|
||
);
|
||
|
||
// Mirrors for use inside async handlers (closure-stable)
|
||
const sessionRef = useRef<Session | null>(null);
|
||
const currentSceneRef = useRef<Scene | null>(null);
|
||
const currentBeatRef = useRef<Beat | null>(null);
|
||
const visitedBeatsRef = useRef<string[]>([]);
|
||
const replaySourceRef = useRef<Session | null>(null);
|
||
const replayIndexRef = useRef(-1);
|
||
const replayActiveRef = useRef(false);
|
||
const exportingStoryRef = useRef(false);
|
||
const exportingGalleryRef = useRef(false);
|
||
// Audio carried in from a `.infiplot` share file, keyed by `${sceneId}:${beatId}`.
|
||
// Survives scene swaps so a player who re-exports a replayed game keeps the
|
||
// baked voices that the original creator already paid to synth — they're
|
||
// free to embed back into the new gallery / share file.
|
||
const prebakedAudioRef = useRef<Record<string, string>>({});
|
||
// 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<string | null>(null);
|
||
|
||
// Image-ready gate: keeps the "transitioning" overlay visible until the
|
||
// actual <img> 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<void> {
|
||
return new Promise<void>((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<Beat | null>(() => {
|
||
if (!currentScene || !currentBeatId) return null;
|
||
return currentScene.beats.find((b) => b.id === currentBeatId) ?? null;
|
||
}, [currentScene, currentBeatId]);
|
||
|
||
const dialogueHistory = useMemo<DialogueHistoryItem[]>(
|
||
() => buildDialogueHistory(session),
|
||
[session],
|
||
);
|
||
|
||
const audioSrc = (currentBeat ? beatAudioMap[currentBeat.id] : undefined) ?? null;
|
||
|
||
useEffect(() => {
|
||
sessionRef.current = session;
|
||
}, [session]);
|
||
useEffect(() => {
|
||
currentSceneRef.current = currentScene;
|
||
}, [currentScene]);
|
||
useEffect(() => {
|
||
currentBeatRef.current = currentBeat;
|
||
}, [currentBeat]);
|
||
useEffect(() => {
|
||
mutedRef.current = muted;
|
||
}, [muted]);
|
||
useEffect(() => {
|
||
phaseRef.current = phase;
|
||
}, [phase]);
|
||
useEffect(() => {
|
||
setVisionClickEnabled(readStoredVisionClick());
|
||
}, []);
|
||
|
||
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<void> => {
|
||
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;
|
||
// Non-BYO relies on the server having provisioned speaker.voice. BYO
|
||
// skipped server TTS, so it needs a baked voice (prebaked card) or a
|
||
// voiceDescription to provision from in the browser.
|
||
if (!byo && !speaker.voice) return;
|
||
if (byo && !speaker.voice && !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: POST just this beat + the speaker's voice (not
|
||
// the whole session) to /api/beat-audio. Returns 204 when the engine
|
||
// had nothing to say (no TTS configured / empty synth) and binary
|
||
// audio otherwise. Both 204 and !ok count as a silence strike so the
|
||
// nudge surfaces when the shared server key is being rate-limited.
|
||
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 },
|
||
voice: speaker.voice,
|
||
}),
|
||
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);
|
||
// Refresh the BYO TTS config so a key entered mid-session takes effect
|
||
// immediately — byoTtsRef is otherwise only read once at mount.
|
||
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:<id>: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<string, { key: string; createdAt: number }> = new Map();
|
||
const sidecars: Map<string, string> = 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#<id> 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<string, GalleryScene> = {};
|
||
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<string, string> = {};
|
||
try {
|
||
setExportProgress({ done: 0, total: 0, label: "正在准备配音" });
|
||
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: "正在准备配音" }),
|
||
});
|
||
} 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: s.orientation ?? "landscape",
|
||
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<string, string> = {};
|
||
try {
|
||
setExportProgress({ done: 0, total: 0, label: "正在准备配音" });
|
||
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: "正在准备配音" }),
|
||
});
|
||
} 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 ?? "剧情分享打包失败");
|
||
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("剧情分享打包失败");
|
||
} finally {
|
||
exportingStoryRef.current = false;
|
||
}
|
||
}, [beatAudioMap]);
|
||
|
||
// ── 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) {
|
||
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;
|
||
|
||
// 三条进入路径:
|
||
// ?card=<m0..f31> → 首页精选卡,直接从 /home/firstact/{name}.json
|
||
// 静态文件加载(已在构建期 prebake,免一切引擎调用)
|
||
// ?preset=<id> → 内置 PRESETS(仍走 /api/start 现场生成)
|
||
// ?custom=1 → 用户自定义 prompt,sessionStorage 取 ws/sg
|
||
// 后走 /api/start 现场生成
|
||
// ?share=1 → 首页上传的剧情分享 JSON,从第一幕开始本地回放
|
||
const cardName = params.get("card");
|
||
const presetId = params.get("preset");
|
||
const isCustom = params.get("custom") === "1";
|
||
const isShare = params.get("share") === "1";
|
||
|
||
if (isShare) {
|
||
(async () => {
|
||
const t0 = Date.now();
|
||
try {
|
||
const raw = sessionStorage.getItem(STORY_SHARE_STORAGE_KEY);
|
||
if (!raw) throw new Error("没有找到要载入的剧情文件。");
|
||
const doc = parseStoryShareDoc(JSON.parse(raw));
|
||
const imported = doc.session;
|
||
const first = imported.history[0];
|
||
if (!first) throw new Error("剧情分享文件没有可载入的剧情。");
|
||
if (!first.scene.imageUrl) throw new Error("剧情分享文件缺少第一幕图片。");
|
||
|
||
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("剧情分享文件缺少初始剧情记忆,无法载入。");
|
||
|
||
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];
|
||
// Stash pre-baked audio (from doc.audioByBeatId) so it survives scene
|
||
// swaps and re-exports. Keyed by `${sceneId}:${beatId}`. Also seed the
|
||
// current beatAudioMap for the first scene so audio plays right away
|
||
// — the scene-change effect normally clears the map on transition,
|
||
// and bare beat ids "b1/b2/..." would otherwise miss prebaked entries.
|
||
if (doc.audioByBeatId) {
|
||
prebakedAudioRef.current = { ...doc.audioByBeatId };
|
||
const seed: Record<string, string> = {};
|
||
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;
|
||
} | 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 };
|
||
} 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,
|
||
};
|
||
// 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;
|
||
|
||
if (!cardName && !livePayload) {
|
||
router.replace("/");
|
||
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 firstactDir = sessionOrientation === "portrait"
|
||
? "firstact-portrait"
|
||
: "firstact";
|
||
|
||
const startT0 = Date.now();
|
||
const fetchStart: Promise<PrebakedFirstAct> = cardName
|
||
? fetch(`/home/${firstactDir}/${encodeURIComponent(cardName)}.json`).then(
|
||
async (r) => {
|
||
if (r.ok) return (await r.json()) as PrebakedFirstAct;
|
||
if (sessionOrientation === "portrait") {
|
||
console.warn(`[play] portrait firstact missing for ${cardName} (HTTP ${r.status}), falling back to landscape`);
|
||
const fb = await fetch(`/home/firstact/${encodeURIComponent(cardName)}.json`);
|
||
if (fb.ok) {
|
||
const fallback = (await fb.json()) as PrebakedFirstAct;
|
||
return { ...fallback, scene: { ...fallback.scene, orientation: "landscape" as const } };
|
||
}
|
||
}
|
||
throw new Error(`找不到精选剧情:${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) => {
|
||
// Resolve to a paintable src before committing to state. Proxy path:
|
||
// a fully-local blob: URL the browser paints atomically (no row-by-row
|
||
// "层层加载"). Direct path (default): the preloaded original URL.
|
||
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,
|
||
};
|
||
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]);
|
||
|
||
// ── 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<SceneResponse>,
|
||
exit: SceneExit,
|
||
visitedForCurrent: string[],
|
||
exitLabel: string,
|
||
retry?: () => void,
|
||
) {
|
||
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 <img> 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)) {
|
||
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("剧情分享文件缺少下一幕图片。");
|
||
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<string> | 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<string>();
|
||
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),
|
||
);
|
||
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),
|
||
);
|
||
}
|
||
|
||
async function onFreeformInput(text: string) {
|
||
if (phase !== "ready" || !session || !currentScene) return;
|
||
if (replayActiveRef.current) detachRecordedReplay();
|
||
|
||
track("freeform_input", {
|
||
scene_index: session.history.length,
|
||
text_length: text.length,
|
||
});
|
||
|
||
const freeformT0 = Date.now();
|
||
setPhase("vision-thinking");
|
||
|
||
try {
|
||
const decision = await classifyFreeform({
|
||
session,
|
||
freeformText: text,
|
||
});
|
||
|
||
if (decision.classify === "insert-beat") {
|
||
// Interactive beat: NPC responds to the player's action, scene stays
|
||
setPhase("inserting-beat");
|
||
const { partial, characters: insertChars } = await requestInsertBeat({
|
||
session,
|
||
freeformAction: decision.freeformAction,
|
||
clientTts: !!byoTtsRef.current,
|
||
});
|
||
|
||
const fromBeatId =
|
||
currentBeatRef.current?.id ?? currentScene.entryBeatId;
|
||
const newBeatId = `b_ins_${Date.now()}_${Math.random()
|
||
.toString(36)
|
||
.slice(2, 6)}`;
|
||
const newBeat: Beat = {
|
||
id: newBeatId,
|
||
narration: partial.narration,
|
||
speaker: partial.speaker,
|
||
line: partial.line,
|
||
lineDelivery: partial.lineDelivery,
|
||
next: { type: "continue", nextBeatId: fromBeatId },
|
||
};
|
||
|
||
const patched: Scene = {
|
||
...currentScene,
|
||
beats: [...currentScene.beats, newBeat],
|
||
};
|
||
const nextVisited = [...visitedBeatsRef.current, newBeatId];
|
||
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(newBeatId);
|
||
if (newBeat.speaker && newBeat.line) {
|
||
void fetchBeatAudio(nextSession, {
|
||
id: newBeatId,
|
||
speaker: newBeat.speaker,
|
||
line: newBeat.line,
|
||
lineDelivery: newBeat.lineDelivery,
|
||
});
|
||
}
|
||
setLastExitLabel(decision.freeformAction);
|
||
setPhase("ready");
|
||
return;
|
||
}
|
||
|
||
// change-scene path
|
||
const visited = [...visitedBeatsRef.current];
|
||
const exit: SceneExit = {
|
||
kind: "freeform",
|
||
action: decision.freeformAction,
|
||
};
|
||
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,
|
||
decision.freeformAction,
|
||
() => onFreeformInput(text),
|
||
);
|
||
} catch (e) {
|
||
if (!handleAuthError(e, () => onFreeformInput(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;
|
||
if (replayActiveRef.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, characters: insertChars } = await requestInsertBeat({
|
||
session,
|
||
freeformAction: decision.intent.freeformAction,
|
||
clientTts: !!byoTtsRef.current,
|
||
});
|
||
|
||
const fromBeatId =
|
||
currentBeatRef.current?.id ?? currentScene.entryBeatId;
|
||
const newBeatId = `b_ins_${Date.now()}_${Math.random()
|
||
.toString(36)
|
||
.slice(2, 6)}`;
|
||
const newBeat: Beat = {
|
||
id: newBeatId,
|
||
narration: partial.narration,
|
||
speaker: partial.speaker,
|
||
line: partial.line,
|
||
lineDelivery: partial.lineDelivery,
|
||
next: { type: "continue", nextBeatId: fromBeatId },
|
||
};
|
||
|
||
const patched: Scene = {
|
||
...currentScene,
|
||
beats: [...currentScene.beats, newBeat],
|
||
};
|
||
|
||
const nextSession: Session = {
|
||
...session,
|
||
history: session.history.map((h, i, arr) =>
|
||
i === arr.length - 1 ? { ...h, scene: patched } : h,
|
||
),
|
||
characters: insertChars,
|
||
};
|
||
setSession(nextSession);
|
||
setCurrentScene(patched);
|
||
setCurrentBeatId(newBeatId);
|
||
// Insert-beat doesn't change scene.id, so the scene effect won't
|
||
// re-fire — manually kick off the audio fetch for the new beat.
|
||
if (newBeat.speaker && newBeat.line) {
|
||
void fetchBeatAudio(nextSession, {
|
||
id: newBeatId,
|
||
speaker: newBeat.speaker,
|
||
line: newBeat.line,
|
||
lineDelivery: newBeat.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),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (!handleAuthError(e, () => onBackgroundClick(click))) {
|
||
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 (
|
||
<div className="min-h-screen flex flex-col items-center justify-center px-8">
|
||
<div className="max-w-md text-center animate-fade-in">
|
||
<p className="text-[10px] smallcaps text-clay-500 mb-6">
|
||
出 · 了 · 点 · 状 · 况
|
||
</p>
|
||
<p className="font-serif italic text-clay-900 text-lg leading-[1.7] mb-6">
|
||
{error}
|
||
</p>
|
||
<Link
|
||
href="/"
|
||
className="mt-4 text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors inline-flex items-center gap-3"
|
||
>
|
||
<i className="fa-solid fa-arrow-left text-[9px]" />
|
||
返 回
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<div className="fixed inset-0 bg-black flex items-center justify-center z-50">
|
||
<PlayCanvas
|
||
imageUrl={imageUrl}
|
||
audioSrc={audioSrc}
|
||
muted={muted}
|
||
phase={phase}
|
||
beat={currentBeat}
|
||
pendingClick={pendingClick}
|
||
onBackgroundClick={onBackgroundClick}
|
||
onAdvance={onAdvance}
|
||
onSelectChoice={onSelectChoice}
|
||
onFreeformInput={onFreeformInput}
|
||
orientation={orientation}
|
||
playerName={session?.playerName}
|
||
visionClickEnabled={visionClickEnabled}
|
||
onOpenSettings={() => setSettingsOpen(true)}
|
||
onImageReady={handleImageReady}
|
||
fullViewport
|
||
dialogueHistory={dialogueHistory}
|
||
disabledChoiceIds={disabledReplayChoiceIds}
|
||
freeformDisabled={replayLocked}
|
||
/>
|
||
{orientation === "portrait" && (
|
||
<div
|
||
className="absolute inset-x-0 top-0 z-10 flex items-center justify-between px-4 pointer-events-none"
|
||
style={{ paddingTop: "max(0.5rem, env(safe-area-inset-top))" }}
|
||
>
|
||
<Link
|
||
href="/"
|
||
className="pointer-events-auto flex h-9 w-9 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-sm transition-colors hover:text-white"
|
||
aria-label="返回"
|
||
>
|
||
<i className="fa-solid fa-arrow-left text-[13px]" />
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
onClick={toggleMuted}
|
||
className="pointer-events-auto flex h-9 w-9 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-sm transition-colors hover:text-white"
|
||
aria-label={muted ? "取消静音" : "静音"}
|
||
>
|
||
<i
|
||
className={`fa-solid ${muted ? "fa-volume-xmark" : "fa-volume-high"} text-[13px]`}
|
||
/>
|
||
</button>
|
||
</div>
|
||
)}
|
||
{settingsOpen && (
|
||
<SettingsModal
|
||
initialVisionClickEnabled={visionClickEnabled}
|
||
onClose={() => setSettingsOpen(false)}
|
||
onSaved={handleSettingsSaved}
|
||
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
|
||
/>
|
||
)}
|
||
{authModalOpen && (
|
||
<AuthModal
|
||
onClose={() => {
|
||
setAuthModalOpen(false);
|
||
// User dismissed login — drop the retry, don't re-run the action.
|
||
authResolveRef.current = null;
|
||
}}
|
||
onSuccess={() => {
|
||
setAuthModalOpen(false);
|
||
const retry = authResolveRef.current;
|
||
authResolveRef.current = null;
|
||
retry?.();
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="min-h-screen flex flex-col">
|
||
{exportProgress && (
|
||
<div
|
||
className="fixed top-4 left-1/2 -translate-x-1/2 z-50 rounded-full bg-black/75 px-4 py-2 text-[11px] smallcaps text-white/95 backdrop-blur-sm shadow-lg flex items-center gap-2"
|
||
>
|
||
<i className="fa-solid fa-circle-notch animate-spin text-[11px] text-amber-300" />
|
||
<span>{exportProgress.label}</span>
|
||
{exportProgress.total > 0 && (
|
||
<span className="num text-white/70">
|
||
{exportProgress.done}/{exportProgress.total}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
<header className="px-5 md:px-12 pt-6 md:pt-8 flex items-center justify-between">
|
||
<Link
|
||
href="/"
|
||
className="text-clay-600 hover:text-clay-900 transition-colors flex items-center gap-3"
|
||
>
|
||
<i className="fa-solid fa-arrow-left text-[12px]" />
|
||
<span className="font-serif text-[22px] md:text-[26px] leading-none tracking-tight">
|
||
Infi<em className="italic font-light text-ember-500">Plot</em>
|
||
</span>
|
||
</Link>
|
||
<div className="flex items-center gap-3">
|
||
<div className="text-[10px] smallcaps text-clay-500 num flex items-center gap-3">
|
||
<span>第 · {String(sceneCount).padStart(3, "0")} · 幕</span>
|
||
<span className="text-clay-300">·</span>
|
||
<span>{String(beatCount).padStart(3, "0")} · 拍</span>
|
||
</div>
|
||
<UserChip onLoginClick={() => setAuthModalOpen(true)} />
|
||
</div>
|
||
</header>
|
||
|
||
<main className="flex-1 flex flex-col items-center justify-center px-4 md:px-8 py-6 md:py-10">
|
||
<PlayCanvas
|
||
imageUrl={imageUrl}
|
||
audioSrc={audioSrc}
|
||
muted={muted}
|
||
phase={phase}
|
||
beat={currentBeat}
|
||
pendingClick={pendingClick}
|
||
onBackgroundClick={onBackgroundClick}
|
||
onAdvance={onAdvance}
|
||
onSelectChoice={onSelectChoice}
|
||
onFreeformInput={onFreeformInput}
|
||
orientation={orientation}
|
||
playerName={session?.playerName}
|
||
visionClickEnabled={visionClickEnabled}
|
||
onOpenSettings={() => setSettingsOpen(true)}
|
||
onImageReady={handleImageReady}
|
||
dialogueHistory={dialogueHistory}
|
||
disabledChoiceIds={disabledReplayChoiceIds}
|
||
freeformDisabled={replayLocked}
|
||
aboveCanvas={
|
||
<button
|
||
type="button"
|
||
onClick={() => void togglePresentation()}
|
||
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2"
|
||
aria-label="进入全屏"
|
||
title="全屏 (F)"
|
||
>
|
||
<i className="fa-solid fa-expand text-[10px]" />
|
||
F · 键 · 全 · 屏
|
||
</button>
|
||
}
|
||
belowCanvas={
|
||
session && session.history.length > 0 ? (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleExportGallery()}
|
||
disabled={!!exportProgress}
|
||
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||
aria-label="导出可交互图集"
|
||
title="导出本局为可交互图集链接(含配音;只会保留最近两次的可交互图集链接)"
|
||
>
|
||
<i className="fa-solid fa-link text-[10px]" />
|
||
导 · 出 · 图 · 集
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleExportStory()}
|
||
disabled={!!exportProgress}
|
||
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||
aria-label="分享当前剧情"
|
||
title="导出本局为可继续游玩的剧情 .infiplot(含配音)"
|
||
>
|
||
<i className="fa-solid fa-share-nodes text-[10px]" />
|
||
分 · 享 · 剧 · 情
|
||
</button>
|
||
</>
|
||
) : null
|
||
}
|
||
aboveCanvasLeft={
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={toggleMuted}
|
||
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2"
|
||
aria-label={muted ? "取消静音" : "静音"}
|
||
title={muted ? "取消静音" : "静音"}
|
||
>
|
||
<i
|
||
className={`fa-solid ${muted ? "fa-volume-xmark" : "fa-volume-high"} text-[10px]`}
|
||
/>
|
||
{muted ? "静 · 音" : "有 · 声"}
|
||
</button>
|
||
|
||
{/* 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 && (
|
||
<span className="flex items-center gap-1 animate-fade-in">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSettingsOpen(true)}
|
||
className="inline-flex items-center gap-1.5 rounded-full border border-ember-500/40 bg-ember-500/10 px-2.5 py-1 text-[10px] text-ember-500 hover:bg-ember-500/20 transition-colors"
|
||
title="效果不满意/经常没声音?填入自己的 API Key 试试"
|
||
>
|
||
<i className="fa-solid fa-volume-xmark text-[9px]" />
|
||
效果不满意/经常没声音?填入自己的 API Key 试试
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setNudgeDismissed(true)}
|
||
aria-label="关闭提示"
|
||
title="关闭"
|
||
className="text-clay-400 hover:text-clay-700 transition-colors"
|
||
>
|
||
<i className="fa-solid fa-xmark text-[10px]" />
|
||
</button>
|
||
</span>
|
||
)}
|
||
</>
|
||
}
|
||
/>
|
||
|
||
<div className="mt-4 max-w-md w-full text-center min-h-[28px] flex items-center justify-center">
|
||
{phase === "loading-first" && (
|
||
<p className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
|
||
正 · 在 · 唤 · 起 · 第 · 一 · 幕
|
||
</p>
|
||
)}
|
||
{phase === "ready" && lastExitLabel && (
|
||
<p className="text-[9px] smallcaps text-clay-400 animate-fade-in">
|
||
<span className="mr-2">上 · 一 · 步 ·</span>
|
||
<span className="text-clay-600">{lastExitLabel}</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
</main>
|
||
|
||
{settingsOpen && (
|
||
<SettingsModal
|
||
initialVisionClickEnabled={visionClickEnabled}
|
||
onClose={() => setSettingsOpen(false)}
|
||
onSaved={handleSettingsSaved}
|
||
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
|
||
/>
|
||
)}
|
||
{authModalOpen && (
|
||
<AuthModal
|
||
onClose={() => {
|
||
setAuthModalOpen(false);
|
||
// User dismissed login — drop the retry, don't re-run the action.
|
||
authResolveRef.current = null;
|
||
}}
|
||
onSuccess={() => {
|
||
setAuthModalOpen(false);
|
||
const retry = authResolveRef.current;
|
||
authResolveRef.current = null;
|
||
retry?.();
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function PlayPage() {
|
||
return (
|
||
<Suspense
|
||
fallback={
|
||
<div className="min-h-screen flex items-center justify-center">
|
||
<span className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
|
||
载入中
|
||
</span>
|
||
</div>
|
||
}
|
||
>
|
||
<PlayInner />
|
||
</Suspense>
|
||
);
|
||
}
|