Files
videoPlayer/components/VideoPlayer.vue
2025-10-02 01:52:03 -06:00

997 lines
22 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>