"use client"; import Link from "next/link"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import type { Beat, BeatChoice, Orientation, SceneExit, } from "@infiplot/types"; import { downloadImagesIndividually, downloadImagesAsZip, inferImageExtension, } from "@/lib/imageZipDownload"; import { useLocalePath } from "@/lib/i18n/hooks"; // ────────────────────────────────────────────────────────────────────── // Gallery — an offline-only replay of a played session. Entered from // /play via the 导出图集 button, which strips the live Session to the // GalleryDoc fields below (no voice base64 / no style reference), writes // it to localStorage under a one-shot id, then opens /gallery#id= // in a new tab. // // No engine calls happen here. Every scene image is a Runware CDN link // the browser already loaded once during play. Choices are clickable: // - advance-beat choices are pure local jumps (the beats live in the // scene already) // - change-scene choices are looked up in `alternates` — main-path picks // resolve to the next visited scene, and any AI-prefetched-but-not-taken // alternates also live there so the player can explore branches the // engine already paid to generate // Choices with no recorded alternate are greyed (no way to navigate // forward without re-calling the engine, which we deliberately don't do). // ────────────────────────────────────────────────────────────────────── export type GalleryScene = { /** Scene id from the original engine. Used to key into `alternates` and * to detect when an alternate happens to be a main-path scene. */ id?: string; imageUrl: string; sceneKey?: string; orientation?: Orientation; beats: Beat[]; entryBeatId: string; /** Beat ids the player walked, in order. Set for main-path scenes; * absent for prefetched alternates the player never entered. */ visitedBeatIds?: string[]; /** How the player left the scene. Same scoping as visitedBeatIds. */ exit?: SceneExit; }; export type GalleryDoc = { /** v1 = scenes only (initial export). v2 = + alternates + characters. * v3 = + beat audio (stored in a sidecar localStorage key so the main * doc stays small and the first paint isn't blocked by JSON.parse-ing * several MB of base64). */ v: 1 | 2 | 3; id: string; createdAt: number; orientation: Orientation; scenes: GalleryScene[]; /** Key: `${parentSceneId}:${choiceId}` → reachable scene. Includes both * main-path picks and AI-prefetched alternates the player abandoned. */ alternates?: Record; /** Cast for the "下载角色图" button. Name + CDN URL only. */ characters?: { name: string; basePortraitUrl?: string }[]; }; const STORAGE_PREFIX = "infiplot:gallery:"; const AUDIO_SUFFIX = ":audio"; const MUTED_STORAGE_KEY = "infiplot:gallery:muted"; function readDoc(id: string): GalleryDoc | null { try { const raw = window.localStorage.getItem(STORAGE_PREFIX + id); if (!raw) return null; const parsed = JSON.parse(raw) as GalleryDoc; if ( (parsed.v !== 1 && parsed.v !== 2 && parsed.v !== 3) || !Array.isArray(parsed.scenes) ) { return null; } return parsed; } catch { return null; } } function readSidecarAudio(id: string): Record { try { const raw = window.localStorage.getItem( STORAGE_PREFIX + id + AUDIO_SUFFIX, ); if (!raw) return {}; const parsed = JSON.parse(raw) as Record; const out: Record = {}; for (const [k, v] of Object.entries(parsed)) { if (typeof v === "string" && v.startsWith("data:")) out[k] = v; } return out; } catch { return {}; } } function detectOrientation(): Orientation { if (typeof window === "undefined") return "landscape"; const portrait = window.matchMedia("(orientation: portrait)").matches; const coarse = window.matchMedia("(pointer: coarse)").matches; return portrait && coarse ? "portrait" : "landscape"; } function findBeat(scene: GalleryScene, beatId: string): Beat | undefined { return scene.beats.find((b) => b.id === beatId); } // ── Identify which choice the player picked at this beat on the main path. // For advance-beat picks we match by the next visited beat id; for change- // scene picks we use the scene's recorded `exit`. Returns null when the // scene is not on the main path (no visitedBeatIds), or when the beat is // not on the visited trail. function pickedChoiceIdAt( scene: GalleryScene, beatId: string, ): string | null { if (!scene.visitedBeatIds) return null; const visited = scene.visitedBeatIds; const idx = visited.indexOf(beatId); if (idx < 0) return null; const beat = findBeat(scene, beatId); if (!beat || beat.next.type !== "choice") return null; const nextVisited = visited[idx + 1]; if (nextVisited) { const c = beat.next.choices.find( (c) => c.effect.kind === "advance-beat" && c.effect.targetBeatId === nextVisited, ); if (c) return c.id; } if ( scene.exit?.kind === "choice" && idx === visited.length - 1 ) { return scene.exit.choiceId; } return null; } // ────────────────────────────────────────────────────────────────────── // Dialogue panel — full beat trail of the current scene // ────────────────────────────────────────────────────────────────────── function DialoguePanel({ scene, portrait, onClose, }: { scene: GalleryScene; portrait: boolean; onClose: () => void; }) { // Use visitedBeatIds when present (main path); else walk the entry chain // through `continue` beats (alternates have no visit trail so we just show // their establishing beat — choice beats can't be auto-resolved). const beatIds = useMemo(() => { if (scene.visitedBeatIds && scene.visitedBeatIds.length > 0) { return scene.visitedBeatIds; } const chain: string[] = []; let cur = scene.entryBeatId; const guard = new Set(); while (cur && !guard.has(cur)) { chain.push(cur); guard.add(cur); const b = findBeat(scene, cur); if (!b || b.next.type !== "continue") break; cur = b.next.nextBeatId; } return chain; }, [scene]); return (
e.stopPropagation()} style={{ background: "rgba(14, 10, 6, 0.92)", border: "1.5px solid rgba(175, 138, 72, 0.72)", borderRadius: "6px", backdropFilter: "blur(14px)", WebkitBackdropFilter: "blur(14px)", boxShadow: "0 10px 42px rgba(0,0,0,0.62)", }} role="dialog" aria-modal="true" aria-label="本幕对话" >
本 · 幕 · 对 · 话
{beatIds.map((bid, i) => { const beat = findBeat(scene, bid); if (!beat) return null; const body = beat.speaker ? beat.line : beat.narration; const narration = beat.speaker ? beat.narration : undefined; return (
第 {String(i + 1).padStart(2, "0")} 拍 {beat.speaker && ( {beat.speaker} )}
{body && (

{body}

)} {narration && (

{narration}

)}
); })} {scene.exit?.kind === "choice" && (

选择 {scene.exit.label}

)} {scene.exit?.kind === "freeform" && (

行动 {scene.exit.action}

)}
); } // ────────────────────────────────────────────────────────────────────── // Choice — rendered above the dialogue card // ────────────────────────────────────────────────────────────────────── type ChoiceState = "picked" | "navigable" | "dead"; function ChoiceButton({ choice, state, vertical, onClick, }: { choice: BeatChoice; state: ChoiceState; vertical: boolean; onClick: () => void; }) { const picked = state === "picked"; const dead = state === "dead"; return ( ); } // ────────────────────────────────────────────────────────────────────── // Slide — one scene + its current beat. All interaction lives here. // ────────────────────────────────────────────────────────────────────── function Slide({ scene, beatId, orientation, alternates, audioByBeatId, muted, dialogueOpen, setDialogueOpen, onAdvanceBeat, onChoice, }: { scene: GalleryScene; beatId: string; orientation: Orientation; alternates: Record; audioByBeatId: Record; muted: boolean; dialogueOpen: boolean; setDialogueOpen: (b: boolean) => void; onAdvanceBeat: (nextBeatId: string) => void; onChoice: (choice: BeatChoice) => void; }) { const portrait = orientation === "portrait"; const intrinsicW = portrait ? 1024 : 1792; const intrinsicH = portrait ? 1792 : 1024; const beat = findBeat(scene, beatId) ?? findBeat(scene, scene.entryBeatId); const audioSrc = beat && scene.id && !muted ? (audioByBeatId[`${scene.id}:${beat.id}`] ?? null) : null; const audioRef = useRef(null); useEffect(() => { const el = audioRef.current; if (!el) return; if (!audioSrc) { el.pause(); return; } el.currentTime = 0; void el.play().catch(() => { // Browsers can refuse autoplay until user interacts — silent fail is fine. }); }, [audioSrc]); const choices: BeatChoice[] = beat?.next.type === "choice" ? (beat.next as { type: "choice"; choices: BeatChoice[] }).choices : []; const pickedId = beat ? pickedChoiceIdAt(scene, beat.id) : null; const sizeStyle: React.CSSProperties = portrait ? { width: "100vw", height: "100dvh", objectFit: "cover" } : { maxWidth: "100vw", maxHeight: "100dvh" }; function choiceState(c: BeatChoice): ChoiceState { if (c.id === pickedId) return "picked"; if (c.effect.kind === "advance-beat") { // Beats are local; always navigable. return "navigable"; } // change-scene: needs an alternate. if (scene.id && alternates[`${scene.id}:${c.id}`]) return "navigable"; return "dead"; } function handleChoiceClick(c: BeatChoice) { const st = choiceState(c); if (st === "dead") return; onChoice(c); } function handleImageClick() { if (!beat) return; if (beat.next.type === "continue") { onAdvanceBeat(beat.next.nextBeatId); } // Choice beats: do nothing — let the player click a choice. } return (
Scene {beat && (
{choices.length > 0 && (
{choices.map((choice) => ( handleChoiceClick(choice)} /> ))}
)} {(beat.narration || beat.line) && (
{ e.stopPropagation(); handleImageClick(); }} style={{ background: "rgba(14, 10, 6, 0.72)", border: "1.5px solid rgba(175, 138, 72, 0.60)", borderRadius: "6px", backdropFilter: "blur(10px)", WebkitBackdropFilter: "blur(10px)", boxShadow: "0 4px 24px rgba(0,0,0,0.55), inset 0 1px 0 rgba(200,165,90,0.10)", }} > {beat.speaker && (

{beat.speaker}

)}

{beat.speaker ? beat.line : beat.narration} {beat.speaker && beat.narration && ( {beat.narration} )}

{beat.next.type === "continue" && ( )}
)}
)} {dialogueOpen && ( setDialogueOpen(false)} /> )} {audioSrc && (
); } // ────────────────────────────────────────────────────────────────────── // Page — owns the navigation stack // ────────────────────────────────────────────────────────────────────── type Frame = { scene: GalleryScene; beatId: string; // Index in the main path array when this frame IS the main-path scene at // that index. null when the frame represents an alternate the player has // stepped into. mainIdx: number | null; }; function GalleryInner() { const lp = useLocalePath(); const [doc, setDoc] = useState(null); const [missingId, setMissingId] = useState(null); const [importing, setImporting] = useState(false); const [importError, setImportError] = useState(null); const [stack, setStack] = useState([]); const [dialogueOpen, setDialogueOpen] = useState(false); const [downloadingScenes, setDownloadingScenes] = useState(false); const [downloadingPortraits, setDownloadingPortraits] = useState(false); const [orientation, setOrientation] = useState("landscape"); const [presentation, setPresentation] = useState(false); // Audio map keyed by `${sceneId}:${beatId}`. Loaded in two phases: the // sidecar localStorage key (gallery export path) is read lazily after first // paint so the multi-MB JSON.parse doesn't block the first scene image's // progressive paint. Imports from `.infiplot` files set this synchronously // since the data is already in memory. const [audioByBeatId, setAudioByBeatId] = useState>({}); const [muted, setMuted] = useState(() => { if (typeof window === "undefined") return false; try { return window.localStorage.getItem(MUTED_STORAGE_KEY) === "1"; } catch { return false; } }); // Top toolbar auto-hide while in fullscreen — it shows briefly on entry, // retracts upward, and pops back down when the cursor approaches the top // edge. Outside presentation mode the bar is always visible. const [toolbarVisible, setToolbarVisible] = useState(true); const hideTimerRef = useRef | null>(null); const preloadedRef = useRef>(new Set()); // Mirror /play's fullscreen behavior — request browser fullscreen so the // tab chrome disappears, with the F key as a shortcut and Esc to exit. // The gallery viewport is already `fixed inset-0`, so this only removes // the browser's own UI, not anything we render. const togglePresentation = useCallback(async () => { const entering = !presentation; if (entering) { try { if (!document.fullscreenElement) { await document.documentElement.requestFullscreen(); } } catch { // ignore — fall back to chrome-less mode anyway } setPresentation(true); } else { try { if (document.fullscreenElement) await document.exitFullscreen(); } catch { // ignore } setPresentation(false); } }, [presentation]); useEffect(() => { const hash = window.location.hash.replace(/^#/, ""); const id = new URLSearchParams(hash).get("id") || hash; if (!id) { setMissingId(""); return; } const d = readDoc(id); if (!d || d.scenes.length === 0) { setMissingId(id); return; } setDoc(d); setOrientation(d.orientation ?? detectOrientation()); const first = d.scenes[0]!; setStack([{ scene: first, beatId: first.entryBeatId, mainIdx: 0 }]); // Lazy-load the audio sidecar AFTER first paint so its JSON.parse (~MBs // of base64) doesn't stall the main thread and let the first image // paint row-by-row. setTimeout(0) yields back to the renderer first. if (d.v === 3) { const t = window.setTimeout(() => { const audio = readSidecarAudio(id); if (Object.keys(audio).length > 0) setAudioByBeatId(audio); }, 0); return () => window.clearTimeout(t); } }, []); // Prefer the doc's stored orientation; fall back to the device. const top = stack[stack.length - 1] ?? null; const alternates = doc?.alternates ?? {}; // Pre-warm the next + previous main scene images so prev/next never flashes. useEffect(() => { if (!doc || !top) return; const set = preloadedRef.current; const candidates: string[] = [top.scene.imageUrl]; if (top.mainIdx !== null) { const prev = doc.scenes[top.mainIdx - 1]; const next = doc.scenes[top.mainIdx + 1]; if (prev?.imageUrl) candidates.push(prev.imageUrl); if (next?.imageUrl) candidates.push(next.imageUrl); } for (const url of candidates) { if (!url || set.has(url)) continue; set.add(url); const img = new Image(); img.crossOrigin = "anonymous"; img.src = url; } }, [doc, top]); // Mainline position for the header counter. Walk the stack from the top // down to the most recent main-path frame; if the player has stepped into // an alternate the counter still shows the last main-path index they were // on, plus a "支线" tag. const mainContextIdx = useMemo(() => { for (let i = stack.length - 1; i >= 0; i--) { const f = stack[i]!; if (f.mainIdx !== null) return f.mainIdx; } return null; }, [stack]); const offMain = top?.mainIdx === null; // ── Navigation actions ────────────────────────────────────────────── const onAdvanceBeat = useCallback((nextBeatId: string) => { setStack((s) => { if (s.length === 0) return s; const t = s[s.length - 1]!; if (!findBeat(t.scene, nextBeatId)) return s; return [...s.slice(0, -1), { ...t, beatId: nextBeatId }]; }); setDialogueOpen(false); }, []); const onChoice = useCallback( (choice: BeatChoice) => { setDialogueOpen(false); if (choice.effect.kind === "advance-beat") { onAdvanceBeat(choice.effect.targetBeatId); return; } // change-scene: resolve via alternates map. const t = stack[stack.length - 1]; if (!t || !t.scene.id) return; const alt = alternates[`${t.scene.id}:${choice.id}`]; if (!alt) return; // If this alternate IS the next main-path scene (the typical case for // the choice the player actually picked), advance mainIdx; otherwise // mark the new frame as off-main. const expectedMainIdx = t.mainIdx !== null ? t.mainIdx + 1 : null; const isMain = expectedMainIdx !== null && doc?.scenes[expectedMainIdx]?.id === alt.id; setStack((s) => [ ...s, { scene: alt, beatId: alt.entryBeatId, mainIdx: isMain ? expectedMainIdx : null, }, ]); }, [alternates, doc, onAdvanceBeat, stack], ); // Prev / next at the scene level (slideshow-style edges + arrow keys). // Implementation: prev pops a stack frame (so alternates back out one step, // then we step back through main path); next walks forward by following the // recorded path — picked choice on main, entry beat advance otherwise. const goPrev = useCallback(() => { setStack((s) => { if (s.length === 0) return s; if (s.length > 1) return s.slice(0, -1); // Single frame: step back along main path. const t = s[0]!; if (t.mainIdx === null || t.mainIdx === 0) return s; const prevIdx = t.mainIdx - 1; const prevScene = doc?.scenes[prevIdx]; if (!prevScene) return s; return [ { scene: prevScene, beatId: prevScene.entryBeatId, mainIdx: prevIdx }, ]; }); setDialogueOpen(false); }, [doc]); const goNext = useCallback(() => { setStack((s) => { if (s.length === 0) return s; const t = s[s.length - 1]!; // If on main and there's a next main scene, jump there directly. if (t.mainIdx !== null && doc) { const nextIdx = t.mainIdx + 1; const nextScene = doc.scenes[nextIdx]; if (nextScene) { return [ { scene: nextScene, beatId: nextScene.entryBeatId, mainIdx: nextIdx, }, ]; } } // Off-main: try advancing the current beat (only meaningful for // continue beats; choice beats are no-ops at the scene-level). const beat = findBeat(t.scene, t.beatId); if (beat && beat.next.type === "continue") { return [...s.slice(0, -1), { ...t, beatId: beat.next.nextBeatId }]; } return s; }); setDialogueOpen(false); }, [doc]); // "返回主线" — collapse the stack to its bottom-most main-path frame. const goBackToMain = useCallback(() => { setStack((s) => { for (let i = s.length - 1; i >= 0; i--) { if (s[i]!.mainIdx !== null) return s.slice(0, i + 1); } return s; }); setDialogueOpen(false); }, []); // On entering presentation: show the bar, then retract after a moment so // the player gets a glance at the controls without them blocking the image. // On leaving: always-visible again. Clears any pending hide timer between // transitions so we never retract back in windowed mode. useEffect(() => { if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null; } if (!presentation) { setToolbarVisible(true); return; } setToolbarVisible(true); hideTimerRef.current = setTimeout(() => { setToolbarVisible(false); hideTimerRef.current = null; }, 2200); return () => { if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null; } }; }, [presentation]); // Mouse-driven reveal while in presentation: cursor near the top edge // re-shows the bar; moving away starts a short hide countdown. useEffect(() => { if (!presentation) return; const SHOW_ZONE = 96; const HIDE_DELAY = 1400; function onMove(e: MouseEvent) { if (e.clientY < SHOW_ZONE) { if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null; } setToolbarVisible(true); } else if (!hideTimerRef.current) { hideTimerRef.current = setTimeout(() => { setToolbarVisible(false); hideTimerRef.current = null; }, HIDE_DELAY); } } window.addEventListener("mousemove", onMove); return () => window.removeEventListener("mousemove", onMove); }, [presentation]); useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key === "ArrowLeft") goPrev(); else if (e.key === "ArrowRight") goNext(); else if (e.key === "f" || e.key === "F") { if (e.metaKey || e.ctrlKey || e.altKey) return; e.preventDefault(); void togglePresentation(); } else if (e.key === "Escape") { if (dialogueOpen) setDialogueOpen(false); else if (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); }; }, [goPrev, goNext, dialogueOpen, presentation, togglePresentation]); const handleDownloadScenes = useCallback(async () => { if (!doc || downloadingScenes) return; setDownloadingScenes(true); try { // Main path + every unique alternate (AI-prefetched branches the player // didn't take). Dedupe by URL — the picked choice's alternate IS the // next main scene, so they overlap, and we never want the same image // saved twice. Main scenes get `scene-NNN`; uniquely-alternate scenes // get `branch-NNN` so the filenames hint at provenance. const seen = new Set(); const files: { url: string; name: string }[] = []; let sceneN = 0; for (const sc of doc.scenes) { if (!sc.imageUrl || seen.has(sc.imageUrl)) continue; seen.add(sc.imageUrl); sceneN++; files.push({ url: sc.imageUrl, name: `infiplot-scene-${String(sceneN).padStart(3, "0")}.${inferImageExtension(sc.imageUrl)}`, }); } let branchN = 0; for (const alt of Object.values(doc.alternates ?? {})) { if (!alt.imageUrl || seen.has(alt.imageUrl)) continue; seen.add(alt.imageUrl); branchN++; files.push({ url: alt.imageUrl, name: `infiplot-branch-${String(branchN).padStart(3, "0")}.${inferImageExtension(alt.imageUrl)}`, }); } const result = await downloadImagesAsZip(files, `infiplot-gallery-${doc.id}.zip`); if (result.downloaded === 0) { alert("所有图片抓取失败,请检查网络后重试"); } else if (result.failed.length > 0) { alert(`已打包 ${result.downloaded} 张,${result.failed.length} 张抓取失败`); } } catch { alert("打包下载失败,请重试"); } finally { setDownloadingScenes(false); } }, [doc, downloadingScenes]); // ── Import a friend-shared `.infiplot` file ────────────────────────── const loadDocFromFile = useCallback(async (file: File) => { setImporting(true); setImportError(null); try { const ab = await file.arrayBuffer(); const r = await fetch("/api/gallery-unpack", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: ab, }); if (!r.ok) { const j = (await r.json().catch(() => ({}))) as { error?: string }; setImportError(j.error ?? `导入失败 (HTTP ${r.status})`); return; } const { docStr } = (await r.json()) as { docStr?: string }; if (!docStr) { setImportError("服务端返回为空"); return; } let parsed: GalleryDoc; try { parsed = JSON.parse(docStr) as GalleryDoc; } catch { setImportError("解密后的内容不是有效的图集数据"); return; } if ( (parsed.v !== 1 && parsed.v !== 2) || !Array.isArray(parsed.scenes) || parsed.scenes.length === 0 ) { setImportError("图集数据格式不被支持"); return; } setDoc(parsed); setOrientation(parsed.orientation ?? detectOrientation()); const first = parsed.scenes[0]!; setStack([{ scene: first, beatId: first.entryBeatId, mainIdx: 0 }]); setMissingId(null); } catch (e) { setImportError(e instanceof Error ? e.message : "导入失败"); } finally { setImporting(false); } }, []); const handleDownloadPortraits = useCallback(async () => { if (!doc || downloadingPortraits) return; const list = doc.characters ?? []; const files = list .filter((c) => !!c.basePortraitUrl) .map((c, i) => { const safeName = c.name.replace(/[^a-zA-Z0-9一-龥_-]/g, "_"); return { url: c.basePortraitUrl as string, name: `infiplot-character-${String(i + 1).padStart(2, "0")}-${safeName || "char"}.jpg`, }; }); if (files.length === 0) return; setDownloadingPortraits(true); try { await downloadImagesIndividually(files); } finally { setDownloadingPortraits(false); } }, [doc, downloadingPortraits]); // ── Render ────────────────────────────────────────────────────────── if (missingId !== null) { return (

