1014 lines
37 KiB
TypeScript
1014 lines
37 KiB
TypeScript
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,
|
|
Subtitles,
|
|
} from 'lucide-react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { clsx } from 'clsx';
|
|
import type { Movie, Subtitle } from '../../types';
|
|
import type { StreamSession } from '../../services/streaming/streamingService';
|
|
import { searchSubtitles, downloadSubtitle, convertSrtToVtt, getAvailableLanguages } from '../../services/subtitles/opensubtitles';
|
|
import { useSettingsStore } from '../../stores/settingsStore';
|
|
|
|
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);
|
|
const [showSubtitles, setShowSubtitles] = useState(false);
|
|
const [availableSubtitles, setAvailableSubtitles] = useState<Subtitle[]>([]);
|
|
const [selectedSubtitle, setSelectedSubtitle] = useState<Subtitle | null>(null);
|
|
const [subtitleTrack, setSubtitleTrack] = useState<TextTrack | null>(null);
|
|
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false);
|
|
const [networkSpeed, setNetworkSpeed] = useState<number>(0);
|
|
const [currentQualityLevel, setCurrentQualityLevel] = useState<string>('auto');
|
|
const [availableQualities, setAvailableQualities] = useState<string[]>(['auto']);
|
|
const { settings } = useSettingsStore();
|
|
|
|
// 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;
|
|
|
|
// Reset available qualities when source changes
|
|
setAvailableQualities(['auto']);
|
|
setCurrentQualityLevel('auto');
|
|
setQuality('auto');
|
|
|
|
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: false, // Disable for better adaptive streaming
|
|
backBufferLength: 90,
|
|
// Enhanced adaptive bitrate configuration
|
|
abrEwmaDefaultEstimate: 500000, // 500KB/s initial estimate
|
|
abrEwmaSlowVoD: 3, // Slow VoD estimate
|
|
abrEwmaFastVoD: 9, // Fast VoD estimate
|
|
abrEwmaSlowLive: 3, // Slow live estimate
|
|
abrEwmaFastLive: 9, // Fast live estimate
|
|
abrBandWidthFactor: 0.95, // Bandwidth factor
|
|
abrBandWidthUpFactor: 0.7, // Bandwidth up factor
|
|
abrMaxWithRealBitrate: false,
|
|
maxBufferLength: 30, // Max buffer length in seconds
|
|
maxMaxBufferLength: 60, // Max max buffer length
|
|
maxBufferSize: 60 * 1000 * 1000, // 60MB max buffer
|
|
maxBufferHole: 0.5, // Max buffer hole in seconds
|
|
highBufferWatchdogPeriod: 2, // High buffer watchdog period
|
|
nudgeOffset: 0.1, // Nudge offset
|
|
nudgeMaxRetry: 3, // Max retry for nudge
|
|
maxFragLoadingTimeOut: 20000, // Max fragment loading timeout
|
|
fragLoadingTimeOut: 20000, // Fragment loading timeout
|
|
manifestLoadingTimeOut: 10000, // Manifest loading timeout
|
|
levelLoadingTimeOut: 10000, // Level loading timeout
|
|
// Enable automatic quality switching
|
|
capLevelToPlayerSize: false, // Don't cap to player size, use network conditions
|
|
startLevel: -1, // Auto-select starting level
|
|
// Network monitoring
|
|
testBandwidth: true,
|
|
progressive: false,
|
|
});
|
|
|
|
hls.loadSource(hlsUrl);
|
|
hls.attachMedia(video);
|
|
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
setIsBuffering(false);
|
|
|
|
// Extract available quality levels from HLS manifest
|
|
const levels = hls.levels;
|
|
const qualities = new Set<string>(['auto']);
|
|
|
|
levels.forEach((level) => {
|
|
const height = level.height || 0;
|
|
if (height >= 2160) {
|
|
qualities.add('2160p');
|
|
} else if (height >= 1080) {
|
|
qualities.add('1080p');
|
|
} else if (height >= 720) {
|
|
qualities.add('720p');
|
|
} else if (height >= 480) {
|
|
qualities.add('480p');
|
|
} else if (height >= 360) {
|
|
qualities.add('360p');
|
|
}
|
|
});
|
|
|
|
// Sort qualities (auto first, then highest to lowest)
|
|
const sortedQualities = Array.from(qualities).sort((a, b) => {
|
|
if (a === 'auto') return -1;
|
|
if (b === 'auto') return 1;
|
|
const aNum = parseInt(a) || 0;
|
|
const bNum = parseInt(b) || 0;
|
|
return bNum - aNum;
|
|
});
|
|
|
|
setAvailableQualities(sortedQualities);
|
|
console.log('[HLS] Available qualities:', sortedQualities);
|
|
|
|
if (initialTime > 0) {
|
|
video.currentTime = initialTime;
|
|
}
|
|
video.play().catch(console.error);
|
|
});
|
|
|
|
// Monitor quality level changes
|
|
hls.on(Hls.Events.LEVEL_SWITCHED, (_event, data) => {
|
|
const level = hls.levels[data.level];
|
|
if (level) {
|
|
setCurrentQualityLevel(`${level.height}p` || 'auto');
|
|
console.log('Quality switched to:', level.height + 'p');
|
|
}
|
|
});
|
|
|
|
// Monitor network speed
|
|
hls.on(Hls.Events.FRAG_LOADED, (_event, data) => {
|
|
if (data.frag && data.stats) {
|
|
const loadTime = (data.stats.tload - data.stats.tfirst) / 1000; // in seconds
|
|
const bytes = data.frag.loaded;
|
|
if (loadTime > 0 && bytes > 0) {
|
|
const speed = (bytes / loadTime) * 8; // bits per second
|
|
setNetworkSpeed(speed);
|
|
}
|
|
}
|
|
});
|
|
|
|
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 playback...');
|
|
setIsBuffering(false);
|
|
if (initialTime > 0) {
|
|
video.currentTime = initialTime;
|
|
}
|
|
video.play().catch((err) => {
|
|
console.warn('[Video] Auto-play blocked:', err.message);
|
|
// Some browsers block autoplay, that's ok - user can click play
|
|
setIsBuffering(false);
|
|
});
|
|
};
|
|
|
|
const handleLoadedMetadata = () => {
|
|
console.log('[Video] Metadata loaded, duration:', video.duration);
|
|
setDuration(video.duration);
|
|
};
|
|
|
|
const handleLoadedData = () => {
|
|
console.log('[Video] Data loaded');
|
|
};
|
|
|
|
const handleLoadStart = () => {
|
|
console.log('[Video] Load started');
|
|
setIsBuffering(true);
|
|
};
|
|
|
|
const handleProgress = () => {
|
|
if (video.buffered.length > 0) {
|
|
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
|
const bufferedPercent = video.duration > 0 ? (bufferedEnd / video.duration) * 100 : 0;
|
|
setBuffered(bufferedPercent);
|
|
}
|
|
};
|
|
|
|
const handleError = (e: Event) => {
|
|
const error = video.error;
|
|
if (error) {
|
|
let errorMsg = 'Unknown error';
|
|
switch (error.code) {
|
|
case error.MEDIA_ERR_ABORTED:
|
|
errorMsg = 'Video loading aborted';
|
|
break;
|
|
case error.MEDIA_ERR_NETWORK:
|
|
errorMsg = 'Network error while loading video';
|
|
break;
|
|
case error.MEDIA_ERR_DECODE:
|
|
errorMsg = 'Video decoding error';
|
|
break;
|
|
case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
|
errorMsg = 'Video format not supported';
|
|
break;
|
|
}
|
|
console.error('[Video] Error:', errorMsg, error);
|
|
}
|
|
setIsBuffering(false);
|
|
};
|
|
|
|
const handleStalled = () => {
|
|
console.warn('[Video] Loading stalled');
|
|
};
|
|
|
|
const handleWaiting = () => {
|
|
console.log('[Video] Waiting for data...');
|
|
setIsBuffering(true);
|
|
};
|
|
|
|
const handlePlaying = () => {
|
|
console.log('[Video] Playing');
|
|
setIsPlaying(true);
|
|
setIsBuffering(false);
|
|
};
|
|
|
|
const handlePause = () => {
|
|
console.log('[Video] Paused');
|
|
setIsPlaying(false);
|
|
};
|
|
|
|
video.addEventListener('canplay', handleCanPlay);
|
|
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
|
video.addEventListener('loadeddata', handleLoadedData);
|
|
video.addEventListener('loadstart', handleLoadStart);
|
|
video.addEventListener('progress', handleProgress);
|
|
video.addEventListener('error', handleError);
|
|
video.addEventListener('stalled', handleStalled);
|
|
video.addEventListener('waiting', handleWaiting);
|
|
video.addEventListener('playing', handlePlaying);
|
|
video.addEventListener('pause', handlePause);
|
|
|
|
return () => {
|
|
video.removeEventListener('canplay', handleCanPlay);
|
|
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
|
video.removeEventListener('loadeddata', handleLoadedData);
|
|
video.removeEventListener('loadstart', handleLoadStart);
|
|
video.removeEventListener('progress', handleProgress);
|
|
video.removeEventListener('error', handleError);
|
|
video.removeEventListener('stalled', handleStalled);
|
|
video.removeEventListener('waiting', handleWaiting);
|
|
video.removeEventListener('playing', handlePlaying);
|
|
video.removeEventListener('pause', handlePause);
|
|
};
|
|
}
|
|
|
|
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]);
|
|
|
|
// Toggle play/pause (defined before keyboard controls to avoid initialization error)
|
|
const togglePlay = useCallback(() => {
|
|
if (videoRef.current) {
|
|
if (isPlaying) {
|
|
videoRef.current.pause();
|
|
} else {
|
|
videoRef.current.play();
|
|
}
|
|
}
|
|
}, [isPlaying]);
|
|
|
|
// Toggle fullscreen (defined before keyboard controls to avoid initialization error)
|
|
const toggleFullscreen = useCallback(() => {
|
|
if (!document.fullscreenElement) {
|
|
containerRef.current?.requestFullscreen();
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
}, []);
|
|
|
|
// 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 'c':
|
|
e.preventDefault();
|
|
setShowSubtitles(!showSubtitles);
|
|
break;
|
|
case 'Escape':
|
|
if (isFullscreen) {
|
|
document.exitFullscreen();
|
|
} else {
|
|
navigate(-1);
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [duration, isFullscreen, navigate, togglePlay, toggleFullscreen, showSubtitles]);
|
|
|
|
// Load subtitles when movie is available
|
|
useEffect(() => {
|
|
if (!movie?.imdb_code) return;
|
|
|
|
const loadSubtitles = async () => {
|
|
setIsLoadingSubtitles(true);
|
|
try {
|
|
const subtitles = await searchSubtitles(movie.imdb_code, settings.preferredLanguage);
|
|
setAvailableSubtitles(subtitles);
|
|
|
|
// Auto-select preferred language if available
|
|
if (subtitles.length > 0) {
|
|
const preferred = subtitles.find(s => s.language === settings.preferredLanguage) || subtitles[0];
|
|
await selectSubtitle(preferred);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading subtitles:', error);
|
|
} finally {
|
|
setIsLoadingSubtitles(false);
|
|
}
|
|
};
|
|
|
|
loadSubtitles();
|
|
}, [movie?.imdb_code, settings.preferredLanguage]);
|
|
|
|
// Select and load subtitle
|
|
const selectSubtitle = useCallback(async (subtitle: Subtitle) => {
|
|
if (!videoRef.current) return;
|
|
|
|
try {
|
|
// Remove existing subtitle tracks
|
|
const existingTracks = Array.from(videoRef.current.querySelectorAll('track'));
|
|
existingTracks.forEach(track => track.remove());
|
|
|
|
const textTracks = Array.from(videoRef.current.textTracks);
|
|
textTracks.forEach(track => {
|
|
track.mode = 'hidden';
|
|
});
|
|
|
|
// Download subtitle
|
|
const subtitleUrl = await downloadSubtitle(subtitle.id);
|
|
if (!subtitleUrl) {
|
|
console.error('Failed to download subtitle');
|
|
return;
|
|
}
|
|
|
|
// Fetch and convert subtitle
|
|
const response = await fetch(subtitleUrl);
|
|
const srtContent = await response.text();
|
|
const vttContent = await convertSrtToVtt(srtContent);
|
|
|
|
// Create blob URL for subtitle
|
|
const blob = new Blob([vttContent], { type: 'text/vtt' });
|
|
const vttUrl = URL.createObjectURL(blob);
|
|
|
|
// Add subtitle track to video using track element
|
|
const trackElement = document.createElement('track');
|
|
trackElement.kind = 'subtitles';
|
|
trackElement.label = subtitle.languageName;
|
|
trackElement.srclang = subtitle.language;
|
|
trackElement.src = vttUrl;
|
|
trackElement.default = true;
|
|
|
|
videoRef.current.appendChild(trackElement);
|
|
|
|
// Wait for track to load
|
|
trackElement.addEventListener('load', () => {
|
|
const track = videoRef.current?.textTracks.getTrackById(trackElement.track?.id || '');
|
|
if (track) {
|
|
track.mode = 'showing';
|
|
setSubtitleTrack(track);
|
|
}
|
|
});
|
|
|
|
setSelectedSubtitle(subtitle);
|
|
} catch (error) {
|
|
console.error('Error loading subtitle:', error);
|
|
}
|
|
}, []);
|
|
|
|
// Toggle subtitles on/off
|
|
const toggleSubtitles = useCallback(() => {
|
|
if (!videoRef.current) return;
|
|
|
|
const tracks = Array.from(videoRef.current.textTracks);
|
|
const activeTrack = tracks.find(t => t.mode === 'showing');
|
|
|
|
if (activeTrack) {
|
|
activeTrack.mode = 'hidden';
|
|
setSelectedSubtitle(null);
|
|
} else if (selectedSubtitle) {
|
|
const track = tracks.find(t => t.language === selectedSubtitle.language);
|
|
if (track) {
|
|
track.mode = 'showing';
|
|
}
|
|
}
|
|
}, [selectedSubtitle]);
|
|
|
|
// 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 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={(e) => {
|
|
// Don't toggle play if clicking on controls
|
|
if (e.target === videoRef.current) {
|
|
togglePlay();
|
|
}
|
|
}}
|
|
playsInline
|
|
/>
|
|
|
|
{/* Click outside to close menus */}
|
|
{(showSettings || showSubtitles) && (
|
|
<div
|
|
className="fixed inset-0 z-40"
|
|
onClick={() => {
|
|
setShowSettings(false);
|
|
setShowSubtitles(false);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 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">
|
|
{/* Subtitles */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setShowSubtitles(!showSubtitles)}
|
|
className={clsx(
|
|
'p-2 hover:bg-white/10 rounded-full transition-colors',
|
|
selectedSubtitle && 'text-netflix-red'
|
|
)}
|
|
title="Subtitles"
|
|
>
|
|
<Subtitles size={20} />
|
|
</button>
|
|
|
|
{showSubtitles && (
|
|
<div className="absolute bottom-full right-0 mb-2 p-3 bg-black/95 rounded-lg min-w-[200px] max-h-[300px] overflow-y-auto z-50">
|
|
<p className="text-xs text-gray-400 mb-2 font-semibold">Subtitles</p>
|
|
{isLoadingSubtitles ? (
|
|
<div className="flex items-center gap-2 py-2">
|
|
<Loader2 size={14} className="animate-spin" />
|
|
<span className="text-xs text-gray-400">Loading...</span>
|
|
</div>
|
|
) : availableSubtitles.length === 0 ? (
|
|
<p className="text-xs text-gray-500 py-2">No subtitles available</p>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={() => {
|
|
if (videoRef.current) {
|
|
const tracks = Array.from(videoRef.current.textTracks);
|
|
tracks.forEach(track => {
|
|
track.mode = 'hidden';
|
|
});
|
|
setSelectedSubtitle(null);
|
|
setSubtitleTrack(null);
|
|
}
|
|
setShowSubtitles(false);
|
|
}}
|
|
className={clsx(
|
|
'block w-full text-left px-3 py-1.5 rounded hover:bg-white/10 text-xs',
|
|
!selectedSubtitle && 'text-netflix-red'
|
|
)}
|
|
>
|
|
Off
|
|
</button>
|
|
{availableSubtitles.map((sub) => (
|
|
<button
|
|
key={sub.id}
|
|
onClick={async () => {
|
|
await selectSubtitle(sub);
|
|
setShowSubtitles(false);
|
|
}}
|
|
className={clsx(
|
|
'block w-full text-left px-3 py-1.5 rounded hover:bg-white/10 text-xs',
|
|
selectedSubtitle?.id === sub.id && 'text-netflix-red'
|
|
)}
|
|
>
|
|
{sub.languageName}
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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-3 bg-black/95 rounded-lg min-w-[200px] z-50">
|
|
<p className="text-xs text-gray-400 mb-2 font-semibold">Quality</p>
|
|
{hlsRef.current ? (
|
|
// HLS stream - show available qualities
|
|
availableQualities.map((q) => {
|
|
const isAvailable = q === 'auto' || availableQualities.includes(q);
|
|
return (
|
|
<button
|
|
key={q}
|
|
onClick={() => {
|
|
if (!isAvailable) return;
|
|
|
|
setQuality(q);
|
|
if (q !== 'auto') {
|
|
// Manual quality selection - find exact or closest match
|
|
const levels = hlsRef.current!.levels;
|
|
const targetHeight = parseInt(q) || 0;
|
|
|
|
// Find the level with the closest height to target
|
|
let bestMatch = -1;
|
|
let bestDiff = Infinity;
|
|
|
|
levels.forEach((level, index) => {
|
|
const height = level.height || 0;
|
|
const diff = Math.abs(height - targetHeight);
|
|
|
|
// Prefer exact match or closest below target
|
|
if (height <= targetHeight && diff < bestDiff) {
|
|
bestMatch = index;
|
|
bestDiff = diff;
|
|
}
|
|
});
|
|
|
|
if (bestMatch !== -1) {
|
|
hlsRef.current!.currentLevel = bestMatch;
|
|
console.log(`[HLS] Switched to quality: ${q} (level ${bestMatch})`);
|
|
} else {
|
|
console.warn(`[HLS] Quality ${q} not available`);
|
|
}
|
|
} else {
|
|
// Auto quality
|
|
hlsRef.current!.currentLevel = -1;
|
|
console.log('[HLS] Switched to auto quality');
|
|
}
|
|
setShowSettings(false);
|
|
}}
|
|
disabled={!isAvailable}
|
|
className={clsx(
|
|
'block w-full text-left px-3 py-1.5 rounded hover:bg-white/10 text-xs transition-colors',
|
|
quality === q && 'text-netflix-red font-semibold',
|
|
!isAvailable && 'opacity-50 cursor-not-allowed'
|
|
)}
|
|
>
|
|
{q === 'auto' ? `Auto (${currentQualityLevel})` : q}
|
|
</button>
|
|
);
|
|
})
|
|
) : (
|
|
// Direct stream - quality selection not available
|
|
<div className="px-3 py-1.5 text-xs text-gray-500">
|
|
Quality selection not available for direct streams
|
|
</div>
|
|
)}
|
|
{networkSpeed > 0 && (
|
|
<div className="mt-2 pt-2 border-t border-white/10">
|
|
<p className="text-xs text-gray-500">
|
|
Network: {formatSpeed(networkSpeed / 8)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|
|
|