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 [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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user