Ctrl K
ComunidadGuías…
frontend/src/components/VideoPlayer.tsxtypescript · @kibot
20/9/2026, 17:27:13
import 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

Comentarios de código