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
+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>
);
}