Initial commit: Video player with HLS support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-02 01:52:03 -06:00
commit 1743d472d2
29 changed files with 14074 additions and 0 deletions

996
components/VideoPlayer.vue Normal file
View File

@@ -0,0 +1,996 @@
<template>
<div class="video-player" @mousemove="showControls" @mouseleave="hideControls">
<video
ref="videoElement"
class="video-element"
preload="auto"
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@progress="onProgress"
@ended="onEnded"
@click="togglePlay"
>
Tu navegador no soporta el elemento de video.
</video>
<div class="controls-overlay" :class="{ visible: controlsVisible || !isPlaying }">
<div class="progress-container" @click="seek">
<div class="progress-bar">
<!-- Barra de buffer (lo que está cargado) -->
<div class="progress-buffered" :style="{ width: bufferedPercent + '%' }"></div>
<!-- Barra de progreso (lo que está reproduciéndose) -->
<div class="progress-filled" :style="{ width: progressPercent + '%' }">
<div class="progress-thumb"></div>
</div>
</div>
<div class="time-display">
<span>{{ formatTime(currentTime) }}</span>
<span class="buffered-info" v-if="bufferedPercent > progressPercent">
💾 {{ Math.round(bufferedPercent) }}% cacheado
</span>
<span>{{ formatTime(duration) }}</span>
</div>
</div>
<div class="controls-bottom">
<button @click="togglePlay" class="control-btn play-btn">
<span v-if="!isPlaying"></span>
<span v-else></span>
</button>
<div class="volume-control">
<button @click="toggleMute" class="control-btn">
<span v-if="!isMuted && volume > 0.5">🔊</span>
<span v-else-if="!isMuted && volume > 0">🔉</span>
<span v-else>🔇</span>
</button>
<input
type="range"
min="0"
max="100"
v-model="volumePercent"
@input="changeVolume"
class="volume-slider"
/>
</div>
<div class="spacer"></div>
<button @click="changeSpeed" class="control-btn speed-btn">
{{ playbackSpeed }}x
</button>
<!-- Botón de calidad -->
<div class="quality-selector" v-if="qualities && qualities.length > 1">
<button @click="toggleQualityMenu" class="control-btn quality-btn">
{{ currentQualityLabel }}
</button>
<div v-if="qualityMenuOpen" class="quality-menu">
<div
v-for="quality in qualities"
:key="quality.quality"
@click="changeQuality(quality)"
class="quality-option"
:class="{ active: currentQuality === quality.quality }"
>
<span class="quality-label">{{ quality.label }}</span>
<span v-if="currentQuality === quality.quality" class="quality-check"></span>
</div>
</div>
</div>
<button @click="toggleFullscreen" class="control-btn">
<span v-if="!isFullscreen"></span>
<span v-else></span>
</button>
</div>
</div>
<div v-if="!isPlaying && currentTime === 0" class="play-overlay" @click="togglePlay">
<div class="play-icon"></div>
</div>
<!-- Banner de calidad inicial -->
<div v-if="showQualityBanner && qualities && qualities.length > 1" class="quality-banner">
<div class="quality-banner-content">
<span class="quality-banner-icon"></span>
<span class="quality-banner-text">
Reproduciendo en <strong>{{ currentQualityLabel }}</strong>.
Puedes cambiar a mayor calidad usando el selector
</span>
<button @click="dismissQualityBanner" class="quality-banner-close"></button>
</div>
</div>
<!-- Indicador de cambio de calidad -->
<div v-if="qualityChanging" class="quality-indicator">
<div class="quality-indicator-content">
<div class="spinner"></div>
<span>Cambiando a {{ newQualityLabel }}...</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Hls from 'hls.js'
interface Quality {
quality: string
label: string
url: string
file: string
isHLS?: boolean
}
const props = defineProps<{
videoUrl: string
qualities?: Quality[]
}>()
const videoElement = ref<HTMLVideoElement | null>(null)
const isPlaying = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const volume = ref(1)
const isMuted = ref(false)
const volumePercent = ref(100)
const playbackSpeed = ref(1)
const isFullscreen = ref(false)
const controlsVisible = ref(false)
const qualityMenuOpen = ref(false)
const qualityChanging = ref(false)
const currentQuality = ref('480p') // Cambio: Por defecto 480p
const newQualityLabel = ref('')
const bufferedPercent = ref(0)
const showQualityBanner = ref(true) // Banner de calidad
let controlsTimeout: NodeJS.Timeout | null = null
let hls: Hls | null = null
const currentVideoUrl = ref(props.videoUrl)
const currentQualityLabel = computed(() => {
if (!props.qualities || props.qualities.length === 0) return '480P'
const quality = props.qualities.find(q => q.quality === currentQuality.value)
return quality?.label || '480P'
})
const progressPercent = computed(() => {
if (!duration.value) return 0
return (currentTime.value / duration.value) * 100
})
// Función para inicializar HLS
const initHLS = (url: string) => {
if (!videoElement.value) return
console.log('Inicializando HLS con URL:', url)
// Limpiar instancia anterior de HLS
if (hls) {
hls.destroy()
hls = null
}
// Si el navegador soporta HLS nativamente (Safari)
if (videoElement.value.canPlayType('application/vnd.apple.mpegurl')) {
console.log('Usando soporte nativo de HLS')
videoElement.value.src = url
videoElement.value.load()
return
}
// Si hls.js está soportado
if (Hls.isSupported()) {
console.log('Usando hls.js')
hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 90,
maxBufferLength: 30,
maxMaxBufferLength: 60
})
hls.loadSource(url)
hls.attachMedia(videoElement.value)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('HLS manifest cargado correctamente')
})
hls.on(Hls.Events.ERROR, (event, data) => {
console.error('Error en HLS:', data)
if (data.fatal) {
console.error('Error fatal en HLS:', data)
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.log('Error de red, intentando recuperar...')
hls?.startLoad()
break
case Hls.ErrorTypes.MEDIA_ERROR:
console.log('Error de media, intentando recuperar...')
hls?.recoverMediaError()
break
default:
console.log('Error irrecuperable')
hls?.destroy()
break
}
}
})
} else {
console.error('HLS no soportado en este navegador')
}
}
// Inicializar con calidad 480p por defecto
watch(() => props.videoUrl, (newUrl) => {
if (!newUrl) return
// Buscar 480p si está disponible, sino usar la URL original
let urlToUse = newUrl
if (props.qualities && props.qualities.length > 0) {
// Buscar calidad 480p
const quality480p = props.qualities.find(q => q.quality === '480p')
if (quality480p) {
urlToUse = quality480p.url
currentQuality.value = '480p'
} else {
// Si no hay 480p, usar la primera calidad disponible
const firstQuality = props.qualities[0]
urlToUse = firstQuality.url
currentQuality.value = firstQuality.quality
}
}
currentVideoUrl.value = urlToUse
// Esperar a que el elemento de video esté disponible
nextTick(() => {
if (!videoElement.value) return
// Si es HLS (termina en .m3u8), usar HLS.js
if (urlToUse.endsWith('.m3u8')) {
initHLS(urlToUse)
} else {
// Video normal
if (hls) {
hls.destroy()
hls = null
}
videoElement.value.src = urlToUse
videoElement.value.load()
}
})
}, { immediate: true })
const togglePlay = () => {
if (!videoElement.value) return
if (isPlaying.value) {
videoElement.value.pause()
isPlaying.value = false
} else {
videoElement.value.play()
isPlaying.value = true
}
}
const onLoadedMetadata = () => {
if (!videoElement.value) return
duration.value = videoElement.value.duration
}
const onTimeUpdate = () => {
if (!videoElement.value) return
currentTime.value = videoElement.value.currentTime
}
const onProgress = () => {
if (!videoElement.value || !duration.value) return
// Calcular cuánto del video está cargado en buffer
const buffered = videoElement.value.buffered
if (buffered.length > 0) {
// Obtener el rango de buffer más grande que contiene la posición actual
let bufferedEnd = 0
for (let i = 0; i < buffered.length; i++) {
const start = buffered.start(i)
const end = buffered.end(i)
if (start <= currentTime.value && end > bufferedEnd) {
bufferedEnd = end
}
}
bufferedPercent.value = (bufferedEnd / duration.value) * 100
}
}
const onEnded = () => {
isPlaying.value = false
currentTime.value = 0
}
const seek = (e: MouseEvent) => {
if (!videoElement.value) return
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
videoElement.value.currentTime = percent * duration.value
}
const toggleMute = () => {
if (!videoElement.value) return
isMuted.value = !isMuted.value
videoElement.value.muted = isMuted.value
}
const changeVolume = () => {
if (!videoElement.value) return
volume.value = volumePercent.value / 100
videoElement.value.volume = volume.value
if (volume.value > 0) {
isMuted.value = false
videoElement.value.muted = false
}
}
const changeSpeed = () => {
if (!videoElement.value) return
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2]
const currentIndex = speeds.indexOf(playbackSpeed.value)
const nextIndex = (currentIndex + 1) % speeds.length
playbackSpeed.value = speeds[nextIndex]
videoElement.value.playbackRate = playbackSpeed.value
}
const toggleQualityMenu = () => {
qualityMenuOpen.value = !qualityMenuOpen.value
}
const changeQuality = async (quality: Quality) => {
if (!videoElement.value || currentQuality.value === quality.quality) {
qualityMenuOpen.value = false
return
}
// Guardar el estado actual
const wasPlaying = isPlaying.value
const savedTime = currentTime.value
// Mostrar indicador
qualityChanging.value = true
newQualityLabel.value = quality.label
qualityMenuOpen.value = false
// Cambiar la calidad
currentQuality.value = quality.quality
currentVideoUrl.value = quality.url
// Si es HLS, usar initHLS
if (quality.url.endsWith('.m3u8')) {
initHLS(quality.url)
} else {
// Video normal
if (hls) {
hls.destroy()
hls = null
}
videoElement.value.src = quality.url
}
// Esperar a que el video cargue
await nextTick()
if (videoElement.value) {
// Restaurar el tiempo
videoElement.value.currentTime = savedTime
// Restaurar la reproducción si estaba reproduciéndose
if (wasPlaying) {
try {
await videoElement.value.play()
isPlaying.value = true
} catch (error) {
console.error('Error al reanudar reproducción:', error)
}
}
}
// Ocultar indicador después de un momento
setTimeout(() => {
qualityChanging.value = false
}, 1000)
}
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
videoElement.value?.parentElement?.requestFullscreen()
isFullscreen.value = true
} else {
document.exitFullscreen()
isFullscreen.value = false
}
}
const dismissQualityBanner = () => {
showQualityBanner.value = false
}
// Auto-ocultar banner después de 10 segundos
onMounted(() => {
setTimeout(() => {
showQualityBanner.value = false
}, 10000)
})
// Ocultar banner al cambiar calidad
watch(currentQuality, () => {
showQualityBanner.value = false
})
const formatTime = (seconds: number) => {
if (isNaN(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
const showControls = () => {
controlsVisible.value = true
if (controlsTimeout) clearTimeout(controlsTimeout)
controlsTimeout = setTimeout(() => {
if (isPlaying.value) {
controlsVisible.value = false
}
}, 3000)
}
const hideControls = () => {
if (controlsTimeout) clearTimeout(controlsTimeout)
if (isPlaying.value) {
controlsVisible.value = false
}
}
onUnmounted(() => {
if (controlsTimeout) clearTimeout(controlsTimeout)
if (hls) {
hls.destroy()
hls = null
}
})
</script>
<style scoped>
.video-player {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 20px;
overflow: hidden;
box-shadow:
0 0 40px rgba(0, 255, 255, 0.3),
0 0 80px rgba(255, 0, 255, 0.2),
inset 0 0 60px rgba(0, 0, 0, 0.5);
}
.video-element {
width: 100%;
height: 100%;
object-fit: contain;
cursor: pointer;
}
.controls-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 20px;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.9) 0%,
rgba(0, 0, 0, 0.7) 50%,
transparent 100%
);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.controls-overlay.visible {
opacity: 1;
pointer-events: all;
}
.progress-container {
margin-bottom: 15px;
cursor: pointer;
}
.progress-bar {
height: 6px;
background: rgba(255, 255, 255, 0.2);
border-radius: 10px;
overflow: hidden;
position: relative;
backdrop-filter: blur(10px);
}
.progress-buffered {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: rgba(0, 255, 255, 0.3);
border-radius: 10px;
transition: width 0.3s ease;
z-index: 1;
}
.progress-filled {
position: relative;
height: 100%;
background: linear-gradient(90deg, #00ffff, #ff00ff, #00ffff);
background-size: 200% 100%;
animation: shimmer 3s linear infinite;
border-radius: 10px;
transition: width 0.1s linear;
z-index: 2;
}
@keyframes shimmer {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
.progress-thumb {
position: absolute;
right: -6px;
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 10px rgba(0, 255, 255, 0.8);
}
.time-display {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
font-size: 12px;
color: #fff;
font-family: monospace;
text-shadow: 0 0 10px rgba(0, 255, 255, 0.5);
gap: 10px;
}
.buffered-info {
font-size: 11px;
color: rgba(0, 255, 255, 0.8);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
animation: pulse-text 2s ease-in-out infinite;
}
@keyframes pulse-text {
0%, 100% {
opacity: 0.8;
}
50% {
opacity: 1;
}
}
.controls-bottom {
display: flex;
align-items: center;
gap: 15px;
}
.control-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
padding: 10px 15px;
border-radius: 10px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.control-btn:hover {
background: rgba(0, 255, 255, 0.3);
border-color: rgba(0, 255, 255, 0.5);
box-shadow: 0 0 20px rgba(0, 255, 255, 0.5);
transform: translateY(-2px);
}
.play-btn {
font-size: 20px;
}
.volume-control {
display: flex;
align-items: center;
gap: 10px;
}
.volume-slider {
width: 80px;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.2);
border-radius: 10px;
outline: none;
cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 10px rgba(255, 0, 255, 0.8);
}
.volume-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 0 10px rgba(255, 0, 255, 0.8);
}
.spacer {
flex: 1;
}
.speed-btn {
min-width: 60px;
font-family: monospace;
}
/* Quality Selector */
.quality-selector {
position: relative;
}
.quality-btn {
min-width: 70px;
font-family: monospace;
font-weight: 600;
}
.quality-menu {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 10px;
background: rgba(0, 0, 0, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(0, 255, 255, 0.3);
border-radius: 12px;
padding: 8px;
min-width: 140px;
box-shadow: 0 0 30px rgba(0, 255, 255, 0.4);
animation: slideUp 0.2s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.quality-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
color: #fff;
font-size: 14px;
}
.quality-option:hover {
background: rgba(0, 255, 255, 0.2);
}
.quality-option.active {
background: rgba(0, 255, 255, 0.3);
border: 1px solid rgba(0, 255, 255, 0.5);
}
.quality-label {
font-weight: 600;
text-transform: uppercase;
}
.quality-check {
color: #00ffff;
font-size: 16px;
margin-left: 10px;
}
/* Quality Indicator */
.quality-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(20px);
padding: 20px 30px;
border-radius: 16px;
border: 1px solid rgba(0, 255, 255, 0.5);
box-shadow: 0 0 40px rgba(0, 255, 255, 0.6);
z-index: 100;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.quality-indicator-content {
display: flex;
align-items: center;
gap: 15px;
color: #fff;
font-size: 16px;
font-weight: 600;
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #00ffff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Banner de calidad inicial */
.quality-banner {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
animation: slideDown 0.5s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translate(-50%, -20px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
.quality-banner-content {
display: flex;
align-items: center;
gap: 12px;
background: rgba(0, 150, 255, 0.95);
backdrop-filter: blur(20px);
padding: 15px 25px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 20px rgba(0, 150, 255, 0.4);
max-width: 90vw;
}
.quality-banner-icon {
font-size: 20px;
flex-shrink: 0;
}
.quality-banner-text {
color: #fff;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
}
.quality-banner-text strong {
font-weight: 700;
color: #ffff00;
}
.quality-banner-close {
background: rgba(255, 255, 255, 0.2);
border: none;
color: #fff;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
flex-shrink: 0;
}
.quality-banner-close:hover {
background: rgba(255, 255, 255, 0.4);
transform: scale(1.1);
}
.play-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(5px);
}
.play-icon {
font-size: 80px;
color: #fff;
background: rgba(0, 255, 255, 0.2);
width: 120px;
height: 120px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 3px solid rgba(0, 255, 255, 0.5);
box-shadow:
0 0 40px rgba(0, 255, 255, 0.6),
inset 0 0 40px rgba(0, 255, 255, 0.2);
transition: all 0.3s ease;
animation: pulse 2s ease-in-out infinite;
}
.play-overlay:hover .play-icon {
transform: scale(1.1);
box-shadow:
0 0 60px rgba(0, 255, 255, 0.8),
inset 0 0 40px rgba(0, 255, 255, 0.3);
}
@keyframes pulse {
0%, 100% {
box-shadow:
0 0 40px rgba(0, 255, 255, 0.6),
inset 0 0 40px rgba(0, 255, 255, 0.2);
}
50% {
box-shadow:
0 0 60px rgba(0, 255, 255, 0.8),
inset 0 0 60px rgba(0, 255, 255, 0.3);
}
}
@media (max-width: 768px) {
/* Hacer los controles siempre visibles en móvil */
.controls-overlay {
opacity: 1;
pointer-events: all;
}
/* Reducir padding de controles */
.controls-overlay {
padding: 10px;
}
/* Ajustar espaciado de botones */
.controls-bottom {
gap: 8px;
}
.volume-slider {
width: 40px;
}
.control-btn {
padding: 6px 10px;
font-size: 12px;
min-width: auto;
}
.play-btn {
font-size: 16px;
padding: 8px 12px;
}
.speed-btn {
min-width: 50px;
font-size: 11px;
}
.quality-btn {
min-width: 55px;
font-size: 11px;
}
/* Ajustar display del tiempo */
.time-display {
font-size: 10px;
flex-wrap: wrap;
gap: 5px;
}
.buffered-info {
font-size: 9px;
}
/* Barra de progreso más visible */
.progress-bar {
height: 8px;
}
.progress-thumb {
width: 16px;
height: 16px;
right: -8px;
}
.play-icon {
width: 80px;
height: 80px;
font-size: 50px;
}
.quality-menu {
right: auto;
left: 50%;
transform: translateX(-50%);
}
.quality-banner-content {
padding: 12px 18px;
gap: 10px;
}
.quality-banner-text {
font-size: 12px;
}
.quality-banner-icon {
font-size: 16px;
}
/* Ocultar control de volumen en móvil para ahorrar espacio */
.volume-control {
display: none;
}
}
</style>