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:
DESKTOP-I1T6TF3\Q
2026-06-02 15:41:36 +08:00
parent cffe4da4ca
commit 9ae91dd3ed
2 changed files with 77 additions and 58 deletions
+47 -35
View File
@@ -238,12 +238,20 @@ function PlayInner() {
const [currentBeatId, setCurrentBeatId] = useState<string | null>(null);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [beatAudioMap, setBeatAudioMap] = useState<Record<string, BeatAudio>>({});
// Lazy-initialize from localStorage so PlayCanvas never mounts with the
// wrong muted value (an effect-based read would briefly let audio play
// before the preference settled in a scenario where audio arrives early).
// 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;
@@ -263,13 +271,11 @@ function PlayInner() {
// 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());
// 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
// 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);
// Mirrors for use inside async handlers (closure-stable)
@@ -329,8 +335,8 @@ function PlayInner() {
sess: Session,
beat: { id: string; speaker?: string; line?: string; lineDelivery?: string },
): 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;
const speaker = sess.characters.find((c) => c.name === beat.speaker);
if (!speaker?.voice) return; // not yet provisioned — server can't synth anyway
@@ -495,8 +501,7 @@ function PlayInner() {
audioEnabled?: boolean;
};
payload = { worldSetting: parsed.worldSetting, styleGuide: parsed.styleGuide };
// default true for older payloads that omit the flag
audioEnabledRef.current = parsed.audioEnabled !== false;
// audioEnabled 已在 useState 初始化时反向投射到 muted;这里无需再额外存。
} catch {
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">
<Link
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]" />
<span className="font-serif text-[15px] leading-none tracking-tight">
<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>
@@ -920,6 +925,32 @@ function PlayInner() {
onBackgroundClick={onBackgroundClick}
onAdvance={onAdvance}
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">
@@ -937,28 +968,9 @@ function PlayInner() {
</div>
</main>
<footer className="px-5 md:px-12 pb-6 flex items-center justify-between">
<button
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>
<footer className="px-5 md:px-12 pb-6 flex items-center justify-center">
{/* 演示 / 静音入口已搬到画面正上方左右两侧;footer 仅留中间的「Ⅰ · Ⅰ」标记 */}
<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>
</div>
);
+30 -23
View File
@@ -1,6 +1,6 @@
"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";
export type Phase =
@@ -170,6 +170,8 @@ export function PlayCanvas({
onAdvance,
onSelectChoice,
fullViewport = false,
aboveCanvas,
aboveCanvasLeft,
}: {
imageUrl: string | null;
audioBase64: string | null;
@@ -182,10 +184,13 @@ export function PlayCanvas({
onAdvance: () => void;
onSelectChoice: (choice: BeatChoice) => void;
fullViewport?: boolean;
// 渲染在图片正上方、右对齐的 slot(画面外、紧贴右上角)。
aboveCanvas?: ReactNode;
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
aboveCanvasLeft?: ReactNode;
}) {
const imgRef = useRef<HTMLImageElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
const [audioDurationMs, setAudioDurationMs] = useState<number | undefined>(
undefined,
);
@@ -282,12 +287,6 @@ export function PlayCanvas({
? "min(100vw, calc(100dvh * 16 / 9))"
: "min(96vw, calc((100dvh - 200px) * 16 / 9))";
const footerHint =
phase === "ready"
? isChoiceBeat
? "选 · 择 · 一 · 项"
: "点 · 击 · 推 · 进"
: "···";
return (
<div
@@ -318,10 +317,6 @@ export function PlayCanvas({
src={imageUrl}
alt="Generated scene"
onClick={handleImageClick}
onLoad={(e) => {
const img = e.currentTarget;
setDims({ w: img.naturalWidth, h: img.naturalHeight });
}}
draggable={false}
className={`block w-auto h-auto select-none animate-fade-in transition-opacity duration-700 ease-out ${
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" />
)}
{/* 画面正上方右对齐的 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 && (
<div className="absolute inset-0 flex flex-col justify-end pointer-events-none select-none">
{choices.length > 0 && (
@@ -470,20 +477,20 @@ export function PlayCanvas({
<p className="text-[9px] smallcaps text-clay-500 animate-slow-pulse">
· · · · · ·
</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>
)}
{!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>
);
}