|
@@ -1,117 +1,201 @@
|
|
|
|
|
|
|
|
import React, { useRef, useState, useEffect } from 'react';
|
|
import React, { useRef, useState, useEffect } from 'react';
|
|
|
|
|
+import './style/index.less';
|
|
|
import { Button } from 'antd';
|
|
import { Button } from 'antd';
|
|
|
|
|
|
|
|
-interface VideoSyncPlayerProps {
|
|
|
|
|
- videoUrls: string[];
|
|
|
|
|
|
|
+interface VideoPlayerProps {
|
|
|
|
|
+ topVideoSrc: string;
|
|
|
|
|
+ leftVideoSrc: string;
|
|
|
|
|
+ rightVideoSrc: string;
|
|
|
|
|
+ audioSrc: string;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const VideoSyncPlayer: React.FC<VideoSyncPlayerProps> = ({ videoUrls }) => {
|
|
|
|
|
- const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
|
|
|
|
|
- const [progress, setProgress] = useState(0);
|
|
|
|
|
|
|
+const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|
|
|
|
+ topVideoSrc,
|
|
|
|
|
+ leftVideoSrc,
|
|
|
|
|
+ rightVideoSrc,
|
|
|
|
|
+ audioSrc
|
|
|
|
|
+ }) => {
|
|
|
|
|
+ const topVideoRef = useRef<HTMLVideoElement>(null);
|
|
|
|
|
+ const leftVideoRef = useRef<HTMLVideoElement>(null);
|
|
|
|
|
+ const rightVideoRef = useRef<HTMLVideoElement>(null);
|
|
|
|
|
+ const audioRef = useRef<HTMLAudioElement>(null);
|
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
|
|
|
+ const [currentTime, setCurrentTime] = useState(0);
|
|
|
const [duration, setDuration] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
|
|
|
+ const [volume, setVolume] = useState(1);
|
|
|
|
|
+ const [isFullscreen, setIsFullscreen] = useState(false);
|
|
|
|
|
+ const [isSeeking, setIsSeeking] = useState(false);
|
|
|
|
|
|
|
|
- // 同步所有视频状态
|
|
|
|
|
- const syncVideos = () => {
|
|
|
|
|
- const mainVideo = videoRefs.current[0];
|
|
|
|
|
- if (!mainVideo) return;
|
|
|
|
|
-
|
|
|
|
|
- const currentTime = mainVideo.currentTime;
|
|
|
|
|
- setProgress((currentTime / duration) * 100);
|
|
|
|
|
-
|
|
|
|
|
- videoRefs.current.forEach(video => {
|
|
|
|
|
- if (video && video !== mainVideo) {
|
|
|
|
|
- video.currentTime = currentTime;
|
|
|
|
|
- if (isPlaying) {
|
|
|
|
|
- video.play()
|
|
|
|
|
- } else {
|
|
|
|
|
- video.pause()
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const formatTime = (time: number) => {
|
|
|
|
|
+ const minutes = Math.floor(time / 60);
|
|
|
|
|
+ const seconds = Math.floor(time % 60);
|
|
|
|
|
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const syncMedia = (time: number) => {
|
|
|
|
|
+ [topVideoRef, leftVideoRef, rightVideoRef].forEach(ref => {
|
|
|
|
|
+ if (ref.current) ref.current.currentTime = time;
|
|
|
});
|
|
});
|
|
|
|
|
+ if (audioRef.current) audioRef.current.currentTime = time;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- // 处理播放/暂停
|
|
|
|
|
const togglePlay = () => {
|
|
const togglePlay = () => {
|
|
|
- setIsPlaying(!isPlaying);
|
|
|
|
|
|
|
+ const shouldPlay = !isPlaying;
|
|
|
|
|
+ [topVideoRef, leftVideoRef, rightVideoRef].forEach(ref => {
|
|
|
|
|
+ if (shouldPlay) {
|
|
|
|
|
+ ref.current?.play()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ref.current?.pause()
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ if (shouldPlay) {
|
|
|
|
|
+ audioRef.current?.play()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ audioRef.current?.pause()
|
|
|
|
|
+ }
|
|
|
|
|
+ setIsPlaying(shouldPlay);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleTimeUpdate = () => {
|
|
|
|
|
+ if (!isSeeking && topVideoRef.current) {
|
|
|
|
|
+ setCurrentTime(topVideoRef.current.currentTime);
|
|
|
|
|
+ }
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- // 处理进度条拖动
|
|
|
|
|
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
- const seekTime = (Number(e.target.value) / 100) * duration;
|
|
|
|
|
- videoRefs.current.forEach(video => {
|
|
|
|
|
- if (video) video.currentTime = seekTime;
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const newTime = parseFloat(e.target.value);
|
|
|
|
|
+ setCurrentTime(newTime);
|
|
|
|
|
+ syncMedia(newTime);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- // 初始化视频设置
|
|
|
|
|
- useEffect(() => {
|
|
|
|
|
- const mainVideo = videoRefs.current[0];
|
|
|
|
|
- if (!mainVideo) return;
|
|
|
|
|
|
|
+ const handleSeekMouseDown = () => {
|
|
|
|
|
+ setIsSeeking(true);
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
|
|
+ const handleSeekMouseUp = () => {
|
|
|
|
|
+ setIsSeeking(false);
|
|
|
|
|
+ if (isPlaying) {
|
|
|
|
|
+ [topVideoRef, leftVideoRef, rightVideoRef].forEach(ref => ref.current?.play());
|
|
|
|
|
+ audioRef.current?.play();
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
+ const newVolume = parseFloat(e.target.value);
|
|
|
|
|
+ setVolume(newVolume);
|
|
|
|
|
+ if (audioRef.current) {
|
|
|
|
|
+ audioRef.current.volume = newVolume;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 全屏控制
|
|
|
|
|
+ const toggleFullscreen = () => {
|
|
|
|
|
+ if (!isFullscreen) {
|
|
|
|
|
+ const container = document.querySelector('.video-container');
|
|
|
|
|
+ if (container?.requestFullscreen) {
|
|
|
|
|
+ container.requestFullscreen();
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if (document.exitFullscreen) {
|
|
|
|
|
+ document.exitFullscreen();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ setIsFullscreen(!isFullscreen);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const topVideo = topVideoRef.current;
|
|
|
const handleLoadedMetadata = () => {
|
|
const handleLoadedMetadata = () => {
|
|
|
- setDuration(mainVideo.duration);
|
|
|
|
|
- if (videoRefs.current.every(v => v!.readyState > 2)) {
|
|
|
|
|
- setIsPlaying(true);
|
|
|
|
|
|
|
+ if (topVideo) {
|
|
|
|
|
+ setDuration(topVideo.duration);
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- mainVideo.addEventListener('loadedmetadata', handleLoadedMetadata);
|
|
|
|
|
|
|
+ if (topVideo) {
|
|
|
|
|
+ topVideo.addEventListener('loadedmetadata', handleLoadedMetadata);
|
|
|
|
|
+ topVideo.addEventListener('timeupdate', handleTimeUpdate);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
return () => {
|
|
return () => {
|
|
|
- mainVideo.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
|
|
|
|
|
|
+ if (topVideo) {
|
|
|
|
|
+ topVideo.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
|
|
|
|
+ topVideo.removeEventListener('timeupdate', handleTimeUpdate);
|
|
|
|
|
+ }
|
|
|
};
|
|
};
|
|
|
}, []);
|
|
}, []);
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
- <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
|
|
|
|
- {/* 主视频区域 */}
|
|
|
|
|
- <div>
|
|
|
|
|
- <video
|
|
|
|
|
- ref={el => videoRefs.current[0] = el}
|
|
|
|
|
- width="1150"
|
|
|
|
|
- height="475"
|
|
|
|
|
- style={{ objectFit: 'cover' }}
|
|
|
|
|
- muted
|
|
|
|
|
- onTimeUpdate={syncVideos}
|
|
|
|
|
- >
|
|
|
|
|
- <source src={videoUrls[0]} type="video/mp4" />
|
|
|
|
|
- </video>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- {/* 副视频区域 */}
|
|
|
|
|
- <div style={{ display: 'flex', justifyContent: 'center', gap: 25 }}>
|
|
|
|
|
- {[1, 2].map(index => (
|
|
|
|
|
|
|
+ <div className={`video-container ${isFullscreen ? 'fullscreen' : ''}`}>
|
|
|
|
|
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <video
|
|
|
|
|
+ ref={topVideoRef}
|
|
|
|
|
+ width="1150"
|
|
|
|
|
+ height="475"
|
|
|
|
|
+ style={{ objectFit: 'cover' }}
|
|
|
|
|
+ muted
|
|
|
|
|
+ >
|
|
|
|
|
+ <source src={topVideoSrc} type="video/mp4" />
|
|
|
|
|
+ </video>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{ display: 'flex', justifyContent: 'center', gap: 25 }}>
|
|
|
<video
|
|
<video
|
|
|
- key={index}
|
|
|
|
|
- ref={el => videoRefs.current[index] = el}
|
|
|
|
|
- width="565"
|
|
|
|
|
- height="375"
|
|
|
|
|
|
|
+ ref={leftVideoRef}
|
|
|
|
|
+ width={isFullscreen ? '640' : '565'}
|
|
|
|
|
+ height={isFullscreen ? '480' : '375'}
|
|
|
style={{ objectFit: 'cover' }}
|
|
style={{ objectFit: 'cover' }}
|
|
|
muted
|
|
muted
|
|
|
- onTimeUpdate={syncVideos}
|
|
|
|
|
>
|
|
>
|
|
|
- <source src={videoUrls[index]} type="video/mp4" />
|
|
|
|
|
|
|
+ <source src={leftVideoSrc} type="video/mp4" />
|
|
|
</video>
|
|
</video>
|
|
|
- ))}
|
|
|
|
|
|
|
+ <video
|
|
|
|
|
+ ref={rightVideoRef}
|
|
|
|
|
+ width={isFullscreen ? '640' : '565'}
|
|
|
|
|
+ height={isFullscreen ? '480' : '375'}
|
|
|
|
|
+ style={{ objectFit: 'cover' }}
|
|
|
|
|
+ muted
|
|
|
|
|
+ >
|
|
|
|
|
+ <source src={rightVideoSrc} type="video/mp4" />
|
|
|
|
|
+ </video>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* 控制区域 */}
|
|
|
|
|
- <div style={{ padding: 20, display: 'flex', flexDirection: 'column' }}>
|
|
|
|
|
- <Button onClick={togglePlay} style={{ marginBottom: 10 }}>
|
|
|
|
|
- {isPlaying ? '暂停' : '播放'}
|
|
|
|
|
|
|
+ <audio ref={audioRef} src={audioSrc} />
|
|
|
|
|
+
|
|
|
|
|
+ {/* 自定义控制器 */}
|
|
|
|
|
+ <div className="video-controls">
|
|
|
|
|
+ <Button onClick={togglePlay}>
|
|
|
|
|
+ {isPlaying ? '❚❚' : '▶'}
|
|
|
</Button>
|
|
</Button>
|
|
|
<input
|
|
<input
|
|
|
type="range"
|
|
type="range"
|
|
|
min="0"
|
|
min="0"
|
|
|
- max="100"
|
|
|
|
|
- value={progress}
|
|
|
|
|
|
|
+ max={duration || 100}
|
|
|
|
|
+ value={currentTime}
|
|
|
onChange={handleSeek}
|
|
onChange={handleSeek}
|
|
|
- style={{ width: '100%' }}
|
|
|
|
|
|
|
+ onMouseDown={handleSeekMouseDown}
|
|
|
|
|
+ onMouseUp={handleSeekMouseUp}
|
|
|
|
|
+ className="progress"
|
|
|
/>
|
|
/>
|
|
|
|
|
+ <span className="time-display">
|
|
|
|
|
+ {formatTime(currentTime)} / {formatTime(duration)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="range"
|
|
|
|
|
+ min="0"
|
|
|
|
|
+ max="1"
|
|
|
|
|
+ step="0.01"
|
|
|
|
|
+ value={volume}
|
|
|
|
|
+ onChange={handleVolumeChange}
|
|
|
|
|
+ className="volume"
|
|
|
|
|
+ />
|
|
|
|
|
+ <Button onClick={toggleFullscreen}>
|
|
|
|
|
+ {isFullscreen ? '⤢' : '⤡'}
|
|
|
|
|
+ </Button>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-export default VideoSyncPlayer;
|
|
|
|
|
|
|
+export default VideoPlayer;
|