diff --git a/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx b/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx index 4dd74d0f3..775aea879 100644 --- a/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx +++ b/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx @@ -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 = ({ onToggleSelect, selectable = true, }) => { + // ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权,点击其他卡片自动暂停当前卡片 ── + // token 用 variantSeed(i+1),与 FrontendPreviewPlayer 内部 variantSeed 一致 + const [activePlayToken, setActivePlayToken] = useState(null) + // count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10)clamp, // 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节 return ( @@ -71,7 +77,9 @@ const CanvasPreviewGrid: React.FC = ({ 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] || "", diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index b9b721b70..8b32d6330 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -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 = ({ variantSeed = 0, variantTitle, compact = false, + activePlayToken = null, + onPlayTokenChange, }) => { const segments = useMemo( () => buildPlaybackSegments(assets, template, serverClips, variantSeed), @@ -339,6 +350,7 @@ const FrontendPreviewPlayer: React.FC = ({ canPlay: videoCanPlay, togglePlayPause: videoTogglePlayPause, seekTo: videoSeekTo, + pause: videoPause, videoRefs, } = useSegmentScheduler(segments) @@ -353,6 +365,10 @@ const FrontendPreviewPlayer: React.FC = ({ // ── 配音音频同步 ── const audioRef = useRef(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 = ({ 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 = ({ [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 = ({ segments.map((seg, i) => (