feat: implement inline video player component for enhanced video playback experience
- Replaced modal video player with an inline video player that renders as a portal. - Added state management for video playback, volume control, and fullscreen functionality. - Introduced new component for inline video playback with customizable controls and metadata display.
This commit is contained in:
parent
5014434717
commit
a752ce964a
|
|
@ -6,7 +6,8 @@ import { Film, Play, Clock, HardDrive, Search, Filter } from "lucide-react";
|
|||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import VideoPlayer from "@/components/video-player";
|
||||
import InlineVideoPlayer from "@/components/inline-video-player";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface Video {
|
||||
id: number;
|
||||
|
|
@ -21,6 +22,7 @@ const VideosPage = () => {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
|
||||
const [scrollPosition, setScrollPosition] = useState(0);
|
||||
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -205,15 +207,17 @@ const VideosPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Player Modal */}
|
||||
{selectedVideo && (
|
||||
<VideoPlayer
|
||||
video={selectedVideo}
|
||||
isOpen={isPlayerOpen}
|
||||
onClose={handleClosePlayer}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{/* Inline Video Player - Rendered as Portal */}
|
||||
{selectedVideo && isPlayerOpen && typeof window !== 'undefined' && createPortal(
|
||||
<InlineVideoPlayer
|
||||
video={selectedVideo}
|
||||
isOpen={isPlayerOpen}
|
||||
onClose={handleClosePlayer}
|
||||
scrollPosition={scrollPosition}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,246 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { X, Play, Pause, Maximize, Minimize, Volume2, VolumeX } from 'lucide-react';
|
||||
|
||||
interface InlineVideoPlayerProps {
|
||||
video: {
|
||||
id: number;
|
||||
title: string;
|
||||
path: string;
|
||||
size: number;
|
||||
thumbnail: string;
|
||||
};
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
scrollPosition?: number;
|
||||
}
|
||||
|
||||
export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPosition }: InlineVideoPlayerProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && videoRef.current) {
|
||||
videoRef.current.src = `/api/stream/${video.id}`;
|
||||
videoRef.current.load();
|
||||
}
|
||||
}, [isOpen, video.id]);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (videoRef.current) {
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMute = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = !isMuted;
|
||||
setIsMuted(!isMuted);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (videoRef.current) {
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
videoRef.current.volume = newVolume;
|
||||
setVolume(newVolume);
|
||||
setIsMuted(newVolume === 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
setDuration(videoRef.current.duration);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (videoRef.current) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left;
|
||||
const newTime = (clickX / rect.width) * duration;
|
||||
videoRef.current.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handlePlayPause();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
// Prevent body scroll when player is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
// Restore body scroll when player is closed
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-50 bg-background transition-opacity duration-300 ${isVisible ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 bg-background border-b border-border">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<span>Back to videos</span>
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-foreground truncate max-w-md">
|
||||
{video.title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Player */}
|
||||
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||
<div className="aspect-video bg-black rounded-lg overflow-hidden relative group">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onMouseMove={() => setShowControls(true)}
|
||||
onMouseLeave={() => setShowControls(false)}
|
||||
controls={false}
|
||||
>
|
||||
<source src={`/api/stream/${video.id}`} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
{/* Video Overlay Controls */}
|
||||
<div className={`absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{/* Center Play Button */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="w-20 h-20 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white/30 transition-all duration-200"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-8 w-8 text-white" />
|
||||
) : (
|
||||
<Play className="h-8 w-8 text-white ml-1" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
className="relative h-2 bg-white/20 rounded-full cursor-pointer mb-4"
|
||||
onClick={handleProgressClick}
|
||||
>
|
||||
<div
|
||||
className="absolute h-full bg-white rounded-full"
|
||||
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="p-2 hover:bg-white/20 rounded-full transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5 text-white" /> : <Play className="h-5 w-5 text-white" />}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleMute}
|
||||
className="p-2 hover:bg-white/20 rounded-full transition-colors"
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-5 w-5 text-white" /> : <Volume2 className="h-5 w-5 text-white" />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-24 h-1 bg-white/20 rounded-full appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-white">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-white">
|
||||
{Math.round(video.size / 1024 / 1024)} MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video info below player */}
|
||||
<div className="mt-6 space-y-2">
|
||||
<h2 className="text-2xl font-bold text-foreground">{video.title}</h2>
|
||||
<p className="text-muted-foreground font-mono text-sm break-all">
|
||||
{video.path}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue