feat(play): hug-canvas action buttons, unified mute, enlarged back-link
UI layout (PlayCanvas + play/page.tsx): - "F · 全 · 屏" button (renamed from 演 · 示 to match what users actually mean by F) floats above the canvas, right-aligned, via a new `aboveCanvas` ReactNode slot that lives on the relative inline-block image wrapper at `bottom-full right-0`. It hugs the actual image right edge regardless of aspect ratio. - "有 · 声 / 静 · 音" button mirrors that on the left via a new `aboveCanvasLeft` slot. - Both slots also render inside the loading placeholder so the two controls appear from frame one, before the scene image arrives. - InfiPlot back-link grows from 15px to 22/26px (mobile/desktop) with a slightly larger arrow, matching the brand'\''s presence on the homepage hero. - Canvas-bottom metadata row (image dims on left, tutorial hint on right) dropped. The "—" placeholder and "···" loading state looked like stray punctuation; users found them noisy. - Footer collapses to a single centered "Ⅰ · Ⅰ" mark. Audio gating logic (play/page.tsx): - Collapse the two-flag audio gate into one source of truth. The homepage "语音配音" choice no longer lives in a separate `audioEnabledRef` flag that gates `fetchBeatAudio` independently of the in-page mute state. Instead the `muted` useState lazy initializer reads `sessionStorage["infiplot:custom"].audioEnabled` and projects it inversely (audioEnabled=false → muted=true) so the 静音/有声 button correctly reflects the homepage selection from the first frame. The in-page toggle remains the source of truth from then on (persisted to localStorage:infiplot:muted). - This fixes a visible disconnect where picking "关闭" on the homepage left the play page showing 有声 because the in-page state had no link to the homepage choice. - The sessionStorage read uses the renamed key "infiplot:custom" (the infiplot rename PR changed it from yume:custom on the home side but the play side hadn'\''t been updated to match). No new TTS quota is ever burned while muted: fetchBeatAudio'\''s mutedRef.current early-return is the only path to /api/beat-audio and is checked before the fetch fires; mute transitions also abort in-flight requests.
This commit is contained in:
+47
-35
@@ -238,12 +238,20 @@ function PlayInner() {
|
|||||||
const [currentBeatId, setCurrentBeatId] = useState<string | null>(null);
|
const [currentBeatId, setCurrentBeatId] = useState<string | null>(null);
|
||||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||||
const [beatAudioMap, setBeatAudioMap] = useState<Record<string, BeatAudio>>({});
|
const [beatAudioMap, setBeatAudioMap] = useState<Record<string, BeatAudio>>({});
|
||||||
// Lazy-initialize from localStorage so PlayCanvas never mounts with the
|
// Lazy-initialize 优先级:本局选择(homepage 的「语音配音」存到 sessionStorage:infiplot:custom)
|
||||||
// wrong muted value (an effect-based read would briefly let audio play
|
// > 上次会话的粘性偏好(localStorage:infiplot:muted) > 默认非静音。
|
||||||
// before the preference settled in a scenario where audio arrives early).
|
// 这样首页选了「关闭」开始游戏,进来就是静音;选「开启」就不是静音;进入 play 页后用户自己
|
||||||
|
// 切换 静音/有声 时再用 localStorage 持久化,下一局开新游戏 sessionStorage 选择会再覆盖。
|
||||||
const [muted, setMuted] = useState<boolean>(() => {
|
const [muted, setMuted] = useState<boolean>(() => {
|
||||||
if (typeof window === "undefined") return false;
|
if (typeof window === "undefined") return false;
|
||||||
try {
|
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";
|
return window.localStorage.getItem(MUTED_STORAGE_KEY) === "1";
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -263,13 +271,11 @@ function PlayInner() {
|
|||||||
// changes so stale in-flight requests can't poison the new scene's map
|
// 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).
|
// (beat ids like "b1" are scene-local and would collide across scenes).
|
||||||
const beatAudioAbortRef = useRef<Map<string, AbortController>>(new Map());
|
const beatAudioAbortRef = useRef<Map<string, AbortController>>(new Map());
|
||||||
// User-toggled "语音配音" from the homepage. Defaults to true for back-compat
|
|
||||||
// when older sessionStorage payloads omit the field. Mutated once in
|
|
||||||
// bootstrap and read by fetchBeatAudio to early-return without any /api call.
|
|
||||||
const audioEnabledRef = useRef<boolean>(true);
|
|
||||||
// Mirrors `muted` so the closure-stable fetchBeatAudio (deps []) can gate on
|
// Mirrors `muted` so the closure-stable fetchBeatAudio (deps []) can gate on
|
||||||
// it. Muting stops TTS *synthesis*, not just playback — TTS is the only sound
|
// 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.
|
// source, so synthesizing audio the user can't hear just burns quota.
|
||||||
|
// 首页「语音配音 关闭」会把 muted 初值置为 true(见上方 useState 初始化),
|
||||||
|
// 不再单独维护 audioEnabledRef —— 单一来源避免两个 flag 漂移。
|
||||||
const mutedRef = useRef<boolean>(muted);
|
const mutedRef = useRef<boolean>(muted);
|
||||||
|
|
||||||
// Mirrors for use inside async handlers (closure-stable)
|
// Mirrors for use inside async handlers (closure-stable)
|
||||||
@@ -329,8 +335,8 @@ function PlayInner() {
|
|||||||
sess: Session,
|
sess: Session,
|
||||||
beat: { id: string; speaker?: string; line?: string; lineDelivery?: string },
|
beat: { id: string; speaker?: string; line?: string; lineDelivery?: string },
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (!audioEnabledRef.current) return; // user toggled 语音配音 → 关闭
|
if (mutedRef.current) return; // 静音 → 不合成 TTS(避免无谓的调用与花费)。
|
||||||
if (mutedRef.current) return; // 静音 → 不合成 TTS(避免无谓的调用与花费)
|
// 「首页选关闭」也走这条路:bootstrap 时 muted 已被初始化为 true。
|
||||||
if (!beat.speaker || !beat.line) return;
|
if (!beat.speaker || !beat.line) return;
|
||||||
const speaker = sess.characters.find((c) => c.name === beat.speaker);
|
const speaker = sess.characters.find((c) => c.name === beat.speaker);
|
||||||
if (!speaker?.voice) return; // not yet provisioned — server can't synth anyway
|
if (!speaker?.voice) return; // not yet provisioned — server can't synth anyway
|
||||||
@@ -495,8 +501,7 @@ function PlayInner() {
|
|||||||
audioEnabled?: boolean;
|
audioEnabled?: boolean;
|
||||||
};
|
};
|
||||||
payload = { worldSetting: parsed.worldSetting, styleGuide: parsed.styleGuide };
|
payload = { worldSetting: parsed.worldSetting, styleGuide: parsed.styleGuide };
|
||||||
// default true for older payloads that omit the flag
|
// audioEnabled 已在 useState 初始化时反向投射到 muted;这里无需再额外存。
|
||||||
audioEnabledRef.current = parsed.audioEnabled !== false;
|
|
||||||
} catch {
|
} catch {
|
||||||
payload = null;
|
payload = null;
|
||||||
}
|
}
|
||||||
@@ -890,10 +895,10 @@ function PlayInner() {
|
|||||||
<header className="px-5 md:px-12 pt-6 md:pt-8 flex items-center justify-between">
|
<header className="px-5 md:px-12 pt-6 md:pt-8 flex items-center justify-between">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="text-clay-600 hover:text-clay-900 transition-colors flex items-center gap-2"
|
className="text-clay-600 hover:text-clay-900 transition-colors flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-arrow-left text-[9px]" />
|
<i className="fa-solid fa-arrow-left text-[12px]" />
|
||||||
<span className="font-serif text-[15px] leading-none tracking-tight">
|
<span className="font-serif text-[22px] md:text-[26px] leading-none tracking-tight">
|
||||||
Infi<em className="italic font-light text-ember-500">Plot</em>
|
Infi<em className="italic font-light text-ember-500">Plot</em>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -920,6 +925,32 @@ function PlayInner() {
|
|||||||
onBackgroundClick={onBackgroundClick}
|
onBackgroundClick={onBackgroundClick}
|
||||||
onAdvance={onAdvance}
|
onAdvance={onAdvance}
|
||||||
onSelectChoice={onSelectChoice}
|
onSelectChoice={onSelectChoice}
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 max-w-md w-full text-center min-h-[28px] flex items-center justify-center">
|
<div className="mt-4 max-w-md w-full text-center min-h-[28px] flex items-center justify-center">
|
||||||
@@ -937,28 +968,9 @@ function PlayInner() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="px-5 md:px-12 pb-6 flex items-center justify-between">
|
<footer className="px-5 md:px-12 pb-6 flex items-center justify-center">
|
||||||
<button
|
{/* 演示 / 静音入口已搬到画面正上方左右两侧;footer 仅留中间的「Ⅰ · Ⅰ」标记 */}
|
||||||
type="button"
|
|
||||||
onClick={() => void togglePresentation()}
|
|
||||||
className="text-[9px] smallcaps text-clay-400 hover:text-clay-700 transition-colors flex items-center gap-2"
|
|
||||||
aria-label="进入演示模式"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-expand text-[10px]" />
|
|
||||||
F · 演 · 示
|
|
||||||
</button>
|
|
||||||
<div className="text-[9px] smallcaps text-clay-400 num">Ⅰ · Ⅰ</div>
|
<div className="text-[9px] smallcaps text-clay-400 num">Ⅰ · Ⅰ</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleMuted}
|
|
||||||
className="text-[9px] smallcaps text-clay-400 hover:text-clay-700 transition-colors flex items-center gap-2 w-[80px] justify-end"
|
|
||||||
aria-label={muted ? "取消静音" : "静音"}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className={`fa-solid ${muted ? "fa-volume-xmark" : "fa-volume-high"} text-[10px]`}
|
|
||||||
/>
|
|
||||||
{muted ? "静 · 音" : "有 · 声"}
|
|
||||||
</button>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import type { Beat, BeatChoice } from "@infiplot/types";
|
import type { Beat, BeatChoice } from "@infiplot/types";
|
||||||
|
|
||||||
export type Phase =
|
export type Phase =
|
||||||
@@ -170,6 +170,8 @@ export function PlayCanvas({
|
|||||||
onAdvance,
|
onAdvance,
|
||||||
onSelectChoice,
|
onSelectChoice,
|
||||||
fullViewport = false,
|
fullViewport = false,
|
||||||
|
aboveCanvas,
|
||||||
|
aboveCanvasLeft,
|
||||||
}: {
|
}: {
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
audioBase64: string | null;
|
audioBase64: string | null;
|
||||||
@@ -182,10 +184,13 @@ export function PlayCanvas({
|
|||||||
onAdvance: () => void;
|
onAdvance: () => void;
|
||||||
onSelectChoice: (choice: BeatChoice) => void;
|
onSelectChoice: (choice: BeatChoice) => void;
|
||||||
fullViewport?: boolean;
|
fullViewport?: boolean;
|
||||||
|
// 渲染在图片正上方、右对齐的 slot(画面外、紧贴右上角)。
|
||||||
|
aboveCanvas?: ReactNode;
|
||||||
|
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
|
||||||
|
aboveCanvasLeft?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const imgRef = useRef<HTMLImageElement>(null);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
|
||||||
const [audioDurationMs, setAudioDurationMs] = useState<number | undefined>(
|
const [audioDurationMs, setAudioDurationMs] = useState<number | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -282,12 +287,6 @@ export function PlayCanvas({
|
|||||||
? "min(100vw, calc(100dvh * 16 / 9))"
|
? "min(100vw, calc(100dvh * 16 / 9))"
|
||||||
: "min(96vw, calc((100dvh - 200px) * 16 / 9))";
|
: "min(96vw, calc((100dvh - 200px) * 16 / 9))";
|
||||||
|
|
||||||
const footerHint =
|
|
||||||
phase === "ready"
|
|
||||||
? isChoiceBeat
|
|
||||||
? "选 · 择 · 一 · 项"
|
|
||||||
: "点 · 击 · 推 · 进"
|
|
||||||
: "···";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -318,10 +317,6 @@ export function PlayCanvas({
|
|||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt="Generated scene"
|
alt="Generated scene"
|
||||||
onClick={handleImageClick}
|
onClick={handleImageClick}
|
||||||
onLoad={(e) => {
|
|
||||||
const img = e.currentTarget;
|
|
||||||
setDims({ w: img.naturalWidth, h: img.naturalHeight });
|
|
||||||
}}
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
className={`block w-auto h-auto select-none animate-fade-in transition-opacity duration-700 ease-out ${
|
className={`block w-auto h-auto select-none animate-fade-in transition-opacity duration-700 ease-out ${
|
||||||
interactive ? "cursor-pointer" : "cursor-wait"
|
interactive ? "cursor-pointer" : "cursor-wait"
|
||||||
@@ -333,6 +328,18 @@ export function PlayCanvas({
|
|||||||
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-clay-900/12 to-transparent pointer-events-none" />
|
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-clay-900/12 to-transparent pointer-events-none" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 画面正上方右对齐的 slot —— 用 bottom-full + right-0 让它整体浮在图片之外、紧贴右上角 */}
|
||||||
|
{!fullViewport && aboveCanvas && (
|
||||||
|
<div className="absolute bottom-full right-0 mb-2 flex items-center gap-2">
|
||||||
|
{aboveCanvas}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!fullViewport && aboveCanvasLeft && (
|
||||||
|
<div className="absolute bottom-full left-0 mb-2 flex items-center gap-2">
|
||||||
|
{aboveCanvasLeft}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{beat && (
|
{beat && (
|
||||||
<div className="absolute inset-0 flex flex-col justify-end pointer-events-none select-none">
|
<div className="absolute inset-0 flex flex-col justify-end pointer-events-none select-none">
|
||||||
{choices.length > 0 && (
|
{choices.length > 0 && (
|
||||||
@@ -470,20 +477,20 @@ export function PlayCanvas({
|
|||||||
<p className="text-[9px] smallcaps text-clay-500 animate-slow-pulse">
|
<p className="text-[9px] smallcaps text-clay-500 animate-slow-pulse">
|
||||||
正 · 在 · 绘 · 制 · 第 · 一 · 幕
|
正 · 在 · 绘 · 制 · 第 · 一 · 幕
|
||||||
</p>
|
</p>
|
||||||
|
{/* 加载占位也挂同一对 slot,让右上 / 左上的操作按钮在第一帧就出现 */}
|
||||||
|
{!fullViewport && aboveCanvas && (
|
||||||
|
<div className="absolute bottom-full right-0 mb-2 flex items-center gap-2">
|
||||||
|
{aboveCanvas}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!fullViewport && aboveCanvasLeft && (
|
||||||
|
<div className="absolute bottom-full left-0 mb-2 flex items-center gap-2">
|
||||||
|
{aboveCanvasLeft}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!fullViewport && (
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between mt-3 px-1 w-full"
|
|
||||||
style={{ maxWidth: "96vw" }}
|
|
||||||
>
|
|
||||||
<span className="text-[9px] smallcaps text-clay-400 num">
|
|
||||||
{dims ? `${dims.w} × ${dims.h} · png` : "—"}
|
|
||||||
</span>
|
|
||||||
<span className="text-[9px] smallcaps text-clay-400">{footerHint}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user