Merge pull request #44 from zonghaoyuan/feat/vision-toggle
feat(play): add vision click setting
This commit is contained in:
+29
-28
@@ -11,7 +11,7 @@ import {
|
||||
type Gender,
|
||||
} from "@/lib/options";
|
||||
import { readStoredTtsConfig } from "@/lib/clientTtsConfig";
|
||||
import { SettingsModal, readStoredPlayerName } from "@/components/SettingsModal";
|
||||
import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal";
|
||||
|
||||
/* ============================================================================
|
||||
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
|
||||
@@ -47,6 +47,7 @@ const OPTS: Opt[] = [
|
||||
{ label: "性向", items: [...GENDERS] },
|
||||
{ label: "绘画风格", modal: true, items: [...ART_STYLES] },
|
||||
{ label: "剧情风格", items: [...PLOT_STYLES], defaultIndex: 1 },
|
||||
{ label: "语音配音", items: ["关闭", "开启"], defaultIndex: 1 },
|
||||
{ label: "内容节奏", items: [...PACINGS], defaultIndex: 1 },
|
||||
];
|
||||
|
||||
@@ -1252,13 +1253,15 @@ export default function HomePage() {
|
||||
// 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:infiplot:hintClosed)。
|
||||
const [hintClosed, setHintClosed] = useState(false);
|
||||
|
||||
// 统一设置弹窗(名字 + 配音 + TTS Key):可选增强,数据只存浏览器。
|
||||
// 统一设置弹窗(名字 + 识图 + TTS Key):可选增强,数据只存浏览器。
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [ttsConfigured, setTtsConfigured] = useState(false);
|
||||
const [playerName, setPlayerName] = useState("");
|
||||
const [audioEnabled, setAudioEnabled] = useState(true);
|
||||
const [visionClickEnabled, setVisionClickEnabled] = useState(true);
|
||||
|
||||
const styleRow = OPTS.findIndex((o) => o.modal);
|
||||
const voiceRow = OPTS.findIndex((o) => o.label === "语音配音");
|
||||
const paceRow = OPTS.findIndex((o) => o.label === "内容节奏");
|
||||
const genderIndex = sel[0] ?? 0;
|
||||
const gender = (OPTS[0]!.items[genderIndex] as Gender) ?? "男性向";
|
||||
const phrases = EXAMPLE_PHRASES[gender];
|
||||
@@ -1300,14 +1303,11 @@ export default function HomePage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 启动时回填配置状态——读 localStorage 判断用户是否已存过 Key / 名字 / 配音偏好。
|
||||
// 启动时回填配置状态——读 localStorage 判断用户是否已存过 Key / 名字。
|
||||
useEffect(() => {
|
||||
setTtsConfigured(readStoredTtsConfig() != null);
|
||||
setPlayerName(readStoredPlayerName());
|
||||
try {
|
||||
const stored = localStorage.getItem("infiplot:muted");
|
||||
if (stored === "1") setAudioEnabled(false);
|
||||
} catch { /* ignore */ }
|
||||
setVisionClickEnabled(readStoredVisionClick());
|
||||
}, []);
|
||||
|
||||
// 输入框随内容自动增高:长文本整段可见(打字与点卡片填入都覆盖)。
|
||||
@@ -1334,7 +1334,9 @@ export default function HomePage() {
|
||||
prompt.trim() || (phrases[phraseIdx] ?? "").trim();
|
||||
const artStyle = ART_STYLES[sel[1] ?? 0] ?? "自动";
|
||||
const plotStyle = PLOT_STYLES[sel[2] ?? 1] ?? "多线转折";
|
||||
const pace = PACINGS[sel[3] ?? 1] ?? "紧凑爽快";
|
||||
const voice = OPTS[voiceRow]!.items[sel[voiceRow] ?? 1]!;
|
||||
const audioEnabled = voice === "开启";
|
||||
const pace = PACINGS[sel[paceRow] ?? 1] ?? "紧凑爽快";
|
||||
|
||||
// worldSetting 顺序很重要:玩家输入若存在,必须放在最前面、单独成段、
|
||||
// 用强指令包住,否则模型会把它当成夹在风格说明里的背景参考、扩写出
|
||||
@@ -1407,6 +1409,8 @@ export default function HomePage() {
|
||||
// 其余选项(剧情风格 / 内容节奏)在预烘焙时已锁成「多线转折 / 紧凑爽快」
|
||||
// 的红果默认基调,对精选卡不再生效。
|
||||
const onCardClick = (idx: number, _card: StoryContent) => {
|
||||
const voice = OPTS[voiceRow]!.items[sel[voiceRow] ?? 1]!;
|
||||
const audioEnabled = voice === "开启";
|
||||
sessionStorage.setItem(
|
||||
"infiplot:custom",
|
||||
JSON.stringify({ worldSetting: "", styleGuide: "", audioEnabled, playerName }),
|
||||
@@ -1428,6 +1432,15 @@ export default function HomePage() {
|
||||
Infi<em className="italic font-light text-ember-500">Plot</em>
|
||||
</span>
|
||||
<div className="flex items-center gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
aria-label="设置"
|
||||
title="设置"
|
||||
className="text-base text-clay-500 hover:text-ember-500 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-gear" />
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/zonghaoyuan/infiplot"
|
||||
target="_blank"
|
||||
@@ -1528,23 +1541,6 @@ export default function HomePage() {
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* 设置入口:与 CategorySelect 视觉一致,点击打开 modal */}
|
||||
<div className="text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="group flex items-center gap-2.5 pb-1.5 border-b border-clay-900/20 hover:border-clay-900/45 transition-colors"
|
||||
>
|
||||
<span className="text-[10px] smallcaps text-clay-500">设置</span>
|
||||
<span className={
|
||||
"font-serif text-base md:text-lg " +
|
||||
(ttsConfigured || playerName ? "text-ember-500" : "text-clay-900")
|
||||
}>
|
||||
{playerName || (ttsConfigured ? "已配置" : "未配置")}
|
||||
</span>
|
||||
<i className="fa-solid fa-gear text-[9px] text-clay-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用提示:可被用户永久关闭(localStorage:infiplot:hintClosed) */}
|
||||
@@ -1714,12 +1710,17 @@ export default function HomePage() {
|
||||
)}
|
||||
{settingsOpen && (
|
||||
<SettingsModal
|
||||
initialAudioEnabled={audioEnabled}
|
||||
initialVisionClickEnabled={visionClickEnabled}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSaved={(settings) => {
|
||||
setTtsConfigured(settings.ttsConfigured);
|
||||
setPlayerName(settings.playerName);
|
||||
setAudioEnabled(settings.audioEnabled);
|
||||
setVisionClickEnabled(settings.visionClickEnabled);
|
||||
if (settings.ttsConfigured && voiceRow >= 0) {
|
||||
const onIdx = OPTS[voiceRow]!.items.indexOf("开启");
|
||||
if (onIdx >= 0)
|
||||
setSel((s) => s.map((v, j) => (j === voiceRow ? onIdx : v)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
+29
-20
@@ -17,8 +17,7 @@ import {
|
||||
} from "@/components/PlayCanvas";
|
||||
import type { DialogueHistoryItem } from "@/components/DialogueHistoryModal";
|
||||
import type { GalleryDoc, GalleryScene } from "@/app/gallery/page";
|
||||
import { TtsKeyModal } from "@/components/TtsKeyModal";
|
||||
import { readStoredPlayerName } from "@/components/SettingsModal";
|
||||
import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal";
|
||||
import { annotateClick } from "@/lib/annotateClient";
|
||||
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
|
||||
import { PRESETS } from "@/lib/presets";
|
||||
@@ -580,9 +579,8 @@ function PlayInner() {
|
||||
const [silenceStrikes, setSilenceStrikes] = useState(0);
|
||||
// Once the player dismisses the silence nudge, keep it gone for this session.
|
||||
const [nudgeDismissed, setNudgeDismissed] = useState(false);
|
||||
// The in-place BYO-key modal, opened from the silence nudge so the player can
|
||||
// add a key without leaving the play page.
|
||||
const [ttsModalOpen, setTtsModalOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [visionClickEnabled, setVisionClickEnabled] = useState(true);
|
||||
|
||||
const startedRef = useRef(false);
|
||||
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
|
||||
@@ -652,6 +650,9 @@ function PlayInner() {
|
||||
useEffect(() => {
|
||||
mutedRef.current = muted;
|
||||
}, [muted]);
|
||||
useEffect(() => {
|
||||
setVisionClickEnabled(readStoredVisionClick());
|
||||
}, []);
|
||||
|
||||
// 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
|
||||
@@ -852,15 +853,10 @@ function PlayInner() {
|
||||
prefetchSceneAudio();
|
||||
}, [muted, prefetchSceneAudio]);
|
||||
|
||||
// ── BYO key enabled/disabled from the play page (silence nudge → modal) ─
|
||||
// On enable: point the synth path at the user's key and immediately
|
||||
// re-synthesize the current scene in-browser, so the voices the player just
|
||||
// missed come back without a reload (their characters already carry
|
||||
// server-provisioned `voice`, which resolveByoVoice reuses with the new key).
|
||||
// On disable: just stop using it; later scenes fall back to the server.
|
||||
const handleByoSaved = useCallback(
|
||||
(configured: boolean) => {
|
||||
const cfg = configured ? loadClientTtsConfig() : null;
|
||||
const handleSettingsSaved = useCallback(
|
||||
(settings: { ttsConfigured: boolean; playerName: string; visionClickEnabled: boolean }) => {
|
||||
setVisionClickEnabled(settings.visionClickEnabled);
|
||||
const cfg = settings.ttsConfigured ? loadClientTtsConfig() : null;
|
||||
byoTtsRef.current = cfg;
|
||||
setByoTtsConfig(cfg);
|
||||
if (cfg) {
|
||||
@@ -1761,6 +1757,8 @@ function PlayInner() {
|
||||
onFreeformInput={onFreeformInput}
|
||||
orientation={orientation}
|
||||
playerName={session?.playerName}
|
||||
visionClickEnabled={visionClickEnabled}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
fullViewport
|
||||
dialogueHistory={dialogueHistory}
|
||||
/>
|
||||
@@ -1788,6 +1786,14 @@ function PlayInner() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{settingsOpen && (
|
||||
<SettingsModal
|
||||
initialVisionClickEnabled={visionClickEnabled}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSaved={handleSettingsSaved}
|
||||
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1838,6 +1844,8 @@ function PlayInner() {
|
||||
onFreeformInput={onFreeformInput}
|
||||
orientation={orientation}
|
||||
playerName={session?.playerName}
|
||||
visionClickEnabled={visionClickEnabled}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
dialogueHistory={dialogueHistory}
|
||||
aboveCanvas={
|
||||
<button
|
||||
@@ -1887,7 +1895,7 @@ function PlayInner() {
|
||||
<span className="flex items-center gap-1 animate-fade-in">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTtsModalOpen(true)}
|
||||
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="经常没声音?填入你自己的小米 MiMo Key(免费),配音更稳定"
|
||||
>
|
||||
@@ -1925,11 +1933,12 @@ function PlayInner() {
|
||||
|
||||
</main>
|
||||
|
||||
{ttsModalOpen && (
|
||||
<TtsKeyModal
|
||||
onClose={() => setTtsModalOpen(false)}
|
||||
onSaved={handleByoSaved}
|
||||
footerNote="保存后会立即用这把 Key 在你的浏览器里合成当前这一幕的配音;本设备后续游玩也会自动使用此 Key。"
|
||||
{settingsOpen && (
|
||||
<SettingsModal
|
||||
initialVisionClickEnabled={visionClickEnabled}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSaved={handleSettingsSaved}
|
||||
footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+38
-21
@@ -178,6 +178,8 @@ export function PlayCanvas({
|
||||
fullViewport = false,
|
||||
orientation = "landscape",
|
||||
playerName,
|
||||
visionClickEnabled = true,
|
||||
onOpenSettings,
|
||||
aboveCanvas,
|
||||
aboveCanvasLeft,
|
||||
belowCanvas,
|
||||
@@ -197,6 +199,9 @@ export function PlayCanvas({
|
||||
// 会话锁定的图片朝向。"portrait" 时整图铺满视口(object-fit:cover)、选项竖排、字号放大。
|
||||
orientation?: Orientation;
|
||||
playerName?: string;
|
||||
// 选择节点点击背景是否触发识图。关闭时背景点击保持静默,用户只能点选项。
|
||||
visionClickEnabled?: boolean;
|
||||
onOpenSettings?: () => void;
|
||||
// 渲染在图片正上方、右对齐的 slot(画面外、紧贴右上角)。
|
||||
aboveCanvas?: ReactNode;
|
||||
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
|
||||
@@ -276,14 +281,18 @@ export function PlayCanvas({
|
||||
}
|
||||
|
||||
function handleImageClick(e: React.MouseEvent<HTMLImageElement>) {
|
||||
if (phase !== "ready" || !imgRef.current || !beat) return;
|
||||
if (phase !== "ready" || !beat) return;
|
||||
if (!typingDone) {
|
||||
skipTypewriter();
|
||||
return;
|
||||
}
|
||||
if (beat.next.type === "continue") {
|
||||
onAdvance();
|
||||
return;
|
||||
}
|
||||
if (!visionClickEnabled || !imgRef.current) return;
|
||||
const el = imgRef.current;
|
||||
const rect = el.getBoundingClientRect();
|
||||
// Portrait renders with object-fit:cover, which scales the 9:16 image to
|
||||
// FILL the box and crops the overflow — so the rendered box ≠ the full
|
||||
// image. Map the click from box-space back into full-image-space via the
|
||||
// cover geometry so the marker lands where the user tapped. Landscape's box
|
||||
// matches the image aspect (no crop), so it keeps simple normalization.
|
||||
let x: number;
|
||||
let y: number;
|
||||
if (orientation === "portrait") {
|
||||
@@ -298,18 +307,6 @@ export function PlayCanvas({
|
||||
x = (e.clientX - rect.left) / rect.width;
|
||||
y = (e.clientY - rect.top) / rect.height;
|
||||
}
|
||||
// If the typewriter is still printing, a click completes it instantly
|
||||
// (standard VN affordance) — the page never sees this click.
|
||||
if (!typingDone) {
|
||||
skipTypewriter();
|
||||
return;
|
||||
}
|
||||
// For continue-type beats, image click advances; for choice beats,
|
||||
// image click goes through vision (treat as freeform action).
|
||||
if (beat.next.type === "continue") {
|
||||
onAdvance();
|
||||
return;
|
||||
}
|
||||
onBackgroundClick({
|
||||
x: Math.max(0, Math.min(1, x)),
|
||||
y: Math.max(0, Math.min(1, y)),
|
||||
@@ -329,6 +326,9 @@ export function PlayCanvas({
|
||||
}
|
||||
|
||||
const interactive = phase === "ready" && !!imageUrl;
|
||||
const imageClickable =
|
||||
interactive &&
|
||||
(!typingDone || beat?.next.type === "continue" || visionClickEnabled);
|
||||
const dimmed = phase === "transitioning";
|
||||
|
||||
const portrait = orientation === "portrait";
|
||||
@@ -393,7 +393,7 @@ export function PlayCanvas({
|
||||
onClick={handleImageClick}
|
||||
draggable={false}
|
||||
className={`block ${portrait ? "" : "w-auto h-auto"} select-none animate-fade-in transition-opacity duration-700 ease-out ${
|
||||
interactive ? "cursor-pointer" : "cursor-wait"
|
||||
imageClickable ? "cursor-pointer" : interactive ? "cursor-default" : "cursor-wait"
|
||||
} ${dimmed ? "opacity-40" : "opacity-100"}`}
|
||||
style={sizeStyle}
|
||||
/>
|
||||
@@ -631,7 +631,7 @@ export function PlayCanvas({
|
||||
|
||||
{typingDone && beat.next.type === "continue" && (
|
||||
<span
|
||||
className="absolute bottom-[6px] right-[42px] text-[10px] animate-slow-pulse"
|
||||
className={`absolute bottom-[6px] ${onOpenSettings ? "right-[74px]" : "right-[42px]"} text-[10px] animate-slow-pulse`}
|
||||
style={{ color: "rgba(195,155,75,0.7)" }}
|
||||
aria-hidden
|
||||
>
|
||||
@@ -639,13 +639,30 @@ export function PlayCanvas({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenSettings();
|
||||
}}
|
||||
className="absolute bottom-[6px] right-[8px] flex h-7 w-7 items-center justify-center text-[rgba(195,155,75,0.78)] transition-colors hover:text-[rgba(245,235,210,0.96)]"
|
||||
aria-label="打开设置"
|
||||
title="设置"
|
||||
>
|
||||
<i className="fa-solid fa-gear text-[12px]" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setHistoryOpen(true);
|
||||
}}
|
||||
className="absolute bottom-[6px] right-[8px] flex h-7 w-7 items-center justify-center text-[rgba(195,155,75,0.78)] transition-colors hover:text-[rgba(245,235,210,0.96)]"
|
||||
className={`absolute bottom-[6px] ${
|
||||
onOpenSettings ? "right-[40px]" : "right-[8px]"
|
||||
} flex h-7 w-7 items-center justify-center text-[rgba(195,155,75,0.78)] transition-colors hover:text-[rgba(245,235,210,0.96)]`}
|
||||
aria-label="打开剧情回溯"
|
||||
title="剧情回溯"
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/lib/ttsPresets";
|
||||
|
||||
const PLAYER_NAME_STORAGE_KEY = "infiplot:playerName";
|
||||
const VISION_CLICK_STORAGE_KEY = "infiplot:visionClick";
|
||||
|
||||
export function readStoredPlayerName(): string {
|
||||
try {
|
||||
@@ -35,15 +36,23 @@ export function writeStoredPlayerName(name: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredVisionClick(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(VISION_CLICK_STORAGE_KEY) !== "0";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
initialAudioEnabled = true,
|
||||
initialVisionClickEnabled = true,
|
||||
onClose,
|
||||
onSaved,
|
||||
footerNote,
|
||||
}: {
|
||||
initialAudioEnabled?: boolean;
|
||||
initialVisionClickEnabled?: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (settings: { ttsConfigured: boolean; playerName: string; audioEnabled: boolean }) => void;
|
||||
onSaved: (settings: { ttsConfigured: boolean; playerName: string; visionClickEnabled: boolean }) => void;
|
||||
footerNote?: ReactNode;
|
||||
}) {
|
||||
const [initialTts] = useState(() => readStoredTtsConfig());
|
||||
@@ -59,7 +68,7 @@ export function SettingsModal({
|
||||
const ttsAlreadyConfigured = initialTts != null;
|
||||
|
||||
const [playerName, setPlayerName] = useState(() => readStoredPlayerName());
|
||||
const [voiceOn, setVoiceOn] = useState(initialAudioEnabled);
|
||||
const [visionClick, setVisionClick] = useState(initialVisionClickEnabled);
|
||||
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
@@ -82,7 +91,7 @@ export function SettingsModal({
|
||||
writeStoredPlayerName(name);
|
||||
|
||||
try {
|
||||
localStorage.setItem("infiplot:muted", voiceOn ? "0" : "1");
|
||||
localStorage.setItem(VISION_CLICK_STORAGE_KEY, visionClick ? "1" : "0");
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const key = apiKey.trim();
|
||||
@@ -97,18 +106,15 @@ export function SettingsModal({
|
||||
ttsConfigured = true;
|
||||
}
|
||||
|
||||
if (ttsConfigured && !voiceOn) setVoiceOn(true);
|
||||
const finalVoiceOn = ttsConfigured ? true : voiceOn;
|
||||
|
||||
onSaved({ ttsConfigured, playerName: name, audioEnabled: finalVoiceOn });
|
||||
onSaved({ ttsConfigured, playerName: name, visionClickEnabled: visionClick });
|
||||
close();
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
clearStoredTtsConfig();
|
||||
writeStoredPlayerName("");
|
||||
try { localStorage.removeItem("infiplot:muted"); } catch { /* ignore */ }
|
||||
onSaved({ ttsConfigured: false, playerName: "", audioEnabled: true });
|
||||
try { localStorage.removeItem(VISION_CLICK_STORAGE_KEY); } catch { /* ignore */ }
|
||||
onSaved({ ttsConfigured: false, playerName: "", visionClickEnabled: true });
|
||||
close();
|
||||
};
|
||||
|
||||
@@ -179,29 +185,29 @@ export function SettingsModal({
|
||||
|
||||
<div className="border-t border-clay-900/8 mx-6 md:mx-8" />
|
||||
|
||||
{/* ── Voice Section (toggle + key as child) ── */}
|
||||
<div className="flex flex-col gap-3 px-6 md:px-8 pt-5 pb-5">
|
||||
{/* ── Vision Click Section ── */}
|
||||
<div className="flex flex-col gap-3 px-6 md:px-8 py-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400">
|
||||
<i className="fa-solid fa-volume-high text-[11px]" />
|
||||
<i className="fa-solid fa-eye text-[11px]" />
|
||||
</span>
|
||||
<span className="font-serif text-base text-clay-900">
|
||||
语音配音
|
||||
点击画面识别
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
{ on: true, label: "开启", icon: "fa-solid fa-volume-high" },
|
||||
{ on: false, label: "关闭", icon: "fa-solid fa-volume-xmark" },
|
||||
{ on: true, label: "开启", icon: "fa-solid fa-wand-magic-sparkles" },
|
||||
{ on: false, label: "关闭", icon: "fa-solid fa-ban" },
|
||||
] as const
|
||||
).map((t) => {
|
||||
const active = voiceOn === t.on;
|
||||
const active = visionClick === t.on;
|
||||
return (
|
||||
<button
|
||||
key={String(t.on)}
|
||||
type="button"
|
||||
onClick={() => setVoiceOn(t.on)}
|
||||
onClick={() => setVisionClick(t.on)}
|
||||
className={
|
||||
"flex items-center justify-center gap-2 rounded-sm border px-3 py-2.5 text-[13px] transition-all " +
|
||||
(active
|
||||
@@ -215,13 +221,20 @@ export function SettingsModal({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-[11px] text-clay-400">
|
||||
开启后,在选择节点点击画面会触发 AI 识图并生成新的剧情分支。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── TTS Key (sub-section, only when voice is on) ── */}
|
||||
{voiceOn && (
|
||||
<div className="mt-3 flex flex-col gap-4 rounded-sm border border-clay-900/8 bg-cream-100/40 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<i className="fa-solid fa-key text-[10px] text-clay-400" />
|
||||
<span className="text-[13px] text-clay-800">
|
||||
<div className="border-t border-clay-900/8 mx-6 md:mx-8" />
|
||||
|
||||
{/* ── TTS Key Section ── */}
|
||||
<div className="flex flex-col gap-3 px-6 md:px-8 pt-5 pb-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-clay-900/10 bg-cream-100 text-clay-400">
|
||||
<i className="fa-solid fa-key text-[11px]" />
|
||||
</span>
|
||||
<span className="font-serif text-base text-clay-900">
|
||||
自带配音 Key
|
||||
</span>
|
||||
<span className="text-[10px] text-clay-400">可选</span>
|
||||
@@ -324,7 +337,7 @@ export function SettingsModal({
|
||||
? "粘贴 sk- 开头的按量 Key"
|
||||
: "粘贴 tp- 开头的套餐 Key"
|
||||
}
|
||||
className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-50 pl-4 pr-11 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
|
||||
className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-100 pl-4 pr-11 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -364,8 +377,6 @@ export function SettingsModal({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Bring-your-own Xiaomi MiMo TTS key modal — shared by the homepage and the
|
||||
// play page. Two-step picker (key family → region for Token Plan only), key
|
||||
// stored CLIENT-SIDE ONLY (see lib/clientTtsConfig). `onSaved(configured)`
|
||||
// fires after a save/disable so each host can react (homepage flips the
|
||||
// 语音配音 toggle; the play page re-synthesizes the current scene in-browser).
|
||||
// `footerNote` lets the host tailor the closing hint to its own context.
|
||||
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import {
|
||||
clearStoredTtsConfig,
|
||||
readStoredTtsConfig,
|
||||
writeStoredTtsConfig,
|
||||
} from "@/lib/clientTtsConfig";
|
||||
import {
|
||||
findTtsPreset,
|
||||
PAYG_PRESET_ID,
|
||||
TTS_KEY_DOC_URL,
|
||||
TTS_REGION_PRESETS,
|
||||
} from "@/lib/ttsPresets";
|
||||
|
||||
const DEFAULT_FOOTER_NOTE: ReactNode =
|
||||
"提示:需将上方「语音配音」设为「开启」配音才会生效。保存后本设备后续游玩会自动使用此 Key。";
|
||||
|
||||
export function TtsKeyModal({
|
||||
onClose,
|
||||
onSaved,
|
||||
footerNote = DEFAULT_FOOTER_NOTE,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSaved: (configured: boolean) => void;
|
||||
footerNote?: ReactNode;
|
||||
}) {
|
||||
// Read storage once; useState initializers ignore later renders, so local
|
||||
// edits aren't clobbered and we don't re-hit localStorage every render.
|
||||
const [initial] = useState(() => readStoredTtsConfig());
|
||||
// Two-step picker: choose key family first, then — only for Token Plan — a
|
||||
// region. Pay-as-you-go (`sk-`) keys hit one fixed endpoint, so no region.
|
||||
const initialKind = findTtsPreset(initial?.presetId)?.kind ?? "token-plan";
|
||||
const [keyType, setKeyType] = useState<"token-plan" | "payg">(initialKind);
|
||||
const [regionId, setRegionId] = useState<string>(
|
||||
initialKind === "token-plan"
|
||||
? (initial?.presetId ?? TTS_REGION_PRESETS[0]!.id)
|
||||
: TTS_REGION_PRESETS[0]!.id,
|
||||
);
|
||||
const [apiKey, setApiKey] = useState<string>(initial?.apiKey ?? "");
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [shown, setShown] = useState(false);
|
||||
const alreadyConfigured = initial != null;
|
||||
// Soft guard: tp- keys belong to Token Plan, sk- to pay-as-you-go. A
|
||||
// mismatched pairing hits the wrong endpoint → guaranteed auth failure →
|
||||
// silent playback (the very symptom BYO exists to kill). Warn, but never
|
||||
// block: prefix conventions could change and a hard gate would lock out an
|
||||
// otherwise-valid key.
|
||||
const expectedPrefix = keyType === "payg" ? "sk-" : "tp-";
|
||||
const prefixMismatch =
|
||||
apiKey.trim().length > 0 && !apiKey.trim().startsWith(expectedPrefix);
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setShown(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
const close = () => {
|
||||
setShown(false);
|
||||
setTimeout(onClose, 280);
|
||||
};
|
||||
const save = () => {
|
||||
const key = apiKey.trim();
|
||||
if (!key) return;
|
||||
const presetId = keyType === "payg" ? PAYG_PRESET_ID : regionId;
|
||||
writeStoredTtsConfig({ presetId, apiKey: key });
|
||||
onSaved(true);
|
||||
close();
|
||||
};
|
||||
const disable = () => {
|
||||
clearStoredTtsConfig();
|
||||
onSaved(false);
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={close}
|
||||
className={
|
||||
"fixed inset-0 z-[60] flex items-center justify-center p-6 md:p-10 transition-all duration-300 " +
|
||||
(shown
|
||||
? "bg-clay-900/30 backdrop-blur-md"
|
||||
: "bg-clay-900/0 backdrop-blur-0")
|
||||
}
|
||||
>
|
||||
<div
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={
|
||||
"flex w-[560px] max-w-[94vw] max-h-[88vh] flex-col overflow-hidden rounded-sm border border-clay-900/15 bg-cream-50 shadow-2xl shadow-clay-900/25 transition-all duration-300 " +
|
||||
(shown ? "opacity-100 scale-100" : "opacity-0 scale-95")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-5 px-6 md:px-8 py-5 border-b border-clay-900/10">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-serif text-xl md:text-2xl text-clay-900">
|
||||
自带配音 Key
|
||||
</span>
|
||||
<span className="text-[11px] text-clay-500 mt-1 tracking-wide">
|
||||
可选 · 用你自己的小米 MiMo 免费额度,配音更稳定、延迟更低
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label="关闭"
|
||||
className="ml-auto text-xl leading-none text-clay-500 hover:text-clay-900 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 overflow-y-auto px-6 md:px-8 py-6">
|
||||
<p className="text-[13px] leading-relaxed text-clay-600">
|
||||
经常没有声音?公共语音模型有调用频率限额(RPM / TPM),同时游玩的人多时很容易撞到限额而静音。填入你自己的小米 MiMo API Key 后,配音将
|
||||
<span className="text-clay-900">直接在你的浏览器里合成</span>
|
||||
、使用你自己的免费额度 ——{" "}
|
||||
<span className="text-clay-900">Key 只保存在本地浏览器、绝不经过我们的服务器</span>
|
||||
。
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] smallcaps text-clay-500">K e y · 类 型</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
{ kind: "token-plan", label: "套餐 Token Plan", sub: "tp- 开头" },
|
||||
{ kind: "payg", label: "按量付费 Pay-as-you-go", sub: "sk- 开头" },
|
||||
] as const
|
||||
).map((t) => {
|
||||
const active = keyType === t.kind;
|
||||
return (
|
||||
<button
|
||||
key={t.kind}
|
||||
type="button"
|
||||
onClick={() => setKeyType(t.kind)}
|
||||
className={
|
||||
"flex flex-col gap-0.5 rounded-sm border px-3 py-2.5 text-left transition-all " +
|
||||
(active
|
||||
? "border-ember-500 bg-ember-500/5 text-clay-900"
|
||||
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
|
||||
}
|
||||
>
|
||||
<span className="text-[13px]">{t.label}</span>
|
||||
<span className="text-[10px] text-clay-400">{t.sub}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{keyType === "token-plan" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] smallcaps text-clay-500">区 域 节 点</span>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{TTS_REGION_PRESETS.map((p) => {
|
||||
const active = p.id === regionId;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setRegionId(p.id)}
|
||||
className={
|
||||
"rounded-sm border px-3 py-2.5 text-left text-[13px] transition-all " +
|
||||
(active
|
||||
? "border-ember-500 bg-ember-500/5 text-clay-900"
|
||||
: "border-clay-900/12 text-clay-600 hover:border-clay-900/35 hover:bg-cream-100")
|
||||
}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-[11px] text-clay-400">
|
||||
选择与你的套餐订阅地区一致的节点(通常也是延迟最低的那个)。
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 rounded-sm border border-clay-900/10 bg-cream-100/60 px-3.5 py-2.5">
|
||||
<i className="fa-solid fa-circle-info mt-0.5 text-[11px] text-clay-400" />
|
||||
<span className="text-[11px] leading-relaxed text-clay-500">
|
||||
按量付费使用统一端点{" "}
|
||||
<span className="text-clay-700">api.xiaomimimo.com</span>
|
||||
,无需选择区域。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] smallcaps text-clay-500">
|
||||
A P I · K e y
|
||||
</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
type={showKey ? "text" : "password"}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={
|
||||
keyType === "payg"
|
||||
? "粘贴 sk- 开头的按量 Key"
|
||||
: "粘贴 tp- 开头的套餐 Key"
|
||||
}
|
||||
className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-100 pl-4 pr-11 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
aria-label={showKey ? "隐藏" : "显示"}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-clay-400 hover:text-clay-700 transition-colors"
|
||||
>
|
||||
<i
|
||||
className={`fa-solid ${showKey ? "fa-eye-slash" : "fa-eye"} text-sm`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{prefixMismatch && (
|
||||
<span className="flex items-start gap-1.5 text-[11px] leading-relaxed text-ember-500">
|
||||
<i className="fa-solid fa-triangle-exclamation mt-0.5 text-[10px]" />
|
||||
此 Key 不是 {expectedPrefix} 开头,可能与所选「
|
||||
{keyType === "payg" ? "按量付费 Pay-as-you-go" : "套餐 Token Plan"}
|
||||
」类型不符,请确认是否填错。
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={TTS_KEY_DOC_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[11px] text-ember-500 hover:text-ember-400 transition-colors"
|
||||
>
|
||||
<i className="fa-brands fa-github text-[11px]" />
|
||||
如何免费申请 Key?查看图文教程
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-clay-400">{footerNote}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-clay-900/10 px-6 md:px-8 py-4">
|
||||
{alreadyConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={disable}
|
||||
className="inline-flex items-center gap-2 rounded-sm border border-clay-900/15 px-4 py-2 font-sans text-sm text-clay-600 transition-colors hover:border-clay-900/35 hover:text-clay-900"
|
||||
>
|
||||
<i className="fa-solid fa-rotate-left text-xs" />
|
||||
停用并清除
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!apiKey.trim()}
|
||||
className="ml-auto inline-flex items-center gap-2 rounded-sm bg-clay-900 px-5 py-2.5 font-sans text-sm text-cream-50 transition-colors hover:bg-ember-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<i className="fa-solid fa-check text-xs" />
|
||||
保存并启用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user