277 lines
7.3 KiB
TypeScript
277 lines
7.3 KiB
TypeScript
![]() |
import React, { useEffect, useRef, useContext, useState } from "react";
|
||
|
import Hls from "hls.js";
|
||
|
import { motion, AnimatePresence } from "framer-motion";
|
||
|
import {
|
||
|
PlayIcon,
|
||
|
PauseIcon,
|
||
|
SpeakerWaveIcon,
|
||
|
SpeakerXMarkIcon,
|
||
|
Cog6ToothIcon,
|
||
|
ArrowsPointingOutIcon,
|
||
|
ArrowsPointingInIcon,
|
||
|
} from "@heroicons/react/24/solid";
|
||
|
import "plyr/dist/plyr.css";
|
||
|
import { VideoPlayerContext } from "./VideoPlayer";
|
||
|
import { formatTime } from "./utlis";
|
||
|
import { PlaybackSpeed } from "./type";
|
||
|
|
||
|
export const Controls = () => {
|
||
|
const {
|
||
|
showControls,
|
||
|
setShowControls,
|
||
|
isSettingsOpen,
|
||
|
setIsSettingsOpen,
|
||
|
playbackSpeed,
|
||
|
setPlaybackSpeed,
|
||
|
videoRef,
|
||
|
isReady,
|
||
|
setIsReady,
|
||
|
isPlaying,
|
||
|
setIsPlaying,
|
||
|
|
||
|
bufferingState,
|
||
|
setBufferingState,
|
||
|
volume,
|
||
|
setVolume,
|
||
|
isMuted,
|
||
|
setIsMuted,
|
||
|
loadingProgress,
|
||
|
setLoadingProgress,
|
||
|
currentTime,
|
||
|
setCurrentTime,
|
||
|
duration,
|
||
|
setDuration,
|
||
|
brightness,
|
||
|
setBrightness,
|
||
|
isDragging,
|
||
|
setIsDragging,
|
||
|
isHovering,
|
||
|
setIsHovering,
|
||
|
progressRef,
|
||
|
} = useContext(VideoPlayerContext);
|
||
|
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
|
if (!videoRef.current || !progressRef.current) return;
|
||
|
|
||
|
const rect = progressRef.current.getBoundingClientRect();
|
||
|
const percent = (e.clientX - rect.left) / rect.width;
|
||
|
videoRef.current.currentTime = percent * videoRef.current.duration;
|
||
|
};
|
||
|
// 控制栏显示逻辑
|
||
|
useEffect(() => {
|
||
|
let timer: number;
|
||
|
|
||
|
if (!isHovering && !isDragging) {
|
||
|
timer = window.setTimeout(() => {
|
||
|
setShowControls(false);
|
||
|
}, 2000);
|
||
|
}
|
||
|
|
||
|
return () => {
|
||
|
if (timer) window.clearTimeout(timer);
|
||
|
};
|
||
|
}, [isHovering, isDragging]);
|
||
|
return (
|
||
|
<motion.div
|
||
|
layoutId="video-controls"
|
||
|
initial={false}
|
||
|
animate={{
|
||
|
opacity: showControls ? 1 : 0,
|
||
|
y: showControls ? 0 : 20,
|
||
|
}}
|
||
|
transition={{
|
||
|
duration: 0.2,
|
||
|
ease: "easeInOut",
|
||
|
}}
|
||
|
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||
|
{/* 进度条 */}
|
||
|
<div
|
||
|
ref={progressRef}
|
||
|
className="relative h-1 mb-4 cursor-pointer group"
|
||
|
onClick={handleProgressClick}
|
||
|
onMouseDown={(e) => {
|
||
|
setIsDragging(true);
|
||
|
handleProgressClick(e);
|
||
|
}}>
|
||
|
{/* 背景条 */}
|
||
|
<div className="absolute w-full h-full bg-black/80 rounded-full" />
|
||
|
{/* 播放进度 */}
|
||
|
<motion.div
|
||
|
className="absolute h-full bg-primary-500 rounded-full"
|
||
|
style={{
|
||
|
width: `${(currentTime / duration) * 100}%`,
|
||
|
}}
|
||
|
transition={{ type: "tween" }}
|
||
|
/>
|
||
|
{/* 进度球 */}
|
||
|
<motion.div
|
||
|
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-primary shadow-lg
|
||
|
${isHovering || isDragging ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
|
||
|
style={{
|
||
|
left: `${(currentTime / duration) * 100}%`,
|
||
|
}}
|
||
|
transition={{ duration: 0.1 }}
|
||
|
/>
|
||
|
{/* 预览进度 */}
|
||
|
<motion.div
|
||
|
className="absolute h-full bg-white/30 rounded-full opacity-0 group-hover:opacity-100 pointer-events-none"
|
||
|
style={{
|
||
|
width: `${(currentTime / duration) * 100}%`,
|
||
|
transform: "scaleY(2.5)",
|
||
|
transformOrigin: "center",
|
||
|
}}
|
||
|
transition={{ duration: 0.1 }}
|
||
|
/>
|
||
|
</div>
|
||
|
|
||
|
{/* 控制按钮区域 */}
|
||
|
<div className="flex items-center justify-between">
|
||
|
<div className="flex items-center space-x-4">
|
||
|
{/* 播放/暂停按钮 */}
|
||
|
<button
|
||
|
onClick={() =>
|
||
|
videoRef.current?.paused
|
||
|
? videoRef.current.play()
|
||
|
: videoRef.current?.pause()
|
||
|
}
|
||
|
className="text-white hover:text-primary-400">
|
||
|
{isPlaying ? (
|
||
|
<PauseIcon className="w-6 h-6" />
|
||
|
) : (
|
||
|
<PlayIcon className="w-6 h-6" />
|
||
|
)}
|
||
|
</button>
|
||
|
|
||
|
{/* 音量控制 */}
|
||
|
<div className="group relative flex items-center">
|
||
|
<button
|
||
|
onClick={() => setIsMuted(!isMuted)}
|
||
|
className="text-white hover:text-primary-400">
|
||
|
{isMuted ? (
|
||
|
<SpeakerXMarkIcon className="w-6 h-6" />
|
||
|
) : (
|
||
|
<SpeakerWaveIcon className="w-6 h-6" />
|
||
|
)}
|
||
|
</button>
|
||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||
|
<div className="bg-black/80 rounded-lg p-2">
|
||
|
<input
|
||
|
type="range"
|
||
|
min="0"
|
||
|
max="1"
|
||
|
step="0.1"
|
||
|
value={volume}
|
||
|
onChange={(e) => {
|
||
|
const newVolume = parseFloat(
|
||
|
e.target.value
|
||
|
);
|
||
|
setVolume(newVolume);
|
||
|
if (videoRef.current) {
|
||
|
videoRef.current.volume = newVolume;
|
||
|
}
|
||
|
}}
|
||
|
className="h-24 w-2 accent-primary-500 [-webkit-appearance:slider-vertical]"
|
||
|
/>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
|
||
|
{/* 时间显示 */}
|
||
|
{duration && (
|
||
|
<span className="text-white text-sm">
|
||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||
|
</span>
|
||
|
)}
|
||
|
</div>
|
||
|
{/* 右侧控制按钮 */}
|
||
|
<div className="flex items-center space-x-4">
|
||
|
{/* 设置按钮 */}
|
||
|
<div className="relative">
|
||
|
<button
|
||
|
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
|
||
|
className="text-white hover:text-primary-400">
|
||
|
<Cog6ToothIcon className="w-6 h-6" />
|
||
|
</button>
|
||
|
{/* 设置菜单 */}
|
||
|
<AnimatePresence>
|
||
|
{isSettingsOpen && (
|
||
|
<motion.div
|
||
|
initial={{ opacity: 0, y: 10 }}
|
||
|
animate={{ opacity: 1, y: 0 }}
|
||
|
exit={{ opacity: 0, y: 10 }}
|
||
|
className="absolute right-0 bottom-full mb-2 bg-black/90 rounded-lg p-4 min-w-[200px]">
|
||
|
{/* 倍速选择 */}
|
||
|
<div className="mb-4">
|
||
|
<h3 className="text-white text-sm mb-2">
|
||
|
播放速度
|
||
|
</h3>
|
||
|
{[0.5, 0.75, 1.0, 1.25, 1.5, 2.0].map(
|
||
|
(speed) => (
|
||
|
<button
|
||
|
key={speed}
|
||
|
onClick={() => {
|
||
|
setPlaybackSpeed(
|
||
|
speed as PlaybackSpeed
|
||
|
);
|
||
|
if (videoRef.current) {
|
||
|
videoRef.current.playbackRate =
|
||
|
speed;
|
||
|
}
|
||
|
}}
|
||
|
className={`block w-full text-left px-2 py-1 text-sm ${
|
||
|
playbackSpeed === speed
|
||
|
? "text-primaryHover"
|
||
|
: "text-white"
|
||
|
}`}>
|
||
|
{speed}x
|
||
|
</button>
|
||
|
)
|
||
|
)}
|
||
|
</div>
|
||
|
|
||
|
{/* 亮度调节 */}
|
||
|
<div className="mb-4">
|
||
|
<h3 className="text-white text-sm mb-2">
|
||
|
亮度
|
||
|
</h3>
|
||
|
<input
|
||
|
type="range"
|
||
|
min="0.1"
|
||
|
max="2"
|
||
|
step="0.1"
|
||
|
value={brightness}
|
||
|
onChange={(e) =>
|
||
|
setBrightness(
|
||
|
parseFloat(e.target.value)
|
||
|
)
|
||
|
}
|
||
|
className="w-full accent-primary-500"
|
||
|
/>
|
||
|
</div>
|
||
|
</motion.div>
|
||
|
)}
|
||
|
</AnimatePresence>
|
||
|
</div>
|
||
|
|
||
|
{/* 全屏按钮 */}
|
||
|
<button
|
||
|
onClick={() => {
|
||
|
if (document.fullscreenElement) {
|
||
|
document.exitFullscreen();
|
||
|
} else {
|
||
|
videoRef.current?.parentElement?.requestFullscreen();
|
||
|
}
|
||
|
}}
|
||
|
className="text-white hover:text-primary-400">
|
||
|
{document.fullscreenElement ? (
|
||
|
<ArrowsPointingInIcon className="w-6 h-6" />
|
||
|
) : (
|
||
|
<ArrowsPointingOutIcon className="w-6 h-6" />
|
||
|
)}
|
||
|
</button>
|
||
|
</div>
|
||
|
</div>
|
||
|
</motion.div>
|
||
|
);
|
||
|
};
|
||
|
export default Controls;
|