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:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Nuxt
|
||||
.nuxt
|
||||
.output
|
||||
.env
|
||||
dist
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Videos (archivos grandes - no subir)
|
||||
videos/*.mp4
|
||||
videos/*.mov
|
||||
videos/*_hls/
|
||||
|
||||
# Mantener estructura
|
||||
!videos/.gitkeep
|
||||
|
||||
# Logs
|
||||
/tmp/*.log
|
||||
|
||||
# Cloudflare credentials (NUNCA subir)
|
||||
.cloudflared/*.json
|
||||
.cloudflared/cert.pem
|
||||
cloudflared
|
||||
|
||||
# Config
|
||||
.autostart-config
|
||||
1370
DOCUMENTATION.md
Normal file
1370
DOCUMENTATION.md
Normal file
File diff suppressed because it is too large
Load Diff
162
README-WINDOWS.md
Normal file
162
README-WINDOWS.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Quantum Player - Scripts de Windows
|
||||
|
||||
## 🚀 Scripts Disponibles
|
||||
|
||||
### **quantum-start.bat**
|
||||
Inicia el servidor Quantum Player desde cualquier ubicación en Windows.
|
||||
```bash
|
||||
# Doble click en el archivo o ejecutar desde CMD/PowerShell
|
||||
quantum-start.bat
|
||||
```
|
||||
|
||||
### **quantum-stop.bat**
|
||||
Detiene el servidor Quantum Player.
|
||||
```bash
|
||||
quantum-stop.bat
|
||||
```
|
||||
|
||||
### **install-autostart.bat**
|
||||
Instala el inicio automático de Quantum Player al encender Windows.
|
||||
```bash
|
||||
# Ejecutar como administrador (click derecho -> Ejecutar como administrador)
|
||||
install-autostart.bat
|
||||
```
|
||||
Esto copiará `quantum-autostart.bat` a la carpeta de Inicio de Windows.
|
||||
|
||||
### **uninstall-autostart.bat**
|
||||
Desinstala el inicio automático.
|
||||
```bash
|
||||
uninstall-autostart.bat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Panel de Configuración Web
|
||||
|
||||
### Acceder al Panel
|
||||
1. Abre el navegador en https://video.nucleoriofrio.com
|
||||
2. Haz click en el botón ⚙️ en el header (esquina superior derecha)
|
||||
3. Ingresa la contraseña: **431522**
|
||||
|
||||
### Opciones Disponibles
|
||||
- **Toggle de Autostart**: Habilita/deshabilita el inicio automático
|
||||
- Visualiza el estado actual del autostart
|
||||
- Instrucciones de instalación
|
||||
|
||||
---
|
||||
|
||||
## 📁 Estructura de Archivos
|
||||
|
||||
```
|
||||
videoPlayer/
|
||||
├── quantum-start.bat # Inicia el servidor
|
||||
├── quantum-stop.bat # Detiene el servidor
|
||||
├── quantum-autostart.bat # Script de autostart (usado por Windows)
|
||||
├── install-autostart.bat # Instala autostart
|
||||
├── uninstall-autostart.bat # Desinstala autostart
|
||||
├── autostart.sh # Script bash para autostart
|
||||
├── .autostart-config # Configuración (enabled/disabled)
|
||||
└── videos/ # Carpeta con los videos
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Cómo Funciona el Autostart
|
||||
|
||||
1. **Instalación Manual (Recomendado)**:
|
||||
- Ejecuta `install-autostart.bat` como administrador
|
||||
- Esto copia el script a `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`
|
||||
- El servidor se iniciará automáticamente al encender Windows
|
||||
|
||||
2. **Control desde la Web**:
|
||||
- El toggle en el panel web controla el archivo `.autostart-config`
|
||||
- Valores: `enabled` o `disabled`
|
||||
- Cuando está `disabled`, el script detecta esto y no inicia el servidor
|
||||
- Cuando está `enabled`, el servidor inicia normalmente
|
||||
|
||||
3. **Desinstalación**:
|
||||
- Ejecuta `uninstall-autostart.bat` para remover de Startup
|
||||
- O elimina manualmente el archivo desde:
|
||||
- `C:\Users\TU_USUARIO\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\quantum-autostart.bat`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Casos de Uso
|
||||
|
||||
### Inicio Manual
|
||||
```bash
|
||||
# Desde Windows Explorer
|
||||
Doble click en quantum-start.bat
|
||||
|
||||
# Desde PowerShell/CMD
|
||||
cd C:\path\to\videoPlayer
|
||||
.\quantum-start.bat
|
||||
```
|
||||
|
||||
### Inicio Automático
|
||||
```bash
|
||||
# 1. Instalar (solo una vez)
|
||||
install-autostart.bat
|
||||
|
||||
# 2. Reiniciar Windows
|
||||
# El servidor se iniciará automáticamente
|
||||
|
||||
# 3. Para deshabilitar temporalmente:
|
||||
# Ir al panel web -> ⚙️ -> Ingresar contraseña -> Toggle OFF
|
||||
```
|
||||
|
||||
### Detener el Servidor
|
||||
```bash
|
||||
quantum-stop.bat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Seguridad
|
||||
|
||||
- **Contraseña del Panel**: 431522
|
||||
- **Cambiar Contraseña**:
|
||||
- Editar `/server/api/config.post.ts`
|
||||
- Buscar `const CORRECT_PASSWORD = '431522'`
|
||||
- Cambiar por tu contraseña
|
||||
- Ejecutar `npm run build`
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### El servidor no inicia
|
||||
```bash
|
||||
# Verificar si WSL está corriendo
|
||||
wsl --list --running
|
||||
|
||||
# Verificar logs
|
||||
wsl -d Ubuntu -e bash -c "cat /tmp/video-player.log"
|
||||
```
|
||||
|
||||
### Autostart no funciona
|
||||
```bash
|
||||
# Verificar que el archivo existe en Startup
|
||||
dir "%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup"
|
||||
|
||||
# Verificar configuración
|
||||
wsl -d Ubuntu -e bash -c "cat /home/draganel/repos/videoPlayer/.autostart-config"
|
||||
```
|
||||
|
||||
### Puerto 3000 ya en uso
|
||||
```bash
|
||||
# Detener todos los procesos
|
||||
quantum-stop.bat
|
||||
|
||||
# Reiniciar
|
||||
quantum-start.bat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 Soporte
|
||||
|
||||
Para más información:
|
||||
- URL Pública: https://video.nucleoriofrio.com
|
||||
- URL Local: http://localhost:3000
|
||||
- Logs: `/tmp/video-player.log` (en WSL)
|
||||
35
README.md
Normal file
35
README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 🎬 Quantum Player - Video Streaming Platform
|
||||
|
||||
## 📖 Tabla de Contenidos
|
||||
|
||||
1. [Historia del Proyecto](#-historia-del-proyecto)
|
||||
2. [Arquitectura del Sistema](#-arquitectura-del-sistema)
|
||||
3. [Stack Tecnológico](#-stack-tecnológico)
|
||||
4. [Funcionamiento Detallado](#-funcionamiento-detallado)
|
||||
5. [HLS Streaming](#-hls-streaming)
|
||||
6. [Sistema de Autostart](#-sistema-de-autostart)
|
||||
7. [Deployment y Networking](#-deployment-y-networking)
|
||||
8. [Problemas y Soluciones](#-problemas-y-soluciones)
|
||||
9. [Guía de Uso](#-guía-de-uso)
|
||||
10. [Referencia Técnica](#-referencia-técnica)
|
||||
|
||||
---
|
||||
|
||||
# 📜 Historia del Proyecto
|
||||
|
||||
## Fase 1: Inicio del Proyecto (Día 1)
|
||||
|
||||
### Contexto Inicial
|
||||
El proyecto comenzó con una necesidad simple: crear un reproductor de video web moderno que pudiera servir archivos de video desde una carpeta local y permitir la descarga en diferentes calidades.
|
||||
|
||||
### Requerimientos Iniciales
|
||||
1. **Framework**: Nuxt 4 (v3.19.2) con Vue 3
|
||||
2. **Estilo**: HTML y CSS vanilla (sin frameworks CSS)
|
||||
3. **Diseño**: Cyberpunk/Glassmorphism
|
||||
4. **Funcionalidades**:
|
||||
- Reproducir videos desde carpeta `/videos`
|
||||
- Selector de calidades (4K, 2K, 1080p, 720p, 480p)
|
||||
- Opciones de descarga
|
||||
- SEO con meta tags
|
||||
- Caching en RAM para reproducción rápida
|
||||
|
||||
79
app.vue
Normal file
79
app.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
useHead({
|
||||
title: 'Quantum Player - Video Player Futurista',
|
||||
meta: [
|
||||
{ name: 'description', content: 'Reproductor de video moderno con diseño cyberpunk. Reproduce tus videos con controles personalizados y múltiples calidades.' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
|
||||
// Open Graph / Facebook
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:url', content: 'https://video.nucleoriofrio.com/' },
|
||||
{ property: 'og:title', content: 'Quantum Player - Video Player Futurista' },
|
||||
{ property: 'og:description', content: 'Reproductor de video moderno con diseño cyberpunk y controles personalizados' },
|
||||
{ property: 'og:image', content: 'https://video.nucleoriofrio.com/og-image.png' },
|
||||
|
||||
// Twitter
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
{ name: 'twitter:url', content: 'https://video.nucleoriofrio.com/' },
|
||||
{ name: 'twitter:title', content: 'Quantum Player - Video Player Futurista' },
|
||||
{ name: 'twitter:description', content: 'Reproductor de video moderno con diseño cyberpunk' },
|
||||
{ name: 'twitter:image', content: 'https://video.nucleoriofrio.com/og-image.png' },
|
||||
|
||||
// Theme
|
||||
{ name: 'theme-color', content: '#00ffff' },
|
||||
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #0a0a0f;
|
||||
color: #fff;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(0, 255, 255, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #00ffff, #ff00ff);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, #00ffff, #00ff88);
|
||||
}
|
||||
</style>
|
||||
19
autostart.sh
Executable file
19
autostart.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script de inicio automático para Quantum Player
|
||||
CONFIG_FILE="/home/draganel/repos/videoPlayer/.autostart-config"
|
||||
|
||||
# Verificar si el autostart está habilitado
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
AUTOSTART_ENABLED=$(cat "$CONFIG_FILE")
|
||||
|
||||
if [ "$AUTOSTART_ENABLED" = "enabled" ]; then
|
||||
cd /home/draganel/repos/videoPlayer
|
||||
|
||||
# Verificar si ya está corriendo
|
||||
if ! pgrep -f "node .output/server/index.mjs" > /dev/null; then
|
||||
echo "🚀 Iniciando Quantum Player automáticamente..."
|
||||
./start.sh
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
BIN
cloudflared.deb
Normal file
BIN
cloudflared.deb
Normal file
Binary file not shown.
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>
|
||||
127
convert-to-hls.sh
Executable file
127
convert-to-hls.sh
Executable file
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script para convertir videos a HLS (HTTP Live Streaming)
|
||||
# HLS divide el video en segmentos pequeños, permitiendo streaming sin límites de tamaño
|
||||
|
||||
set -e
|
||||
|
||||
VIDEOS_DIR="/home/draganel/repos/videoPlayer/videos"
|
||||
cd "$VIDEOS_DIR"
|
||||
|
||||
echo "🎬 Convirtiendo videos a formato HLS (HTTP Live Streaming)..."
|
||||
echo "📁 Directorio: $VIDEOS_DIR"
|
||||
echo ""
|
||||
|
||||
# Función para obtener la resolución del video
|
||||
get_resolution() {
|
||||
local file="$1"
|
||||
ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "$file"
|
||||
}
|
||||
|
||||
# Función para generar HLS con múltiples calidades
|
||||
convert_to_hls() {
|
||||
local input="$1"
|
||||
local base_name="$2"
|
||||
|
||||
# Crear directorio para este video
|
||||
local output_dir="${base_name}_hls"
|
||||
|
||||
if [ -d "$output_dir" ]; then
|
||||
echo " ⏭️ HLS ya existe para $base_name, saltando..."
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
|
||||
echo " 🔄 Generando HLS para $base_name..."
|
||||
|
||||
# Obtener resolución original
|
||||
local current_height=$(get_resolution "$input")
|
||||
|
||||
# Construir comando ffmpeg con múltiples calidades
|
||||
local cmd="ffmpeg -i \"$input\" -hide_banner -loglevel error -stats"
|
||||
|
||||
# Variable para master playlist
|
||||
local variants=""
|
||||
|
||||
# 4K (2160p) - 8 Mbps
|
||||
if [ "$current_height" -ge 2160 ]; then
|
||||
cmd="$cmd -vf scale=-2:2160 -c:v libx264 -b:v 8M -maxrate 8.5M -bufsize 16M -c:a aac -b:a 192k -f hls -hls_time 6 -hls_playlist_type vod -hls_segment_filename \"$output_dir/4k_%03d.ts\" \"$output_dir/4k.m3u8\""
|
||||
variants="$variants\n#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=3840x2160\n4k.m3u8"
|
||||
fi
|
||||
|
||||
# 2K (1440p) - 5 Mbps
|
||||
if [ "$current_height" -ge 1440 ]; then
|
||||
cmd="$cmd -vf scale=-2:1440 -c:v libx264 -b:v 5M -maxrate 5.5M -bufsize 10M -c:a aac -b:a 192k -f hls -hls_time 6 -hls_playlist_type vod -hls_segment_filename \"$output_dir/2k_%03d.ts\" \"$output_dir/2k.m3u8\""
|
||||
variants="$variants\n#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=2560x1440\n2k.m3u8"
|
||||
fi
|
||||
|
||||
# 1080p - 3 Mbps
|
||||
if [ "$current_height" -ge 1080 ]; then
|
||||
cmd="$cmd -vf scale=-2:1080 -c:v libx264 -b:v 3M -maxrate 3.5M -bufsize 6M -c:a aac -b:a 192k -f hls -hls_time 6 -hls_playlist_type vod -hls_segment_filename \"$output_dir/1080p_%03d.ts\" \"$output_dir/1080p.m3u8\""
|
||||
variants="$variants\n#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1920x1080\n1080p.m3u8"
|
||||
fi
|
||||
|
||||
# 720p - 1.5 Mbps
|
||||
if [ "$current_height" -ge 720 ]; then
|
||||
cmd="$cmd -vf scale=-2:720 -c:v libx264 -b:v 1500k -maxrate 1650k -bufsize 3M -c:a aac -b:a 128k -f hls -hls_time 6 -hls_playlist_type vod -hls_segment_filename \"$output_dir/720p_%03d.ts\" \"$output_dir/720p.m3u8\""
|
||||
variants="$variants\n#EXT-X-STREAM-INF:BANDWIDTH=1500000,RESOLUTION=1280x720\n720p.m3u8"
|
||||
fi
|
||||
|
||||
# 480p - 800 Kbps (siempre se genera)
|
||||
cmd="$cmd -vf scale=-2:480 -c:v libx264 -b:v 800k -maxrate 900k -bufsize 1600k -c:a aac -b:a 128k -f hls -hls_time 6 -hls_playlist_type vod -hls_segment_filename \"$output_dir/480p_%03d.ts\" \"$output_dir/480p.m3u8\""
|
||||
variants="$variants\n#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=854x480\n480p.m3u8"
|
||||
|
||||
# Ejecutar conversión
|
||||
eval $cmd
|
||||
|
||||
# Crear master playlist
|
||||
echo "#EXTM3U" > "$output_dir/master.m3u8"
|
||||
echo "#EXT-X-VERSION:3" >> "$output_dir/master.m3u8"
|
||||
echo -e "$variants" >> "$output_dir/master.m3u8"
|
||||
|
||||
echo " ✅ HLS generado para $base_name"
|
||||
echo " 📂 Directorio: $output_dir/"
|
||||
echo " 🎯 Master playlist: $output_dir/master.m3u8"
|
||||
}
|
||||
|
||||
# Procesar cada archivo de video
|
||||
shopt -s nullglob
|
||||
for video in *.mp4 *.MP4 *.mkv *.MKV; do
|
||||
# Verificar que el archivo existe
|
||||
[ -f "$video" ] || continue
|
||||
|
||||
# Saltar archivos que ya tienen sufijo de calidad o HLS
|
||||
if [[ "$video" =~ _(4k|2k|1080p|720p|480p|360p)\.(mp4|MP4|mkv|MKV)$ ]] || [[ "$video" =~ _hls ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "📹 Procesando: $video"
|
||||
|
||||
# Extraer nombre base
|
||||
filename=$(basename -- "$video")
|
||||
extension="${filename##*.}"
|
||||
base_name="${filename%.*}"
|
||||
|
||||
# Convertir a HLS
|
||||
convert_to_hls "$video" "$base_name"
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "✨ ¡Conversión a HLS completada!"
|
||||
echo ""
|
||||
echo "📊 Directorios HLS generados:"
|
||||
ls -d *_hls 2>/dev/null | while read dir; do
|
||||
size=$(du -sh "$dir" | awk '{print $1}')
|
||||
segments=$(find "$dir" -name "*.ts" | wc -l)
|
||||
echo " 📂 $dir - $size ($segments segmentos)"
|
||||
done
|
||||
echo ""
|
||||
echo "💡 Ventajas de HLS:"
|
||||
echo " ✅ Sin límites de tamaño (streaming por segmentos)"
|
||||
echo " ✅ Cambio automático de calidad según conexión"
|
||||
echo " ✅ Compatible con Cloudflare"
|
||||
echo " ✅ Menor uso de ancho de banda"
|
||||
113
convert-videos.sh
Executable file
113
convert-videos.sh
Executable file
@@ -0,0 +1,113 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script para convertir videos a múltiples calidades
|
||||
# Uso: ./convert-videos.sh
|
||||
|
||||
set -e
|
||||
|
||||
VIDEOS_DIR="/home/draganel/repos/videoPlayer/videos"
|
||||
cd "$VIDEOS_DIR"
|
||||
|
||||
echo "🎬 Iniciando conversión de videos a múltiples calidades..."
|
||||
echo "📁 Directorio: $VIDEOS_DIR"
|
||||
echo ""
|
||||
|
||||
# Función para obtener la resolución del video
|
||||
get_resolution() {
|
||||
local file="$1"
|
||||
ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "$file"
|
||||
}
|
||||
|
||||
# Función para convertir a una calidad específica
|
||||
convert_quality() {
|
||||
local input="$1"
|
||||
local base_name="$2"
|
||||
local extension="$3"
|
||||
local quality="$4"
|
||||
local height="$5"
|
||||
local bitrate="$6"
|
||||
|
||||
local output="${base_name}_${quality}${extension}"
|
||||
|
||||
# Si el archivo ya existe, saltarlo
|
||||
if [ -f "$output" ]; then
|
||||
echo " ⏭️ ${quality} ya existe, saltando..."
|
||||
return
|
||||
fi
|
||||
|
||||
# Obtener la resolución actual del video
|
||||
local current_height=$(get_resolution "$input")
|
||||
|
||||
# No escalar si el video original es más pequeño que la calidad objetivo
|
||||
if [ "$current_height" -lt "$height" ]; then
|
||||
echo " ⚠️ ${quality} - Video original (${current_height}p) es menor que ${height}p, saltando..."
|
||||
return
|
||||
fi
|
||||
|
||||
echo " 🔄 Convirtiendo a ${quality}..."
|
||||
|
||||
ffmpeg -i "$input" \
|
||||
-vf "scale=-2:${height}" \
|
||||
-c:v libx264 \
|
||||
-preset medium \
|
||||
-crf 23 \
|
||||
-b:v ${bitrate} \
|
||||
-c:a aac \
|
||||
-b:a 192k \
|
||||
-movflags +faststart \
|
||||
-y \
|
||||
"$output" \
|
||||
-hide_banner \
|
||||
-loglevel error \
|
||||
-stats
|
||||
|
||||
echo " ✅ ${quality} completado!"
|
||||
}
|
||||
|
||||
# Procesar cada archivo de video que NO tenga sufijo de calidad
|
||||
for video in *.mp4 *.MP4 *.mkv *.MKV 2>/dev/null; do
|
||||
# Verificar que el archivo existe
|
||||
[ -f "$video" ] || continue
|
||||
|
||||
# Saltar archivos que ya tienen sufijo de calidad
|
||||
if [[ "$video" =~ _(4k|2k|1080p|720p|480p|360p)\.(mp4|MP4|mkv|MKV)$ ]]; then
|
||||
echo "⏭️ Saltando $video (ya tiene sufijo de calidad)"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "📹 Procesando: $video"
|
||||
|
||||
# Extraer nombre base y extensión
|
||||
filename=$(basename -- "$video")
|
||||
extension="${filename##*.}"
|
||||
base_name="${filename%.*}"
|
||||
|
||||
# Convertir a diferentes calidades
|
||||
# 4K (2160p) - 20 Mbps
|
||||
convert_quality "$video" "$base_name" ".mp4" "4k" 2160 "20M"
|
||||
|
||||
# 2K (1440p) - 12 Mbps
|
||||
convert_quality "$video" "$base_name" ".mp4" "2k" 1440 "12M"
|
||||
|
||||
# 1080p - 8 Mbps
|
||||
convert_quality "$video" "$base_name" ".mp4" "1080p" 1080 "8M"
|
||||
|
||||
# 720p - 5 Mbps
|
||||
convert_quality "$video" "$base_name" ".mp4" "720p" 720 "5M"
|
||||
|
||||
# 480p - 2.5 Mbps
|
||||
convert_quality "$video" "$base_name" ".mp4" "480p" 480 "2500k"
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "✨ ¡Conversión completada!"
|
||||
echo ""
|
||||
echo "📊 Archivos generados:"
|
||||
ls -lh *_*.mp4 2>/dev/null | awk '{print " 📹", $9, "-", $5}'
|
||||
echo ""
|
||||
echo "💡 Consejo: Los archivos originales siguen en la carpeta."
|
||||
echo " Si deseas conservar solo las versiones convertidas,"
|
||||
echo " puedes eliminar los archivos sin sufijo de calidad."
|
||||
29
install-autostart.bat
Normal file
29
install-autostart.bat
Normal file
@@ -0,0 +1,29 @@
|
||||
@echo off
|
||||
echo Installing Quantum Player Autostart...
|
||||
echo.
|
||||
|
||||
REM Verificar si el archivo de autostart ya existe
|
||||
if exist "%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\quantum-autostart.bat" (
|
||||
echo Autostart already installed. Updating...
|
||||
del "%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\quantum-autostart.bat"
|
||||
)
|
||||
|
||||
REM Copiar el script de autostart al Startup folder
|
||||
copy "%~dp0quantum-autostart.bat" "%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\quantum-autostart.bat"
|
||||
|
||||
if %errorlevel% == 0 (
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Autostart installed successfully!
|
||||
echo Quantum Player will start automatically when Windows boots.
|
||||
echo ========================================
|
||||
echo.
|
||||
echo You can disable autostart from the Settings panel in the app.
|
||||
) else (
|
||||
echo.
|
||||
echo ERROR: Failed to install autostart.
|
||||
echo Please run this script as Administrator.
|
||||
)
|
||||
|
||||
echo.
|
||||
pause
|
||||
5
nuxt.config.ts
Normal file
5
nuxt.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2024-04-03',
|
||||
devtools: { enabled: true }
|
||||
})
|
||||
9342
package-lock.json
generated
Normal file
9342
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "video-player",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"start": "npm run build && ./start.sh",
|
||||
"stop": "./stop.sh",
|
||||
"tunnel": "./cloudflared tunnel run video-player"
|
||||
},
|
||||
"dependencies": {
|
||||
"hls.js": "^1.6.13",
|
||||
"nuxt": "^3.13.0",
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/devtools": "latest"
|
||||
}
|
||||
}
|
||||
809
pages/index.vue
Normal file
809
pages/index.vue
Normal file
@@ -0,0 +1,809 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="background-effect">
|
||||
<div class="gradient-orb orb-1"></div>
|
||||
<div class="gradient-orb orb-2"></div>
|
||||
<div class="gradient-orb orb-3"></div>
|
||||
<div class="grid-overlay"></div>
|
||||
</div>
|
||||
|
||||
<header class="header">
|
||||
<h1 class="logo">
|
||||
<span class="logo-icon">▶</span>
|
||||
<span class="logo-text">Quantum Player</span>
|
||||
</h1>
|
||||
<div class="header-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Videos</span>
|
||||
<span class="stat-value">{{ videos.length }}</span>
|
||||
</div>
|
||||
<button @click="showSettings = true" class="settings-btn" title="Configuración">
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<SettingsModal :is-open="showSettings" @close="showSettings = false" />
|
||||
|
||||
<main class="main-content">
|
||||
<div v-if="selectedVideo" class="player-section">
|
||||
<div class="player-header">
|
||||
<h2 class="video-title">{{ selectedVideo.name }}</h2>
|
||||
<div class="player-actions">
|
||||
<div v-if="selectedVideo.qualities && selectedVideo.qualities.length > 0" class="download-menu">
|
||||
<button @click="toggleDownloadMenu" class="download-btn">
|
||||
⬇ Descargar
|
||||
</button>
|
||||
<div v-if="downloadMenuOpen" class="download-dropdown">
|
||||
<a
|
||||
v-for="quality in selectedVideo.qualities"
|
||||
:key="quality.quality"
|
||||
:href="quality.url"
|
||||
:download="selectedVideo.name + '_' + quality.quality + selectedVideo.extension"
|
||||
class="download-option"
|
||||
@click="downloadMenuOpen = false"
|
||||
>
|
||||
{{ quality.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="selectedVideo = null" class="close-btn">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<VideoPlayer :video-url="selectedVideo.url" :qualities="selectedVideo.qualities" />
|
||||
</div>
|
||||
|
||||
<section class="videos-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="title-line"></span>
|
||||
Biblioteca
|
||||
<span class="title-line"></span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="videos.length === 0" class="empty-state">
|
||||
<div class="empty-icon">📁</div>
|
||||
<h3>No hay videos disponibles</h3>
|
||||
<p>Agrega archivos de video a la carpeta <code>/videos</code></p>
|
||||
</div>
|
||||
|
||||
<div v-else class="videos-grid">
|
||||
<div
|
||||
v-for="(video, index) in videos"
|
||||
:key="video.id"
|
||||
class="video-card"
|
||||
:style="{ animationDelay: `${index * 0.1}s` }"
|
||||
>
|
||||
<div class="card-inner" @click="selectVideo(video)">
|
||||
<div class="card-icon">
|
||||
<span>▶</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h3 class="card-title">{{ video.name }}</h3>
|
||||
<div class="card-meta">
|
||||
<span class="meta-badge">{{ video.extension }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-hover-effect"></div>
|
||||
</div>
|
||||
|
||||
<!-- Botón de descarga directa -->
|
||||
<div class="card-actions">
|
||||
<a
|
||||
:href="getOriginalVideoUrl(video)"
|
||||
:download="video.name + video.extension"
|
||||
class="download-direct-btn"
|
||||
@click.stop
|
||||
title="Descargar video original"
|
||||
>
|
||||
<span class="download-icon">⬇</span>
|
||||
<span class="download-text">Descargar Original</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Quality {
|
||||
quality: string
|
||||
label: string
|
||||
url: string
|
||||
file: string
|
||||
}
|
||||
|
||||
interface Video {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
extension: string
|
||||
isHLS?: boolean
|
||||
qualities?: Quality[]
|
||||
}
|
||||
|
||||
const selectedVideo = ref<Video | null>(null)
|
||||
const videos = ref<Video[]>([])
|
||||
const downloadMenuOpen = ref(false)
|
||||
const showSettings = ref(false)
|
||||
|
||||
const { data } = await useFetch('/api/videos')
|
||||
if (data.value?.videos) {
|
||||
videos.value = data.value.videos
|
||||
}
|
||||
|
||||
const selectVideo = (video: Video) => {
|
||||
selectedVideo.value = video
|
||||
downloadMenuOpen.value = false
|
||||
// Scroll to top suavemente
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const toggleDownloadMenu = () => {
|
||||
downloadMenuOpen.value = !downloadMenuOpen.value
|
||||
}
|
||||
|
||||
const getOriginalVideoUrl = (video: Video) => {
|
||||
// Si tiene HLS, el video original está en /videos/nombre.mp4
|
||||
if (video.isHLS) {
|
||||
return `/videos/${video.name}${video.extension}`
|
||||
}
|
||||
|
||||
// Si no tiene HLS, buscar la calidad "auto" (original)
|
||||
const originalQuality = video.qualities?.find(q => q.quality === 'auto')
|
||||
if (originalQuality) {
|
||||
return originalQuality.url
|
||||
}
|
||||
|
||||
// Fallback al URL principal
|
||||
return video.url
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
background: #0a0a0f;
|
||||
color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.background-effect {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gradient-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
opacity: 0.4;
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: radial-gradient(circle, #00ffff, transparent);
|
||||
top: -200px;
|
||||
left: -200px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, #ff00ff, transparent);
|
||||
top: 50%;
|
||||
right: -150px;
|
||||
animation-delay: -7s;
|
||||
}
|
||||
|
||||
.orb-3 {
|
||||
width: 450px;
|
||||
height: 450px;
|
||||
background: radial-gradient(circle, #00ff88, transparent);
|
||||
bottom: -200px;
|
||||
left: 50%;
|
||||
animation-delay: -14s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(50px, -50px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-30px, 30px) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.grid-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 255, 255, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 255, 255, 0.1) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
padding: 30px 40px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: linear-gradient(135deg, #00ffff, #ff00ff);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 30px rgba(0, 255, 255, 0.5);
|
||||
animation: glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 30px rgba(0, 255, 255, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 50px rgba(255, 0, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
background: linear-gradient(135deg, #00ffff, #ff00ff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #00ffff, #00ff88);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.settings-btn:hover {
|
||||
background: linear-gradient(135deg, rgba(0, 255, 255, 0.2), rgba(255, 0, 255, 0.2));
|
||||
border-color: rgba(0, 255, 255, 0.5);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 255, 0.4);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
padding: 40px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.player-section {
|
||||
margin-bottom: 60px;
|
||||
animation: slideDown 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.player-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #fff, #00ffff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.player-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.download-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background: rgba(0, 255, 255, 0.2);
|
||||
border: 1px solid rgba(0, 255, 255, 0.4);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background: rgba(0, 255, 255, 0.3);
|
||||
border-color: rgba(0, 255, 255, 0.6);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 255, 0.5);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.download-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 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: 160px;
|
||||
box-shadow: 0 0 30px rgba(0, 255, 255, 0.4);
|
||||
animation: slideDownMenu 0.2s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@keyframes slideDownMenu {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.download-option {
|
||||
display: block;
|
||||
padding: 12px 16px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.download-option:hover {
|
||||
background: rgba(0, 255, 255, 0.2);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: rgba(255, 0, 100, 0.2);
|
||||
border: 1px solid rgba(255, 0, 100, 0.4);
|
||||
color: #fff;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(255, 0, 100, 0.4);
|
||||
transform: rotate(90deg);
|
||||
box-shadow: 0 0 20px rgba(255, 0, 100, 0.6);
|
||||
}
|
||||
|
||||
.videos-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.title-line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, #00ffff, transparent);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 20px;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.empty-state code {
|
||||
background: rgba(0, 255, 255, 0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: #00ffff;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.videos-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
.video-card {
|
||||
animation: fadeInUp 0.6s ease both;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 25px;
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-inner:hover {
|
||||
transform: translateY(-8px);
|
||||
border-color: rgba(0, 255, 255, 0.5);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.5),
|
||||
0 0 40px rgba(0, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.card-inner:hover .card-hover-effect {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-hover-effect {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
rgba(0, 255, 255, 0.1),
|
||||
transparent
|
||||
);
|
||||
transform: rotate(45deg);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, rgba(0, 255, 255, 0.2), rgba(255, 0, 255, 0.2));
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-inner:hover .card-icon {
|
||||
background: linear-gradient(135deg, rgba(0, 255, 255, 0.4), rgba(255, 0, 255, 0.4));
|
||||
transform: scale(1.1) rotate(10deg);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
color: #fff;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: rgba(0, 255, 255, 0.1);
|
||||
border: 1px solid rgba(0, 255, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: #00ffff;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Botón de descarga directa */
|
||||
.card-actions {
|
||||
margin-top: 15px;
|
||||
padding: 0 15px 15px 15px;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.video-card:hover .card-actions {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.download-direct-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(135deg, rgba(0, 255, 255, 0.15), rgba(255, 0, 255, 0.15));
|
||||
border: 1px solid rgba(0, 255, 255, 0.3);
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.download-direct-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.download-direct-btn:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.download-direct-btn:hover {
|
||||
background: linear-gradient(135deg, rgba(0, 255, 255, 0.3), rgba(255, 0, 255, 0.3));
|
||||
border-color: rgba(0, 255, 255, 0.6);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 255, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.download-icon {
|
||||
font-size: 18px;
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
.download-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.videos-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 20px;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.videos-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.title-line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mostrar botón de descarga siempre en móvil */
|
||||
.card-actions {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.download-text {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-stats {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
17
public/favicon.svg
Normal file
17
public/favicon.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#00ffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#ff00ff;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="100" height="100" rx="20" fill="url(#grad1)" filter="url(#glow)"/>
|
||||
<polygon points="35,25 35,75 75,50" fill="#ffffff" opacity="0.95"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 685 B |
45
public/og-image.svg
Normal file
45
public/og-image.svg
Normal file
@@ -0,0 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0f;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1a1a2f;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="logoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#00ffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#ff00ff;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="10" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="630" fill="url(#bgGrad)"/>
|
||||
|
||||
<!-- Grid pattern -->
|
||||
<pattern id="grid" x="0" y="0" width="50" height="50" patternUnits="userSpaceOnUse">
|
||||
<path d="M 50 0 L 0 0 0 50" fill="none" stroke="#00ffff" stroke-width="0.5" opacity="0.2"/>
|
||||
</pattern>
|
||||
<rect width="1200" height="630" fill="url(#grid)"/>
|
||||
|
||||
<!-- Glowing orbs -->
|
||||
<circle cx="200" cy="150" r="150" fill="#00ffff" opacity="0.15" filter="url(#glow)"/>
|
||||
<circle cx="1000" cy="450" r="200" fill="#ff00ff" opacity="0.15" filter="url(#glow)"/>
|
||||
<circle cx="600" cy="500" r="100" fill="#00ff88" opacity="0.15" filter="url(#glow)"/>
|
||||
|
||||
<!-- Logo Icon -->
|
||||
<rect x="450" y="150" width="120" height="120" rx="20" fill="url(#logoGrad)" filter="url(#glow)"/>
|
||||
<polygon points="490,190 490,270 550,230" fill="#ffffff" opacity="0.95"/>
|
||||
|
||||
<!-- Text -->
|
||||
<text x="600" y="260" font-family="Arial, sans-serif" font-size="72" font-weight="bold" fill="url(#logoGrad)" filter="url(#glow)">Quantum Player</text>
|
||||
<text x="600" y="320" font-family="Arial, sans-serif" font-size="32" fill="#ffffff" opacity="0.8">Video Player Futurista</text>
|
||||
<text x="600" y="370" font-family="Arial, sans-serif" font-size="24" fill="#00ffff" opacity="0.6">Diseño Cyberpunk • Controles Personalizados • Múltiples Calidades</text>
|
||||
|
||||
<!-- Bottom accent line -->
|
||||
<line x1="300" y1="500" x2="900" y2="500" stroke="url(#logoGrad)" stroke-width="3" filter="url(#glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
5
quantum-autostart.bat
Normal file
5
quantum-autostart.bat
Normal file
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
REM Quantum Player Autostart Script
|
||||
REM Copy this file to: %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup
|
||||
|
||||
wsl -d Ubuntu -e bash -c "cd /home/draganel/repos/videoPlayer && bash autostart.sh"
|
||||
3
quantum-start.bat
Normal file
3
quantum-start.bat
Normal file
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
echo Starting Quantum Player...
|
||||
wsl -d Ubuntu -e bash -c "cd /home/draganel/repos/videoPlayer && ./start.sh"
|
||||
3
quantum-stop.bat
Normal file
3
quantum-stop.bat
Normal file
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
echo Stopping Quantum Player...
|
||||
wsl -d Ubuntu -e bash -c "cd /home/draganel/repos/videoPlayer && ./stop.sh"
|
||||
54
server/api/config.post.ts
Normal file
54
server/api/config.post.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { defineEventHandler, readBody, createError } from 'h3'
|
||||
import { promises as fs } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const CONFIG_FILE = join(process.cwd(), '.autostart-config')
|
||||
const CORRECT_PASSWORD = '431522'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const body = await readBody(event)
|
||||
|
||||
// Verificar contraseña
|
||||
if (body.password !== CORRECT_PASSWORD) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: 'Contraseña incorrecta'
|
||||
})
|
||||
}
|
||||
|
||||
// Actualizar configuración
|
||||
if (body.action === 'toggle-autostart') {
|
||||
const currentConfig = await fs.readFile(CONFIG_FILE, 'utf-8').catch(() => 'enabled')
|
||||
const newConfig = currentConfig.trim() === 'enabled' ? 'disabled' : 'enabled'
|
||||
await fs.writeFile(CONFIG_FILE, newConfig, 'utf-8')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
autostart: newConfig === 'enabled'
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener estado actual
|
||||
if (body.action === 'get-status') {
|
||||
const currentConfig = await fs.readFile(CONFIG_FILE, 'utf-8').catch(() => 'enabled')
|
||||
return {
|
||||
success: true,
|
||||
autostart: currentConfig.trim() === 'enabled'
|
||||
}
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Acción inválida'
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'Error al actualizar configuración'
|
||||
})
|
||||
}
|
||||
})
|
||||
140
server/api/videos.get.ts
Normal file
140
server/api/videos.get.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { stat } from 'fs/promises'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const videosPath = join(process.cwd(), 'videos')
|
||||
|
||||
// Verificar si existe la carpeta
|
||||
try {
|
||||
await fs.access(videosPath)
|
||||
} catch {
|
||||
return { videos: [] }
|
||||
}
|
||||
|
||||
const files = await fs.readdir(videosPath)
|
||||
|
||||
// Detectar videos originales (archivos de video sin sufijos de calidad)
|
||||
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv']
|
||||
const videoFiles = files.filter(file => {
|
||||
const ext = file.toLowerCase().substring(file.lastIndexOf('.'))
|
||||
const nameWithoutExt = file.substring(0, file.lastIndexOf('.'))
|
||||
const hasQualitySuffix = /_(4k|2k|1080p|720p|480p|360p)$/i.test(nameWithoutExt)
|
||||
return videoExtensions.includes(ext) && !hasQualitySuffix
|
||||
})
|
||||
|
||||
const videoMap = new Map<string, any>()
|
||||
|
||||
// Procesar cada video original
|
||||
for (const file of videoFiles) {
|
||||
const ext = file.substring(file.lastIndexOf('.'))
|
||||
const baseName = file.substring(0, file.lastIndexOf('.'))
|
||||
|
||||
// Verificar si existe carpeta HLS
|
||||
const hlsDir = join(videosPath, `${baseName}_hls`)
|
||||
let isHLS = false
|
||||
let hlsQualities: any[] = []
|
||||
|
||||
try {
|
||||
const hlsStat = await stat(hlsDir)
|
||||
if (hlsStat.isDirectory()) {
|
||||
// Buscar archivos .m3u8 para detectar calidades disponibles
|
||||
const hlsFiles = await fs.readdir(hlsDir)
|
||||
const qualityFiles = hlsFiles.filter(f =>
|
||||
f.endsWith('.m3u8') &&
|
||||
f !== 'master.m3u8' &&
|
||||
!f.includes('_vtt') // Excluir archivos de subtítulos
|
||||
)
|
||||
|
||||
qualityFiles.forEach(qFile => {
|
||||
const quality = qFile.replace('.m3u8', '')
|
||||
hlsQualities.push({
|
||||
quality,
|
||||
label: quality.toUpperCase(),
|
||||
url: `/videos/${baseName}_hls/${qFile}`,
|
||||
file: qFile,
|
||||
isHLS: true
|
||||
})
|
||||
})
|
||||
|
||||
if (hlsQualities.length > 0) {
|
||||
isHLS = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No existe HLS para este video
|
||||
}
|
||||
|
||||
if (isHLS) {
|
||||
// Video con HLS
|
||||
videoMap.set(baseName, {
|
||||
id: baseName,
|
||||
name: baseName,
|
||||
extension: ext.toLowerCase(),
|
||||
isHLS: true,
|
||||
qualities: hlsQualities,
|
||||
url: `/videos/${baseName}_hls/master.m3u8`
|
||||
})
|
||||
} else {
|
||||
// Video sin HLS, buscar versiones de calidad tradicionales
|
||||
const qualities = [
|
||||
{
|
||||
quality: 'auto',
|
||||
label: 'Original',
|
||||
url: `/videos/${file}`,
|
||||
file,
|
||||
isHLS: false
|
||||
}
|
||||
]
|
||||
|
||||
// Buscar versiones con sufijos de calidad
|
||||
for (const qualitySuffix of ['4k', '2k', '1080p', '720p', '480p', '360p']) {
|
||||
const qualityFile = `${baseName}_${qualitySuffix}${ext}`
|
||||
if (files.includes(qualityFile)) {
|
||||
qualities.push({
|
||||
quality: qualitySuffix,
|
||||
label: qualitySuffix.toUpperCase(),
|
||||
url: `/videos/${qualityFile}`,
|
||||
file: qualityFile,
|
||||
isHLS: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
videoMap.set(baseName, {
|
||||
id: baseName,
|
||||
name: baseName,
|
||||
extension: ext.toLowerCase(),
|
||||
isHLS: false,
|
||||
qualities,
|
||||
url: `/videos/${file}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir a array y ordenar calidades
|
||||
const videos = Array.from(videoMap.values()).map(video => {
|
||||
// Ordenar calidades de mayor a menor
|
||||
const qualityOrder = { '4k': 0, '2k': 1, '1080p': 2, '720p': 3, '480p': 4, '360p': 5, 'auto': 6 }
|
||||
video.qualities.sort((a: any, b: any) => {
|
||||
return (qualityOrder[a.quality as keyof typeof qualityOrder] || 999) -
|
||||
(qualityOrder[b.quality as keyof typeof qualityOrder] || 999)
|
||||
})
|
||||
|
||||
// Establecer la URL por defecto (la mejor calidad disponible)
|
||||
if (video.isHLS) {
|
||||
video.url = `/videos/${video.name}_hls/master.m3u8`
|
||||
} else {
|
||||
video.url = video.qualities[0].url
|
||||
}
|
||||
|
||||
return video
|
||||
})
|
||||
|
||||
return { videos }
|
||||
} catch (error) {
|
||||
console.error('Error reading videos:', error)
|
||||
return { videos: [], error: 'Failed to load videos' }
|
||||
}
|
||||
})
|
||||
94
server/middleware/videos.ts
Normal file
94
server/middleware/videos.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createReadStream, statSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const url = getRequestURL(event)
|
||||
|
||||
// Solo manejar rutas que empiecen con /videos/
|
||||
if (!url.pathname.startsWith('/videos/')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extraer el path del archivo (puede incluir subcarpetas para HLS)
|
||||
const file = url.pathname.substring('/videos/'.length)
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validar que el path solo contenga caracteres seguros (permite subcarpetas con /)
|
||||
if (!/^[a-zA-Z0-9_\-\.\/]+$/.test(file)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid file path'
|
||||
})
|
||||
}
|
||||
|
||||
const filePath = join(process.cwd(), 'videos', file)
|
||||
|
||||
try {
|
||||
// Verificar que el archivo existe
|
||||
const stats = statSync(filePath)
|
||||
|
||||
if (!stats.isFile()) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'File not found'
|
||||
})
|
||||
}
|
||||
|
||||
// Determinar el tipo MIME basado en la extensión
|
||||
const ext = file.toLowerCase().substring(file.lastIndexOf('.'))
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.ogg': 'video/ogg',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mkv': 'video/x-matroska',
|
||||
'.m3u8': 'application/vnd.apple.mpegurl',
|
||||
'.ts': 'video/mp2t'
|
||||
}
|
||||
const mimeType = mimeTypes[ext] || 'application/octet-stream'
|
||||
|
||||
// Configurar headers para streaming de video
|
||||
setResponseHeaders(event, {
|
||||
'Content-Type': mimeType,
|
||||
'Content-Length': String(stats.size),
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'public, max-age=604800' // 7 días
|
||||
})
|
||||
|
||||
// Manejar range requests para seeking en el video
|
||||
const range = getRequestHeader(event, 'range')
|
||||
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, '').split('-')
|
||||
const start = parseInt(parts[0], 10)
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1
|
||||
const chunkSize = (end - start) + 1
|
||||
|
||||
setResponseStatus(event, 206) // Partial Content
|
||||
setResponseHeaders(event, {
|
||||
'Content-Range': `bytes ${start}-${end}/${stats.size}`,
|
||||
'Content-Length': String(chunkSize)
|
||||
})
|
||||
|
||||
return createReadStream(filePath, { start, end })
|
||||
}
|
||||
|
||||
// Retornar el archivo completo si no hay range request
|
||||
return createReadStream(filePath)
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'File not found'
|
||||
})
|
||||
}
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Error reading file'
|
||||
})
|
||||
}
|
||||
})
|
||||
41
start.sh
Executable file
41
start.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script para iniciar Quantum Player con Cloudflare Tunnel
|
||||
# Uso: ./start.sh
|
||||
|
||||
# Colores para el output
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # Sin color
|
||||
|
||||
echo -e "${BLUE}🚀 Iniciando Quantum Player...${NC}"
|
||||
|
||||
# Verificar si ya está corriendo
|
||||
if pgrep -f "node .output/server/index.mjs" > /dev/null; then
|
||||
echo -e "${GREEN}✓ El servidor ya está corriendo${NC}"
|
||||
else
|
||||
echo -e "${BLUE}→ Iniciando servidor Nuxt...${NC}"
|
||||
PORT=3000 node .output/server/index.mjs > /dev/null 2>&1 &
|
||||
sleep 2
|
||||
echo -e "${GREEN}✓ Servidor iniciado en puerto 3000${NC}"
|
||||
fi
|
||||
|
||||
# Verificar si cloudflared ya está corriendo
|
||||
if pgrep -f "cloudflared tunnel run" > /dev/null; then
|
||||
echo -e "${GREEN}✓ Cloudflare Tunnel ya está corriendo${NC}"
|
||||
else
|
||||
echo -e "${BLUE}→ Iniciando Cloudflare Tunnel...${NC}"
|
||||
./cloudflared tunnel run video-player > /dev/null 2>&1 &
|
||||
sleep 3
|
||||
echo -e "${GREEN}✓ Tunnel iniciado${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "✓ Quantum Player está en línea!"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo -e "${NC}"
|
||||
echo -e "🌐 URL Pública: ${BLUE}https://video.nucleoriofrio.com${NC}"
|
||||
echo -e "🏠 URL Local: ${BLUE}http://localhost:3000${NC}"
|
||||
echo ""
|
||||
echo -e "Para detener los servicios ejecuta: ${BLUE}./stop.sh${NC}"
|
||||
33
stop.sh
Executable file
33
stop.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script para detener Quantum Player y Cloudflare Tunnel
|
||||
# Uso: ./stop.sh
|
||||
|
||||
# Colores para el output
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # Sin color
|
||||
|
||||
echo -e "${YELLOW}🛑 Deteniendo Quantum Player...${NC}"
|
||||
|
||||
# Detener servidor Nuxt
|
||||
if pgrep -f "node .output/server/index.mjs" > /dev/null; then
|
||||
pkill -f "node .output/server/index.mjs"
|
||||
echo -e "${RED}✓ Servidor Nuxt detenido${NC}"
|
||||
else
|
||||
echo "→ El servidor Nuxt no estaba corriendo"
|
||||
fi
|
||||
|
||||
# Detener Cloudflare Tunnel
|
||||
if pgrep -f "cloudflared tunnel run" > /dev/null; then
|
||||
pkill -f "cloudflared tunnel run"
|
||||
echo -e "${RED}✓ Cloudflare Tunnel detenido${NC}"
|
||||
else
|
||||
echo "→ El tunnel no estaba corriendo"
|
||||
fi
|
||||
|
||||
echo -e "${RED}"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "✓ Todos los servicios han sido detenidos"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo -e "${NC}"
|
||||
3
tsconfig.json
Normal file
3
tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
18
uninstall-autostart.bat
Normal file
18
uninstall-autostart.bat
Normal file
@@ -0,0 +1,18 @@
|
||||
@echo off
|
||||
echo Uninstalling Quantum Player Autostart...
|
||||
echo.
|
||||
|
||||
if exist "%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\quantum-autostart.bat" (
|
||||
del "%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\quantum-autostart.bat"
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Autostart uninstalled successfully!
|
||||
echo Quantum Player will no longer start automatically.
|
||||
echo ========================================
|
||||
) else (
|
||||
echo.
|
||||
echo Autostart is not installed.
|
||||
)
|
||||
|
||||
echo.
|
||||
pause
|
||||
0
videos/.gitkeep
Normal file
0
videos/.gitkeep
Normal file
Reference in New Issue
Block a user