Files
beStream/src/components/player/StreamingPlayer.tsx
kharonsec 766cbbce89
Some checks failed
CI / Lint & Type Check (push) Failing after 43s
CI / Tests (push) Successful in 1m0s
CI / Build Web (push) Has been skipped
CI / Security Scan (push) Successful in 46s
CI / Build Electron (Linux) (push) Has been skipped
CI / Build Tauri (ubuntu-latest) (push) Has been skipped
CI / Build Electron (Windows) (push) Has been cancelled
CI / Build Tauri (windows-latest) (push) Has been cancelled
Add local profiles, smart features, and Google Cast support
- Local Profiles: Profile selector, manager, avatar system, profile-aware stores
- Smart Features: Continue Watching, personalized recommendations, auto-quality, smart downloads
- Google Cast: Cast service with web SDK and Capacitor Android plugin interface
- Settings: New toggles for auto-quality and smart downloads

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:44:13 +01:00

1254 lines
48 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,
AlertTriangle,
MonitorPlay,
} 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 } from '../../services/subtitles/opensubtitles';
import { useSettingsStore } from '../../stores/settingsStore';
import { isCapacitor } from '../../utils/platform';
import { playWithNativePlayer } from '../../plugins/ExoPlayer';
import CastButton from './CastButton';
import { useCast } from '../../hooks/useCast';
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 hasAppliedInitialTimeRef = useRef(false); // Track if initial seek has been applied
const lastPlaybackPositionRef = useRef<number>(0); // Track last playback position for source switches
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 [, 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 [audioWarning, setAudioWarning] = useState<string | null>(null);
const [audioWarningDismissed, setAudioWarningDismissed] = useState(false);
const [useNativePlayer, setUseNativePlayer] = useState(false);
const [isNativePlayerPlaying, setIsNativePlayerPlaying] = useState(false);
const { settings } = useSettingsStore();
// Cast integration
const { castMedia } = useCast();
// Detect potential audio issues on Android with MKV files and enable native player
useEffect(() => {
// Only check on Capacitor (Android) since desktop has FFmpeg transcoding
if (!isCapacitor()) return;
// Check if this is an MKV file (common for TV shows from EZTV)
const videoName = streamSession?.videoFile?.name?.toLowerCase() || '';
const isMkv = videoName.endsWith('.mkv');
if (isMkv && !hlsUrl) {
// MKV files often have AC3/DTS audio that Android WebView can't play
// Enable native player option
setUseNativePlayer(true);
setAudioWarning(
'This video uses an MKV format. Click "Play with Native Player" for better audio codec support, or try a different torrent with MP4 format.'
);
} else {
setAudioWarning(null);
setUseNativePlayer(false);
}
}, [streamSession?.videoFile?.name, hlsUrl]);
// Handle launching native ExoPlayer for better codec support
const handleNativePlayerLaunch = useCallback(async () => {
if (!streamUrl) return;
setIsNativePlayerPlaying(true);
// Pause webview video
if (videoRef.current) {
videoRef.current.pause();
}
const title = movie.title || 'Video';
const startPos = currentTime;
console.log('[NativePlayer] Launching ExoPlayer with URL:', streamUrl);
const result = await playWithNativePlayer(streamUrl, title, startPos);
setIsNativePlayerPlaying(false);
if (result && result.success) {
console.log('[NativePlayer] Playback ended at:', result.position, 'completed:', result.completed);
// Update current time from native player
if (result.position > 0) {
setCurrentTime(result.position);
if (videoRef.current) {
videoRef.current.currentTime = result.position;
}
// Report time to parent
if (onTimeUpdate) {
onTimeUpdate(result.position, result.duration || duration);
}
}
// If video completed, navigate back
if (result.completed) {
navigate(-1);
}
} else {
console.error('[NativePlayer] Failed to play with native player');
// Show error and fall back to webview
setAudioWarning('Native player failed. Using built-in player which may have audio issues with this format.');
}
}, [streamUrl, movie.title, currentTime, duration, navigate, onTimeUpdate]);
// 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();
// Save current playback position before potentially switching sources
// This prevents the video from jumping back to initialTime when HLS becomes available
if (video.currentTime > 0 && !video.paused) {
lastPlaybackPositionRef.current = video.currentTime;
console.log('[Source] Saving current position before switch:', video.currentTime);
}
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
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);
// Determine the correct seek position:
// - If we were already playing (source switch), use last playback position
// - If this is first load and initialTime is set, use that
// - Otherwise start from beginning
const seekPosition = lastPlaybackPositionRef.current > 0
? lastPlaybackPositionRef.current
: (!hasAppliedInitialTimeRef.current && initialTime > 0 ? initialTime : 0);
if (seekPosition > 0) {
console.log('[HLS] Seeking to position:', seekPosition);
video.currentTime = seekPosition;
}
hasAppliedInitialTimeRef.current = true;
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) {
// Try to get stats from the fragment if available
const frag = data.frag;
if (frag && 'stats' in frag && frag.stats) {
const stats = frag.stats as { tload?: number; tfirst?: number };
if (stats.tload && stats.tfirst) {
const loadTime = (stats.tload - stats.tfirst) / 1000; // in seconds
const bytes = (frag as any).loaded || 0;
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);
// Determine the correct seek position:
// - If we were already playing (source switch), use last playback position
// - If this is first load and initialTime is set, use that
// - Otherwise start from beginning
const seekPosition = lastPlaybackPositionRef.current > 0
? lastPlaybackPositionRef.current
: (!hasAppliedInitialTimeRef.current && initialTime > 0 ? initialTime : 0);
if (seekPosition > 0) {
console.log('[Video] Seeking to position:', seekPosition);
video.currentTime = seekPosition;
}
hasAppliedInitialTimeRef.current = true;
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);
// Check for audio tracks on Android - if no audio tracks detected, show warning
if (isCapacitor()) {
const audioTracks = (video as any).audioTracks;
// Log audio tracks info for debugging
console.log('[Video] Audio tracks:', audioTracks?.length || 'unknown');
if (audioTracks && audioTracks.length === 0) {
setAudioWarning(
'No audio track detected. This video file may use an audio codec that Android cannot play. Try a different torrent.'
);
}
}
};
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 - codec not supported';
// If HLS is available, try switching to it
if (hlsUrl && !hlsRef.current) {
console.log('[Video] Decode error detected, switching to HLS transcoding...');
// Save current position before switching
const currentPosition = video.currentTime > 0 ? video.currentTime : lastPlaybackPositionRef.current;
// Force HLS playback
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
});
hls.loadSource(hlsUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (currentPosition > 0) {
video.currentTime = currentPosition;
}
video.play().catch(console.error);
});
hlsRef.current = hls;
}
break;
case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
errorMsg = 'Video format not supported';
// If HLS is available, try switching to it
if (hlsUrl && !hlsRef.current) {
console.log('[Video] Format not supported, switching to HLS transcoding...');
// Save current position before switching
const currentPosition = video.currentTime > 0 ? video.currentTime : lastPlaybackPositionRef.current;
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
});
hls.loadSource(hlsUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (currentPosition > 0) {
video.currentTime = currentPosition;
}
video.play().catch(console.error);
});
hlsRef.current = hls;
}
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();
}
};
// Note: initialTime intentionally omitted - only used on first load via hasAppliedInitialTimeRef
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [streamUrl, hlsUrl]);
// 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);
// Keep track of playback position for source switches
if (video.currentTime > 0) {
lastPlaybackPositionRef.current = 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);
}
}, []);
// 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
/>
{/* Audio Warning Banner for Android MKV files with Native Player option */}
<AnimatePresence>
{audioWarning && !audioWarningDismissed && useNativePlayer && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-50 flex items-center justify-center bg-black/90"
>
<div className="max-w-md mx-4 text-center">
<AlertTriangle size={48} className="text-yellow-500 mx-auto mb-4" />
<h2 className="text-xl font-bold text-white mb-2">Audio May Not Work</h2>
<p className="text-gray-300 mb-6">
This video uses an MKV format with audio codecs that may not play in the browser.
Use the Native Player for full audio support.
</p>
<div className="flex flex-col gap-3">
<button
onClick={handleNativePlayerLaunch}
disabled={isNativePlayerPlaying}
className="flex items-center justify-center gap-2 px-6 py-3 bg-netflix-red hover:bg-netflix-red-hover rounded-lg font-semibold text-white transition-colors disabled:opacity-50"
>
<MonitorPlay size={20} />
{isNativePlayerPlaying ? 'Opening...' : 'Use Native Player (Recommended)'}
</button>
<button
onClick={() => setAudioWarningDismissed(true)}
className="px-6 py-3 bg-white/10 hover:bg-white/20 rounded-lg font-medium text-gray-300 transition-colors"
>
Try Browser Player Anyway
</button>
</div>
<p className="text-gray-500 text-sm mt-4">
Tip: MP4 torrents usually have better compatibility
</p>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Native Player Loading Overlay */}
<AnimatePresence>
{isNativePlayerPlaying && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 z-50"
>
<Loader2 size={48} className="animate-spin text-netflix-red mb-4" />
<p className="text-white text-lg">Opening Native Player...</p>
<p className="text-gray-400 text-sm mt-2">ExoPlayer with full codec support</p>
</motion.div>
)}
</AnimatePresence>
{/* 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>
{/* Cast Button */}
<CastButton
size={20}
onCastStart={() => {
// Cast the current stream
const streamTocast = hlsUrl || streamUrl;
if (streamTocast) {
castMedia({
url: streamTocast,
title: movie.title,
subtitle: `${movie.year}${movie.runtime} min`,
imageUrl: movie.large_cover_image || movie.medium_cover_image,
startTime: currentTime,
});
// Pause local playback when casting starts
videoRef.current?.pause();
}
}}
onCastEnd={() => {
// Resume local playback when casting ends
videoRef.current?.play();
}}
/>
{/* 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>
);
}