First big commit
This commit is contained in:
599
src/components/player/StreamingPlayer.tsx
Normal file
599
src/components/player/StreamingPlayer.tsx
Normal file
@@ -0,0 +1,599 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Hls from 'hls.js';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Settings,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Wifi,
|
||||
Users,
|
||||
HardDrive,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { clsx } from 'clsx';
|
||||
import type { Movie } from '../../types';
|
||||
import type { StreamSession } from '../../services/streaming/streamingService';
|
||||
|
||||
interface StreamingPlayerProps {
|
||||
movie: Movie;
|
||||
streamUrl: string;
|
||||
hlsUrl?: string;
|
||||
streamSession?: StreamSession | null;
|
||||
onTimeUpdate?: (currentTime: number, duration: number) => void;
|
||||
initialTime?: number;
|
||||
}
|
||||
|
||||
export default function StreamingPlayer({
|
||||
movie,
|
||||
streamUrl,
|
||||
hlsUrl,
|
||||
streamSession,
|
||||
onTimeUpdate,
|
||||
initialTime = 0,
|
||||
}: StreamingPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const currentSourceRef = useRef<string | null>(null); // Track current source to avoid re-setting
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [quality, setQuality] = useState('auto');
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
// Format time to MM:SS or HH:MM:SS
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (!isFinite(seconds)) return '0:00';
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hrs > 0) {
|
||||
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Format bytes
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Format speed
|
||||
const formatSpeed = (bytesPerSecond: number): string => {
|
||||
return formatBytes(bytesPerSecond) + '/s';
|
||||
};
|
||||
|
||||
// Initialize HLS or direct video
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const useHls = hlsUrl && Hls.isSupported();
|
||||
|
||||
if (useHls) {
|
||||
// Skip if we already have this HLS source loaded
|
||||
if (currentSourceRef.current === hlsUrl && hlsRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Using HLS playback');
|
||||
currentSourceRef.current = hlsUrl;
|
||||
|
||||
// Cleanup previous HLS instance
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
}
|
||||
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
backBufferLength: 90,
|
||||
});
|
||||
|
||||
hls.loadSource(hlsUrl);
|
||||
hls.attachMedia(video);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
setIsBuffering(false);
|
||||
if (initialTime > 0) {
|
||||
video.currentTime = initialTime;
|
||||
}
|
||||
video.play().catch(console.error);
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.error('HLS error:', data);
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
hls.destroy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
hlsRef.current = hls;
|
||||
|
||||
return () => {
|
||||
hls.destroy();
|
||||
hlsRef.current = null;
|
||||
};
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl') && hlsUrl) {
|
||||
// Safari native HLS - skip if same source
|
||||
if (currentSourceRef.current === hlsUrl) {
|
||||
return;
|
||||
}
|
||||
currentSourceRef.current = hlsUrl;
|
||||
video.src = hlsUrl;
|
||||
video.play().catch(console.error);
|
||||
} else if (streamUrl) {
|
||||
// Direct video stream - skip if same source
|
||||
if (currentSourceRef.current === streamUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Using direct video playback:', streamUrl);
|
||||
currentSourceRef.current = streamUrl;
|
||||
video.src = streamUrl;
|
||||
|
||||
// Set up event handlers only once
|
||||
const handleCanPlay = () => {
|
||||
console.log('Video can play, starting...');
|
||||
setIsBuffering(false);
|
||||
video.play().catch((err) => {
|
||||
console.error('Auto-play failed:', err);
|
||||
// Some browsers block autoplay, that's ok - user can click play
|
||||
});
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
console.error('Video error:', video.error);
|
||||
setIsBuffering(false);
|
||||
};
|
||||
|
||||
video.addEventListener('canplay', handleCanPlay, { once: true });
|
||||
video.addEventListener('error', handleError, { once: true });
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('canplay', handleCanPlay);
|
||||
video.removeEventListener('error', handleError);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
}
|
||||
};
|
||||
}, [streamUrl, hlsUrl, initialTime]);
|
||||
|
||||
// Show/hide controls
|
||||
const handleMouseMove = useCallback(() => {
|
||||
setShowControls(true);
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
if (isPlaying) {
|
||||
setShowControls(false);
|
||||
}
|
||||
}, 3000);
|
||||
}, [isPlaying]);
|
||||
|
||||
// Video event handlers
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
setCurrentTime(video.currentTime);
|
||||
onTimeUpdate?.(video.currentTime, video.duration);
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
setDuration(video.duration);
|
||||
};
|
||||
|
||||
const handleProgress = () => {
|
||||
if (video.buffered.length > 0) {
|
||||
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||
setBuffered((bufferedEnd / video.duration) * 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWaiting = () => setIsBuffering(true);
|
||||
const handleCanPlay = () => setIsBuffering(false);
|
||||
const handlePlay = () => setIsPlaying(true);
|
||||
const handlePause = () => setIsPlaying(false);
|
||||
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.addEventListener('progress', handleProgress);
|
||||
video.addEventListener('waiting', handleWaiting);
|
||||
video.addEventListener('canplay', handleCanPlay);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.removeEventListener('progress', handleProgress);
|
||||
video.removeEventListener('waiting', handleWaiting);
|
||||
video.removeEventListener('canplay', handleCanPlay);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
};
|
||||
}, [onTimeUpdate]);
|
||||
|
||||
// Keyboard controls
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
video.currentTime = Math.max(0, video.currentTime - 10);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
video.currentTime = Math.min(duration, video.currentTime + 10);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setVolume((v) => Math.min(1, v + 0.1));
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setVolume((v) => Math.max(0, v - 0.1));
|
||||
break;
|
||||
case 'm':
|
||||
e.preventDefault();
|
||||
setIsMuted((m) => !m);
|
||||
break;
|
||||
case 'f':
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case 'Escape':
|
||||
if (isFullscreen) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [duration, isFullscreen, navigate]);
|
||||
|
||||
// Update volume
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = volume;
|
||||
videoRef.current.muted = isMuted;
|
||||
}
|
||||
}, [volume, isMuted]);
|
||||
|
||||
// Fullscreen change
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
const togglePlay = () => {
|
||||
if (videoRef.current) {
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
containerRef.current?.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pos = (e.clientX - rect.left) / rect.width;
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = pos * duration;
|
||||
}
|
||||
};
|
||||
|
||||
const skip = (seconds: number) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = Math.max(
|
||||
0,
|
||||
Math.min(duration, videoRef.current.currentTime + seconds)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-full bg-black"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => isPlaying && setShowControls(false)}
|
||||
>
|
||||
{/* Video Element */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full"
|
||||
onClick={togglePlay}
|
||||
playsInline
|
||||
/>
|
||||
|
||||
{/* Buffering Indicator */}
|
||||
<AnimatePresence>
|
||||
{isBuffering && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 flex flex-col items-center justify-center bg-black/50"
|
||||
>
|
||||
<Loader2 size={48} className="animate-spin text-netflix-red mb-4" />
|
||||
{streamSession && streamSession.status === 'downloading' && (
|
||||
<div className="text-center">
|
||||
<p className="text-lg mb-2">Buffering...</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{(streamSession.progress * 100).toFixed(1)}% downloaded
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Controls Overlay */}
|
||||
<AnimatePresence>
|
||||
{showControls && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute inset-0 flex flex-col justify-between"
|
||||
>
|
||||
{/* Top Bar */}
|
||||
<div className="bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-semibold">{movie.title}</h1>
|
||||
<p className="text-sm text-gray-400">{movie.year}</p>
|
||||
</div>
|
||||
|
||||
{/* Stream Stats */}
|
||||
{streamSession && (
|
||||
<div className="flex items-center gap-4 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Wifi size={14} className="text-green-400" />
|
||||
{formatSpeed(streamSession.downloadSpeed)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={14} />
|
||||
{streamSession.peers}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive size={14} />
|
||||
{formatBytes(streamSession.downloaded)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center Play Button */}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
{!isBuffering && (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={togglePlay}
|
||||
className="w-20 h-20 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={40} fill="white" />
|
||||
) : (
|
||||
<Play size={40} fill="white" className="ml-1" />
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<div className="bg-gradient-to-t from-black/80 to-transparent p-4 space-y-3">
|
||||
{/* Download Progress (if still downloading) */}
|
||||
{streamSession && streamSession.progress < 1 && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<div className="flex-1 h-1 bg-white/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${streamSession.progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{(streamSession.progress * 100).toFixed(0)}% buffered</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
className="relative h-1 bg-white/20 rounded-full cursor-pointer group"
|
||||
onClick={handleSeek}
|
||||
>
|
||||
{/* Buffered */}
|
||||
<div
|
||||
className="absolute h-full bg-white/30 rounded-full"
|
||||
style={{ width: `${buffered}%` }}
|
||||
/>
|
||||
{/* Progress */}
|
||||
<div
|
||||
className="absolute h-full bg-netflix-red rounded-full"
|
||||
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
|
||||
/>
|
||||
{/* Scrubber */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-netflix-red rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ left: `calc(${(currentTime / duration) * 100 || 0}% - 8px)` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls Row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
||||
</button>
|
||||
|
||||
{/* Skip Back */}
|
||||
<button
|
||||
onClick={() => skip(-10)}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
|
||||
{/* Skip Forward */}
|
||||
<button
|
||||
onClick={() => skip(10)}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="flex items-center gap-2 group/vol">
|
||||
<button
|
||||
onClick={() => setIsMuted(!isMuted)}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<VolumeX size={20} />
|
||||
) : (
|
||||
<Volume2 size={20} />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={(e) => {
|
||||
setVolume(parseFloat(e.target.value));
|
||||
setIsMuted(false);
|
||||
}}
|
||||
className="w-20 accent-netflix-red opacity-0 group-hover/vol:opacity-100 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-sm ml-2">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Settings */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
|
||||
{showSettings && (
|
||||
<div className="absolute bottom-full right-0 mb-2 p-2 bg-black/90 rounded-lg min-w-[150px]">
|
||||
<p className="text-xs text-gray-400 mb-2">Quality</p>
|
||||
{['auto', '1080p', '720p', '480p'].map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => {
|
||||
setQuality(q);
|
||||
setShowSettings(false);
|
||||
}}
|
||||
className={clsx(
|
||||
'block w-full text-left px-3 py-1 rounded hover:bg-white/10',
|
||||
quality === q && 'text-netflix-red'
|
||||
)}
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
{isFullscreen ? <Minimize size={20} /> : <Maximize size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user