🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
997 lines
22 KiB
Vue
997 lines
22 KiB
Vue
<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>
|