forked from 77media/video-flow
254 lines
9.9 KiB
TypeScript
254 lines
9.9 KiB
TypeScript
'use client'; // Add this to ensure it's a client component
|
||
|
||
import React, { useState, useRef } from 'react';
|
||
import { Edit2, Trash2, Play, Pause, Volume2, VolumeX, Maximize, Minimize } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import dynamic from 'next/dynamic';
|
||
|
||
interface VideoGridLayoutProps {
|
||
videos: {
|
||
id: string;
|
||
url: string;
|
||
title: string;
|
||
date?: string;
|
||
}[];
|
||
onEdit?: (id: string) => void;
|
||
onDelete?: (id: string) => void;
|
||
}
|
||
|
||
function VideoGridLayoutComponent({ videos, onEdit, onDelete }: VideoGridLayoutProps) {
|
||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||
const [isPlaying, setIsPlaying] = useState<{ [key: string]: boolean }>({});
|
||
const [isMuted, setIsMuted] = useState<{ [key: string]: boolean }>({});
|
||
const [volume, setVolume] = useState<{ [key: string]: number }>({});
|
||
const [isFullscreen, setIsFullscreen] = useState<{ [key: string]: boolean }>({});
|
||
const videoRefs = useRef<{ [key: string]: HTMLVideoElement | null }>({});
|
||
|
||
const handleMouseEnter = (id: string) => {
|
||
setHoveredId(id);
|
||
};
|
||
|
||
const handleMouseLeave = (id: string) => {
|
||
setHoveredId(null);
|
||
// 暂停视频并重新静音以便下次预览
|
||
const video = videoRefs.current[id];
|
||
if (video) {
|
||
video.pause();
|
||
video.muted = true;
|
||
setIsPlaying(prev => ({ ...prev, [id]: false }));
|
||
setIsMuted(prev => ({ ...prev, [id]: true }));
|
||
}
|
||
};
|
||
|
||
const togglePlay = (id: string) => {
|
||
const video = videoRefs.current[id];
|
||
if (video) {
|
||
if (video.paused) {
|
||
// 在用户主动播放时取消静音
|
||
video.muted = isMuted[id] !== false;
|
||
video.play();
|
||
setIsPlaying(prev => ({ ...prev, [id]: true }));
|
||
} else {
|
||
video.pause();
|
||
setIsPlaying(prev => ({ ...prev, [id]: false }));
|
||
}
|
||
}
|
||
};
|
||
|
||
const toggleMute = (id: string, event: React.MouseEvent) => {
|
||
event.stopPropagation(); // 防止触发父元素的点击事件
|
||
const video = videoRefs.current[id];
|
||
if (video) {
|
||
video.muted = !video.muted;
|
||
setIsMuted(prev => ({ ...prev, [id]: video.muted }));
|
||
}
|
||
};
|
||
|
||
const handleVolumeChange = (id: string, newVolume: number) => {
|
||
const video = videoRefs.current[id];
|
||
if (video) {
|
||
video.volume = newVolume;
|
||
setVolume(prev => ({ ...prev, [id]: newVolume }));
|
||
}
|
||
};
|
||
|
||
const toggleFullscreen = (id: string, event: React.MouseEvent) => {
|
||
event.stopPropagation();
|
||
const video = videoRefs.current[id];
|
||
if (video) {
|
||
if (!document.fullscreenElement) {
|
||
// 进入全屏
|
||
video.requestFullscreen?.() ||
|
||
(video as any).webkitRequestFullscreen?.() ||
|
||
(video as any).msRequestFullscreen?.();
|
||
setIsFullscreen(prev => ({ ...prev, [id]: true }));
|
||
} else {
|
||
// 退出全屏
|
||
document.exitFullscreen?.() ||
|
||
(document as any).webkitExitFullscreen?.() ||
|
||
(document as any).msExitFullscreen?.();
|
||
setIsFullscreen(prev => ({ ...prev, [id]: false }));
|
||
}
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-6">
|
||
{videos.map((video) => (
|
||
<div
|
||
key={video.id}
|
||
className="group relative bg-white/5 rounded-lg overflow-hidden transition-all duration-300 hover:bg-white/10"
|
||
onMouseEnter={() => handleMouseEnter(video.id)}
|
||
onMouseLeave={() => handleMouseLeave(video.id)}
|
||
>
|
||
{/* 视频容器 */}
|
||
<div className="relative aspect-video">
|
||
<video
|
||
ref={(el) => (videoRefs.current[video.id] = el)}
|
||
src={video.url}
|
||
suppressHydrationWarning
|
||
className="w-full h-full object-cover"
|
||
loop
|
||
muted={isMuted[video.id] !== false} // 默认静音,除非明确设置为false
|
||
playsInline
|
||
preload="none"
|
||
poster={`${video.url}?vframe/jpg/offset/1`}
|
||
onLoadedData={() => {
|
||
// 设置默认音量
|
||
if (videoRefs.current[video.id] && !volume[video.id]) {
|
||
videoRefs.current[video.id]!.volume = 0.8;
|
||
setVolume(prev => ({ ...prev, [video.id]: 0.8 }));
|
||
}
|
||
}}
|
||
/>
|
||
|
||
{/* 播放按钮遮罩 */}
|
||
<div
|
||
className={`absolute inset-0 flex items-center justify-center bg-black/40 transition-opacity duration-300
|
||
${hoveredId === video.id && !isPlaying[video.id] ? 'opacity-100' : 'opacity-0'}
|
||
`}
|
||
onClick={() => togglePlay(video.id)}
|
||
>
|
||
<Play className="w-12 h-12 text-white/90" />
|
||
</div>
|
||
|
||
{/* 顶部操作按钮 */}
|
||
<div
|
||
className={`absolute top-4 right-4 flex gap-2 transition-all duration-300 transform
|
||
${hoveredId === video.id ? 'translate-y-0 opacity-100' : 'translate-y-[-10px] opacity-0'}
|
||
`}
|
||
>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="w-8 h-8 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm"
|
||
onClick={() => onEdit?.(video.id)}
|
||
>
|
||
<Edit2 className="w-4 h-4 text-white" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="w-8 h-8 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm"
|
||
onClick={() => onDelete?.(video.id)}
|
||
>
|
||
<Trash2 className="w-4 h-4 text-white" />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 底部控制区域 */}
|
||
<div
|
||
className={`absolute bottom-4 left-4 right-4 transition-all duration-300 transform
|
||
${hoveredId === video.id ? 'translate-y-0 opacity-100' : 'translate-y-[10px] opacity-0'}
|
||
`}
|
||
>
|
||
{/* 音量控制滑块 */}
|
||
{isPlaying[video.id] && (
|
||
<div className="flex items-center gap-2 bg-black/60 rounded-full px-3 py-2 backdrop-blur-sm mb-3">
|
||
<Volume2 className="w-4 h-4 text-white" />
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="1"
|
||
step="0.1"
|
||
value={volume[video.id] || 0.8}
|
||
onChange={(e) => handleVolumeChange(video.id, parseFloat(e.target.value))}
|
||
className="flex-1 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]:rounded-full [&::-webkit-slider-thumb]:bg-white
|
||
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:shadow-lg
|
||
[&::-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]:border-none [&::-moz-range-thumb]:shadow-lg"
|
||
style={{
|
||
background: `linear-gradient(to right, white 0%, white ${(volume[video.id] || 0.8) * 100}%, rgba(255,255,255,0.2) ${(volume[video.id] || 0.8) * 100}%, rgba(255,255,255,0.2) 100%)`
|
||
}}
|
||
/>
|
||
<span className="text-xs text-white/70 w-8 text-center">
|
||
{Math.round((volume[video.id] || 0.8) * 100)}%
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 控制按钮组 */}
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="w-8 h-8 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm"
|
||
onClick={() => togglePlay(video.id)}
|
||
title={isPlaying[video.id] ? "暂停" : "播放"}
|
||
>
|
||
{isPlaying[video.id] ? (
|
||
<Pause className="w-4 h-4 text-white" />
|
||
) : (
|
||
<Play className="w-4 h-4 text-white" />
|
||
)}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="w-8 h-8 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm"
|
||
onClick={(e) => toggleMute(video.id, e)}
|
||
title={isMuted[video.id] !== false ? "取消静音" : "静音"}
|
||
>
|
||
{isMuted[video.id] !== false ? (
|
||
<VolumeX className="w-4 h-4 text-white" />
|
||
) : (
|
||
<Volume2 className="w-4 h-4 text-white" />
|
||
)}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="w-8 h-8 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm"
|
||
onClick={(e) => toggleFullscreen(video.id, e)}
|
||
title={isFullscreen[video.id] ? "退出全屏" : "全屏"}
|
||
>
|
||
{isFullscreen[video.id] ? (
|
||
<Minimize className="w-4 h-4 text-white" />
|
||
) : (
|
||
<Maximize className="w-4 h-4 text-white" />
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 视频信息 */}
|
||
<div className="p-4">
|
||
<h3 className="text-white text-lg font-medium mb-1">{video.title}</h3>
|
||
{video.date && (
|
||
<p className="text-white/60 text-sm">{video.date}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Export as a client-only component to prevent hydration issues
|
||
export const VideoGridLayout = dynamic(() => Promise.resolve(VideoGridLayoutComponent), {
|
||
ssr: false
|
||
});
|