图 · 集 · 找 · 不 · 到

{missingId ? "这份图集存在本机浏览器里,可能已被清理,或不在当前设备上。" : "想看朋友分享的图集?选他发给你的 .infiplot 文件;想自己导出?去游戏页点「导出图集」。"}

{importError && (

{importError}

)} 返回
); } if (!doc || !top) { return (
载 · 入 · 中
); } const total = doc.scenes.length; const counterIdx = mainContextIdx !== null ? mainContextIdx : 0; const portraitCount = (doc.characters ?? []).filter( (c) => !!c.basePortraitUrl, ).length; // Prev disabled at the very start of the main path with a length-1 stack. const atVeryStart = stack.length === 1 && stack[0]!.mainIdx === 0; // Next disabled at the last main scene's terminal beat (or any time there's // no main-next AND no beat to advance to). const beatAtTop = findBeat(top.scene, top.beatId); const hasMainNext = top.mainIdx !== null && top.mainIdx < total - 1; const hasBeatNext = beatAtTop?.next.type === "continue"; const atVeryEnd = !hasMainNext && !hasBeatNext; return (
{/* Top bar — auto-hides in fullscreen presentation mode (see toolbarVisible) */}
返回
第 · {String(counterIdx + 1).padStart(3, "0")} · 幕 / {String(total).padStart(3, "0")} {offMain && ( )}
{Object.keys(audioByBeatId).length > 0 && ( )} {portraitCount > 0 && ( )}
{(downloadingScenes || downloadingPortraits) && (
{downloadingScenes ? "正在抓取图片并打包 zip,完成后会自动开始下载" : "浏览器顶部如弹出「允许此网站下载多个文件」,请点「允许」,否则只能下到第一张"}
)} {/* Left / Right slide nav */} {/* Bottom hint */}
← · → · 切 · 幕 · · F · 全 · 屏 · · ▼ · 推 · 进 · · 选 · 项 · 探 · 支 · 线
); } export default function GalleryPage() { return ( 载 · 入 · 中 } > ); }