feat(#1741): 批量预览卡片缩小至3/5 + 每个视频播放都有声音(配音全挂载+播放互斥+静音按钮) #1742
@@ -6,8 +6,10 @@
|
||||
* - variantSeed 让每个变体素材排布/起始点不同,画面有可见差异
|
||||
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用
|
||||
* - 勾选框决定提交时生成哪些变体
|
||||
* - 每个变体都挂载同一条配音 URL(浏览器缓存不重复下载);播放互斥:
|
||||
* 点击某卡片播放时其他卡片自动暂停,同一时刻只有一路声音(#1741)
|
||||
*/
|
||||
import React from "react"
|
||||
import React, { useState } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { TitleSettings } from "../types"
|
||||
@@ -20,7 +22,7 @@ interface CanvasPreviewGridProps {
|
||||
videoRatio: string
|
||||
titles: string[]
|
||||
titleSettings: TitleSettings
|
||||
/** 共用配音预览音频(仅第 1 个变体播放,避免多路音频重叠) */
|
||||
/** 共用配音预览音频 URL(#1741:每个变体都挂载,播放互斥保证同一时刻只有一路发声) */
|
||||
voiceAudioUrl?: string
|
||||
/** 勾选的变体序号 */
|
||||
selectedIds: number[]
|
||||
@@ -41,6 +43,10 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
onToggleSelect,
|
||||
selectable = true,
|
||||
}) => {
|
||||
// ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权,点击其他卡片自动暂停当前卡片 ──
|
||||
// token 用 variantSeed(i+1),与 FrontendPreviewPlayer 内部 variantSeed 一致
|
||||
const [activePlayToken, setActivePlayToken] = useState<number | null>(null)
|
||||
|
||||
// count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10)clamp,
|
||||
// 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节
|
||||
return (
|
||||
@@ -71,7 +77,9 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
ready={assets.length > 0}
|
||||
variantSeed={i + 1}
|
||||
variantTitle={titles[i] || ""}
|
||||
voiceAudioUrl={i === 0 ? voiceAudioUrl : undefined}
|
||||
voiceAudioUrl={voiceAudioUrl}
|
||||
activePlayToken={activePlayToken}
|
||||
onPlayTokenChange={setActivePlayToken}
|
||||
compact
|
||||
titleSettings={{
|
||||
title: titles[i] || "",
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
PauseCircleOutlined,
|
||||
SoundOutlined,
|
||||
LoadingOutlined,
|
||||
AudioOutlined,
|
||||
AudioMutedOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
@@ -51,6 +53,13 @@ interface FrontendPreviewPlayerProps {
|
||||
variantTitle?: string
|
||||
/** 紧凑模式(批量网格中使用,缩小内边距/标题尺寸) */
|
||||
compact?: boolean
|
||||
/**
|
||||
* 批量网格播放互斥(#1741):当前持有播放权的实例 token(variantSeed)。
|
||||
* 持有权变化且不等于自身时,本实例自动暂停(视频+配音)。单视频模式不传。
|
||||
*/
|
||||
activePlayToken?: number | null
|
||||
/** 播放权变化回调:本实例请求播放时传自身 variantSeed,暂停时传 null */
|
||||
onPlayTokenChange?: (token: number | null) => void
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -157,6 +166,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
variantSeed = 0,
|
||||
variantTitle,
|
||||
compact = false,
|
||||
activePlayToken = null,
|
||||
onPlayTokenChange,
|
||||
}) => {
|
||||
const segments = useMemo(
|
||||
() => buildPlaybackSegments(assets, template, serverClips, variantSeed),
|
||||
@@ -339,6 +350,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
canPlay: videoCanPlay,
|
||||
togglePlayPause: videoTogglePlayPause,
|
||||
seekTo: videoSeekTo,
|
||||
pause: videoPause,
|
||||
videoRefs,
|
||||
} = useSegmentScheduler(segments)
|
||||
|
||||
@@ -353,6 +365,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
// ── 配音音频同步 ──
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const prevIsPlayingRef = useRef(false)
|
||||
// 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片
|
||||
const [muted, setMuted] = useState(false)
|
||||
// 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底
|
||||
const hasVoice = !!voiceAudioUrl
|
||||
|
||||
useEffect(() => {
|
||||
if (!voiceAudioUrl) {
|
||||
@@ -370,7 +386,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
if (audioRef.current.src !== voiceAudioUrl) {
|
||||
audioRef.current.src = voiceAudioUrl
|
||||
}
|
||||
}, [voiceAudioUrl])
|
||||
audioRef.current.muted = muted
|
||||
}, [voiceAudioUrl, muted])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
@@ -409,17 +426,42 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
[effectiveUseWebCodecs, canvasControls, videoSeekTo],
|
||||
)
|
||||
|
||||
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停(视频+配音) ──
|
||||
useEffect(() => {
|
||||
if (activePlayToken == null || activePlayToken === variantSeed) return
|
||||
if (effectiveUseWebCodecs) {
|
||||
if (canvasState.isPlaying) canvasControls.pause()
|
||||
} else if (isPlaying) {
|
||||
videoPause()
|
||||
}
|
||||
// isPlaying/canvasState.isPlaying 不放依赖:只在 token 变化时执行一次暂停,
|
||||
// token 等于自身时本实例的播放在 handleTogglePlay 里处理
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activePlayToken, variantSeed, effectiveUseWebCodecs])
|
||||
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
if (effectiveUseWebCodecs) {
|
||||
if (canvasState.isPlaying) {
|
||||
canvasControls.pause()
|
||||
onPlayTokenChange?.(null)
|
||||
} else {
|
||||
onPlayTokenChange?.(variantSeed)
|
||||
canvasControls.play()
|
||||
}
|
||||
} else {
|
||||
// video fallback:先上报播放权(暂停其他卡片),再切换本卡片播放/暂停
|
||||
onPlayTokenChange?.(isPlaying ? null : variantSeed)
|
||||
videoTogglePlayPause()
|
||||
}
|
||||
}, [effectiveUseWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
|
||||
}, [
|
||||
effectiveUseWebCodecs,
|
||||
canvasState.isPlaying,
|
||||
canvasControls,
|
||||
videoTogglePlayPause,
|
||||
isPlaying,
|
||||
variantSeed,
|
||||
onPlayTokenChange,
|
||||
])
|
||||
|
||||
// ── 进度条拖拽 ──
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
@@ -608,7 +650,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
segments.map((seg, i) => (
|
||||
<video
|
||||
key={seg.assetId}
|
||||
muted
|
||||
muted={hasVoice || muted}
|
||||
ref={(el) => {
|
||||
videoRefs.current[i] = el
|
||||
}}
|
||||
@@ -743,6 +785,45 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 静音/有声切换(#1741):左上角,默认有声;批量与单视频均可单独静音 */}
|
||||
{segments.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={muted ? "取消静音" : "静音"}
|
||||
title={muted ? "取消静音" : "静音"}
|
||||
onClick={() => setMuted((m) => !m)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
width: compact ? 26 : 30,
|
||||
height: compact ? 26 : 30,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: "50%",
|
||||
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
|
||||
fontSize: compact ? 13 : 15,
|
||||
cursor: "pointer",
|
||||
zIndex: 10,
|
||||
padding: 0,
|
||||
transition: "background 0.15s, color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
|
||||
}}
|
||||
>
|
||||
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 片段指示器 — 右上角胶囊 */}
|
||||
<div
|
||||
style={{
|
||||
@@ -753,9 +834,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontSize: 10,
|
||||
fontSize: compact ? 9 : 10,
|
||||
fontWeight: 500,
|
||||
padding: "2px 8px",
|
||||
padding: compact ? "1px 6px" : "2px 8px",
|
||||
borderRadius: 999,
|
||||
zIndex: 10,
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
@@ -774,8 +855,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
right: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "12px 16px 16px",
|
||||
gap: compact ? 6 : 10,
|
||||
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
|
||||
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
|
||||
backdropFilter: "blur(4px)",
|
||||
WebkitBackdropFilter: "blur(4px)",
|
||||
@@ -788,10 +869,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
background: "rgba(255,255,255,0.15)",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontSize: compact ? 14 : 16,
|
||||
cursor: "pointer",
|
||||
width: 32,
|
||||
height: 32,
|
||||
width: compact ? 26 : 32,
|
||||
height: compact ? 26 : 32,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -811,9 +892,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontSize: compact ? 10 : 11,
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
minWidth: 72,
|
||||
minWidth: compact ? 58 : 72,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
|
||||
@@ -3282,10 +3282,20 @@
|
||||
/* ============================================================
|
||||
批量前端 Canvas 预览网格(Issue #1677 修正:纯前端实时预览)
|
||||
============================================================ */
|
||||
/* #1741:卡片整体缩小至约 3/5——宽屏排 3 列(卡片限宽 220px 居中),
|
||||
中屏自动回退 2 列,窄屏 1 列(见下方媒体查询);卡片保持 9:16 比例不变形 */
|
||||
.xx-canvas-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 220px));
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 窄屏单列时卡片限宽居中,避免 1fr 拉伸导致卡片过高 */
|
||||
@media (max-width: 960px) {
|
||||
.xx-canvas-grid {
|
||||
grid-template-columns: minmax(0, 320px);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-canvas-grid-card {
|
||||
@@ -3435,9 +3445,8 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── 响应式:窄屏批量网格回退单列 ── */
|
||||
/* ── 响应式:窄屏批量网格回退单列(.xx-canvas-grid 的窄屏限宽见网格定义处 #1741) ── */
|
||||
@media (max-width: 960px) {
|
||||
.xx-canvas-grid,
|
||||
.xx-batch-gen-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* CanvasPreviewGrid 单测(Issue #1741)
|
||||
*
|
||||
* 验证:
|
||||
* - N=3 时每个变体都拿到 voiceAudioUrl(修复前仅 index 0 有,视频 2/3 无声)
|
||||
* - 每个变体都收到 activePlayToken / onPlayTokenChange(播放互斥接线)
|
||||
* - 某个实例上报播放 → 所有实例的 activePlayToken 变为该实例(其他实例收到 token≠自身,自动暂停)
|
||||
* - 实例上报暂停(null)→ 播放权释放
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import { render, fireEvent } from "@testing-library/react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
const playerCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
|
||||
|
||||
vi.mock("@/pages/generate/components/FrontendPreviewPlayer", () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
playerCalls.push(props)
|
||||
const seed = props.variantSeed as number
|
||||
const token = props.activePlayToken as number | null
|
||||
const change = props.onPlayTokenChange as (t: number | null) => void
|
||||
return (
|
||||
<div data-testid={`player-${seed}`}>
|
||||
<span data-testid={`voice-${seed}`}>{props.voiceAudioUrl ? "has-voice" : "no-voice"}</span>
|
||||
<span data-testid={`token-${seed}`}>{token == null ? "none" : String(token)}</span>
|
||||
<button type="button" onClick={() => change(seed)}>
|
||||
play-{seed}
|
||||
</button>
|
||||
<button type="button" onClick={() => change(null)}>
|
||||
pause-{seed}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
import CanvasPreviewGrid from "@/pages/generate/components/CanvasPreviewGrid"
|
||||
|
||||
function makeAsset(id: string): AssetItem {
|
||||
return {
|
||||
id,
|
||||
library_id: "lib-1",
|
||||
name: `${id}.mp4`,
|
||||
storage_key: `media/${id}.mp4`,
|
||||
file_url: `https://cdn.example.com/${id}.mp4`,
|
||||
mime_type: "video/mp4",
|
||||
metadata: { duration: 10 },
|
||||
duration: 10,
|
||||
}
|
||||
}
|
||||
|
||||
const titleSettings = {
|
||||
size: 36,
|
||||
font: "思源黑体",
|
||||
color: "#fff",
|
||||
position: "bottom" as const,
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
posX: null,
|
||||
posY: null,
|
||||
}
|
||||
|
||||
function renderGrid(count = 3) {
|
||||
playerCalls.length = 0
|
||||
return render(
|
||||
<CanvasPreviewGrid
|
||||
count={count}
|
||||
assets={[makeAsset("a1"), makeAsset("a2"), makeAsset("a3")]}
|
||||
template={null}
|
||||
videoRatio="9:16"
|
||||
titles={["标题1", "标题2", "标题3"]}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrl="https://cdn.example.com/tts.mp3"
|
||||
selectedIds={[0, 1, 2]}
|
||||
onToggleSelect={() => {}}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe("CanvasPreviewGrid 配音与播放互斥 (#1741)", () => {
|
||||
it("N=3 时每个变体都拿到 voiceAudioUrl(不再只有 index 0)", () => {
|
||||
renderGrid(3)
|
||||
expect(playerCalls).toHaveLength(3)
|
||||
playerCalls.forEach((p, i) => {
|
||||
expect(p.voiceAudioUrl).toBe("https://cdn.example.com/tts.mp3")
|
||||
expect(p.variantSeed).toBe(i + 1)
|
||||
})
|
||||
})
|
||||
|
||||
it("每个变体都接线 activePlayToken / onPlayTokenChange", () => {
|
||||
renderGrid(3)
|
||||
playerCalls.forEach((p) => {
|
||||
expect(p.activePlayToken).toBeNull()
|
||||
expect(typeof p.onPlayTokenChange).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
it("点击实例2播放:所有实例 activePlayToken 变为 2(其他实例自动暂停)", () => {
|
||||
const { getByTestId } = renderGrid(3)
|
||||
fireEvent.click(getByTestId("player-2").querySelector("button")!)
|
||||
expect(getByTestId("token-1").textContent).toBe("2")
|
||||
expect(getByTestId("token-2").textContent).toBe("2")
|
||||
expect(getByTestId("token-3").textContent).toBe("2")
|
||||
})
|
||||
|
||||
it("正在播放实例上报暂停后,播放权释放(token 回 null)", () => {
|
||||
const { getByTestId } = renderGrid(3)
|
||||
fireEvent.click(getByTestId("player-3").querySelector("button")!)
|
||||
expect(getByTestId("token-1").textContent).toBe("3")
|
||||
|
||||
fireEvent.click(getByTestId("player-3").querySelectorAll("button")[1])
|
||||
expect(getByTestId("token-3").textContent).toBe("none")
|
||||
})
|
||||
|
||||
it("渲染 N 个勾选标签(视频1..N)且配音状态全部 has-voice", () => {
|
||||
const { getByTestId } = renderGrid(3)
|
||||
expect(getByTestId("voice-1").textContent).toBe("has-voice")
|
||||
expect(getByTestId("voice-2").textContent).toBe("has-voice")
|
||||
expect(getByTestId("voice-3").textContent).toBe("has-voice")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* FrontendPreviewPlayer 音频行为单测(Issue #1741)
|
||||
*
|
||||
* useSegmentScheduler/useCanvasPlayer 用 mock 控制播放态,专注验证本组件的音频逻辑:
|
||||
* - 有配音时 video 保持 muted(素材原声不与配音混音)
|
||||
* - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声)
|
||||
* - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换
|
||||
* - 批量播放互斥:activePlayToken 变为其他实例且本实例在播放时,调用 pause
|
||||
* - 点击播放/暂停时上报播放权(onPlayTokenChange)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { render, screen, fireEvent } from "@testing-library/react"
|
||||
import FrontendPreviewPlayer from "@/pages/generate/components/FrontendPreviewPlayer"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isPlaying: false,
|
||||
pause: vi.fn(),
|
||||
togglePlayPause: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/generate/hooks/useSegmentScheduler", () => ({
|
||||
useSegmentScheduler: () => ({
|
||||
isPlaying: mocks.isPlaying,
|
||||
currentTime: 0,
|
||||
totalDuration: 20,
|
||||
currentSegmentIndex: 0,
|
||||
canPlay: true,
|
||||
togglePlayPause: mocks.togglePlayPause,
|
||||
seekTo: vi.fn(),
|
||||
pause: mocks.pause,
|
||||
videoRefs: { current: [] as (HTMLVideoElement | null)[] },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/generate/hooks/useCanvasPlayer", () => ({
|
||||
useCanvasPlayer: () => ({
|
||||
state: {
|
||||
isPlaying: false,
|
||||
isReady: false,
|
||||
isBuffering: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
errorMessage: "",
|
||||
hasDecodeError: false,
|
||||
},
|
||||
controls: { play: vi.fn(), pause: vi.fn(), seek: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.isPlaying = false
|
||||
mocks.pause.mockClear()
|
||||
mocks.togglePlayPause.mockClear()
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
function makeAsset(id: string): AssetItem {
|
||||
return {
|
||||
id,
|
||||
library_id: "lib-1",
|
||||
name: `${id}.mp4`,
|
||||
storage_key: `media/${id}.mp4`,
|
||||
file_url: `https://cdn.example.com/${id}.mp4`,
|
||||
mime_type: "video/mp4",
|
||||
metadata: { duration: 10, width: 1080, height: 1920 },
|
||||
duration: 10,
|
||||
}
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
assets: [makeAsset("a1"), makeAsset("a2")],
|
||||
template: null,
|
||||
videoRatio: "9:16",
|
||||
ready: true,
|
||||
}
|
||||
|
||||
function videos(): HTMLVideoElement[] {
|
||||
return Array.from(document.querySelectorAll("video"))
|
||||
}
|
||||
|
||||
describe("FrontendPreviewPlayer 音频行为 (#1741)", () => {
|
||||
it("有配音时 video 保持 muted(素材原声不与配音混音)", () => {
|
||||
render(<FrontendPreviewPlayer {...baseProps} voiceAudioUrl="https://cdn.example.com/tts.mp3" />)
|
||||
expect(videos()).toHaveLength(2)
|
||||
videos().forEach((v) => expect(v.muted).toBe(true))
|
||||
})
|
||||
|
||||
it("无配音时 video 不 muted(素材原声兜底)", () => {
|
||||
render(<FrontendPreviewPlayer {...baseProps} />)
|
||||
videos().forEach((v) => expect(v.muted).toBe(false))
|
||||
})
|
||||
|
||||
it("无配音时点静音按钮,video 切换为 muted;再点恢复", () => {
|
||||
render(<FrontendPreviewPlayer {...baseProps} />)
|
||||
const vs = videos()
|
||||
expect(vs[0].muted).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "静音" }))
|
||||
videos().forEach((v) => expect(v.muted).toBe(true))
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "取消静音" }))
|
||||
videos().forEach((v) => expect(v.muted).toBe(false))
|
||||
})
|
||||
|
||||
it("批量播放互斥:token 变为其他实例且本实例在播放时调用 pause", () => {
|
||||
mocks.isPlaying = true
|
||||
const { rerender } = render(
|
||||
<FrontendPreviewPlayer
|
||||
{...baseProps}
|
||||
variantSeed={2}
|
||||
activePlayToken={2}
|
||||
onPlayTokenChange={() => {}}
|
||||
compact
|
||||
/>,
|
||||
)
|
||||
expect(mocks.pause).not.toHaveBeenCalled()
|
||||
|
||||
// 播放权切给实例 3
|
||||
rerender(
|
||||
<FrontendPreviewPlayer
|
||||
{...baseProps}
|
||||
variantSeed={2}
|
||||
activePlayToken={3}
|
||||
onPlayTokenChange={() => {}}
|
||||
compact
|
||||
/>,
|
||||
)
|
||||
expect(mocks.pause).toHaveBeenCalledTimes(1)
|
||||
|
||||
// token 切回自己:不重复暂停
|
||||
rerender(
|
||||
<FrontendPreviewPlayer
|
||||
{...baseProps}
|
||||
variantSeed={2}
|
||||
activePlayToken={2}
|
||||
onPlayTokenChange={() => {}}
|
||||
compact
|
||||
/>,
|
||||
)
|
||||
expect(mocks.pause).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("未播放时 token 变化不触发暂停(effect 仅在本实例播放时生效)", () => {
|
||||
// mocks.isPlaying = false(beforeEach 重置)
|
||||
const { rerender } = render(
|
||||
<FrontendPreviewPlayer {...baseProps} variantSeed={1} activePlayToken={1} compact />,
|
||||
)
|
||||
rerender(<FrontendPreviewPlayer {...baseProps} variantSeed={1} activePlayToken={2} compact />)
|
||||
expect(mocks.pause).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("暂停状态下点击播放按钮:上报播放权为自身 variantSeed 并触发播放", () => {
|
||||
const onToken = vi.fn()
|
||||
render(
|
||||
<FrontendPreviewPlayer {...baseProps} variantSeed={3} onPlayTokenChange={onToken} compact />,
|
||||
)
|
||||
// 暂停态有两个图标播放按钮(中央大按钮 + 控制条按钮),均调 handleTogglePlay,点中央那个
|
||||
const playButtons = screen.getAllByRole("button").filter((b) => !b.getAttribute("aria-label"))
|
||||
expect(playButtons.length).toBeGreaterThanOrEqual(1)
|
||||
fireEvent.click(playButtons[0])
|
||||
expect(onToken).toHaveBeenCalledWith(3)
|
||||
expect(mocks.togglePlayPause).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("每个卡片都渲染独立静音按钮", () => {
|
||||
render(
|
||||
<div>
|
||||
<FrontendPreviewPlayer {...baseProps} variantSeed={1} compact />
|
||||
<FrontendPreviewPlayer {...baseProps} variantSeed={2} compact />
|
||||
<FrontendPreviewPlayer {...baseProps} variantSeed={3} compact />
|
||||
</div>,
|
||||
)
|
||||
expect(screen.getAllByRole("button", { name: "静音" })).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user