forked from 77media/video-flow
pc端播放组件优化
This commit is contained in:
parent
e6a3916f66
commit
cbc4a2193d
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useRef, useEffect, useState, SetStateAction, useMemo } from 'react';
|
import React, { useRef, useEffect, useState, SetStateAction, useMemo } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Edit3, Play, Pause, Volume2, VolumeX, Maximize, Minimize, Loader2, X, Scissors, RotateCcw, MessageCircleMore, Download, ArrowDownWideNarrow, CircleAlert, PenTool } from 'lucide-react';
|
import { Edit3, Play, Pause, Volume2, VolumeX, Maximize, Minimize, Loader2, X, Scissors, RotateCcw, MessageCircleMore, Download, ArrowDownWideNarrow, PictureInPicture2, PenTool } from 'lucide-react';
|
||||||
import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
|
import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
|
||||||
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||||||
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
|
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
|
||||||
@ -69,6 +69,8 @@ export const MediaViewer = React.memo(function MediaViewer({
|
|||||||
// 音量控制状态
|
// 音量控制状态
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [volume, setVolume] = useState(0.8);
|
const [volume, setVolume] = useState(0.8);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
|
||||||
// 最终视频控制状态
|
// 最终视频控制状态
|
||||||
const [isFinalVideoPlaying, setIsFinalVideoPlaying] = useState(true);
|
const [isFinalVideoPlaying, setIsFinalVideoPlaying] = useState(true);
|
||||||
@ -175,6 +177,9 @@ export const MediaViewer = React.memo(function MediaViewer({
|
|||||||
if (finalVideoRef.current) {
|
if (finalVideoRef.current) {
|
||||||
setFinalVideoReady(true);
|
setFinalVideoReady(true);
|
||||||
applyVolumeSettings(finalVideoRef.current);
|
applyVolumeSettings(finalVideoRef.current);
|
||||||
|
try {
|
||||||
|
setDuration(Number.isFinite(finalVideoRef.current.duration) ? finalVideoRef.current.duration : 0);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
// 如果当前状态是应该播放的,尝试播放
|
// 如果当前状态是应该播放的,尝试播放
|
||||||
if (isFinalVideoPlaying) {
|
if (isFinalVideoPlaying) {
|
||||||
@ -235,16 +240,15 @@ export const MediaViewer = React.memo(function MediaViewer({
|
|||||||
// 全屏控制
|
// 全屏控制
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
setUserHasInteracted(true);
|
setUserHasInteracted(true);
|
||||||
|
const target = activeVideoRef().current;
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
// 进入全屏
|
if (target) {
|
||||||
if (finalVideoRef.current) {
|
target.requestFullscreen?.() ||
|
||||||
finalVideoRef.current.requestFullscreen?.() ||
|
(target as any).webkitRequestFullscreen?.() ||
|
||||||
(finalVideoRef.current as any).webkitRequestFullscreen?.() ||
|
(target as any).msRequestFullscreen?.();
|
||||||
(finalVideoRef.current as any).msRequestFullscreen?.();
|
|
||||||
setIsFullscreen(true);
|
setIsFullscreen(true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 退出全屏
|
|
||||||
document.exitFullscreen?.() ||
|
document.exitFullscreen?.() ||
|
||||||
(document as any).webkitExitFullscreen?.() ||
|
(document as any).webkitExitFullscreen?.() ||
|
||||||
(document as any).msExitFullscreen?.();
|
(document as any).msExitFullscreen?.();
|
||||||
@ -263,6 +267,9 @@ export const MediaViewer = React.memo(function MediaViewer({
|
|||||||
} else {
|
} else {
|
||||||
mainVideoRef.current.pause();
|
mainVideoRef.current.pause();
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
setDuration(Number.isFinite(mainVideoRef.current.duration) ? mainVideoRef.current.duration : 0);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}, [isVideoPlaying]);
|
}, [isVideoPlaying]);
|
||||||
|
|
||||||
@ -289,6 +296,61 @@ export const MediaViewer = React.memo(function MediaViewer({
|
|||||||
}
|
}
|
||||||
}, [volume, isMuted]);
|
}, [volume, isMuted]);
|
||||||
|
|
||||||
|
// 绑定时间更新(同时监听两个 video)
|
||||||
|
useEffect(() => {
|
||||||
|
const mv = mainVideoRef.current;
|
||||||
|
const fv = finalVideoRef.current;
|
||||||
|
const onTimeUpdate = (e: Event) => {
|
||||||
|
const el = e.currentTarget as HTMLVideoElement;
|
||||||
|
setCurrentTime(el.currentTime || 0);
|
||||||
|
if (Number.isFinite(el.duration)) setDuration(el.duration);
|
||||||
|
};
|
||||||
|
mv?.addEventListener('timeupdate', onTimeUpdate);
|
||||||
|
fv?.addEventListener('timeupdate', onTimeUpdate);
|
||||||
|
mv?.addEventListener('loadedmetadata', onTimeUpdate);
|
||||||
|
fv?.addEventListener('loadedmetadata', onTimeUpdate);
|
||||||
|
return () => {
|
||||||
|
mv?.removeEventListener('timeupdate', onTimeUpdate);
|
||||||
|
fv?.removeEventListener('timeupdate', onTimeUpdate);
|
||||||
|
mv?.removeEventListener('loadedmetadata', onTimeUpdate);
|
||||||
|
fv?.removeEventListener('loadedmetadata', onTimeUpdate);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeVideoRef = () => {
|
||||||
|
// 根据当前阶段选择活跃的 video 引用
|
||||||
|
const effectiveStage = (selectedView === 'final' && taskObject.final?.url)
|
||||||
|
? 'final_video'
|
||||||
|
: (!['script', 'character', 'scene'].includes(taskObject.currentStage) ? 'video' : taskObject.currentStage);
|
||||||
|
return effectiveStage === 'final_video' ? finalVideoRef : mainVideoRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressPercent = duration > 0 ? Math.min(100, Math.max(0, (currentTime / duration) * 100)) : 0;
|
||||||
|
|
||||||
|
const formatRemaining = (dur: number, cur: number) => {
|
||||||
|
const remain = Math.max(0, Math.round(dur - cur));
|
||||||
|
const m = Math.floor(remain / 60);
|
||||||
|
const s = remain % 60;
|
||||||
|
return `-${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seekTo = (pct: number) => {
|
||||||
|
const ref = activeVideoRef().current;
|
||||||
|
if (!ref || !Number.isFinite(ref.duration)) return;
|
||||||
|
const t = (pct / 100) * ref.duration;
|
||||||
|
ref.currentTime = t;
|
||||||
|
setCurrentTime(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestPip = async () => {
|
||||||
|
try {
|
||||||
|
const ref = activeVideoRef().current as any;
|
||||||
|
if (ref && typeof ref.requestPictureInPicture === 'function') {
|
||||||
|
await ref.requestPictureInPicture();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
// 监听全屏状态变化
|
// 监听全屏状态变化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFullscreenChange = () => {
|
const handleFullscreenChange = () => {
|
||||||
@ -317,41 +379,54 @@ export const MediaViewer = React.memo(function MediaViewer({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 渲染音量控制组件
|
// 渲染底部通栏控制条(与图2一致)
|
||||||
const renderVolumeControls = () => (
|
const renderBottomControls = (
|
||||||
<div className="flex items-center gap-2">
|
isFinal: boolean,
|
||||||
{/* 静音按钮 */}
|
onPlayToggle: () => void,
|
||||||
<GlassIconButton
|
playing: boolean
|
||||||
icon={isMuted ? VolumeX : Volume2}
|
) => (
|
||||||
onClick={toggleMute}
|
<div
|
||||||
size="sm"
|
className="absolute left-0 right-0 bottom-2 z-[21] px-6"
|
||||||
/>
|
data-alt={isFinal ? 'final-controls' : 'video-controls'}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 播放/暂停 */}
|
||||||
|
<GlassIconButton icon={playing ? Pause : Play} onClick={onPlayToggle} size="sm" />
|
||||||
|
|
||||||
{/* 音量滑块 - 一直显示 */}
|
{/* 静音,仅图标 */}
|
||||||
<div className="flex items-center gap-2">
|
<GlassIconButton icon={isMuted ? VolumeX : Volume2} onClick={toggleMute} size="sm" />
|
||||||
<div className="relative">
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div className="flex-1 flex items-center">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max="1"
|
max="100"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={volume}
|
value={progressPercent}
|
||||||
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
|
onChange={(e) => seekTo(parseFloat(e.target.value))}
|
||||||
className="w-16 h-1 bg-white/20 rounded-lg appearance-none cursor-pointer
|
className="w-full h-1 bg-white/20 rounded-lg appearance-none cursor-pointer
|
||||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||||
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white
|
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white
|
||||||
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:shadow-lg
|
[&::-webkit-slider-thumb]:cursor-pointer
|
||||||
[&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-3 [&::-moz-range-thumb]:rounded-full
|
[&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-3 [&::-moz-range-thumb]:rounded-full
|
||||||
[&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:cursor-pointer
|
[&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:border-none"
|
||||||
[&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:shadow-lg"
|
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(to right, white 0%, white ${volume * 100}%, rgba(255,255,255,0.2) ${volume * 100}%, rgba(255,255,255,0.2) 100%)`
|
background: `linear-gradient(to right, white 0%, white ${progressPercent}%, rgba(255,255,255,0.2) ${progressPercent}%, rgba(255,255,255,0.2) 100%)`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-white/70 w-8 text-center">
|
|
||||||
{Math.round(volume * 100)}%
|
{/* 剩余时间 */}
|
||||||
</span>
|
<div className="text-white/80 text-sm w-14 text-right select-none" data-alt="time-remaining">
|
||||||
|
{formatRemaining(duration, currentTime)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 画中画 */}
|
||||||
|
<GlassIconButton icon={PictureInPicture2} onClick={requestPip} size="sm" />
|
||||||
|
|
||||||
|
{/* 全屏 */}
|
||||||
|
<GlassIconButton icon={isFullscreen ? Minimize : Maximize} onClick={toggleFullscreen} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -432,35 +507,9 @@ export const MediaViewer = React.memo(function MediaViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部控制区域 */}
|
{/* 底部通栏控制区域 */}
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.6 }}>
|
||||||
className="absolute bottom-16 left-4 z-10 flex items-center gap-3"
|
{renderBottomControls(true, toggleFinalVideoPlay, isFinalVideoPlaying)}
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ delay: 1, duration: 0.6 }}
|
|
||||||
>
|
|
||||||
{/* 播放/暂停按钮 */}
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.9 }}
|
|
||||||
className="relative"
|
|
||||||
>
|
|
||||||
<GlassIconButton
|
|
||||||
icon={isFinalVideoPlaying ? Pause : Play}
|
|
||||||
onClick={toggleFinalVideoPlay}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* 音量控制 */}
|
|
||||||
{renderVolumeControls()}
|
|
||||||
|
|
||||||
{/* 全屏按钮 */}
|
|
||||||
<GlassIconButton
|
|
||||||
icon={isFullscreen ? Minimize : Maximize}
|
|
||||||
onClick={toggleFullscreen}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -625,31 +674,11 @@ export const MediaViewer = React.memo(function MediaViewer({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence> */}
|
</AnimatePresence> */}
|
||||||
|
|
||||||
{/* 底部控制区域 */}
|
{/* 底部通栏控制区域(仅生成成功时显示) */}
|
||||||
{ taskObject.videos.data[currentSketchIndex].video_status === 1 && (
|
{ taskObject.videos.data[currentSketchIndex].video_status === 1 && (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||||
className="absolute bottom-4 left-4 z-[21] flex items-center gap-3"
|
{renderBottomControls(false, onToggleVideoPlay, isVideoPlaying)}
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.8 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
{/* 播放按钮 */}
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.9 }}
|
|
||||||
className="relative"
|
|
||||||
>
|
|
||||||
<GlassIconButton
|
|
||||||
icon={isVideoPlaying ? Pause : Play}
|
|
||||||
onClick={onToggleVideoPlay}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* 音量控制 */}
|
|
||||||
{renderVolumeControls()}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user