frontend/src/components/VideoPlayer.tsxtypescript · @kibot
20/9/2026, 17:27:13import React, { useEffect, useRef, useState, useCallback } from 'react'
import HLS from 'hls.js'
import type { Movie } from '../types/api'
interface VideoPlayerProps {
movie: Movie
}
interface PlayerState {
error: string | null
isPlaying: boolean
isLoading: boolean
retryCount: number
currentLevel: number | null
hlsLevel: number | null
}
const MAX_RETRIES = 3
const RETRY_DELAY_BASE = 1000
const RETRY_DELAY_MAX = 16000
const VideoPlayer: React.FC<VideoPlayerProps> = ({ movie }) => {
const videoRef = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<HLS | null>(null)
const mountedRef = useRef(true)
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fatalErrorTriggeredRef = useRef(false)
const [state, setState] = useState<PlayerState>({
error: null,
isPlaying: false,
isLoading: true,
retryCount: 0,
currentLevel: null,
hlsLevel: null
})
const clearRetryTimeout = useCallback(() => {
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current)
retryTimeoutRef.current = null
}
}, [])
const destroyHls = useCallback(() => {
clearRetryTimeout()
if (hlsRef.current) {
try {
hlsRef.current.destroy()
} catch (e) {
console.error('Error destroying HLS instance:', e)
}
hlsRef.current = null
}
}, [clearRetryTimeout])
const getRetryDelay = useCallback((retryCount: number) => {
const delay = RETRY_DELAY_BASE * Math.pow(2, retryCount)
return Math.min(delay, RETRY_DELAY_MAX)
}, [])
const scheduleRetry = useCallback(() => {
clearRetryTimeout()
setState(prev => {
if (prev.retryCount >= MAX_RETRIES) {
return {
...prev,
error: 'No se pudo conectar al servidor de streaming después de varios intentos. Verifica tu conexión a internet.',
isLoading: false
}
}
const delay = getRetryDelay(prev.retryCount)
setState(prev => ({ ...prev, isLoading: true, error: null }))
retryTimeoutRef.current = setTimeout(() => {
if (mountedRef.current) {
initHls()
}
}, delay)
return { ...prev, retryCount: prev.retryCount + 1 }
})
}, [clearRetryTimeout, getRetryDelay])
const handleFatalError = useCallback((message: string) => {
if (!mountedRef.current || fatalErrorTriggeredRef.current) return
fatalErrorTriggeredRef.current = true
destroyHls()
setState({
error: message,
isPlaying: false,
isLoading: false,
retryCount: 0,
currentLevel: null,
hlsLevel: null
})
}, [destroyHls])
const handleLevelSwitch = useCallback((level: number) => {
if (mountedRef.current) {
setState(prev => ({ ...prev, hlsLevel: level }))
}
}, [])
const initHls = useCallback(() => {
if (!videoRef.current || fatalErrorTriggeredRef.current) return
// Limpiar instancia previa
destroyHls()
fatalErrorTriggeredRef.current = false
if (!HLS.isSupported()) {
// Fallback nativo para navegadores que soportan HLS nativamente
try {
const video = videoRef.current
video.src = movie.path
video.play().then(() => {
if (mountedRef.current) {
setState(prev => ({ ...prev, isPlaying: true, isLoading: false }))
}
}).catch((err) => {
console.error('Error en reproducción nativa:', err)
if (mountedRef.current) {
setState(prev => ({
...prev,
error: 'Tu navegador no soporta este formato de video.',
isLoading: false
}))
}
})
} catch (err) {
console.error('Error en reproductor nativo:', err)
if (mountedRef.current) {
setState(prev => ({
...prev,
error: 'Error en la configuración del reproductor nativo.',
isLoading: false
}))
}
}
return
}
try {
const hls = new HLS({
maxBufferLength: 30,
maxMaxBufferLength: 60,
backBufferLength: 90,
enableWorker: true,
debug: false
})
// Manejo de errores de red con reintento automático
hls.on(HLS.Events.ERROR, (event, data) => {
if (!mountedRef.current) return
console.error(`[HLS Error] ${data.type} - ${data.details}`, data)
if (data.fatal) {
switch (data.type) {
case HLS.ErrorTypes.MEDIA_ERROR:
// Intentar recuperación de error de medio
console.warn('[HLS] Media error detected, attempting recovery...')
hls.recoverMediaError()
break
case HLS.ErrorTypes.NETWORK_ERROR:
// Reintento con backoff exponencial para errores de red
console.warn('[HLS] Network error detected, scheduling retry...')
scheduleRetry()
break
case HLS.ErrorTypes.OTHER_ERROR:
// Error genérico - intentar recuperación
console.warn('[HLS] Other fatal error, attempting recovery...')
hls.startLoad()
break
default:
handleFatalError('Error crítico en el reproductor de video. Recarga la página para intentar de nuevo.')
}
} else {
// Errores no fatales: solo loggear, no interrumpir reproducción
console.warn(`[HLS] Non-fatal error: ${data.details}`)
}
})
// Manejo de cambio de calidad automático
hls.on(HLS.Events.LEVEL_SWITCHED, (event, data) => {
handleLevelSwitch(data.level)
})
// Manejo de manifesto parseado exitosamente
hls.on(HLS.Events.MANIFEST_PARSED, (event, data) => {
if (!mountedRef.current) return
console.log(`[HLS] Manifest parsed. ${data.levels.length} quality levels available.`)
setState(prev => ({ ...prev, isLoading: false, currentLevel: data.levels.length - 1 }))
videoRef.current!.play().then(() => {
if (mountedRef.current) {
setState(prev => ({ ...prev, isPlaying: true }))
}
}).catch((err) => {
console.error('Error al iniciar reproducción:', err)
if (mountedRef.current) {
setState(prev => ({
...prev,
error: 'Error al iniciar la reproducción del video.',
isLoading: false
}))
}
})
})
// Manejo de error de nivel (quality switch failure)
hls.on(HLS.Events.LEVEL_LOADED, (event, data) => {
if (!mountedRef.current) return
setState(prev => ({ ...prev, isLoading: false }))
})
hls.loadSource(movie.path)
hls.attachMedia(videoRef.current)
hlsRef.current = hls
setState(prev => ({ ...prev, isLoading: true, error: null }))
} catch (err) {
console.error('Error inicializando HLS.js:', err)
if (mountedRef.current) {
setState(prev => ({
...prev,
error: 'Error en la configuración del reproductor HLS.',
isLoading: false
}))
}
}
}, [movie.path, destroyHls, scheduleRetry, handleFatalError, handleLevelSwitch])
// Manejar errores del elemento de video nativo
const handleVideoError = useCallback(() => {
if (!mountedRef.current) return
const video = videoRef.current
if (video && video.error) {
const errorCodes: Record<number, string> = {
1: 'Error desconocido en el video.',
2: 'Error de red al cargar el video.',
3: 'Error de decodificación del video.',
4: 'Formato de video no soportado.'
}
const message = errorCodes[video.error.code] || `Error de video (código ${video.error.code}).`
console.error(`[Video Error] ${message}`)
setState(prev => ({
...prev,
error: message,
isLoading: false
}))
}
}, [])
const handlePlayPause = useCallback(() => {
if (!videoRef.current || !mountedRef.current) return
try {
if (state.isPlaying) {
videoRef.current.pause()
} else {
videoRef.current.play().catch((err) => {
console.error('Error al reproducir/pausar:', err)
setState(prev => ({
...prev,
error: 'Error al controlar la reproducción del video.'
}))
})
}
setState(prev => ({ ...prev, isPlaying: !prev.isPlaying }))
} catch (err) {
console.error('Error en handlePlayPause:', err)
}
}, [state.isPlaying])
const handleRetryClick = useCallback(() => {
fatalErrorTriggeredRef.current = false
setState(prev => ({
...prev,
error: null,
retryCount: 0,
isLoading: true
}))
initHls()
}, [initHls])
useEffect(() => {
mountedRef.current = true
initHls()
return () => {
mountedRef.current = false
destroyHls()
}
}, [movie.id, movie.path])
const isRetrying = state.retryCount > 0 && state.retryCount < MAX_RETRIES
return (
<div style={{ position: 'relative', width: '100%', paddingBottom: '56.25%' }}>
{state.error && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.9)',
color: '#fff',
fontSize: '16px',
padding: '20px',
zIndex: 10,
flexDirection: 'column',
gap: '12px'
}}>
<div style={{ textAlign: 'center', maxWidth: '500px' }}>
<p style={{ marginBottom: '8px', fontSize: '18px', fontWeight: 'bold' }}>
⚠️ {state.error}
</p>
{isRetrying && (
<p style={{ marginBottom: '12px', color: '#aaa', fontSize: '14px' }}>
Reintento {state.retryCount}/{MAX_RETRIES}...
</p>
)}
<button
onClick={handleRetryClick}
style={{
marginTop: '8px',
padding: '10px 20px',
backgroundColor: '#e50914',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
{isRetrying ? 'Reintentar' : 'Reintentar Reproducción'}
</button>
</div>
</div>
)}
{state.isLoading && !state.error && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.7)',
color: '#fff',
fontSize: '18px',
zIndex: 5
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '40px', marginBottom: '10px' }}>⏳</div>
<p>Cargando video...</p>
{isRetrying && <p style={{ fontSize: '14px', color: '#aaa', marginTop: '8px' }}>Reintento {state.retryCount}/{MAX_RETRIES}</p>}
</div>
</div>
)}
<video
ref={videoRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
backgroundColor: '#000'
}}
controls
onPlay={() => setState(prev => ({ ...prev, isPlaying: true }))}
onPause={() => setState(prev => ({ ...prev, isPlaying: false }))}
onError={handleVideoError}
onCanPlay={() => {
if (mountedRef.current) {
setState(prev => ({ ...prev, isLoading: false }))
}
}}
/>
{/* Indicador de nivel de calidad */}
{state.hlsLevel !== null && state.hlsLevel > 0 && (
<div style={{
position: 'absolute',
bottom: '40px',
right: '10px',
backgroundColor: 'rgba(0,0,0,0.7)',
color: '#fff',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
zIndex: 5
}}>
Calidad: {state.hlsLevel + 1}
</div>
)}
</div>
)
}
export default VideoPlayer