"use client"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { DialogueHistoryModal, type DialogueHistoryItem, } from "@/components/DialogueHistoryModal"; import type { Beat, BeatChoice, Orientation } from "@infiplot/types"; export type Phase = | "loading-first" // first scene not yet rendered | "ready" // current beat is interactive | "vision-thinking" // background click → waiting on vision verdict | "inserting-beat" // vision-driven beat being generated | "transitioning"; // changing scenes (cache miss or speculative wait) const SHADOW = "0 1px 0 rgba(45,24,16,0.05), 0 36px 64px -28px rgba(45,24,16,0.25), 0 8px 18px -6px rgba(45,24,16,0.10)"; const DEFAULT_CHAR_MS = 28; const MIN_CHAR_MS = 30; // Voice playback speed multiplier. >1 speeds up the (somewhat slow) MiMo voice // while preserving pitch. Typewriter pacing is divided by the same factor. const SPEECH_RATE = 1.2; // If audio metadata never arrives within this window, give up waiting and // let the typewriter run at default speed. const AUDIO_WAIT_TIMEOUT_MS = 2500; // ── Typewriter hook ──────────────────────────────────────────────────── // Returns the progressively-revealed text, a `done` flag, and a `skip()` that // instantly completes the current text. Reset is keyed by `resetKey` (the beat // id) rather than the text, so a new beat whose line happens to match the // previous one still replays from scratch. // // When `targetDurationMs` is provided we space characters to span that audio // duration, keeping text and voice in lockstep. While `waitForAudio` is true // and we don't yet know a duration, the typewriter holds (so text doesn't // race ahead of an audio that's still loading). function useTypewriter( text: string, resetKey: string, opts: { targetDurationMs?: number; waitForAudio: boolean } = { waitForAudio: false, }, ): { shown: string; done: boolean; skip: () => void } { const { targetDurationMs, waitForAudio } = opts; const [displayed, setDisplayed] = useState(""); const [prevKey, setPrevKey] = useState(resetKey); const timer = useRef | null>(null); // Sticky once the player has skipped this beat: prevents a late-arriving // audio metadata event from re-triggering the effect and replaying the text. const skippedRef = useRef(false); // Render-phase reset (React "adjust state on prop change" pattern): when the // beat changes, drop the old progress before this render commits. if (resetKey !== prevKey) { setPrevKey(resetKey); setDisplayed(""); skippedRef.current = false; } useEffect(() => { if (!text) return; // `=== undefined` (not `!targetDurationMs`): 0 means "audio failed or // timed out — run at default speed". The original truthy check stalled // the typewriter forever on those fallback paths. if (waitForAudio && targetDurationMs === undefined) return; // If the player skipped, settle on the full text and don't restart even // when audio metadata arrives late and re-triggers this effect. if (skippedRef.current) { setDisplayed(text); return; } const speed = targetDurationMs && text.length > 0 ? Math.max(MIN_CHAR_MS, targetDurationMs / text.length) : DEFAULT_CHAR_MS; let i = 0; timer.current = setInterval(() => { i += 1; setDisplayed(text.slice(0, i)); if (i >= text.length && timer.current) { clearInterval(timer.current); timer.current = null; } }, speed); return () => { if (timer.current) clearInterval(timer.current); timer.current = null; }; }, [resetKey, text, targetDurationMs, waitForAudio]); const skip = useCallback(() => { if (timer.current) { clearInterval(timer.current); timer.current = null; } skippedRef.current = true; setDisplayed(text); }, [text]); // During the throwaway render where the beat just changed, `displayed` still // holds the previous beat's text — coerce it to empty so nothing stale shows. const shown = resetKey === prevKey ? displayed : ""; const done = text.length === 0 || shown.length >= text.length; return { shown, done, skip }; } // ── Choice button ────────────────────────────────────────────────────── function ChoiceButton({ index, label, disabled, disabledTitle, vertical, onClick, }: { index: number; label: string; disabled: boolean; disabledTitle?: string; vertical: boolean; onClick: () => void; }) { return ( ); } // ── Main component ───────────────────────────────────────────────────── export function PlayCanvas({ imageUrl, audioSrc, muted, phase, beat, pendingClick, onBackgroundClick, onAdvance, onSelectChoice, onFreeformInput, fullViewport = false, orientation = "landscape", playerName, visionClickEnabled = true, onOpenSettings, onImageReady, aboveCanvas, aboveCanvasLeft, belowCanvas, dialogueHistory = [], disabledChoiceIds = [], freeformDisabled = false, }: { imageUrl: string | null; audioSrc: string | null; muted: boolean; phase: Phase; beat: Beat | null; pendingClick: { x: number; y: number } | null; onBackgroundClick: (click: { x: number; y: number }) => void; onAdvance: () => void; onSelectChoice: (choice: BeatChoice) => void; onFreeformInput?: (text: string) => void; fullViewport?: boolean; // 会话锁定的图片朝向。"portrait" 时整图铺满视口(object-fit:cover)、选项竖排、字号放大。 orientation?: Orientation; playerName?: string; // 选择节点点击背景是否触发识图。关闭时背景点击保持静默,用户只能点选项。 visionClickEnabled?: boolean; onOpenSettings?: () => void; onImageReady?: () => void; // 渲染在图片正上方、右对齐的 slot(画面外、紧贴右上角)。 aboveCanvas?: ReactNode; // 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。 aboveCanvasLeft?: ReactNode; // 渲染在图片正下方、右对齐的 slot(画面外、紧贴右下角),与 aboveCanvas 垂直镜像。 belowCanvas?: ReactNode; dialogueHistory?: DialogueHistoryItem[]; disabledChoiceIds?: readonly string[]; freeformDisabled?: boolean; }) { const imgRef = useRef(null); const audioRef = useRef(null); const [historyOpen, setHistoryOpen] = useState(false); const [freeformOpen, setFreeformOpen] = useState(false); const [freeformText, setFreeformText] = useState(""); const freeformInputRef = useRef(null); const displaySpeaker = (s: string | undefined) => s === "你" && playerName ? playerName : s; const [audioDurationMs, setAudioDurationMs] = useState( undefined, ); const isChoiceBeat = beat?.next.type === "choice"; const choices: BeatChoice[] = isChoiceBeat ? (beat!.next as { type: "choice"; choices: BeatChoice[] }).choices : []; const disabledChoices = new Set(disabledChoiceIds); const displayBody = beat?.speaker ? beat.line ?? "" : beat?.narration ?? ""; const { shown: typedBody, done: typingDone, skip: skipTypewriter } = useTypewriter(displayBody, beat?.id ?? "", { targetDurationMs: audioDurationMs, waitForAudio: Boolean(audioSrc), }); // ── Audio source change ────────────────────────────────────────────── // Reset duration when audio source changes; if loading takes too long, // unblock the typewriter via timeout so text doesn't stall. useEffect(() => { setAudioDurationMs(undefined); if (!audioSrc) return; const timer = setTimeout(() => { setAudioDurationMs((prev) => prev ?? 0); }, AUDIO_WAIT_TIMEOUT_MS); return () => clearTimeout(timer); }, [audioSrc]); // ── Mute toggle ─────────────────────────────────────────────────────── useEffect(() => { const el = audioRef.current; if (!el) return; el.muted = muted; el.playbackRate = SPEECH_RATE; if (!muted && audioSrc && el.paused) { el.play().catch(() => { // autoplay blocked — silent until next interaction }); } }, [muted, audioSrc]); function handleAudioMetadata() { const el = audioRef.current; if (!el) return; el.playbackRate = SPEECH_RATE; // Effective playback time is shorter once sped up — keep the typewriter in sync. const ms = Number.isFinite(el.duration) ? (el.duration * 1000) / SPEECH_RATE : 0; setAudioDurationMs(ms > 0 ? ms : 0); if (!muted) { el.play().catch(() => { // autoplay blocked }); } } function handleAudioError() { // Treat as zero duration so the typewriter runs at default speed. setAudioDurationMs(0); } function handleImageClick(e: React.MouseEvent) { if (phase !== "ready" || !beat) return; if (!typingDone) { skipTypewriter(); return; } if (beat.next.type === "continue") { onAdvance(); return; } if (freeformDisabled || !visionClickEnabled || !imgRef.current) return; const el = imgRef.current; const rect = el.getBoundingClientRect(); let x: number; let y: number; if (orientation === "portrait") { const nw = el.naturalWidth || 1024; const nh = el.naturalHeight || 1792; const scale = Math.max(rect.width / nw, rect.height / nh); const dispW = nw * scale; const dispH = nh * scale; x = (e.clientX - rect.left + (dispW - rect.width) / 2) / dispW; y = (e.clientY - rect.top + (dispH - rect.height) / 2) / dispH; } else { x = (e.clientX - rect.left) / rect.width; y = (e.clientY - rect.top) / rect.height; } onBackgroundClick({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)), }); } // Card swallows its own clicks so they never fall through to the image's // vision (识图) trigger: while typing a click completes the text, a continue // beat advances, and a choice beat stays inert (player must pick an option). function handleCardClick() { if (phase !== "ready" || !beat) return; if (!typingDone) { skipTypewriter(); return; } if (beat.next.type === "continue") onAdvance(); } const interactive = phase === "ready" && !!imageUrl; const imageClickable = interactive && (!typingDone || beat?.next.type === "continue" || (visionClickEnabled && !freeformDisabled)); const dimmed = phase === "transitioning"; const portrait = orientation === "portrait"; const intrinsicW = portrait ? 1024 : 1792; const intrinsicH = portrait ? 1792 : 1024; // Portrait (mobile) always fills the whole viewport with object-fit:cover so // the 9:16 image matches the exact device/window — no letterbox. Landscape // keeps the prior contain-style sizing so the full 16:9 frame stays visible. const sizeStyle: React.CSSProperties = portrait ? { width: "100%", height: "100%", objectFit: "cover" } : fullViewport ? { width: "100%", height: "100%", objectFit: "contain" } : { width: "100%", height: "100%" }; const canvasStyle: React.CSSProperties = portrait ? { width: "100vw", height: "100dvh" } : { width: fullViewport ? "min(100vw, calc(100dvh * 16 / 9))" : "min(96vw, calc((100dvh - 200px) * 16 / 9))", aspectRatio: "16 / 9", maxHeight: fullViewport ? "100dvh" : "calc(100dvh - 200px)", }; const placeholderStyle: React.CSSProperties = portrait ? { width: "100vw", height: "100dvh" } : { width: fullViewport ? "min(100vw, calc(100dvh * 16 / 9))" : "min(96vw, calc((100dvh - 200px) * 16 / 9))", }; return (
{/* Hidden audio element — voice playback for the current beat */} {audioSrc && (