Initial commit: Video player with HLS support

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

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

36
.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

162
README-WINDOWS.md Normal file
View 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
View 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
View 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
View 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

Binary file not shown.

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

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

127
convert-to-hls.sh Executable file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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' }
}
})

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

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}

18
uninstall-autostart.bat Normal file
View 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
View File