From 9ae91dd3ed11606894a20309e80dce772ccd979b Mon Sep 17 00:00:00 2001 From: "DESKTOP-I1T6TF3\\Q" <2291969160@qq.com> Date: Tue, 2 Jun 2026 15:41:36 +0800 Subject: [PATCH] feat(play): hug-canvas action buttons, unified mute, enlarged back-link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/app/play/page.tsx | 82 +++++++++++++++++------------- apps/web/components/PlayCanvas.tsx | 53 ++++++++++--------- 2 files changed, 77 insertions(+), 58 deletions(-) diff --git a/apps/web/app/play/page.tsx b/apps/web/app/play/page.tsx index a0b82fe..cca2428 100644 --- a/apps/web/app/play/page.tsx +++ b/apps/web/app/play/page.tsx @@ -238,12 +238,20 @@ function PlayInner() { const [currentBeatId, setCurrentBeatId] = useState(null); const [imageUrl, setImageUrl] = useState(null); const [beatAudioMap, setBeatAudioMap] = useState>({}); - // 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(() => { 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>(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(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(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 => { - 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() {
- - + + InfiPlot @@ -920,6 +925,32 @@ function PlayInner() { onBackgroundClick={onBackgroundClick} onAdvance={onAdvance} onSelectChoice={onSelectChoice} + aboveCanvas={ + + } + aboveCanvasLeft={ + + } />
@@ -937,28 +968,9 @@ function PlayInner() {
-