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(null); const hlsRef = useRef(null); const containerRef = useRef(null); const controlsTimeoutRef = useRef(); const currentSourceRef = useRef(null); // Track current source to avoid re-setting const hasAppliedInitialTimeRef = useRef(false); // Track if initial seek has been applied const lastPlaybackPositionRef = useRef(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([]); const [selectedSubtitle, setSelectedSubtitle] = useState(null); const [, setSubtitleTrack] = useState(null); const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); const [networkSpeed, setNetworkSpeed] = useState(0); const [currentQualityLevel, setCurrentQualityLevel] = useState('auto'); const [availableQualities, setAvailableQualities] = useState(['auto']); const [audioWarning, setAudioWarning] = useState(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(['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) => { 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 (
isPlaying && setShowControls(false)} > {/* Video Element */}