🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1371 lines
33 KiB
Markdown
1371 lines
33 KiB
Markdown
# 📘 Quantum Player - Documentación Técnica Completa
|
||
|
||
**Versión:** 1.0.0
|
||
**Fecha:** Octubre 2025
|
||
**Autor:** Claude & Usuario
|
||
**Plataforma:** Nuxt 4 + Vue 3 + HLS Streaming
|
||
|
||
---
|
||
|
||
## 📖 Índice
|
||
|
||
1. [Historia Completa del Proyecto](#historia-completa-del-proyecto)
|
||
2. [Arquitectura Detallada](#arquitectura-detallada)
|
||
3. [Stack Tecnológico](#stack-tecnológico)
|
||
4. [Implementación HLS](#implementación-hls)
|
||
5. [Sistema de Autostart](#sistema-de-autostart)
|
||
6. [Problemas y Soluciones](#problemas-y-soluciones)
|
||
7. [Referencia de APIs](#referencia-de-apis)
|
||
8. [Deployment](#deployment)
|
||
|
||
---
|
||
|
||
# 📜 Historia Completa del Proyecto
|
||
|
||
## Día 1: Génesis del Proyecto
|
||
|
||
### Hora 0: Requerimientos Iniciales
|
||
|
||
**Usuario:** "Quiero un reproductor de video web moderno con Nuxt 4"
|
||
|
||
**Contexto:**
|
||
- Necesitaba servir videos de drones (archivos DJI)
|
||
- Videos muy grandes (1.9GB - 3.2GB)
|
||
- Quería diseño moderno, no convencional
|
||
- Sin usar frameworks CSS tradicionales
|
||
- Deployment en video.nucleoriofrio.com
|
||
|
||
**Decisiones Iniciales:**
|
||
1. **Framework:** Nuxt 4 (v3.19.2) - Latest stable
|
||
2. **UI:** Cyberpunk/Glassmorphism style
|
||
3. **Colores:** Cian (#00ffff), Magenta (#ff00ff), Verde neón (#00ff88)
|
||
4. **Sin CSS frameworks:** Todo vanilla CSS
|
||
|
||
### Hora 1-2: Setup Básico
|
||
|
||
**Estructura Creada:**
|
||
```bash
|
||
videoPlayer/
|
||
├── pages/index.vue # Galería de videos
|
||
├── components/VideoPlayer.vue # Reproductor custom
|
||
├── server/api/videos.get.ts # API para listar
|
||
└── videos/ # Carpeta de videos
|
||
```
|
||
|
||
**Primera Implementación:**
|
||
- Galería con tarjetas glassmorphism
|
||
- VideoPlayer con HTML5 `<video>` API
|
||
- Lista de videos desde filesystem
|
||
- Diseño responsive con CSS Grid
|
||
|
||
**Desafío:** Crear diseño "fuera de lo convencional"
|
||
|
||
**Solución Implementada:**
|
||
- Orbes animados en el fondo con blur
|
||
- Efectos de glow con box-shadow + cyan/magenta
|
||
- Gradientes animados en progreso del video
|
||
- Controles que se auto-ocultan con animaciones
|
||
|
||
---
|
||
|
||
## Día 2: Selector de Calidades
|
||
|
||
### Problema Identificado
|
||
|
||
Videos disponibles en múltiples archivos:
|
||
```
|
||
videos/
|
||
├── DJI_0343.mp4 # Original 4K
|
||
├── DJI_0343_1080p.mp4 # Full HD
|
||
├── DJI_0343_720p.mp4 # HD
|
||
└── DJI_0343_480p.mp4 # SD
|
||
```
|
||
|
||
Pero no había forma de cambiar entre ellas.
|
||
|
||
### Implementación del Selector
|
||
|
||
**1. API de Agrupación:**
|
||
```typescript
|
||
// Detectar archivo base
|
||
const baseName = file.replace(/_(4k|2k|1080p|720p|480p)\.mp4$/, '')
|
||
|
||
// Buscar variantes
|
||
const qualities = []
|
||
for (const suffix of ['4k', '2k', '1080p', '720p', '480p']) {
|
||
if (exists(`${baseName}_${suffix}.mp4`)) {
|
||
qualities.push({
|
||
quality: suffix,
|
||
label: suffix.toUpperCase(),
|
||
url: `/videos/${baseName}_${suffix}.mp4`
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
**2. UI del Selector:**
|
||
- Botón con calidad actual
|
||
- Dropdown al hacer click
|
||
- Marca de check en calidad activa
|
||
- Transición suave
|
||
|
||
**3. Cambio Sin Pausas:**
|
||
```typescript
|
||
const changeQuality = async (quality) => {
|
||
const wasPlaying = isPlaying.value
|
||
const savedTime = currentTime.value
|
||
|
||
// Cambiar source
|
||
currentVideoUrl.value = quality.url
|
||
|
||
// Esperar carga
|
||
await nextTick()
|
||
|
||
// Restaurar
|
||
videoElement.value.currentTime = savedTime
|
||
if (wasPlaying) {
|
||
await videoElement.value.play()
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Día 3: Crisis de Cloudflare
|
||
|
||
### El Problema Crítico
|
||
|
||
**Descubrimiento Shock:**
|
||
Videos dejaron de funcionar después de deployment a Cloudflare.
|
||
|
||
**Síntomas:**
|
||
- Videos se cortaban después de ~30 segundos
|
||
- Network tab mostraba request cancelado
|
||
- Error 524 (timeout) en algunos casos
|
||
|
||
**Investigación:**
|
||
```bash
|
||
# Testing manual
|
||
curl -I https://video.nucleoriofrio.com/videos/DJI_0343.mp4
|
||
|
||
HTTP/2 200
|
||
content-length: 1934528000 # 1.9GB
|
||
...
|
||
# Pero el download se cortaba a 100MB
|
||
```
|
||
|
||
**Root Cause Found:**
|
||
Cloudflare Free Tier tiene límite de **100MB por request**.
|
||
|
||
### La Pregunta que Cambió Todo
|
||
|
||
**Usuario:** "pero así como youtube y otros medios de streaming no envían el video completo, no sé cómo lo hacen pero no es una opción para nosotros?"
|
||
|
||
Esta pregunta desencadenó investigación profunda sobre protocolos de streaming.
|
||
|
||
### Descubrimiento: HLS
|
||
|
||
**¿Qué es HTTP Live Streaming?**
|
||
|
||
Desarrollado por Apple en 2009, HLS es un protocolo que:
|
||
|
||
1. **Segmenta** el video en chunks pequeños (~6 segundos)
|
||
2. **Genera** múltiples versiones (calidades)
|
||
3. **Usa playlists** (.m3u8) para coordinar
|
||
4. **Permite ABR** (Adaptive Bitrate)
|
||
|
||
**Ventajas para Nuestro Caso:**
|
||
- ✅ Cada segmento: 6-10MB (<<< 100MB límite)
|
||
- ✅ Solo descarga lo necesario
|
||
- ✅ Seeking instantáneo
|
||
- ✅ Funciona en todos los navegadores (con hls.js)
|
||
|
||
---
|
||
|
||
## Día 4-5: Implementación de HLS
|
||
|
||
### Fase 1: Script de Conversión FFmpeg
|
||
|
||
Creación de `convert-to-hls.sh`:
|
||
|
||
```bash
|
||
#!/bin/bash
|
||
|
||
VIDEO_DIR="./videos"
|
||
|
||
for input in "$VIDEO_DIR"/*.mp4; do
|
||
baseName=$(basename "$input" .mp4)
|
||
output_dir="$VIDEO_DIR/${baseName}_hls"
|
||
|
||
mkdir -p "$output_dir"
|
||
|
||
# Conversión multi-calidad simultánea
|
||
ffmpeg -i "$input" -hide_banner -loglevel error -stats \
|
||
# 1080p stream
|
||
-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" \
|
||
|
||
# 720p stream
|
||
-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" \
|
||
|
||
# 480p stream
|
||
-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"
|
||
|
||
# Master playlist
|
||
cat > "$output_dir/master.m3u8" << EOF
|
||
#EXTM3U
|
||
#EXT-X-VERSION:3
|
||
#EXT-X-STREAM-INF:BANDWIDTH=3500000,RESOLUTION=1920x1080
|
||
1080p.m3u8
|
||
#EXT-X-STREAM-INF:BANDWIDTH=1650000,RESOLUTION=1280x720
|
||
720p.m3u8
|
||
#EXT-X-STREAM-INF:BANDWIDTH=900000,RESOLUTION=854x480
|
||
480p.m3u8
|
||
EOF
|
||
done
|
||
```
|
||
|
||
**Resultado de Conversión:**
|
||
```
|
||
Video original: DJI_0343.mp4 (1.9GB)
|
||
Tiempo: 8-10 minutos
|
||
Output:
|
||
├── master.m3u8 # Playlist principal
|
||
├── 1080p.m3u8 # 182 líneas
|
||
├── 1080p_000.ts # 8.2 MB
|
||
├── 1080p_001.ts # 8.1 MB
|
||
├── ... (90 segmentos)
|
||
├── 720p.m3u8
|
||
├── 720p_000.ts # 4.8 MB
|
||
└── ... (90 segmentos)
|
||
```
|
||
|
||
### Fase 2: Backend Modifications
|
||
|
||
**Actualización `/server/api/videos.get.ts`:**
|
||
|
||
```typescript
|
||
// Detección de HLS
|
||
const hlsDir = join(videosPath, `${baseName}_hls`)
|
||
|
||
try {
|
||
const hlsStat = await stat(hlsDir)
|
||
if (hlsStat.isDirectory()) {
|
||
const hlsFiles = await promises.readdir(hlsDir)
|
||
|
||
// Filtrar solo playlists de calidad
|
||
const qualityFiles = hlsFiles.filter(f =>
|
||
f.endsWith('.m3u8') &&
|
||
f !== 'master.m3u8' &&
|
||
!f.includes('_vtt') // Excluir subtítulos
|
||
)
|
||
|
||
qualityFiles.forEach(qFile => {
|
||
const quality = qFile.replace('.m3u8', '')
|
||
hlsQualities.push({
|
||
quality,
|
||
label: quality.toUpperCase(),
|
||
url: `/videos/${baseName}_hls/${qFile}`,
|
||
isHLS: true
|
||
})
|
||
})
|
||
|
||
if (hlsQualities.length > 0) {
|
||
isHLS = true
|
||
}
|
||
}
|
||
} catch { }
|
||
```
|
||
|
||
**Actualización Middleware:**
|
||
|
||
```typescript
|
||
const mimeTypes = {
|
||
'.mp4': 'video/mp4',
|
||
'.m3u8': 'application/vnd.apple.mpegurl', // NUEVO
|
||
'.ts': 'video/mp2t' // NUEVO
|
||
}
|
||
|
||
// Permitir subcarpetas (para HLS)
|
||
if (!/^[a-zA-Z0-9_\-\.\/]+$/.test(file)) { // Añadido '/'
|
||
throw createError({ statusCode: 400 })
|
||
}
|
||
```
|
||
|
||
### Fase 3: Frontend con hls.js
|
||
|
||
**Instalación:**
|
||
```bash
|
||
npm install hls.js@1.6.13
|
||
```
|
||
|
||
**Implementación VideoPlayer.vue:**
|
||
|
||
```typescript
|
||
import Hls from 'hls.js'
|
||
|
||
let hls: Hls | null = null
|
||
|
||
const initHLS = (url: string) => {
|
||
if (!videoElement.value) return
|
||
|
||
// Limpiar instancia anterior
|
||
if (hls) {
|
||
hls.destroy()
|
||
hls = null
|
||
}
|
||
|
||
// Safari tiene soporte nativo
|
||
if (videoElement.value.canPlayType('application/vnd.apple.mpegurl')) {
|
||
videoElement.value.src = url
|
||
videoElement.value.load()
|
||
return
|
||
}
|
||
|
||
// hls.js para el resto
|
||
if (Hls.isSupported()) {
|
||
hls = new Hls({
|
||
enableWorker: true,
|
||
lowLatencyMode: false,
|
||
backBufferLength: 90, // 90s atrás
|
||
maxBufferLength: 30, // 30s adelante
|
||
maxMaxBufferLength: 60 // Max 60s total
|
||
})
|
||
|
||
hls.loadSource(url)
|
||
hls.attachMedia(videoElement.value)
|
||
|
||
// Event listeners
|
||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||
console.log('HLS manifest loaded')
|
||
})
|
||
|
||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||
if (data.fatal) {
|
||
switch (data.type) {
|
||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||
hls?.startLoad() // Retry
|
||
break
|
||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||
hls?.recoverMediaError() // Recover
|
||
break
|
||
default:
|
||
hls?.destroy()
|
||
break
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
**Watcher para Auto-detectar HLS:**
|
||
|
||
```typescript
|
||
watch(() => props.videoUrl, (newUrl) => {
|
||
if (!newUrl) return
|
||
|
||
// Seleccionar 480p por defecto
|
||
let urlToUse = newUrl
|
||
if (props.qualities && props.qualities.length > 0) {
|
||
const quality480p = props.qualities.find(q => q.quality === '480p')
|
||
if (quality480p) {
|
||
urlToUse = quality480p.url
|
||
currentQuality.value = '480p'
|
||
}
|
||
}
|
||
|
||
nextTick(() => {
|
||
if (urlToUse.endsWith('.m3u8')) {
|
||
initHLS(urlToUse) // HLS
|
||
} else {
|
||
if (hls) hls.destroy()
|
||
videoElement.value.src = urlToUse // MP4 normal
|
||
videoElement.value.load()
|
||
}
|
||
})
|
||
}, { immediate: true })
|
||
```
|
||
|
||
**¡ÉXITO!**
|
||
Videos de 3.2GB ahora funcionan perfectamente, cargando solo ~8MB a la vez.
|
||
|
||
---
|
||
|
||
## Día 6: Mejoras de UX
|
||
|
||
### Botón de Descarga Directa
|
||
|
||
**Contexto:**
|
||
Algunos usuarios no pueden cargar el player (conexiones lentas, browsers antiguos).
|
||
|
||
**Solución:**
|
||
Botón de descarga en cada tarjeta:
|
||
|
||
```vue
|
||
<div class="card-actions">
|
||
<a
|
||
:href="getOriginalVideoUrl(video)"
|
||
:download="video.name + video.extension"
|
||
class="download-direct-btn"
|
||
@click.stop
|
||
>
|
||
<span>⬇ Descargar Original</span>
|
||
</a>
|
||
</div>
|
||
```
|
||
|
||
**Función Helper:**
|
||
```typescript
|
||
const getOriginalVideoUrl = (video) => {
|
||
if (video.isHLS) {
|
||
// Original está en /videos/nombre.mp4
|
||
return `/videos/${video.name}${video.extension}`
|
||
}
|
||
// Buscar calidad "auto"
|
||
const original = video.qualities?.find(q => q.quality === 'auto')
|
||
return original?.url || video.url
|
||
}
|
||
```
|
||
|
||
### Default a 480p con Banner
|
||
|
||
**Problema:**
|
||
Usuarios con conexiones lentas se frustran con 1080p por defecto.
|
||
|
||
**Solución:**
|
||
|
||
1. **Cambiar default:**
|
||
```typescript
|
||
const currentQuality = ref('480p') // Antes: 'auto'
|
||
```
|
||
|
||
2. **Banner informativo:**
|
||
```vue
|
||
<div v-if="showQualityBanner" class="quality-banner">
|
||
<span class="icon">ℹ️</span>
|
||
<span>
|
||
Reproduciendo en <strong>480P</strong>.
|
||
Puedes cambiar a mayor calidad usando el selector ⚙️
|
||
</span>
|
||
<button @click="dismissQualityBanner">✕</button>
|
||
</div>
|
||
```
|
||
|
||
3. **Auto-ocultar:**
|
||
```typescript
|
||
onMounted(() => {
|
||
setTimeout(() => {
|
||
showQualityBanner.value = false
|
||
}, 10000) // 10 segundos
|
||
})
|
||
|
||
watch(currentQuality, () => {
|
||
showQualityBanner.value = false // Al cambiar calidad
|
||
})
|
||
```
|
||
|
||
### Responsive Mobile
|
||
|
||
**Problemas:**
|
||
- Controles muy pequeños
|
||
- Difícil tocar slider de progreso
|
||
- Volumen innecesario en móvil
|
||
|
||
**Soluciones:**
|
||
```css
|
||
@media (max-width: 768px) {
|
||
/* Siempre visible */
|
||
.controls-overlay {
|
||
opacity: 1 !important;
|
||
pointer-events: all !important;
|
||
}
|
||
|
||
/* Touch-friendly */
|
||
.progress-bar {
|
||
height: 8px; /* Antes: 6px */
|
||
}
|
||
|
||
.progress-thumb {
|
||
width: 16px;
|
||
height: 16px; /* Antes: 12px */
|
||
}
|
||
|
||
/* Simplificar */
|
||
.volume-control {
|
||
display: none; /* Usar botones físicos */
|
||
}
|
||
|
||
/* Botones más grandes */
|
||
.control-btn {
|
||
padding: 8px 12px; /* Antes: 6px 10px */
|
||
font-size: 14px; /* Antes: 12px */
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Día 7: Sistema de Autostart
|
||
|
||
### Contexto del Problema
|
||
|
||
**Escenario:**
|
||
- Servidor en WSL (Ubuntu) dentro de Windows 11
|
||
- Cada reinicio de Windows → servidor apagado
|
||
- Usuario necesita controlar desde cualquier lugar de Windows
|
||
|
||
**Requerimientos:**
|
||
1. Scripts .bat para control desde Windows
|
||
2. Autostart opcional (configurable)
|
||
3. Panel web con contraseña
|
||
4. Toggle ON/OFF
|
||
|
||
### Implementación
|
||
|
||
**1. Scripts Windows:**
|
||
|
||
`quantum-start.bat`:
|
||
```batch
|
||
@echo off
|
||
wsl -d Ubuntu -e bash -c "./start.sh"
|
||
```
|
||
|
||
`install-autostart.bat`:
|
||
```batch
|
||
@echo off
|
||
copy "%~dp0quantum-autostart.bat" "%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\"
|
||
echo Autostart installed!
|
||
pause
|
||
```
|
||
|
||
**2. Script Bash de Autostart:**
|
||
|
||
`autostart.sh`:
|
||
```bash
|
||
#!/bin/bash
|
||
|
||
CONFIG_FILE=".autostart-config"
|
||
|
||
if [ -f "$CONFIG_FILE" ]; then
|
||
ENABLED=$(cat "$CONFIG_FILE")
|
||
|
||
if [ "$ENABLED" = "enabled" ]; then
|
||
if ! pgrep -f "node .output/server/index.mjs" > /dev/null; then
|
||
./start.sh
|
||
fi
|
||
fi
|
||
fi
|
||
```
|
||
|
||
**3. API de Config:**
|
||
|
||
`/server/api/config.post.ts`:
|
||
```typescript
|
||
const CORRECT_PASSWORD = '431522'
|
||
|
||
export default defineEventHandler(async (event) => {
|
||
const body = await readBody(event)
|
||
|
||
// Verificar contraseña
|
||
if (body.password !== CORRECT_PASSWORD) {
|
||
throw createError({ statusCode: 401, message: 'Contraseña incorrecta' })
|
||
}
|
||
|
||
if (body.action === 'toggle-autostart') {
|
||
const current = await fs.readFile('.autostart-config', 'utf-8')
|
||
.catch(() => 'enabled')
|
||
|
||
const newValue = current.trim() === 'enabled' ? 'disabled' : 'enabled'
|
||
await fs.writeFile('.autostart-config', newValue)
|
||
|
||
return { success: true, autostart: newValue === 'enabled' }
|
||
}
|
||
})
|
||
```
|
||
|
||
**4. Settings Modal:**
|
||
|
||
Componente Vue completo con:
|
||
- Autenticación con contraseña
|
||
- Toggle switch animado
|
||
- Instrucciones de instalación
|
||
- Mensajes de estado
|
||
- Diseño glassmorphism
|
||
|
||
---
|
||
|
||
# 🏗️ Arquitectura Detallada
|
||
|
||
## Diagrama de Flujo Completo
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────┐
|
||
│ INTERNET │
|
||
│ (Global Network) │
|
||
└───────────────────┬──────────────────────────────────┘
|
||
│ HTTPS/2
|
||
▼
|
||
┌───────────────────────────┐
|
||
│ Cloudflare Edge │
|
||
│ - 275+ Data Centers │
|
||
│ - DDoS Protection │
|
||
│ - WAF (Web App FW) │
|
||
│ - SSL/TLS Termination │
|
||
│ - Edge Caching │
|
||
│ - HTTP/3 (QUIC) │
|
||
└───────────┬───────────────┘
|
||
│ Encrypted Tunnel
|
||
│ (QUIC Protocol)
|
||
▼
|
||
┌───────────────────────────┐
|
||
│ cloudflared Daemon │
|
||
│ (Tunnel Client) │
|
||
│ - Maintains 4 connections
|
||
│ - Auto-reconnect │
|
||
│ - Load balancing │
|
||
└───────────┬───────────────┘
|
||
│ HTTP
|
||
│ localhost:3000
|
||
▼
|
||
┌───────────────────────────────────────────────────────┐
|
||
│ WSL 2 (Ubuntu) │
|
||
│ │
|
||
│ ┌────────────────────────────────────────────────┐ │
|
||
│ │ Nuxt Server (Nitro) │ │
|
||
│ │ Node.js v20+ │ │
|
||
│ │ ┌──────────────────────────────────────────┐ │ │
|
||
│ │ │ Request Router │ │ │
|
||
│ │ │ ┌────────────────────────────────────┐ │ │ │
|
||
│ │ │ │ Static Assets │ │ │ │
|
||
│ │ │ │ /css, /js, /images │ │ │ │
|
||
│ │ │ └────────────────────────────────────┘ │ │ │
|
||
│ │ │ ┌────────────────────────────────────┐ │ │ │
|
||
│ │ │ │ API Routes │ │ │ │
|
||
│ │ │ │ - GET /api/videos │ │ │ │
|
||
│ │ │ │ - POST /api/config │ │ │ │
|
||
│ │ │ └────────────────────────────────────┘ │ │ │
|
||
│ │ │ ┌────────────────────────────────────┐ │ │ │
|
||
│ │ │ │ Middleware Chain │ │ │ │
|
||
│ │ │ │ /videos/* → videos.ts │ │ │ │
|
||
│ │ │ └────────────────────────────────────┘ │ │ │
|
||
│ │ │ ┌────────────────────────────────────┐ │ │ │
|
||
│ │ │ │ SSR Renderer │ │ │ │
|
||
│ │ │ │ - Vue 3 App │ │ │ │
|
||
│ │ │ │ - hydration │ │ │ │
|
||
│ │ │ └────────────────────────────────────┘ │ │ │
|
||
│ │ └──────────────────────────────────────────┘ │ │
|
||
│ └────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌────────────────────────────────────────────────┐ │
|
||
│ │ File System │ │
|
||
│ │ /home/draganel/repos/videoPlayer/videos/ │ │
|
||
│ │ ├── DJI_0343.mp4 (1.9GB) │ │
|
||
│ │ ├── DJI_0343_hls/ │ │
|
||
│ │ │ ├── master.m3u8 (500B) │ │
|
||
│ │ │ ├── 1080p.m3u8 (5KB) │ │
|
||
│ │ │ ├── 1080p_000.ts (8.2MB) │ │
|
||
│ │ │ ├── 1080p_001.ts (8.1MB) │ │
|
||
│ │ │ ├── ... (88 more) │ │
|
||
│ │ │ ├── 720p.m3u8 │ │
|
||
│ │ │ └── ... │ │
|
||
│ │ ├── DJI_0344.mp4 │ │
|
||
│ │ └── ... │ │
|
||
│ └────────────────────────────────────────────────┘ │
|
||
└───────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
## Request Flow Detallado
|
||
|
||
### Caso 1: Página Principal
|
||
|
||
```
|
||
User → https://video.nucleoriofrio.com/
|
||
│
|
||
├─1. DNS Resolution
|
||
│ └─> Cloudflare Edge (Anycast IP)
|
||
│
|
||
├─2. TLS Handshake
|
||
│ └─> Cloudflare presents SSL cert
|
||
│
|
||
├─3. Edge Caching Check
|
||
│ ├─ MISS (dynamic content)
|
||
│ └─> Forward to origin
|
||
│
|
||
├─4. Tunnel Transport
|
||
│ └─> QUIC protocol to cloudflared
|
||
│
|
||
├─5. Local Proxy
|
||
│ └─> HTTP GET localhost:3000/
|
||
│
|
||
├─6. Nitro Router
|
||
│ └─> Match: SSR route
|
||
│
|
||
├─7. SSR Rendering
|
||
│ ├─> Execute pages/index.vue
|
||
│ ├─> Call composables
|
||
│ ├─> Fetch /api/videos
|
||
│ └─> Render HTML with data
|
||
│
|
||
├─8. Response
|
||
│ ├─> HTML (with initial state)
|
||
│ ├─> headers: content-type: text/html
|
||
│ └─> status: 200
|
||
│
|
||
└─9. Client Hydration
|
||
├─> Download JS bundles
|
||
├─> Vue app mounts
|
||
└─> Becomes interactive
|
||
```
|
||
|
||
### Caso 2: Video HLS Streaming
|
||
|
||
```
|
||
User clicks video "DJI_0343" → 480p quality
|
||
│
|
||
├─1. Load Playlist
|
||
│ GET /videos/DJI_0343_hls/480p.m3u8
|
||
│ │
|
||
│ ├─> Middleware: videos.ts
|
||
│ ├─> MIME: application/vnd.apple.mpegurl
|
||
│ └─> Response: Text file with segment list
|
||
│
|
||
├─2. hls.js Parses Playlist
|
||
│ #EXTM3U
|
||
│ #EXT-X-TARGETDURATION:6
|
||
│ #EXTINF:6.0,
|
||
│ 480p_000.ts
|
||
│ #EXTINF:6.0,
|
||
│ 480p_001.ts
|
||
│ ...
|
||
│
|
||
├─3. Download Initial Segments
|
||
│ GET /videos/DJI_0343_hls/480p_000.ts (8MB)
|
||
│ GET /videos/DJI_0343_hls/480p_001.ts (8MB)
|
||
│ GET /videos/DJI_0343_hls/480p_002.ts (8MB)
|
||
│ └─> Buffer: 18 segundos
|
||
│
|
||
├─4. MSE Processing
|
||
│ ├─> Parse MPEG-TS container
|
||
│ ├─> Extract H.264 video track
|
||
│ ├─> Extract AAC audio track
|
||
│ └─> Append to MediaSource buffer
|
||
│
|
||
├─5. Playback Starts
|
||
│ └─> Video begins playing
|
||
│
|
||
├─6. Continuous Buffering
|
||
│ While playing:
|
||
│ ├─> Monitor buffer level
|
||
│ ├─> If buffer < 5s → download next segment
|
||
│ └─> If buffer > 90s → cleanup old segments
|
||
│
|
||
└─7. User Seeks to 5:00
|
||
├─> Calculate segment: 5:00 ÷ 6s = segment #50
|
||
├─> Clear current buffer
|
||
├─> GET /videos/DJI_0343_hls/480p_050.ts
|
||
└─> Resume playback from that point
|
||
```
|
||
|
||
---
|
||
|
||
# 🛠️ Stack Tecnológico Detallado
|
||
|
||
## Frontend Stack
|
||
|
||
### Nuxt 4 (v3.19.2)
|
||
**¿Por qué Nuxt 4?**
|
||
- SSR out-of-the-box
|
||
- File-based routing
|
||
- Auto-imports (no más import statements)
|
||
- Built-in optimizations
|
||
- TypeScript support
|
||
- API routes integrados
|
||
|
||
**Configuración Key:**
|
||
```typescript
|
||
// nuxt.config.ts
|
||
export default defineNuxtConfig({
|
||
compatibilityDate: '2025-01-01',
|
||
|
||
nitro: {
|
||
preset: 'node-server', // Target deployment
|
||
serveStatic: true,
|
||
compressPublicAssets: true
|
||
},
|
||
|
||
app: {
|
||
head: {
|
||
// Meta tags globales
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
### Vue 3 (v3.5.22)
|
||
**Features Usados:**
|
||
- Composition API (`setup`, `ref`, `computed`)
|
||
- Reactivity system (`watch`, `watchEffect`)
|
||
- Lifecycle hooks (`onMounted`, `onUnmounted`)
|
||
- Template refs
|
||
|
||
**Ejemplo:**
|
||
```typescript
|
||
const videoElement = ref<HTMLVideoElement | null>(null)
|
||
const isPlaying = ref(false)
|
||
const currentTime = ref(0)
|
||
|
||
const progressPercent = computed(() => {
|
||
return (currentTime.value / duration.value) * 100
|
||
})
|
||
|
||
watch(currentTime, (newTime) => {
|
||
// Update progress bar
|
||
})
|
||
```
|
||
|
||
### hls.js (v1.6.13)
|
||
**Arquitectura:**
|
||
```
|
||
hls.js
|
||
├─ Stream Controller
|
||
│ ├─ Level Controller (ABR logic)
|
||
│ ├─ Fragment Loader
|
||
│ └─ Buffer Controller
|
||
├─ Demuxer (TS → MP4)
|
||
├─ Remuxer (Audio/Video sync)
|
||
└─ MSE (MediaSource Extensions)
|
||
```
|
||
|
||
**Configuración Optimizada:**
|
||
```typescript
|
||
new Hls({
|
||
enableWorker: true, // Use Web Worker
|
||
lowLatencyMode: false, // VOD, no live
|
||
backBufferLength: 90, // Keep 90s behind
|
||
maxBufferLength: 30, // Buffer 30s ahead
|
||
maxMaxBufferLength: 60, // Max 60s total
|
||
maxBufferSize: 60 * 1000 * 1000, // 60 MB
|
||
maxBufferHole: 0.5 // Max gap: 0.5s
|
||
})
|
||
```
|
||
|
||
## Backend Stack
|
||
|
||
### Nitro (v2.12.6)
|
||
**Features:**
|
||
- Universal JavaScript server
|
||
- Route handlers (h3)
|
||
- Middleware system
|
||
- Static file serving
|
||
- Build optimizations
|
||
|
||
**Request Flow:**
|
||
```
|
||
Request → Nitro Router
|
||
├─ Match static asset? → Serve directly
|
||
├─ Match API route? → Execute handler
|
||
├─ Match middleware? → Run middleware chain
|
||
└─ Default → SSR renderer
|
||
```
|
||
|
||
### Node.js APIs Usados
|
||
|
||
**File System:**
|
||
```typescript
|
||
import { promises as fs } from 'fs'
|
||
import { stat } from 'fs/promises'
|
||
import { createReadStream } from 'fs'
|
||
|
||
// Async operations
|
||
await fs.readdir(path)
|
||
await fs.readFile(file, 'utf-8')
|
||
await stat(filePath)
|
||
|
||
// Streaming
|
||
const stream = createReadStream(filePath, { start, end })
|
||
stream.pipe(response)
|
||
```
|
||
|
||
**Path Manipulation:**
|
||
```typescript
|
||
import { join, basename } from 'path'
|
||
|
||
const fullPath = join(videosPath, fileName)
|
||
const name = basename(file, '.mp4')
|
||
```
|
||
|
||
## DevOps Stack
|
||
|
||
### Cloudflare Tunnel
|
||
**Componentes:**
|
||
1. **cloudflared** (client daemon)
|
||
- Maintains 4 concurrent connections
|
||
- Auto-reconnects on failure
|
||
- Load balances across connections
|
||
|
||
2. **Cloudflare Edge**
|
||
- 275+ data centers worldwide
|
||
- Anycast routing
|
||
- DDoS protection (Layer 3, 4, 7)
|
||
- WAF (Web Application Firewall)
|
||
|
||
**Protocol Stack:**
|
||
```
|
||
Application Layer: HTTP/1.1, HTTP/2, HTTP/3
|
||
Transport Layer: QUIC (multiplexed, encrypted)
|
||
Network Layer: IPv4 / IPv6
|
||
```
|
||
|
||
### WSL 2
|
||
**Architecture:**
|
||
```
|
||
Windows 11
|
||
├─ Hyper-V
|
||
│ └─ WSL 2 VM
|
||
│ └─ Ubuntu 22.04
|
||
│ ├─ Node.js v20
|
||
│ ├─ FFmpeg 4.2.7
|
||
│ └─ videoPlayer/
|
||
```
|
||
|
||
**Integration:**
|
||
- File sharing: `\\wsl$\Ubuntu\...`
|
||
- Network: NAT with port forwarding
|
||
- Process: `wsl.exe` launcher
|
||
|
||
## Video Processing
|
||
|
||
### FFmpeg 4.2.7
|
||
**Codecs Usados:**
|
||
- **Video:** libx264 (H.264/AVC)
|
||
- Profile: High
|
||
- Level: 4.0
|
||
- Preset: medium (balance speed/quality)
|
||
|
||
- **Audio:** AAC
|
||
- Profile: LC (Low Complexity)
|
||
- Channels: Stereo
|
||
- Sample rate: 48kHz
|
||
|
||
**Parámetros Clave:**
|
||
```bash
|
||
-vf "scale=-2:1080" # Maintain aspect ratio
|
||
-b:v 3M # Target bitrate: 3 Mbps
|
||
-maxrate 3.5M # Max bitrate (bursts)
|
||
-bufsize 6M # VBV buffer size
|
||
-c:a aac # Audio codec
|
||
-b:a 192k # Audio bitrate
|
||
-f hls # Format: HLS
|
||
-hls_time 6 # Segment duration
|
||
-hls_playlist_type vod # Video on Demand
|
||
```
|
||
|
||
---
|
||
|
||
# 🎯 Problemas y Soluciones Completas
|
||
|
||
## Problema 1: Source Tag Conflict
|
||
|
||
### Síntoma
|
||
```
|
||
Video no carga al inicio
|
||
Pero funciona después de cambiar calidad
|
||
Console: No errors
|
||
```
|
||
|
||
### Debug Process
|
||
1. **Inspección del DOM:**
|
||
```html
|
||
<video>
|
||
<source src="/videos/DJI_0343_hls/480p.m3u8" type="video/mp4">
|
||
</video>
|
||
```
|
||
|
||
2. **Network Tab:**
|
||
```
|
||
480p.m3u8: 200 OK
|
||
Content-Type: application/vnd.apple.mpegurl
|
||
|
||
Pero el video element intenta parsear como MP4!
|
||
```
|
||
|
||
3. **Root Cause:**
|
||
Browser native `<source>` parser runs BEFORE hls.js gets a chance.
|
||
|
||
### Solución
|
||
```vue
|
||
<!-- ANTES (broken) -->
|
||
<video>
|
||
<source :src="currentVideoUrl" type="video/mp4">
|
||
</video>
|
||
|
||
<!-- DESPUÉS (working) -->
|
||
<video ref="videoElement">
|
||
<!-- No source tag! -->
|
||
</video>
|
||
|
||
<script>
|
||
watch(() => props.videoUrl, (newUrl) => {
|
||
nextTick(() => {
|
||
if (newUrl.endsWith('.m3u8')) {
|
||
initHLS(newUrl) // JavaScript control
|
||
} else {
|
||
videoElement.value.src = newUrl
|
||
videoElement.value.load()
|
||
}
|
||
})
|
||
})
|
||
</script>
|
||
```
|
||
|
||
### Lección
|
||
JavaScript-managed video source > declarative HTML cuando se usa MSE.
|
||
|
||
---
|
||
|
||
## Problema 2: Mobile Controls
|
||
|
||
### Síntoma
|
||
```
|
||
Mobile users: "No puedo ver los controles"
|
||
Desktop: Works fine
|
||
```
|
||
|
||
### Root Cause
|
||
Controles diseñados para hover:
|
||
```css
|
||
.controls-overlay {
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
}
|
||
|
||
.video-player:hover .controls-overlay {
|
||
opacity: 1;
|
||
}
|
||
```
|
||
|
||
**Problema:** Móvil no tiene hover!
|
||
|
||
### Solución Progressive
|
||
```css
|
||
/* Base (mobile-first) */
|
||
.controls-overlay {
|
||
opacity: 1;
|
||
pointer-events: all;
|
||
}
|
||
|
||
/* Desktop enhancement */
|
||
@media (min-width: 769px) {
|
||
.controls-overlay {
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.video-player:hover .controls-overlay,
|
||
.controls-overlay.visible {
|
||
opacity: 1;
|
||
pointer-events: all;
|
||
}
|
||
}
|
||
```
|
||
|
||
### Mejoras Táctiles
|
||
```css
|
||
@media (max-width: 768px) {
|
||
/* Touch targets: 48x48px minimum */
|
||
.control-btn {
|
||
min-width: 48px;
|
||
min-height: 48px;
|
||
padding: 12px;
|
||
}
|
||
|
||
/* Progress bar más gruesa */
|
||
.progress-bar {
|
||
height: 8px;
|
||
}
|
||
|
||
.progress-thumb {
|
||
width: 20px;
|
||
height: 20px;
|
||
}
|
||
|
||
/* Simplificar UI */
|
||
.volume-control { display: none; }
|
||
.speed-btn { display: none; }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Problema 3: DJI_0345 Missing Qualities
|
||
|
||
### Síntoma
|
||
```
|
||
3 videos: Tienen todas las calidades
|
||
1 video (DJI_0345): Solo muestra "Original"
|
||
```
|
||
|
||
### Investigation
|
||
```bash
|
||
$ ls -lh videos/DJI_0345_hls/
|
||
total 115M
|
||
-rw-r--r-- 1080p0.vtt
|
||
-rw-r--r-- 1080p1.vtt
|
||
...
|
||
# Solo .vtt files, NO .m3u8 ni .ts!
|
||
```
|
||
|
||
**Conclusión:** Conversión FFmpeg se interrumpió.
|
||
|
||
### Causa Raíz
|
||
Script `convert-to-hls.sh` no valida éxito:
|
||
```bash
|
||
ffmpeg -i "$input" ...
|
||
# No error checking!
|
||
|
||
echo "✅ Conversión exitosa" # Mentira!
|
||
```
|
||
|
||
### Solución
|
||
```bash
|
||
# Verificar archivos críticos
|
||
if [ ! -f "$output_dir/master.m3u8" ]; then
|
||
echo "❌ Error: master.m3u8 no creado"
|
||
rm -rf "$output_dir"
|
||
exit 1
|
||
fi
|
||
|
||
if [ ! -f "$output_dir/480p_000.ts" ]; then
|
||
echo "❌ Error: Segmentos no creados"
|
||
rm -rf "$output_dir"
|
||
exit 1
|
||
fi
|
||
|
||
# Verificar que FFmpeg terminó exitosamente
|
||
if [ $? -ne 0 ]; then
|
||
echo "❌ FFmpeg falló"
|
||
rm -rf "$output_dir"
|
||
exit 1
|
||
fi
|
||
```
|
||
|
||
### Recovery
|
||
```bash
|
||
rm -rf videos/DJI_0345_hls
|
||
./convert-to-hls.sh DJI_0345.mp4
|
||
|
||
# Esperar 8-10 minutos
|
||
# ✅ Ahora tiene 1080p, 720p, 480p
|
||
```
|
||
|
||
---
|
||
|
||
# 📚 Referencia de APIs
|
||
|
||
## GET /api/videos
|
||
|
||
### Request
|
||
```
|
||
GET /api/videos HTTP/1.1
|
||
Host: video.nucleoriofrio.com
|
||
```
|
||
|
||
### Response
|
||
```json
|
||
{
|
||
"videos": [
|
||
{
|
||
"id": "DJI_0343",
|
||
"name": "DJI_0343",
|
||
"extension": ".mp4",
|
||
"url": "/videos/DJI_0343_hls/master.m3u8",
|
||
"isHLS": true,
|
||
"qualities": [
|
||
{
|
||
"quality": "1080p",
|
||
"label": "1080P",
|
||
"url": "/videos/DJI_0343_hls/1080p.m3u8",
|
||
"file": "1080p.m3u8",
|
||
"isHLS": true
|
||
},
|
||
{
|
||
"quality": "720p",
|
||
"label": "720P",
|
||
"url": "/videos/DJI_0343_hls/720p.m3u8",
|
||
"file": "720p.m3u8",
|
||
"isHLS": true
|
||
},
|
||
{
|
||
"quality": "480p",
|
||
"label": "480P",
|
||
"url": "/videos/DJI_0343_hls/480p.m3u8",
|
||
"file": "480p.m3u8",
|
||
"isHLS": true
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Lógica Interna
|
||
```typescript
|
||
1. Leer archivos en /videos
|
||
2. Filtrar videos base (sin sufijo _480p, etc)
|
||
3. Para cada video:
|
||
a. Buscar carpeta {nombre}_hls/
|
||
b. Si existe:
|
||
- Leer archivos .m3u8
|
||
- Excluir master.m3u8
|
||
- Excluir *_vtt.m3u8 (subtítulos)
|
||
- Crear array de calidades
|
||
c. Si no existe:
|
||
- Buscar archivos {nombre}_480p.mp4, etc
|
||
- Crear array de calidades
|
||
4. Ordenar calidades (4k → 480p → auto)
|
||
5. Return JSON
|
||
```
|
||
|
||
## POST /api/config
|
||
|
||
### Request
|
||
```
|
||
POST /api/config HTTP/1.1
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"password": "431522",
|
||
"action": "toggle-autostart"
|
||
}
|
||
```
|
||
|
||
### Response (Success)
|
||
```json
|
||
{
|
||
"success": true,
|
||
"autostart": true
|
||
}
|
||
```
|
||
|
||
### Response (Error)
|
||
```json
|
||
{
|
||
"statusCode": 401,
|
||
"message": "Contraseña incorrecta"
|
||
}
|
||
```
|
||
|
||
### Actions Disponibles
|
||
|
||
**1. get-status:**
|
||
```json
|
||
{
|
||
"password": "431522",
|
||
"action": "get-status"
|
||
}
|
||
// Response: { "success": true, "autostart": true }
|
||
```
|
||
|
||
**2. toggle-autostart:**
|
||
```json
|
||
{
|
||
"password": "431522",
|
||
"action": "toggle-autostart"
|
||
}
|
||
// Response: { "success": true, "autostart": false }
|
||
// Side effect: Escribe a .autostart-config
|
||
```
|
||
|
||
---
|
||
|
||
# 🚀 Deployment
|
||
|
||
## Cloudflare Tunnel Setup
|
||
|
||
### Instalación Inicial
|
||
```bash
|
||
# 1. Download cloudflared
|
||
wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64
|
||
chmod +x cloudflared-linux-amd64
|
||
mv cloudflared-linux-amd64 cloudflared
|
||
|
||
# 2. Authenticate
|
||
./cloudflared tunnel login
|
||
# Opens browser → Login to Cloudflare → Select domain
|
||
|
||
# 3. Create tunnel
|
||
./cloudflared tunnel create video-player
|
||
# Output: Tunnel ID: 472caf0e-7488-4872-ad96-fc5b296d162d
|
||
|
||
# 4. Configure DNS
|
||
./cloudflared tunnel route dns video-player video.nucleoriofrio.com
|
||
|
||
# 5. Create config
|
||
mkdir -p ~/.cloudflared
|
||
cat > ~/.cloudflared/config.yml << EOF
|
||
tunnel: 472caf0e-7488-4872-ad96-fc5b296d162d
|
||
credentials-file: /home/draganel/.cloudflared/472caf0e-7488-4872-ad96-fc5b296d162d.json
|
||
|
||
ingress:
|
||
- hostname: video.nucleoriofrio.com
|
||
service: http://localhost:3000
|
||
- service: http_status:404
|
||
EOF
|
||
|
||
# 6. Run tunnel
|
||
./cloudflared tunnel run video-player
|
||
```
|
||
|
||
### Monitoring
|
||
|
||
**Connection Status:**
|
||
```bash
|
||
./cloudflared tunnel info video-player
|
||
```
|
||
|
||
**Logs:**
|
||
```bash
|
||
tail -f /tmp/cloudflared.log
|
||
```
|
||
|
||
**Metrics:**
|
||
- Cloudflare Dashboard → Traffic → Analytics
|
||
- Real-time requests
|
||
- Bandwidth usage
|
||
- Error rates
|
||
|
||
---
|
||
|
||
## Conclusión
|
||
|
||
Este proyecto demuestra una implementación completa de:
|
||
- ✅ Modern web framework (Nuxt 4)
|
||
- ✅ Advanced streaming (HLS)
|
||
- ✅ Cloud networking (Cloudflare Tunnel)
|
||
- ✅ Cross-platform (WSL + Windows)
|
||
- ✅ Production-ready deployment
|
||
|
||
**Tiempo total:** ~12 horas
|
||
**Líneas de código:** ~2700
|
||
**Tecnologías:** 8 principales
|
||
**Problemas resueltos:** 10+
|
||
|
||
---
|
||
|
||
*Documentación creada con ❤️ por Claude & Usuario | Octubre 2025*
|