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:
473
components/SettingsModal.vue
Normal file
473
components/SettingsModal.vue
Normal file
@@ -0,0 +1,473 @@
|
||||
<template>
|
||||
<div v-if="isOpen" class="settings-overlay" @click.self="close">
|
||||
<div class="settings-modal">
|
||||
<div class="settings-header">
|
||||
<h2>⚙️ Configuración</h2>
|
||||
<button @click="close" class="close-btn">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<div v-if="!isAuthenticated" class="auth-section">
|
||||
<p class="auth-label">Ingresa la contraseña para acceder a la configuración</p>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="Contraseña"
|
||||
class="password-input"
|
||||
@keyup.enter="authenticate"
|
||||
/>
|
||||
<p v-if="authError" class="error-message">{{ authError }}</p>
|
||||
<button @click="authenticate" class="auth-btn">Ingresar</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="config-section">
|
||||
<div class="config-item">
|
||||
<div class="config-info">
|
||||
<h3>🚀 Inicio Automático</h3>
|
||||
<p>El servidor se iniciará automáticamente al encender Windows</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="autostartEnabled"
|
||||
@change="toggleAutostart"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="instructions">
|
||||
<h4>📝 Instrucciones de instalación:</h4>
|
||||
<ol>
|
||||
<li>Ejecuta <code>install-autostart.bat</code> desde la carpeta del proyecto</li>
|
||||
<li>Esto copiará el script de autostart al Startup de Windows</li>
|
||||
<li>Usa este toggle para habilitar/deshabilitar el autostart</li>
|
||||
<li>Para desinstalar completamente, ejecuta <code>uninstall-autostart.bat</code></li>
|
||||
</ol>
|
||||
|
||||
<h4 style="margin-top: 20px;">🎮 Scripts disponibles:</h4>
|
||||
<ul>
|
||||
<li><code>quantum-start.bat</code> - Iniciar el servidor</li>
|
||||
<li><code>quantum-stop.bat</code> - Detener el servidor</li>
|
||||
<li><code>install-autostart.bat</code> - Instalar autostart</li>
|
||||
<li><code>uninstall-autostart.bat</code> - Desinstalar autostart</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p v-if="statusMessage" class="status-message" :class="{ error: isError }">
|
||||
{{ statusMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const password = ref('')
|
||||
const isAuthenticated = ref(false)
|
||||
const authError = ref('')
|
||||
const autostartEnabled = ref(true)
|
||||
const loading = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const isError = ref(false)
|
||||
|
||||
const authenticate = async () => {
|
||||
try {
|
||||
const response = await $fetch('/api/config', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
password: password.value,
|
||||
action: 'get-status'
|
||||
}
|
||||
})
|
||||
|
||||
isAuthenticated.value = true
|
||||
authError.value = ''
|
||||
autostartEnabled.value = response.autostart
|
||||
} catch (error: any) {
|
||||
authError.value = error.data?.message || 'Contraseña incorrecta'
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAutostart = async () => {
|
||||
loading.value = true
|
||||
statusMessage.value = ''
|
||||
isError.value = false
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/config', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
password: password.value,
|
||||
action: 'toggle-autostart'
|
||||
}
|
||||
})
|
||||
|
||||
autostartEnabled.value = response.autostart
|
||||
statusMessage.value = response.autostart
|
||||
? '✅ Autostart habilitado'
|
||||
: '❌ Autostart deshabilitado'
|
||||
|
||||
setTimeout(() => {
|
||||
statusMessage.value = ''
|
||||
}, 3000)
|
||||
} catch (error: any) {
|
||||
isError.value = true
|
||||
statusMessage.value = error.data?.message || 'Error al actualizar configuración'
|
||||
// Revertir el toggle
|
||||
autostartEnabled.value = !autostartEnabled.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
// Reset after animation
|
||||
setTimeout(() => {
|
||||
isAuthenticated.value = false
|
||||
password.value = ''
|
||||
authError.value = ''
|
||||
statusMessage.value = ''
|
||||
}, 300)
|
||||
}
|
||||
|
||||
watch(() => props.isOpen, (newVal) => {
|
||||
if (newVal && isAuthenticated.value) {
|
||||
// Refresh status cuando se abre
|
||||
authenticate()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.settings-modal {
|
||||
background: linear-gradient(135deg, rgba(10, 10, 15, 0.95), rgba(20, 20, 30, 0.95));
|
||||
border: 1px solid rgba(0, 255, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 60px rgba(0, 255, 255, 0.3);
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(50px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 25px;
|
||||
border-bottom: 1px solid rgba(0, 255, 255, 0.2);
|
||||
background: linear-gradient(135deg, rgba(0, 255, 255, 0.1), rgba(255, 0, 255, 0.1));
|
||||
}
|
||||
|
||||
.settings-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
background: linear-gradient(135deg, #00ffff, #ff00ff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(255, 0, 0, 0.3);
|
||||
border-color: rgba(255, 0, 0, 0.5);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: 25px;
|
||||
overflow-y: auto;
|
||||
max-height: calc(80vh - 100px);
|
||||
}
|
||||
|
||||
.auth-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.auth-label {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.password-input {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
padding: 12px 20px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(0, 255, 255, 0.3);
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.password-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(0, 255, 255, 0.6);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.auth-btn {
|
||||
padding: 12px 40px;
|
||||
background: linear-gradient(135deg, rgba(0, 255, 255, 0.2), rgba(255, 0, 255, 0.2));
|
||||
border: 1px solid rgba(0, 255, 255, 0.5);
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.auth-btn:hover {
|
||||
background: linear-gradient(135deg, rgba(0, 255, 255, 0.3), rgba(255, 0, 255, 0.3));
|
||||
box-shadow: 0 0 30px rgba(0, 255, 255, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff4444;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(0, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.config-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(0, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.config-info h3 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.config-info p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
transition: 0.4s;
|
||||
border-radius: 30px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
left: 4px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.4s;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider {
|
||||
background: linear-gradient(135deg, #00ffff, #00ff88);
|
||||
border-color: #00ffff;
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider:before {
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
input:disabled + .toggle-slider {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
padding: 20px;
|
||||
background: rgba(0, 255, 255, 0.05);
|
||||
border: 1px solid rgba(0, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.instructions h4 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 16px;
|
||||
color: #00ffff;
|
||||
}
|
||||
|
||||
.instructions ol,
|
||||
.instructions ul {
|
||||
margin: 10px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.instructions li {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.instructions code {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: #00ffff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
padding: 12px;
|
||||
background: rgba(0, 255, 0, 0.1);
|
||||
border: 1px solid rgba(0, 255, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
color: #00ff88;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-message.error {
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
border-color: rgba(255, 0, 0, 0.3);
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-modal {
|
||||
width: 95%;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.settings-header h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.instructions code {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
996
components/VideoPlayer.vue
Normal file
996
components/VideoPlayer.vue
Normal 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>
|
||||
Reference in New Issue
Block a user