Migrate to Chromium, unified VNC, thumbnails, autologin CDP detection

---

- Migrado base Docker de ubuntu:22.04 + Google Chrome para debian:bookworm-slim + Chromium
- Dockerfile refatorado com multi-stage build (node:22-alpine builder + debian runtime) e single RUN layer para imagem menor
- VNC unificado: removido novnc por stream, substituído por websockify global na porta 6080 com token-based routing
- Implementado sistema de thumbnails por stream via ffmpeg (captura do HLS) com endpoint GET/POST e atualização no card
- Autologin reescrito com detecção via Chrome DevTools Protocol: pula credenciais se já autenticado
- Adicionado padrão desiredState (running/stopped) persistido no JSON, restaurado via restore-streams.sh ao reiniciar container
- UI traduzida para inglês, formulário reorganizado com tooltips, seção avançada colapsável e GOP automático
- Player simplificado: modos HLS e HTML unificados, removido modo m3u8 separado
- Adicionado campo threads no ffmpeg; suporte a seccomp:unconfined no docker-compose

---
This commit is contained in:
2026-04-24 23:08:42 -03:00
parent 30b0597380
commit 1f8385e450
29 changed files with 1084 additions and 5412 deletions
+18
View File
@@ -0,0 +1,18 @@
# Dev
node_modules
.next
npm-debug.log*
# Git
.git
.gitignore
# Docker
.dockerignore
# Desnecessário no contexto de build
README.md
*.sh
.env*
.vscode
*.md
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Kralot
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+148
View File
@@ -1 +1,149 @@
# DecapStream # DecapStream
Turn any web page into an RTMP/HLS stream. Chromium renders the page, ffmpeg captures it, MediaMTX publishes it. Built for NOC environments and digital signage.
## How it works
Each stream runs its own isolated stack inside the container:
```
Xvfb (virtual display)
└── Chromium (opens the URL)
└── ffmpeg (x11grab → libx264 → RTMP → MediaMTX → HLS)
└── x11vnc (live VNC access via noVNC)
```
All processes are managed by Supervisord. The web UI is a Next.js app that controls everything via a REST API.
## Features
- **Stream any URL** — if it loads in a browser, it streams
- **Dashboard with live thumbnails** — captured from the HLS output, refreshable on demand
- **VNC access** — inspect any stream's virtual display from the browser via unified noVNC (single port, token routing)
- **Autologin with CDP detection** — configure credentials per stream; on restart, queries Chrome DevTools Protocol to skip login if the session is still alive
- **Persistent desired state** — streams remember if they were running or stopped and restore automatically on container restart
- **Fully configurable encoding** — resolution, scale, FPS, bitrate, preset, tune, GOP, threads, all per stream
- **Built-in HLS player** — watch any stream in the browser; also serves a standalone embeddable HTML page per stream
## Quick Start
```yaml
# docker-compose.yml
services:
decap-stream:
image: ghcr.io/riguettodev/decap-stream:latest
container_name: decap-stream
restart: unless-stopped
shm_size: "2gb"
security_opt:
- seccomp:unconfined # required for Chromium syscalls
environment:
TZ: America/Sao_Paulo
ports:
- "3000:3000" # Web UI
- "1935:1935" # RTMP input
- "8888:8888" # HLS output
- "6080:6080" # noVNC
volumes:
- decap-stream:/app/data
volumes:
decap-stream:
```
```bash
docker compose up -d
```
Open **http://localhost:3000** and add your first stream.
> `seccomp:unconfined` is required because Chromium uses syscalls blocked by Docker's default seccomp profile.
> `shm_size: 2gb` prevents Chromium from crashing on `/dev/shm` exhaustion under load.
## Ports
| Port | Service |
|------|---------|
| `3000` | Web UI (Next.js) |
| `1935` | RTMP ingest (MediaMTX) |
| `8888` | HLS output (MediaMTX) |
| `6080` | noVNC unified (token-based routing to all streams) |
## RTMP & HLS URLs
Each stream gets a slug ID you define (e.g. `grafana-prod`):
| Protocol | URL |
|----------|-----|
| RTMP ingest | `rtmp://<host>:1935/live/<id>` |
| HLS manifest | `http://<host>:8888/live/<id>/index.m3u8` |
| VNC | `http://<host>:6080/vnc.html?autoconnect=true&path=websockify%3Ftoken%3D<id>` |
## Stream Configuration
| Field | Default | Description |
|-------|---------|-------------|
| `id` | | Unique slug (lowercase, numbers, hyphens) |
| `name` | | Display name |
| `url` | | URL to open in Chromium |
| `user` / `pass` | | Credentials for autologin (optional) |
| `delay` | `15s` | Seconds before ffmpeg starts (allows page to load) |
| `resolution` | `1920x1080` | Virtual display and capture size |
| `scale` | `1280x720` | Output video resolution |
| `fps` | `30` | Capture framerate |
| `bitrate` | `1500k` | Video bitrate |
| `bufsize` | `3000k` | Encoder buffer size |
| `preset` | `ultrafast` | x264 preset |
| `tune` | `stillimage` | x264 tune (`stillimage` for dashboards, `zerolatency` for dynamic content) |
| `gop` | `60` | Keyframe interval (auto-calculated as 2× FPS in the UI) |
| `threads` | `0` | ffmpeg encoding threads (`0` = auto-detect) |
## Architecture
```
┌──────────────────────────────────────────────────────────────┐
│ Container │
│ │
│ Next.js :3000 ──API──► Supervisord │
│ ├── novnc :6080 (global) │
│ └── per stream: │
│ ├── xvfb (display) │
│ ├── chromium (browser) │
│ ├── autologin (CDP) │
│ ├── x11vnc (VNC) │
│ └── ffmpeg (encode) │
│ │ │
│ MediaMTX :1935/:8888 ◄────RTMP────────┘ │
└──────────────────────────────────────────────────────────────┘
```
- `streams.json` flat file + one directory per stream under `/app/data/streams/{id}/`
- Each stream generates a `stream.conf` from a template; Supervisord picks it up via `[include]`
- Display number `:n` is auto-allocated; VNC port = `5900+n`, debug port = `9221+n`
## Development
```bash
npm install
npm run dev # dev server — supervisorctl calls are mocked automatically
npm run build # build Next.js standalone
npm run lint
./build.sh # interactive Docker build
```
In dev mode (`NODE_ENV !== "production"`), all `supervisorctl` and `captureThumb` calls are replaced with console logs.
## Stack
- [Next.js 15](https://nextjs.org/) + TypeScript + Tailwind CSS v4
- [Supervisord](http://supervisord.org/)
- [MediaMTX](https://github.com/bluenviron/mediamtx)
- [HLS.js](https://github.com/video-dev/hls.js/)
- [noVNC](https://novnc.com/)
- Chromium, ffmpeg
## License
MIT
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
set -e set -e
DEFAULT_IMAGE="git.kralot.cloud/decap-stream" DEFAULT_IMAGE="git.kralot.cloud/kralot/decap-stream"
DEFAULT_VERSION="0.0.0" DEFAULT_VERSION="0.0.0"
DEFAULT_LATEST="latest" DEFAULT_LATEST="latest"
+14
View File
@@ -36,5 +36,19 @@ stderr_logfile=/app/data/logs/nextjs.log
# Cada stream gera /app/data/streams/{id}/stream.conf # Cada stream gera /app/data/streams/{id}/stream.conf
# O supervisord inclui todos automaticamente # O supervisord inclui todos automaticamente
[program:novnc]
command=websockify --web /usr/share/novnc --target-config /app/data/vnc-tokens 6080
autorestart=true
priority=3
stdout_logfile=/app/data/logs/novnc.log
stderr_logfile=/app/data/logs/novnc.log
[program:restore-streams]
command=/opt/scripts/restore-streams.sh
autorestart=false
priority=4
stdout_logfile=/app/data/logs/restore.log
stderr_logfile=/app/data/logs/restore.log
[include] [include]
files = /app/data/streams/*/stream.conf files = /app/data/streams/*/stream.conf
+45 -39
View File
@@ -1,55 +1,61 @@
FROM ubuntu:22.04 # ── Stage 1: Next.js build ───────────────────────────────────────────────────
ENV DEBIAN_FRONTEND=noninteractive FROM node:22-alpine AS builder
ENV TZ=America/Sao_Paulo
# ── Sistema base ─────────────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y \
xvfb x11vnc novnc websockify \
ffmpeg supervisor curl wget gnupg xdotool tzdata \
--no-install-recommends && \
ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime && \
wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \
apt-get install -y ./google-chrome-stable_current_amd64.deb && \
rm google-chrome-stable_current_amd64.deb && \
rm -rf /var/lib/apt/lists/*
# ── Node.js 22 ───────────────────────────────────────────────────────────────
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*
# ── MediaMTX ─────────────────────────────────────────────────────────────────
ARG MEDIAMTX_VERSION=1.17.1
RUN wget -q "https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz" -O /tmp/mediamtx.tar.gz && \
tar -xzf /tmp/mediamtx.tar.gz -C /usr/local/bin mediamtx && \
rm /tmp/mediamtx.tar.gz
# ── Next.js build ────────────────────────────────────────────────────────────
WORKDIR /build WORKDIR /build
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY src/ ./src/ COPY src/ ./src/
COPY next.config.ts tsconfig.json postcss.config.mjs ./ COPY next.config.ts tsconfig.json postcss.config.mjs ./
RUN npm run build RUN npm run build
# ── Montar app standalone ──────────────────────────────────────────────────── # ── Stage 2: runtime ─────────────────────────────────────────────────────────
RUN mkdir -p /app/.next && \ FROM debian:bookworm-slim
cp -r .next/standalone/. /app/ && \ ENV DEBIAN_FRONTEND=noninteractive
cp -r .next/static /app/.next/static && \
mkdir -p /app/public && \ ARG MEDIAMTX_VERSION=1.17.1
(cp -r public/. /app/public/ 2>/dev/null || true)
# Tudo em um único RUN para que o cleanup seja efetivo no tamanho final
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
xvfb x11vnc novnc websockify \
ffmpeg supervisor xdotool tzdata \
chromium \
curl gnupg \
&& ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \
\
# Node.js 22
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
\
# MediaMTX
&& curl -fsSL "https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz" \
-o /tmp/mediamtx.tar.gz \
&& tar -xzf /tmp/mediamtx.tar.gz -C /usr/local/bin mediamtx \
\
# Remove ferramentas usadas só no build
&& apt-get remove -y curl gnupg \
&& apt-get autoremove -y \
&& apt-get clean \
&& find /usr/lib/chromium/locales -name '*.pak' ! -name 'en-US.pak' -delete \
&& rm -rf \
/var/lib/apt/lists/* \
/tmp/* /var/tmp/* \
/usr/share/doc \
/usr/share/man \
/usr/share/locale \
/usr/lib/locale
COPY --from=builder /build/.next/standalone/ /app/
COPY --from=builder /build/.next/static/ /app/.next/static/
# ── Configs e scripts ────────────────────────────────────────────────────────
COPY config/supervisord.conf /etc/supervisor/supervisord.conf COPY config/supervisord.conf /etc/supervisor/supervisord.conf
COPY config/mediamtx.yml /etc/mediamtx.yml COPY config/mediamtx.yml /etc/mediamtx.yml
COPY scripts/ /opt/scripts/ COPY scripts/ /opt/scripts/
RUN chmod +x /opt/scripts/*.sh
# ── Entrypoint ───────────────────────────────────────────────────────────────
COPY docker/entrypoint.sh /entrypoint.sh COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /opt/scripts/*.sh /entrypoint.sh
VOLUME ["/app/data"] VOLUME ["/app/data"]
EXPOSE 3000 1935 8888 6081 EXPOSE 3000 1935 8888 6080
CMD ["/entrypoint.sh"] CMD ["/entrypoint.sh"]
+1 -1
View File
@@ -1,5 +1,5 @@
SHELL := /bin/bash SHELL := /bin/bash
IMAGE ?= git.kralot.cloud/decap-stream IMAGE ?= git.kralot.cloud/kralot/decap-stream
TAG ?= "" TAG ?= ""
.PHONY: build push .PHONY: build push
+4 -2
View File
@@ -1,16 +1,18 @@
services: services:
decap-stream: decap-stream:
image: git.kralot.cloud/decap-stream:latest image: git.kralot.cloud/kralot/decap-stream:latest
container_name: decap-stream container_name: decap-stream
restart: unless-stopped restart: unless-stopped
shm_size: "2gb" shm_size: "2gb"
security_opt:
- seccomp:unconfined
environment: environment:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo
ports: ports:
- "3000:3000" # Web UI - "3000:3000" # Web UI
- "1935:1935" # RTMP (MediaMTX) - "1935:1935" # RTMP (MediaMTX)
- "8888:8888" # HLS (MediaMTX) - "8888:8888" # HLS (MediaMTX)
- "6081:6081" # VNC (noVNC) - "6080:6080" # VNC (noVNC)
volumes: volumes:
- decap-stream:/app/data - decap-stream:/app/data
+12 -4
View File
@@ -1,13 +1,21 @@
#!/bin/bash #!/bin/bash
set -e set -e
# Garante estrutura de dados persistente
mkdir -p /app/data/streams mkdir -p /app/data/streams
mkdir -p /app/data/logs mkdir -p /app/data/logs
mkdir -p /app/data/vnc-tokens
# Restaura streams que existiam antes de um restart
# O supervisord já vai incluir os stream.conf via [include]
# Garante que os scripts têm permissão de execução
find /app/data/streams -name "*.sh" -exec chmod +x {} \; find /app/data/streams -name "*.sh" -exec chmod +x {} \;
# #19 — restaura streams para o desiredState após restart do container
NODE_PATH=/app/node_modules node -e "
const fs = require('fs');
const { execSync } = require('child_process');
const streamsFile = '/app/data/streams.json';
if (!fs.existsSync(streamsFile)) process.exit(0);
const streams = JSON.parse(fs.readFileSync(streamsFile, 'utf-8'));
// Apenas aguarda o supervisord estar pronto, o restore ocorre via script separado
fs.writeFileSync('/app/data/.pending-restore', JSON.stringify(streams.map(s => ({ id: s.id, desiredState: s.desiredState }))));
" 2>/dev/null || true
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
+101 -4958
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -9,17 +9,12 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@tabler/icons-react": "^3.40.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"nanoid": "^5.1.7", "nanoid": "^5.1.7",
"next": "^16.2.2", "next": "^16.2.2",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"shadcn": "^4.0.3",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, },
+29 -3
View File
@@ -1,11 +1,37 @@
#!/bin/bash #!/bin/bash
# Gerado automaticamente pela API — não editar manualmente # Auto-generated by API — do not edit manually
# Stream: {{STREAM_ID}} # Stream: {{STREAM_ID}}
[ -z "{{USER}}" ] && exit 0
sleep {{STREAM_DELAY}}
# Query Chrome DevTools Protocol to detect current page URL
CURRENT_URL=$(node -e "
const http = require('http');
http.get('http://localhost:{{DEBUG_PORT}}/json', res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => {
try {
const tabs = JSON.parse(d);
const page = tabs.find(t => t.type === 'page');
process.stdout.write(page ? page.url : '');
} catch { process.stdout.write(''); }
});
}).on('error', () => process.stdout.write(''));
" 2>/dev/null)
# If we got a URL and it doesn't look like a login page, skip autologin
if [ -n "$CURRENT_URL" ] && ! echo "$CURRENT_URL" | grep -qiE '/(login|signin|sign-in|auth|sso|oauth)'; then
exit 0
fi
DISPLAY={{DISPLAY}} xdotool search --sync --onlyvisible --class chromium windowfocus windowraise
sleep 1
DISPLAY={{DISPLAY}} xdotool type --clearmodifiers --delay 50 "{{USER}}" DISPLAY={{DISPLAY}} xdotool type --clearmodifiers --delay 50 "{{USER}}"
DISPLAY={{DISPLAY}} xdotool key Tab DISPLAY={{DISPLAY}} xdotool key Tab
sleep 0.3 sleep 0.3
DISPLAY={{DISPLAY}} xdotool type --clearmodifiers --delay 50 "{{PASS}}" DISPLAY={{DISPLAY}} xdotool type --clearmodifiers --delay 50 "{{PASS}}"
DISPLAY={{DISPLAY}} xdotool key Return DISPLAY={{DISPLAY}} xdotool key Return
sleep 3
DISPLAY={{DISPLAY}} xdotool key F11
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# #19 — aguarda supervisord e restaura streams para o desiredState salvo
sleep 5
STREAMS_FILE="/app/data/streams.json"
[ -f "$STREAMS_FILE" ] || exit 0
node -e "
const fs = require('fs');
const { execSync } = require('child_process');
const streams = JSON.parse(fs.readFileSync('$STREAMS_FILE', 'utf-8'));
const ctl = (cmd) => { try { execSync('supervisorctl -c /etc/supervisor/supervisord.conf ' + cmd, { stdio: 'pipe' }); } catch {} };
for (const s of streams) {
if (s.desiredState === 'running') {
console.log('[restore] starting', s.id);
['xvfb','chromium','autologin','x11vnc','ffmpeg'].forEach(p => ctl('start ' + p + '-' + s.id));
} else {
console.log('[restore] keeping stopped', s.id);
}
}
"
+16 -19
View File
@@ -8,29 +8,32 @@ priority=10
stdout_logfile=/app/data/streams/{{STREAM_ID}}/xvfb.log stdout_logfile=/app/data/streams/{{STREAM_ID}}/xvfb.log
stderr_logfile=/app/data/streams/{{STREAM_ID}}/xvfb.log stderr_logfile=/app/data/streams/{{STREAM_ID}}/xvfb.log
[program:chrome-{{STREAM_ID}}] [program:chromium-{{STREAM_ID}}]
command=bash -c "rm -rf /app/data/streams/{{STREAM_ID}}/chrome-profile/Singleton* && google-chrome \ command=bash -c "rm -rf \
/app/data/streams/{{STREAM_ID}}/chrome-profile/Singleton* \
/app/data/streams/{{STREAM_ID}}/chrome-profile/.org.chromium.* \
'/app/data/streams/{{STREAM_ID}}/chrome-profile/Default/Crash Reports' \
/app/data/streams/{{STREAM_ID}}/chrome-profile/Default/.org.chromium.* \
&& chromium \
--no-sandbox \ --no-sandbox \
--disable-gpu \
--window-size={{RESOLUTION}} \
--start-maximized \
--user-data-dir=/app/data/streams/{{STREAM_ID}}/chrome-profile \
--test-type \ --test-type \
--disable-infobars \ --disable-gpu \
--window-size={{CHROME_SIZE}} \
--start-fullscreen \
--user-data-dir=/app/data/streams/{{STREAM_ID}}/chrome-profile \
--no-first-run \ --no-first-run \
--disable-extensions \ --disable-extensions \
--disable-background-networking \ --disable-background-networking \
--disable-sync \ --disable-sync \
--disable-translate \
--disable-background-timer-throttling \ --disable-background-timer-throttling \
--remote-debugging-port={{DEBUG_PORT}} \ --remote-debugging-port={{DEBUG_PORT}} \
"{{STREAM_URL}}"" '{{STREAM_URL}}'"
environment=DISPLAY={{DISPLAY}} environment=DISPLAY={{DISPLAY}}
autorestart=true autorestart=true
priority=20 priority=20
startsecs=5 startsecs=5
stdout_logfile=/app/data/streams/{{STREAM_ID}}/chrome.log stdout_logfile=/app/data/streams/{{STREAM_ID}}/chromium.log
stderr_logfile=/app/data/streams/{{STREAM_ID}}/chrome.log stderr_logfile=/app/data/streams/{{STREAM_ID}}/chromium.log
[program:autologin-{{STREAM_ID}}] [program:autologin-{{STREAM_ID}}]
command=/app/data/streams/{{STREAM_ID}}/autologin.sh command=/app/data/streams/{{STREAM_ID}}/autologin.sh
@@ -43,22 +46,16 @@ stderr_logfile=/app/data/streams/{{STREAM_ID}}/autologin.log
[program:x11vnc-{{STREAM_ID}}] [program:x11vnc-{{STREAM_ID}}]
environment=DISPLAY={{DISPLAY}} environment=DISPLAY={{DISPLAY}}
command=bash -c "while [ ! -e /tmp/.X11-unix/X$(echo $DISPLAY | cut -d: -f2 | cut -d. -f1) ]; do sleep 0.2; done; exec x11vnc -nopw -listen 0.0.0.0 -rfbport {{VNC_PORT}} -xkb -forever -shared" command=bash -c "while [ ! -e /tmp/.X11-unix/X$(echo $DISPLAY | cut -d: -f2 | cut -d. -f1) ]; do sleep 0.2; done; exec x11vnc -nopw -listen 0.0.0.0 -rfbport {{VNC_PORT}} -xkb -forever -shared -threads"
autorestart=true autorestart=true
priority=40 priority=40
stdout_logfile=/app/data/streams/{{STREAM_ID}}/vnc.log stdout_logfile=/app/data/streams/{{STREAM_ID}}/vnc.log
stderr_logfile=/app/data/streams/{{STREAM_ID}}/vnc.log stderr_logfile=/app/data/streams/{{STREAM_ID}}/vnc.log
[program:novnc-{{STREAM_ID}}]
command=websockify --web /usr/share/novnc {{NOVNC_PORT}} localhost:{{VNC_PORT}}
autorestart=true
priority=50
stdout_logfile=/app/data/streams/{{STREAM_ID}}/novnc.log
stderr_logfile=/app/data/streams/{{STREAM_ID}}/novnc.log
[program:ffmpeg-{{STREAM_ID}}] [program:ffmpeg-{{STREAM_ID}}]
command=bash -c "sleep {{STREAM_DELAY}} && ffmpeg \ command=bash -c "sleep {{STREAM_DELAY}} && ffmpeg \
-loglevel warning \ -loglevel warning \
-threads {{THREADS}} \
-f x11grab \ -f x11grab \
-video_size {{RESOLUTION}} \ -video_size {{RESOLUTION}} \
-framerate {{FPS}} \ -framerate {{FPS}} \
+16 -7
View File
@@ -1,20 +1,29 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { getStream } from "@/lib/db" import { getStream, saveStream } from "@/lib/db"
import { startStream, stopStream, restartStream } from "@/lib/supervisor" import { startStream, stopStream, restartStream } from "@/lib/supervisor"
type Ctx = { params: Promise<{ id: string; action: string }> } type Ctx = { params: Promise<{ id: string; action: string }> }
export async function POST(_req: Request, { params }: Ctx) { export async function POST(_req: Request, { params }: Ctx) {
const { id, action } = await params const { id, action } = await params
const stream = getStream(id)
if (!getStream(id)) return NextResponse.json({ error: "não encontrado" }, { status: 404 }) if (!stream) return NextResponse.json({ error: "not found" }, { status: 404 })
switch (action) { switch (action) {
case "start": startStream(id); break case "start":
case "stop": stopStream(id); break saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() }) // #19
case "restart": restartStream(id); break startStream(id)
break
case "stop":
saveStream({ ...stream, desiredState: "stopped", updatedAt: new Date().toISOString() }) // #19
stopStream(id)
break
case "restart":
saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() }) // #19
restartStream(id)
break
default: default:
return NextResponse.json({ error: "ação inválida" }, { status: 400 }) return NextResponse.json({ error: "invalid action" }, { status: 400 })
} }
return NextResponse.json({ ok: true }) return NextResponse.json({ ok: true })
+3 -3
View File
@@ -8,14 +8,14 @@ type Ctx = { params: Promise<{ id: string }> }
export async function GET(_req: Request, { params }: Ctx) { export async function GET(_req: Request, { params }: Ctx) {
const { id } = await params const { id } = await params
const stream = getStream(id) const stream = getStream(id)
if (!stream) return NextResponse.json({ error: "não encontrado" }, { status: 404 }) if (!stream) return NextResponse.json({ error: "not found" }, { status: 404 })
return NextResponse.json(stream) return NextResponse.json(stream)
} }
export async function PATCH(req: Request, { params }: Ctx) { export async function PATCH(req: Request, { params }: Ctx) {
const { id } = await params const { id } = await params
const stream = getStream(id) const stream = getStream(id)
if (!stream) return NextResponse.json({ error: "não encontrado" }, { status: 404 }) if (!stream) return NextResponse.json({ error: "not found" }, { status: 404 })
const body = (await req.json()) as StreamUpdate const body = (await req.json()) as StreamUpdate
// id e portas não podem ser alterados via PATCH // id e portas não podem ser alterados via PATCH
@@ -32,7 +32,7 @@ export async function PATCH(req: Request, { params }: Ctx) {
export async function DELETE(_req: Request, { params }: Ctx) { export async function DELETE(_req: Request, { params }: Ctx) {
const { id } = await params const { id } = await params
if (!getStream(id)) return NextResponse.json({ error: "não encontrado" }, { status: 404 }) if (!getStream(id)) return NextResponse.json({ error: "not found" }, { status: 404 })
removeStream(id) removeStream(id)
deleteStream(id) deleteStream(id)
+1 -1
View File
@@ -6,6 +6,6 @@ type Ctx = { params: Promise<{ id: string }> }
export async function GET(_req: Request, { params }: Ctx) { export async function GET(_req: Request, { params }: Ctx) {
const { id } = await params const { id } = await params
if (!getStream(id)) return NextResponse.json({ error: "não encontrado" }, { status: 404 }) if (!getStream(id)) return NextResponse.json({ error: "not found" }, { status: 404 })
return NextResponse.json(getStreamStatus(id)) return NextResponse.json(getStreamStatus(id))
} }
+26
View File
@@ -0,0 +1,26 @@
import fs from "fs"
import path from "path"
import { NextResponse } from "next/server"
import { getStream } from "@/lib/db"
import { captureThumb } from "@/lib/supervisor"
const DATA_DIR = process.env.DATA_DIR ?? "/app/data"
type Ctx = { params: Promise<{ id: string }> }
export async function GET(_req: Request, { params }: Ctx) {
const { id } = await params
const thumbPath = path.join(DATA_DIR, "streams", id, "thumb.jpg")
if (!fs.existsSync(thumbPath)) return new Response("not found", { status: 404 })
const buffer = fs.readFileSync(thumbPath)
return new Response(buffer, {
headers: { "Content-Type": "image/jpeg", "Cache-Control": "no-cache, no-store" },
})
}
export async function POST(_req: Request, { params }: Ctx) {
const { id } = await params
if (!getStream(id)) return NextResponse.json({ error: "not found" }, { status: 404 })
captureThumb(id, 5)
return NextResponse.json({ ok: true })
}
+2 -2
View File
@@ -9,8 +9,8 @@ export async function GET(req: Request) {
const lines = ["#EXTM3U"] const lines = ["#EXTM3U"]
for (const s of streams) { for (const s of streams) {
lines.push(`#EXTINF:-1,${s.name}`) lines.push(`#EXTINF:-1 tvg-id="${s.id}" tvg-name="${s.name}" group-title="DecapStream",${s.name} [${s.id}] ${s.resolution} ${s.fps}fps`)
lines.push(`http://${host}:${port}/live/${s.id}/index.m3u8`) lines.push(`http://${host}:${port}/live/${s.id}`)
} }
return new Response(lines.join("\n"), { return new Response(lines.join("\n"), {
+7 -4
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { readStreams, saveStream, allocatePorts, getStream } from "@/lib/db" import { readStreams, saveStream, allocatePorts, getStream } from "@/lib/db"
import { provisionStream, startStream } from "@/lib/supervisor" import { provisionStream, startStream, normalizeScale, captureThumb } from "@/lib/supervisor"
import { STREAM_DEFAULTS, type StreamCreate } from "@/types/stream" import { STREAM_DEFAULTS, type StreamCreate } from "@/types/stream"
export async function GET() { export async function GET() {
@@ -13,13 +13,13 @@ export async function POST(req: Request) {
const body = (await req.json()) as StreamCreate const body = (await req.json()) as StreamCreate
if (!body.id || !SLUG_RE.test(body.id)) if (!body.id || !SLUG_RE.test(body.id))
return NextResponse.json({ error: "id inválido: use apenas letras minúsculas, números e hífen" }, { status: 400 }) return NextResponse.json({ error: "invalid id: use only lowercase letters, numbers and hyphens" }, { status: 400 })
if (!body.name || !body.url) if (!body.name || !body.url)
return NextResponse.json({ error: "name e url são obrigatórios" }, { status: 400 }) return NextResponse.json({ error: "name and url are required" }, { status: 400 })
if (getStream(body.id)) if (getStream(body.id))
return NextResponse.json({ error: "já existe uma stream com esse id" }, { status: 409 }) return NextResponse.json({ error: "a stream with this id already exists" }, { status: 409 })
const ports = allocatePorts() const ports = allocatePorts()
const now = new Date().toISOString() const now = new Date().toISOString()
@@ -27,7 +27,9 @@ export async function POST(req: Request) {
const stream = { const stream = {
...STREAM_DEFAULTS, ...STREAM_DEFAULTS,
...body, ...body,
scale: normalizeScale(body.scale ?? STREAM_DEFAULTS.scale), // #13
...ports, ...ports,
desiredState: "running" as const, // #19
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
} }
@@ -35,6 +37,7 @@ export async function POST(req: Request) {
saveStream(stream) saveStream(stream)
provisionStream(stream) provisionStream(stream)
startStream(stream.id) startStream(stream.id)
captureThumb(stream.id, 60)
return NextResponse.json(stream, { status: 201 }) return NextResponse.json(stream, { status: 201 })
} }
+6 -1
View File
@@ -15,7 +15,8 @@
--primary-foreground: #0a0a0a; --primary-foreground: #0a0a0a;
--destructive: #ef4444; --destructive: #ef4444;
--destructive-foreground: #fff; --destructive-foreground: #fff;
--accent: #1a1a1a; --accent: #2a2a2a;
--accent-hover: #333333;
--accent-foreground: #ededed; --accent-foreground: #ededed;
--ring: #444444; --ring: #444444;
--radius: 0.5rem; --radius: 0.5rem;
@@ -31,3 +32,7 @@ body {
color: var(--foreground); color: var(--foreground);
margin: 0; margin: 0;
} }
button {
cursor: pointer;
}
+120 -37
View File
@@ -1,25 +1,96 @@
"use client" "use client"
import { useEffect, useState, useCallback } from "react" import { useEffect, useState, useCallback } from "react"
import { useRouter } from "next/navigation" import { Plus, Download, RefreshCw, Settings, X } from "lucide-react"
import { Plus, Download, RefreshCw } from "lucide-react"
import { StreamCard } from "@/components/StreamCard" import { StreamCard } from "@/components/StreamCard"
import type { Stream } from "@/types/stream" import type { Stream } from "@/types/stream"
type CardSize = "sm" | "md" | "lg"
function SkeletonCard({ size = "sm" }: { size?: CardSize }) {
const widths = { sm: "max-w-[200px]", md: "max-w-[240px]", lg: "max-w-[300px]" }
return (
<div className={`rounded-lg border border-border bg-card p-3 flex flex-col gap-2.5 w-full ${widths[size]} animate-pulse`}>
<div className="flex justify-between items-start">
<div className="flex flex-col gap-1.5">
<div className="h-4 w-28 bg-muted rounded" />
<div className="h-3 w-16 bg-muted rounded" />
</div>
<div className="h-3 w-12 bg-muted rounded" />
</div>
<div className="h-3 w-full bg-muted rounded" />
<div className="flex flex-col gap-1.5">
{[...Array(4)].map((_, i) => <div key={i} className="h-8 w-full bg-muted rounded" />)}
</div>
</div>
)
}
// #7 — settings popup
function SettingsPopup({ cardSize, onCardSize, onClose }: {
cardSize: CardSize
onCardSize: (s: CardSize) => void
onClose: () => void
}) {
return (
<>
<div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} />
<div className="fixed top-16 right-6 z-50 w-64 rounded-lg border border-border shadow-2xl p-4 flex flex-col gap-4" style={{ background: "#1c1c1c" }}>
<div className="flex items-center justify-between">
<p className="text-sm font-semibold">Settings</p>
<button onClick={onClose} className="p-1 rounded hover:bg-[#2a2a2a] cursor-pointer transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-col gap-2">
<p className="text-xs text-muted-foreground tracking-wider">Card size</p>
<div className="flex gap-2">
{(["sm", "md", "lg"] as CardSize[]).map((s) => (
<button
key={s}
onClick={() => onCardSize(s)}
className={`flex-1 py-1.5 rounded border text-xs transition-colors cursor-pointer ${
cardSize === s
? "border-primary bg-primary text-primary-foreground"
: "border-border hover:bg-[#2a2a2a]"
}`}
>
{s === "sm" ? "Small" : s === "md" ? "Medium" : "Big"}
</button>
))}
</div>
</div>
</div>
</>
)
}
export default function GalleryPage() { export default function GalleryPage() {
const router = useRouter()
const [streams, setStreams] = useState<Stream[]>([]) const [streams, setStreams] = useState<Stream[]>([])
const [statuses, setStatuses] = useState<Record<string, Record<string, string>>>({}) const [statuses, setStatuses] = useState<Record<string, Record<string, string>>>({})
const [localStatuses, setLocalStatuses] = useState<Record<string, string | null>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [cardSize, setCardSize] = useState<CardSize>("md")
const [settingsOpen, setSettingsOpen] = useState(false)
const fetchStreams = useCallback(async () => { useEffect(() => {
const saved = localStorage.getItem("cardSize") as CardSize | null
if (saved) setCardSize(saved)
}, [])
const fetchStreams = useCallback(async (manual = false) => {
if (manual) setRefreshing(true)
const res = await fetch("/api/streams") const res = await fetch("/api/streams")
const data: Stream[] = await res.json() const data: Stream[] = await res.json()
setStreams(data) setStreams(data)
setLoading(false) setLoading(false)
if (manual) setRefreshing(false)
}, []) }, [])
const fetchStatuses = useCallback(async (list: Stream[]) => { const fetchStatuses = useCallback(async (list: Stream[]) => {
if (list.length === 0) return
const results = await Promise.all( const results = await Promise.all(
list.map(async (s) => { list.map(async (s) => {
const res = await fetch(`/api/streams/${s.id}/status`) const res = await fetch(`/api/streams/${s.id}/status`)
@@ -30,9 +101,7 @@ export default function GalleryPage() {
setStatuses(Object.fromEntries(results)) setStatuses(Object.fromEntries(results))
}, []) }, [])
useEffect(() => { useEffect(() => { fetchStreams() }, [fetchStreams])
fetchStreams()
}, [fetchStreams])
useEffect(() => { useEffect(() => {
if (streams.length === 0) return if (streams.length === 0) return
@@ -41,62 +110,76 @@ export default function GalleryPage() {
return () => clearInterval(interval) return () => clearInterval(interval)
}, [streams, fetchStatuses]) }, [streams, fetchStatuses])
const setLocalStatus = useCallback((id: string, s: string | null) => {
setLocalStatuses((prev) => ({ ...prev, [id]: s }))
}, [])
function downloadPlaylist() { function downloadPlaylist() {
const host = window.location.hostname window.location.href = `/api/streams/playlist?host=${window.location.hostname}&port=8888`
window.location.href = `/api/streams/playlist?host=${host}&port=8888`
} }
const showSkeleton = loading || refreshing
// #6 — todos os botões do header com mesmo padding e tamanho
const btnBase = "flex items-center gap-1.5 text-sm px-3 py-1.5 h-8 rounded border border-border hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer"
const btnPrimary = "flex items-center gap-1.5 text-sm px-3 py-1.5 h-8 rounded border border-primary bg-primary text-primary-foreground hover:bg-[#2a2a2a] hover:text-foreground hover:border-border active:bg-[#333] transition-colors cursor-pointer"
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
{/* Header */}
<header className="border-b border-border px-6 py-4 flex items-center justify-between"> <header className="border-b border-border px-6 py-4 flex items-center justify-between">
<h1 className="text-lg font-semibold tracking-tight">DecapStream</h1> <h1 className="text-lg font-semibold tracking-tight">Decap Stream</h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button {/* #6 — refresh com h-8 explícito igual aos outros */}
onClick={() => { fetchStreams() }} <button onClick={() => fetchStreams(true)} className={btnBase} title="Atualizar">
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded border border-border hover:bg-accent transition-colors" <RefreshCw className={`w-3.5 h-3.5 ${refreshing ? "animate-spin" : ""}`} />
>
<RefreshCw className="w-3.5 h-3.5" />
</button> </button>
<button <button onClick={downloadPlaylist} className={btnBase}>
onClick={downloadPlaylist}
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded border border-border hover:bg-accent transition-colors"
>
<Download className="w-3.5 h-3.5" /> Playlist .m3u <Download className="w-3.5 h-3.5" /> Playlist .m3u
</button> </button>
<button {/* #7 — botão de config */}
onClick={() => router.push("/streams/new")} <button onClick={() => setSettingsOpen((v) => !v)} className={btnBase} title="Settings">
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors" <Settings className="w-3.5 h-3.5" />
> </button>
<Plus className="w-3.5 h-3.5" /> Nova stream <button onClick={() => window.location.href = "/streams/new"} className={btnPrimary}>
<Plus className="w-3.5 h-3.5" /> New stream
</button> </button>
</div> </div>
</header> </header>
{/* Grid */} {/* #7 — popup de configurações */}
{settingsOpen && (
<SettingsPopup
cardSize={cardSize}
onCardSize={(s) => { setCardSize(s); localStorage.setItem("cardSize", s); setSettingsOpen(false) }}
onClose={() => setSettingsOpen(false)}
/>
)}
<main className="flex-1 p-6"> <main className="flex-1 p-6">
{loading ? ( {showSkeleton ? (
<div className="flex items-center justify-center h-64 text-muted-foreground text-sm"> <div className="flex flex-wrap gap-4">
Carregando... {[...Array(refreshing ? Math.max(streams.length, 1) : 4)].map((_, i) => (
<SkeletonCard key={i} size={cardSize} />
))}
</div> </div>
) : streams.length === 0 ? ( ) : streams.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 gap-3 text-muted-foreground"> <div className="flex flex-col items-center justify-center h-64 gap-3 text-muted-foreground">
<p className="text-sm">Nenhuma stream configurada.</p> <p className="text-sm">No streams configured.</p>
<button <button onClick={() => window.location.href = "/streams/new"} className={btnPrimary}>
onClick={() => router.push("/streams/new")} <Plus className="w-3.5 h-3.5" /> New stream
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Plus className="w-3.5 h-3.5" /> Nova stream
</button> </button>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="flex flex-wrap gap-4">
{streams.map((s) => ( {streams.map((s) => (
<StreamCard <StreamCard
key={s.id} key={s.id}
stream={s} stream={s}
status={statuses[s.id]} status={statuses[s.id]}
onRefresh={fetchStreams} localStatus={localStatuses[s.id] ?? null}
cardSize={cardSize}
onRefresh={() => fetchStreams()}
onLocalStatus={setLocalStatus}
/> />
))} ))}
</div> </div>
+2 -2
View File
@@ -45,13 +45,13 @@ export async function GET(req: NextRequest, { params }: Ctx) {
hls.attachMedia(document.getElementById('v')); hls.attachMedia(document.getElementById('v'));
hls.on(Hls.Events.MANIFEST_PARSED,function(){document.getElementById('v').play();}); hls.on(Hls.Events.MANIFEST_PARSED,function(){document.getElementById('v').play();});
hls.on(Hls.Events.ERROR,function(e,d){ hls.on(Hls.Events.ERROR,function(e,d){
if(d.fatal){showMsg('Erro: '+d.type+' — reconectando...');setTimeout(load,3000);} if(d.fatal){showMsg('Error: '+d.type+' — reconnecting...');setTimeout(load,3000);}
}); });
} }
var last=0; var last=0;
setInterval(function(){ setInterval(function(){
var v=document.getElementById('v'); var v=document.getElementById('v');
if(v.currentTime===last&&!v.paused){showMsg('Stream travada — recarregando...');load();} if(v.currentTime===last&&!v.paused){showMsg('Stream stalled — reloading...');load();}
last=v.currentTime; last=v.currentTime;
},10000); },10000);
load(); load();
+85 -74
View File
@@ -2,22 +2,49 @@
import { Suspense } from "react" import { Suspense } from "react"
import { useParams, useSearchParams, useRouter } from "next/navigation" import { useParams, useSearchParams, useRouter } from "next/navigation"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState, useCallback } from "react"
import Script from "next/script"
import { ArrowLeft } from "lucide-react" import { ArrowLeft } from "lucide-react"
type Mode = "hls" | "m3u8" | "html" type Mode = "hls" | "html"
declare global { declare global {
interface Window { interface Window { Hls: any }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Hls: any
}
} }
function HLSPlayer({ src }: { src: string }) { function BackButton({ onClick }: { onClick: () => void }) {
const [visible, setVisible] = useState(true)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const show = useCallback(() => {
setVisible(true)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => setVisible(false), 5000)
}, [])
useEffect(() => {
show()
window.addEventListener("mousemove", show)
return () => {
window.removeEventListener("mousemove", show)
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [show])
return (
<button
onClick={onClick}
style={{ opacity: visible ? 1 : 0, transition: "opacity 0.4s" }}
className="absolute top-4 left-4 z-20 flex items-center gap-1.5 text-sm text-white bg-black/40 px-3 py-1.5 rounded-lg cursor-pointer"
>
<ArrowLeft className="w-4 h-4" /> Back
</button>
)
}
// HLS e M3U8 usam a mesma lógica — HLS.js carregado inline via fetch, não via <Script>
function VideoPlayer({ src, controls }: { src: string; controls?: boolean }) {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<unknown>(null) const hlsRef = useRef<any>(null)
const [msg, setMsg] = useState("") const [msg, setMsg] = useState("")
function showMsg(text: string) { function showMsg(text: string) {
@@ -25,102 +52,86 @@ function HLSPlayer({ src }: { src: string }) {
setTimeout(() => setMsg(""), 4000) setTimeout(() => setMsg(""), 4000)
} }
function load() { const load = useCallback((Hls: any) => {
if (!videoRef.current || !window.Hls) return const v = videoRef.current
// eslint-disable-next-line @typescript-eslint/no-explicit-any if (!v) return
const Hls = window.Hls as any if (hlsRef.current) hlsRef.current.destroy()
if (hlsRef.current) (hlsRef.current as { destroy: () => void }).destroy()
const hls = new Hls({ const hls = new Hls({
liveSyncDurationCount: 2, liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 4, liveMaxLatencyDurationCount: 6,
manifestLoadingTimeOut: 10000, manifestLoadingTimeOut: 15000,
manifestLoadingMaxRetry: 10, manifestLoadingMaxRetry: 20,
fragLoadingTimeOut: 10000, manifestLoadingRetryDelay: 1000,
fragLoadingTimeOut: 15000,
fragLoadingMaxRetry: 10, fragLoadingMaxRetry: 10,
}) })
hlsRef.current = hls hlsRef.current = hls
hls.loadSource(src) hls.loadSource(src)
hls.attachMedia(videoRef.current) hls.attachMedia(v)
hls.on(Hls.Events.MANIFEST_PARSED, () => videoRef.current?.play()) hls.on(Hls.Events.MANIFEST_PARSED, () => v.play())
hls.on(Hls.Events.ERROR, (_: unknown, d: { fatal: boolean; type: string }) => { hls.on(Hls.Events.ERROR, (_: any, d: any) => {
if (d.fatal) { if (d.fatal) { showMsg(`Error: ${d.type} — reconnecting...`); setTimeout(() => load(Hls), 3000) }
showMsg(`Erro: ${d.type} — reconectando...`)
setTimeout(load, 3000)
}
}) })
} }, [src])
useEffect(() => { useEffect(() => {
let last = 0
const interval = setInterval(() => {
const v = videoRef.current const v = videoRef.current
if (!v) return if (!v) return
if (v.currentTime === last && !v.paused) {
showMsg("Stream travada — recarregando...") // Carrega HLS.js dinamicamente via import para evitar problemas com <Script>
load() const script = document.createElement("script")
script.src = "https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"
script.onload = () => {
const Hls = window.Hls
if (Hls.isSupported()) {
load(Hls)
} else if (v.canPlayType("application/vnd.apple.mpegurl")) {
// Safari nativo
v.src = src
v.play()
} }
}
document.head.appendChild(script)
// stall detection
let last = 0
const interval = setInterval(() => {
if (!v) return
if (v.currentTime === last && !v.paused) { showMsg("Stream stalled — reloading..."); hlsRef.current && load(window.Hls) }
last = v.currentTime last = v.currentTime
}, 10000) }, 10000)
return () => clearInterval(interval)
// eslint-disable-next-line react-hooks/exhaustive-deps return () => {
}, []) clearInterval(interval)
hlsRef.current?.destroy()
document.head.removeChild(script)
}
}, [load, src])
return ( return (
<> <>
<Script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js" onLoad={load} /> <video ref={videoRef} autoPlay muted playsInline controls={controls} className="w-screen h-screen object-contain bg-black" />
<video ref={videoRef} autoPlay muted playsInline className="w-screen h-screen object-contain bg-black" />
{msg && ( {msg && (
<div className="fixed top-4 left-1/2 -translate-x-1/2 bg-black/75 text-white px-5 py-2 rounded-lg text-sm z-10"> <div className="fixed top-4 left-1/2 -translate-x-1/2 bg-black/75 text-white px-5 py-2 rounded-lg text-sm z-10">{msg}</div>
{msg}
</div>
)} )}
</> </>
) )
} }
function M3U8Player({ src }: { src: string }) {
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
const v = videoRef.current
if (!v) return
if (v.canPlayType("application/vnd.apple.mpegurl")) {
v.src = src
v.play()
}
}, [src])
return (
<video ref={videoRef} src={src} autoPlay muted playsInline controls className="w-screen h-screen object-contain bg-black" />
)
}
// Componente interno que usa useSearchParams — precisa estar dentro de Suspense
function PlayerInner() { function PlayerInner() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter() const router = useRouter()
const mode = (searchParams.get("mode") ?? "hls") as Mode const mode = (searchParams.get("mode") ?? "hls") as Mode
const host = typeof window !== "undefined" ? window.location.hostname : "localhost" const host = typeof window !== "undefined" ? window.location.hostname : "localhost"
const hlsSrc = `http://${host}:8888/live/${id}/index.m3u8`
const streamSrc = `http://${host}:8888/live/${id}/index.m3u8`
return ( return (
<div className="relative bg-black w-screen h-screen overflow-hidden"> <div className="relative bg-black w-screen h-screen overflow-hidden">
<button <BackButton onClick={() => router.push("/")} />
onClick={() => router.push("/")} {mode === "hls" && <VideoPlayer src={streamSrc} controls />}
className="absolute top-4 left-4 z-20 flex items-center gap-1.5 text-sm text-white/70 hover:text-white bg-black/50 px-3 py-1.5 rounded-lg transition-colors" {mode === "html" && <iframe src={`/player-static/${id}`} className="w-screen h-screen border-0" allowFullScreen />}
>
<ArrowLeft className="w-4 h-4" /> Voltar
</button>
{mode === "hls" && <HLSPlayer src={hlsSrc} />}
{mode === "m3u8" && <M3U8Player src={hlsSrc} />}
{mode === "html" && (
<iframe
src={`/player-static/${id}`}
className="w-screen h-screen border-0"
allowFullScreen
/>
)}
</div> </div>
) )
} }
+130 -92
View File
@@ -1,152 +1,190 @@
"use client" "use client"
import { useRouter } from "next/navigation" import { useState, useEffect } from "react"
import { useState } from "react" import { MoreHorizontal, Play, Globe, Monitor, Pencil, RotateCcw, Square, Trash2, Circle, Copy, Check, Video, ImageUp } from "lucide-react"
import { MoreHorizontal, Play, Globe, FileVideo, Monitor, Pencil, RotateCcw, Square, Trash2, Circle } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import type { Stream } from "@/types/stream" import type { Stream } from "@/types/stream"
interface Props { interface Props {
stream: Stream stream: Stream
status?: Record<string, string> status?: Record<string, string>
localStatus?: string | null
cardSize?: "sm" | "md" | "lg"
onRefresh: () => void onRefresh: () => void
onLocalStatus: (id: string, s: string | null) => void
} }
function StatusBadge({ status }: { status?: Record<string, string> }) { function StatusBadge({ status, localStatus }: { status?: Record<string, string>; localStatus?: string | null }) {
if (!status) return null const label = localStatus ?? (
const ffmpeg = status.ffmpeg status?.ffmpeg === "RUNNING" ? "running" :
status?.ffmpeg === "STARTING" ? "starting" :
status?.ffmpeg === "FATAL" ? "error" :
status?.ffmpeg === "STOPPED" ? "stopped" : "..."
)
const color = const color =
ffmpeg === "RUNNING" ? "bg-green-500" : label === "running" ? "bg-green-500" :
ffmpeg === "STARTING" ? "bg-yellow-500" : label === "starting" ? "bg-yellow-500" :
ffmpeg === "FATAL" ? "bg-red-500" : label === "restarting" ? "bg-yellow-500" :
"bg-zinc-500" label === "stopping" ? "bg-orange-500" :
const label = label === "error" ? "bg-red-500" : "bg-zinc-500"
ffmpeg === "RUNNING" ? "running" :
ffmpeg === "STARTING" ? "starting" :
ffmpeg === "FATAL" ? "error" :
"stopped"
return ( return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground"> <span className="flex items-center gap-1.5 text-xs text-muted-foreground whitespace-nowrap">
<Circle className={cn("w-2 h-2 fill-current", color)} /> <Circle className={cn("w-2 h-2 fill-current shrink-0", color)} />
{label} {label}
</span> </span>
) )
} }
export function StreamCard({ stream, status, onRefresh }: Props) { function copyToClipboard(text: string) {
const router = useRouter() if (navigator.clipboard && window.isSecureContext) {
const [menuOpen, setMenuOpen] = useState(false) return navigator.clipboard.writeText(text)
const [loading, setLoading] = useState<string | null>(null) }
const el = document.createElement("textarea")
el.value = text
el.style.position = "fixed"
el.style.opacity = "0"
document.body.appendChild(el)
el.focus()
el.select()
document.execCommand("copy")
document.body.removeChild(el)
return Promise.resolve()
}
async function action(act: string) { const CARD_WIDTHS = { sm: "max-w-[200px]", md: "max-w-[240px]", lg: "max-w-[300px]" }
setLoading(act)
export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRefresh, onLocalStatus }: Props) {
const [menuOpen, setMenuOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [thumbKey, setThumbKey] = useState(0)
const [thumbError, setThumbError] = useState(false)
const [thumbCapturing, setThumbCapturing] = useState(false)
useEffect(() => {
if (!thumbError || thumbCapturing) return
const interval = setInterval(() => setThumbKey((k) => k + 1), 15000)
return () => clearInterval(interval)
}, [thumbError, thumbCapturing])
async function action(act: string, optimisticStatus: string) {
onLocalStatus(stream.id, optimisticStatus)
setMenuOpen(false)
await fetch(`/api/streams/${stream.id}/${act}`, { method: "POST" }) await fetch(`/api/streams/${stream.id}/${act}`, { method: "POST" })
setLoading(null)
onRefresh() onRefresh()
setTimeout(() => onLocalStatus(stream.id, null), 15000)
} }
async function remove() { async function remove() {
if (!confirm(`Deletar stream "${stream.name}"?`)) return if (!confirm(`Delete stream "${stream.name}"?`)) return
await fetch(`/api/streams/${stream.id}`, { method: "DELETE" }) await fetch(`/api/streams/${stream.id}`, { method: "DELETE" })
onRefresh() onRefresh()
} }
function openVNC() { function openVNC() {
const host = window.location.hostname const token = encodeURIComponent(`token=${stream.id}`)
window.open(`http://${host}:${stream.novncPort}/vnc.html`, "_blank") window.open(`http://${window.location.hostname}:6080/vnc.html?autoconnect=true&path=websockify%3F${token}`, "_blank")
} }
function copyRTMP() {
const url = `rtmp://${window.location.hostname}:1935/live/${stream.id}`
copyToClipboard(url).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
async function refreshThumb() {
setMenuOpen(false)
setThumbCapturing(true)
setThumbError(false)
await fetch(`/api/streams/${stream.id}/thumb`, { method: "POST" })
// 5s delay in backend + a few seconds for ffmpeg to process
setTimeout(() => {
setThumbKey((k) => k + 1)
setThumbCapturing(false)
}, 9000)
}
function play(mode: string) {
window.location.href = `/player/${stream.id}?mode=${mode}`
}
const playBtn = "w-full flex items-center gap-2 text-xs px-3 py-2 rounded border border-border bg-muted hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer"
const menuItem = "w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer"
return ( return (
<div className="relative rounded-lg border border-border bg-card p-4 flex flex-col gap-3"> <div className={cn("relative rounded-lg border border-border bg-card p-3 flex flex-col gap-2.5 w-full", CARD_WIDTHS[cardSize])}>
{/* Header */}
{/* Thumbnail */}
<div className="w-full aspect-video rounded overflow-hidden bg-muted flex items-center justify-center">
{thumbCapturing ? (
<span className="text-xs text-muted-foreground animate-pulse">Capturing...</span>
) : thumbError ? (
<Video className="w-5 h-5 text-muted-foreground/25" />
) : (
<img
key={thumbKey}
src={`/api/streams/${stream.id}/thumb?t=${thumbKey}`}
className="w-full h-full object-cover"
onError={() => setThumbError(true)}
onLoad={() => setThumbError(false)}
/>
)}
</div>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="font-semibold truncate">{stream.name}</p> <p className="font-semibold text-sm truncate">{stream.name}</p>
<p className="text-xs text-muted-foreground font-mono truncate">{stream.id}</p> <p className="text-xs text-muted-foreground font-mono truncate">{stream.id}</p>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
<StatusBadge status={status} /> <StatusBadge status={status} localStatus={localStatus} />
<button <button onClick={() => setMenuOpen((v) => !v)} className="p-1 rounded hover:bg-[#2a2a2a] transition-colors cursor-pointer">
onClick={() => setMenuOpen((v) => !v)}
className="p-1 rounded hover:bg-accent transition-colors"
>
<MoreHorizontal className="w-4 h-4" /> <MoreHorizontal className="w-4 h-4" />
</button> </button>
</div> </div>
</div> </div>
{/* URL */}
<p className="text-xs text-muted-foreground truncate" title={stream.url}>{stream.url}</p> <p className="text-xs text-muted-foreground truncate" title={stream.url}>{stream.url}</p>
{/* Play buttons */} <div className="flex flex-col gap-1.5">
<div className="grid grid-cols-2 gap-2"> <button onClick={() => play("hls")} className={playBtn}><Play className="w-3 h-3 shrink-0" /> Play Stream</button>
<button <button onClick={() => play("html")} className={playBtn}><Globe className="w-3 h-3 shrink-0" /> Run HTML</button>
onClick={() => router.push(`/player/${stream.id}?mode=hls`)} <button onClick={openVNC} className={playBtn}><Monitor className="w-3 h-3 shrink-0" /> Open VNC</button>
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
>
<Play className="w-3 h-3" /> Play HLS
</button>
<button
onClick={() => router.push(`/player/${stream.id}?mode=html`)}
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
>
<Globe className="w-3 h-3" /> Play HTML
</button>
<button
onClick={() => router.push(`/player/${stream.id}?mode=m3u8`)}
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
>
<FileVideo className="w-3 h-3" /> Play m3u8
</button>
<button
onClick={openVNC}
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
>
<Monitor className="w-3 h-3" /> Open VNC
</button>
</div> </div>
{/* Dropdown menu */}
{menuOpen && ( {menuOpen && (
<> <>
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} /> <div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute top-10 right-4 z-20 min-w-[160px] rounded-lg border border-border bg-card shadow-lg overflow-hidden"> <div className="absolute top-10 right-2 z-50 min-w-[180px] rounded-lg border border-border shadow-2xl overflow-hidden"
<button style={{ background: "#1c1c1c" }}>
onClick={() => { setMenuOpen(false); router.push(`/streams/${stream.id}/edit`) }} <button onClick={() => { setMenuOpen(false); window.location.href = `/streams/${stream.id}/edit` }} className={menuItem}>
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors" <Pencil className="w-3.5 h-3.5" /> Edit
>
<Pencil className="w-3.5 h-3.5" /> Editar
</button> </button>
<button <button onClick={() => action("restart", "restarting")} className={menuItem}>
onClick={() => { setMenuOpen(false); action("restart") }}
disabled={!!loading}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" /> Restart <RotateCcw className="w-3.5 h-3.5" /> Restart
</button> </button>
{status?.ffmpeg === "RUNNING" ? ( {status?.ffmpeg === "RUNNING" || localStatus === "restarting" ? (
<button <button onClick={() => action("stop", "stopping")} className={menuItem}>
onClick={() => { setMenuOpen(false); action("stop") }}
disabled={!!loading}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
>
<Square className="w-3.5 h-3.5" /> Stop <Square className="w-3.5 h-3.5" /> Stop
</button> </button>
) : ( ) : (
<button <button onClick={() => action("start", "starting")} className={menuItem}>
onClick={() => { setMenuOpen(false); action("start") }}
disabled={!!loading}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
>
<Play className="w-3.5 h-3.5" /> Start <Play className="w-3.5 h-3.5" /> Start
</button> </button>
)} )}
<button onClick={() => { setMenuOpen(false); copyRTMP() }} className={menuItem}>
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? "Copied!" : "Copy RTMP"}
</button>
<div className="border-t border-border" /> <div className="border-t border-border" />
<button <button onClick={refreshThumb} disabled={thumbCapturing} className={cn(menuItem, thumbCapturing && "opacity-50")}>
onClick={() => { setMenuOpen(false); remove() }} <ImageUp className="w-3.5 h-3.5" />
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-accent transition-colors" {thumbCapturing ? "Capturing..." : "Refresh thumbnail"}
> </button>
<Trash2 className="w-3.5 h-3.5" /> Deletar <div className="border-t border-border" />
<button onClick={() => { setMenuOpen(false); remove() }} className={cn(menuItem, "text-destructive")}>
<Trash2 className="w-3.5 h-3.5" /> Delete
</button> </button>
</div> </div>
</> </>
+166 -86
View File
@@ -2,51 +2,82 @@
import { useState } from "react" import { useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ChevronDown, ChevronRight, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { STREAM_DEFAULTS, type Stream, type StreamCreate, type StreamUpdate } from "@/types/stream" import { STREAM_DEFAULTS, type Stream, type StreamCreate, type StreamUpdate } from "@/types/stream"
interface Props { interface Props { initial?: Stream }
initial?: Stream
}
const SLUG_RE = /^[a-z0-9-]+$/ const SLUG_RE = /^[a-z0-9-]+$/
const PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium"] const PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium"]
const TUNES = ["stillimage", "zerolatency", "film", "animation"] const TUNES = ["stillimage", "animation", "zerolatency", "film", "fastdecode"]
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) { const TOOLTIPS = {
id: "Unique identifier used in URLs, RTMP path (/live/{id}) and HLS path. Lowercase letters, numbers and hyphens only.",
name: "Display name shown in the interface. Does not affect the stream.",
url: "The URL Chromium will open and capture as a video stream.",
user: "Username for auto-login. Chromium will type this into the first form field after the page loads.",
pass: "Password for auto-login. Typed after the username field.",
resolution: "Virtual display (Xvfb) and Chromium window size. This is what ffmpeg captures. Format: WIDTHxHEIGHT.",
scale: "Output video resolution. Can be lower than the capture resolution to reduce bandwidth. Format: WIDTHxHEIGHT.",
fps: "Frames per second for capture and encoding. Higher values produce smoother video but increase CPU and bandwidth usage.",
bitrate: "Target video bitrate. Higher values improve quality at the cost of bandwidth. Examples: 1500k, 3000k.",
bufsize: "Encoder buffer size. Controls bitrate variance. Recommended: 2× bitrate.",
preset: "Encoding speed vs compression trade-off. Faster presets use less CPU but produce larger files at the same quality. From fastest to slowest: ultrafast → superfast → veryfast → faster → fast → medium.",
tune: "Optimizes the encoder for your content type.\n• stillimage — best for static or slow-changing content\n• animation — solid colors, UI, charts\n• zerolatency — minimizes encoding delay\n• film — natural video with grain\n• fastdecode — easier to decode on the client side",
delay: "Seconds to wait after Chromium starts before ffmpeg begins capturing. Gives the page time to fully load and render.",
gop: "Keyframe interval in frames. Recommended: 2× FPS. Affects HLS segment alignment and seek accuracy. Auto-calculated from FPS unless manually changed.",
threads: "Number of ffmpeg encoding threads. 0 = auto-detect (recommended). Increasing this can reduce latency on multi-core systems at the cost of slightly reduced compression efficiency.",
}
function Tooltip({ text }: { text: string }) {
return (
<div className="relative group inline-flex items-center">
<span className="w-3.5 h-3.5 rounded-full border border-muted-foreground/40 text-muted-foreground/70 text-[10px] flex items-center justify-center cursor-help select-none leading-none flex-shrink-0">?</span>
<div className="absolute bottom-full left-0 mb-2 z-50 hidden group-hover:block w-56 rounded bg-[#1c1c1c] border border-border px-2.5 py-2 text-xs text-muted-foreground shadow-xl pointer-events-none whitespace-pre-line">
{text}
</div>
</div>
)
}
function Field({ label, tooltip, required, error, children }: {
label: string
tooltip?: string
required?: boolean
error?: string
children: React.ReactNode
}) {
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{label}</label> <div className="flex items-center gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{tooltip && <Tooltip text={tooltip} />}
</div>
{children} {children}
{error && <p className="text-xs text-destructive">{error}</p>} {error && <p className="text-xs text-destructive">{error}</p>}
</div> </div>
) )
} }
const inputClass = "w-full rounded border border-border bg-muted px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring transition-colors"
const selectClass = cn(inputClass, "appearance-none bg-muted text-foreground")
function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) { function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return <input className={cn(inputClass, className)} {...props} />
}
function Select({ className, children, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
return ( return (
<input <select className={cn(selectClass, className)} {...props}>
className={cn( {children}
"w-full rounded border border-border bg-muted px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring transition-colors", </select>
className
)}
{...props}
/>
) )
} }
function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) { function normalizeScaleDisplay(s: string) { return s.replace(":", "x") }
return (
<select
className={cn(
"w-full rounded border border-border bg-muted px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring transition-colors",
className
)}
{...props}
/>
)
}
export function StreamForm({ initial }: Props) { export function StreamForm({ initial }: Props) {
const router = useRouter() const router = useRouter()
@@ -62,29 +93,56 @@ export function StreamForm({ initial }: Props) {
...(initial ? { ...(initial ? {
delay: initial.delay, delay: initial.delay,
resolution: initial.resolution, resolution: initial.resolution,
scale: initial.scale, scale: normalizeScaleDisplay(initial.scale),
fps: initial.fps, fps: initial.fps,
bitrate: initial.bitrate, bitrate: initial.bitrate,
bufsize: initial.bufsize, bufsize: initial.bufsize,
preset: initial.preset, preset: initial.preset,
tune: initial.tune, tune: initial.tune,
gop: initial.gop, gop: initial.gop,
threads: initial.threads ?? 0,
} : {}), } : {}),
}) })
const [gopManuallyEdited, setGopManuallyEdited] = useState(isEdit)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
function set(key: keyof StreamCreate, value: string | number) { function set(key: keyof StreamCreate, value: string | number) {
setForm((f) => ({ ...f, [key]: value })) setForm((f) => ({ ...f, [key]: value }))
setErrors((e) => { const n = { ...e }; delete n[key]; return n }) setErrors((e) => { const n = { ...e }; delete n[key as string]; return n })
}
function setFps(value: number) {
set("fps", value)
if (!gopManuallyEdited) set("gop", value * 2)
}
function setGop(value: number) {
setGopManuallyEdited(true)
set("gop", value)
}
function setScale(value: string) {
set("scale", value.replace(":", "x"))
if (value && !/^\d+[x:]\d+$/.test(value)) {
setErrors((e) => ({ ...e, scale: "Invalid format. Use 1280x720" }))
} else {
setErrors((e) => { const n = { ...e }; delete n.scale; return n })
}
} }
function validate(): boolean { function validate(): boolean {
const e: Record<string, string> = {} const e: Record<string, string> = {}
if (!isEdit && !SLUG_RE.test(form.id)) e.id = "Apenas letras minúsculas, números e hífen" if (!isEdit && (!form.id || !SLUG_RE.test(form.id))) e.id = "Lowercase letters, numbers and hyphens only"
if (!form.name.trim()) e.name = "Obrigatório" if (!isEdit && !form.id) e.id = "Required"
if (!form.url.trim()) e.url = "Obrigatório" if (!form.name.trim()) e.name = "Required"
if (!form.url.trim()) e.url = "Required"
if (!form.resolution.trim()) e.resolution = "Required"
if (!form.scale.trim() || !/^\d+[x:]\d+$/.test(form.scale)) e.scale = "Invalid format. Use 1280x720"
if (!form.bitrate.trim()) e.bitrate = "Required"
if (!form.bufsize.trim()) e.bufsize = "Required"
setErrors(e) setErrors(e)
return Object.keys(e).length === 0 return Object.keys(e).length === 0
} }
@@ -94,11 +152,10 @@ export function StreamForm({ initial }: Props) {
setSaving(true) setSaving(true)
if (isEdit) { if (isEdit) {
const body: StreamUpdate = { ...form } fetch(`/api/streams/${initial!.id}`, {
await fetch(`/api/streams/${initial!.id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(form as StreamUpdate),
}) })
} else { } else {
await fetch("/api/streams", { await fetch("/api/streams", {
@@ -108,105 +165,128 @@ export function StreamForm({ initial }: Props) {
}) })
} }
setSaving(false) window.location.href = "/"
router.push("/")
} }
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
<header className="border-b border-border px-6 py-4 flex items-center gap-4"> <header className="border-b border-border px-6 py-4 flex items-center gap-4">
<button onClick={() => router.push("/")} className="text-sm text-muted-foreground hover:text-foreground transition-colors"> <button onClick={() => router.push("/")} className="text-sm text-muted-foreground hover:text-foreground transition-colors">
Voltar Back
</button> </button>
<h1 className="text-lg font-semibold">{isEdit ? `Editar${initial!.name}` : "Nova stream"}</h1> <h1 className="text-lg font-semibold">{isEdit ? `Edit — ${initial!.name}` : "New stream"}</h1>
</header> </header>
<main className="flex-1 p-6 max-w-2xl mx-auto w-full flex flex-col gap-8"> <main className="flex-1 p-6 max-w-2xl mx-auto w-full flex flex-col gap-6">
{/* Identificação */} {/* Identification */}
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Identificação</h2> <h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Identification</h2>
<Field label="ID (slug)" error={errors.id}> <Field label="ID" tooltip={TOOLTIPS.id} required error={errors.id}>
<Input <Input value={form.id} disabled={isEdit} placeholder="my-stream" onChange={(e) => set("id", e.target.value.toLowerCase())} />
value={form.id}
disabled={isEdit}
placeholder="mapa-zabbix"
onChange={(e) => set("id", e.target.value.toLowerCase())}
/>
</Field> </Field>
<Field label="Nome" error={errors.name}> <Field label="Name" tooltip={TOOLTIPS.name} required error={errors.name}>
<Input value={form.name} placeholder="Mapa Zabbix" onChange={(e) => set("name", e.target.value)} /> <Input value={form.name} placeholder="My Stream" onChange={(e) => set("name", e.target.value)} />
</Field> </Field>
</section> </section>
{/* Fonte */} <hr className="border-border" />
{/* Source */}
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Fonte</h2> <h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Source</h2>
<Field label="URL" error={errors.url}> <Field label="URL" tooltip={TOOLTIPS.url} required error={errors.url}>
<Input value={form.url} placeholder="https://..." onChange={(e) => set("url", e.target.value)} /> <Input value={form.url} placeholder="https://..." onChange={(e) => set("url", e.target.value)} />
</Field> </Field>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Field label="Usuário (opcional)"> <Field label="Username" tooltip={TOOLTIPS.user}>
<Input value={form.user ?? ""} onChange={(e) => set("user", e.target.value)} /> <Input value={form.user ?? ""} onChange={(e) => set("user", e.target.value)} />
</Field> </Field>
<Field label="Senha (opcional)"> <Field label="Password" tooltip={TOOLTIPS.pass}>
<Input type="password" value={form.pass ?? ""} onChange={(e) => set("pass", e.target.value)} /> <Input type="password" value={form.pass ?? ""} onChange={(e) => set("pass", e.target.value)} />
</Field> </Field>
</div> </div>
</section> </section>
{/* FFmpeg */} <hr className="border-border" />
{/* Stream */}
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">FFmpeg / Xvfb</h2> <h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Stream</h2>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Field label="Resolução (Xvfb/Chrome)"> <Field label="Resolution" tooltip={TOOLTIPS.resolution} required error={errors.resolution}>
<Input value={form.resolution} placeholder="1920x1080" onChange={(e) => set("resolution", e.target.value)} /> <Input value={form.resolution} placeholder="1920x1080" onChange={(e) => set("resolution", e.target.value)} />
</Field> </Field>
<Field label="Scale (stream output)"> <Field label="Scale" tooltip={TOOLTIPS.scale} required error={errors.scale}>
<Input value={form.scale} placeholder="1280:720" onChange={(e) => set("scale", e.target.value)} /> <Input value={form.scale} placeholder="1280x720" onChange={(e) => setScale(e.target.value)} />
</Field> </Field>
<Field label="FPS"> </div>
<Input type="number" value={form.fps} onChange={(e) => set("fps", Number(e.target.value))} />
<div className="grid grid-cols-3 gap-4">
<Field label="FPS" tooltip={TOOLTIPS.fps} required>
<Input type="number" min={1} value={form.fps} onChange={(e) => setFps(Number(e.target.value))} />
</Field> </Field>
<Field label="Delay (s)"> <Field label="Bitrate" tooltip={TOOLTIPS.bitrate} required error={errors.bitrate}>
<Input type="number" value={form.delay} onChange={(e) => set("delay", Number(e.target.value))} />
</Field>
<Field label="Bitrate">
<Input value={form.bitrate} placeholder="1500k" onChange={(e) => set("bitrate", e.target.value)} /> <Input value={form.bitrate} placeholder="1500k" onChange={(e) => set("bitrate", e.target.value)} />
</Field> </Field>
<Field label="Bufsize"> <Field label="Bufsize" tooltip={TOOLTIPS.bufsize} required error={errors.bufsize}>
<Input value={form.bufsize} placeholder="1500k" onChange={(e) => set("bufsize", e.target.value)} /> <Input value={form.bufsize} placeholder="3000k" onChange={(e) => set("bufsize", e.target.value)} />
</Field> </Field>
<Field label="GOP"> </div>
<Input type="number" value={form.gop} onChange={(e) => set("gop", Number(e.target.value))} />
</Field> <div className="grid grid-cols-2 gap-4">
<Field label="Preset"> <Field label="Preset" tooltip={TOOLTIPS.preset} required>
<Select value={form.preset} onChange={(e) => set("preset", e.target.value)}> <Select value={form.preset} onChange={(e) => set("preset", e.target.value)}>
{PRESETS.map((p) => <option key={p}>{p}</option>)} {PRESETS.map((p) => <option key={p} value={p} style={{ background: "#1a1a1a", color: "#ededed" }}>{p}</option>)}
</Select> </Select>
</Field> </Field>
<Field label="Tune"> <Field label="Tune" tooltip={TOOLTIPS.tune} required>
<Select value={form.tune} onChange={(e) => set("tune", e.target.value)}> <Select value={form.tune} onChange={(e) => set("tune", e.target.value)}>
{TUNES.map((t) => <option key={t}>{t}</option>)} {TUNES.map((t) => <option key={t} value={t} style={{ background: "#1a1a1a", color: "#ededed" }}>{t}</option>)}
</Select> </Select>
</Field> </Field>
</div> </div>
{/* Advanced */}
<div className="flex flex-col gap-3 mt-1">
<button
type="button"
onClick={() => setAdvancedOpen((v) => !v)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer w-fit"
>
{advancedOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
Advanced settings
</button>
{advancedOpen && (
<div className="flex flex-col gap-4 rounded border border-border bg-muted/30 p-4">
<div className="flex items-start gap-2 text-xs text-yellow-500/80">
<TriangleAlert className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<span>Changing these settings incorrectly may break the stream.</span>
</div>
<div className="grid grid-cols-3 gap-4">
<Field label="Boot delay (s)" tooltip={TOOLTIPS.delay}>
<Input type="number" min={0} value={form.delay} onChange={(e) => set("delay", Number(e.target.value))} />
</Field>
<Field label="GOP" tooltip={TOOLTIPS.gop}>
<Input type="number" min={1} value={form.gop} onChange={(e) => setGop(Number(e.target.value))} />
</Field>
<Field label="Threads" tooltip={TOOLTIPS.threads}>
<Input type="number" min={0} value={form.threads ?? 0} onChange={(e) => set("threads", Number(e.target.value))} />
</Field>
</div>
</div>
)}
</div>
</section> </section>
{/* Actions */}
<div className="flex gap-3 pb-8"> <div className="flex gap-3 pb-8">
<button <button onClick={() => router.push("/")} className="px-4 py-2 rounded border border-border text-sm hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer">
onClick={() => router.push("/")} Cancel
className="px-4 py-2 rounded border border-border text-sm hover:bg-accent transition-colors"
>
Cancelar
</button> </button>
<button <button onClick={submit} disabled={saving} className="px-4 py-2 rounded border border-primary bg-primary text-primary-foreground text-sm hover:bg-[#2a2a2a] hover:text-foreground hover:border-border active:bg-[#333] transition-colors disabled:opacity-50 cursor-pointer">
onClick={submit} {saving ? "Saving..." : isEdit ? "Save changes" : "Create stream"}
disabled={saving}
className="px-4 py-2 rounded bg-primary text-primary-foreground text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{saving ? "Salvando..." : isEdit ? "Salvar alterações" : "Criar stream"}
</button> </button>
</div> </div>
</main> </main>
-2
View File
@@ -40,7 +40,6 @@ export function deleteStream(id: string): void {
export function allocatePorts(): { export function allocatePorts(): {
display: string display: string
vncPort: number vncPort: number
novncPort: number
debugPort: number debugPort: number
} { } {
const streams = readStreams() const streams = readStreams()
@@ -52,7 +51,6 @@ export function allocatePorts(): {
return { return {
display: `:${n}`, display: `:${n}`,
vncPort: 5900 + n, vncPort: 5900 + n,
novncPort: 6080 + n,
debugPort: 9221 + n, debugPort: 9221 + n,
} }
} }
+41 -23
View File
@@ -1,10 +1,12 @@
import fs from "fs" import fs from "fs"
import path from "path" import path from "path"
import { execSync } from "child_process" import { execSync, spawn } from "child_process"
import type { Stream } from "@/types/stream" import type { Stream } from "@/types/stream"
const DATA_DIR = process.env.DATA_DIR ?? "/app/data" const DATA_DIR = process.env.DATA_DIR ?? "/app/data"
const STREAMS_DIR = path.join(DATA_DIR, "streams") const STREAMS_DIR = path.join(DATA_DIR, "streams")
const VNC_TOKENS_DIR = path.join(DATA_DIR, "vnc-tokens")
const IS_DEV = process.env.NODE_ENV !== "production"
function streamDir(id: string) { function streamDir(id: string) {
return path.join(STREAMS_DIR, id) return path.join(STREAMS_DIR, id)
@@ -14,8 +16,6 @@ function render(template: string, vars: Record<string, string | number>): string
return template.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k] ?? "")) return template.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k] ?? ""))
} }
const IS_DEV = process.env.NODE_ENV !== "production"
function supervisorctl(cmd: string) { function supervisorctl(cmd: string) {
if (IS_DEV) { if (IS_DEV) {
console.log(`[supervisor mock] supervisorctl ${cmd}`) console.log(`[supervisor mock] supervisorctl ${cmd}`)
@@ -24,10 +24,20 @@ function supervisorctl(cmd: string) {
try { try {
execSync(`supervisorctl -c /etc/supervisor/supervisord.conf ${cmd}`, { stdio: "pipe" }) execSync(`supervisorctl -c /etc/supervisor/supervisord.conf ${cmd}`, { stdio: "pipe" })
} catch { } catch {
// supervisorctl retorna exit 1 em alguns casos não-fatais (ex: já parado) // supervisorctl retorna exit 1 em alguns casos não-fatais
} }
} }
// #6 — converte "1920x1080" → "1920,1080" para o Chrome
function resolutionToChrome(res: string): string {
return res.replace("x", ",")
}
// #13 — normaliza scale: aceita "1280x720" ou "1280:720", sempre salva "1280:720"
export function normalizeScale(scale: string): string {
return scale.replace("x", ":")
}
export function provisionStream(stream: Stream): void { export function provisionStream(stream: Stream): void {
const dir = streamDir(stream.id) const dir = streamDir(stream.id)
fs.mkdirSync(path.join(dir, "chrome-profile"), { recursive: true }) fs.mkdirSync(path.join(dir, "chrome-profile"), { recursive: true })
@@ -36,10 +46,10 @@ export function provisionStream(stream: Stream): void {
STREAM_ID: stream.id, STREAM_ID: stream.id,
DISPLAY: stream.display, DISPLAY: stream.display,
RESOLUTION: stream.resolution, RESOLUTION: stream.resolution,
CHROME_SIZE: resolutionToChrome(stream.resolution),
STREAM_URL: stream.url, STREAM_URL: stream.url,
DEBUG_PORT: stream.debugPort, DEBUG_PORT: stream.debugPort,
VNC_PORT: stream.vncPort, VNC_PORT: stream.vncPort,
NOVNC_PORT: stream.novncPort,
STREAM_DELAY: stream.delay, STREAM_DELAY: stream.delay,
FPS: stream.fps, FPS: stream.fps,
PRESET: stream.preset, PRESET: stream.preset,
@@ -47,38 +57,40 @@ export function provisionStream(stream: Stream): void {
GOP: stream.gop, GOP: stream.gop,
BITRATE: stream.bitrate, BITRATE: stream.bitrate,
BUFSIZE: stream.bufsize, BUFSIZE: stream.bufsize,
SCALE: normalizeScale(stream.scale),
THREADS: stream.threads ?? 0,
USER: stream.user ?? "", USER: stream.user ?? "",
PASS: stream.pass ?? "", PASS: stream.pass ?? "",
} }
// autologin.sh
const autologinTpl = fs.readFileSync("/opt/scripts/autologin.template.sh", "utf-8") const autologinTpl = fs.readFileSync("/opt/scripts/autologin.template.sh", "utf-8")
const autologinPath = path.join(dir, "autologin.sh") const autologinPath = path.join(dir, "autologin.sh")
fs.writeFileSync(autologinPath, render(autologinTpl, vars), "utf-8") fs.writeFileSync(autologinPath, render(autologinTpl, vars), "utf-8")
fs.chmodSync(autologinPath, 0o755) fs.chmodSync(autologinPath, 0o755)
// stream.conf
const confTpl = fs.readFileSync("/opt/scripts/stream.template.conf", "utf-8") const confTpl = fs.readFileSync("/opt/scripts/stream.template.conf", "utf-8")
const confPath = path.join(dir, "stream.conf") const confPath = path.join(dir, "stream.conf")
fs.writeFileSync(confPath, render(confTpl, vars), "utf-8") fs.writeFileSync(confPath, render(confTpl, vars), "utf-8")
// Recarrega supervisord para reconhecer o novo conf fs.mkdirSync(VNC_TOKENS_DIR, { recursive: true })
fs.writeFileSync(
path.join(VNC_TOKENS_DIR, `${stream.id}.cfg`),
`${stream.id}: localhost:${stream.vncPort}\n`,
"utf-8"
)
supervisorctl("reread") supervisorctl("reread")
supervisorctl("update") supervisorctl("update")
} }
export function startStream(id: string): void { export function startStream(id: string): void {
const programs = ["xvfb", "chrome", "autologin", "x11vnc", "novnc", "ffmpeg"] const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"]
for (const p of programs) { for (const p of programs) supervisorctl(`start ${p}-${id}`)
supervisorctl(`start ${p}-${id}`)
}
} }
export function stopStream(id: string): void { export function stopStream(id: string): void {
const programs = ["ffmpeg", "novnc", "x11vnc", "autologin", "chrome", "xvfb"] const programs = ["ffmpeg", "x11vnc", "autologin", "chromium", "xvfb"]
for (const p of programs) { for (const p of programs) supervisorctl(`stop ${p}-${id}`)
supervisorctl(`stop ${p}-${id}`)
}
} }
export function restartStream(id: string): void { export function restartStream(id: string): void {
@@ -88,28 +100,35 @@ export function restartStream(id: string): void {
export function removeStream(id: string): void { export function removeStream(id: string): void {
stopStream(id) stopStream(id)
const confPath = path.join(streamDir(id), "stream.conf") const confPath = path.join(streamDir(id), "stream.conf")
if (fs.existsSync(confPath)) fs.unlinkSync(confPath) if (fs.existsSync(confPath)) fs.unlinkSync(confPath)
const tokenPath = path.join(VNC_TOKENS_DIR, `${id}.cfg`)
if (fs.existsSync(tokenPath)) fs.unlinkSync(tokenPath)
supervisorctl("reread") supervisorctl("reread")
supervisorctl("update") supervisorctl("update")
// Remove pasta da stream
fs.rmSync(streamDir(id), { recursive: true, force: true }) fs.rmSync(streamDir(id), { recursive: true, force: true })
} }
export function captureThumb(streamId: string, delay = 60): void {
if (IS_DEV) { console.log(`[thumb mock] captureThumb ${streamId} delay=${delay}s`); return }
const thumbPath = path.join(STREAMS_DIR, streamId, "thumb.jpg")
const tmpPath = `${thumbPath}.tmp`
const child = spawn("bash", ["-c",
`sleep ${delay} && ffmpeg -y -loglevel error -i http://localhost:8888/live/${streamId}/index.m3u8 -vframes 1 -q:v 2 "${tmpPath}" && mv "${tmpPath}" "${thumbPath}"`
], { detached: true, stdio: "ignore" })
child.unref()
}
export type ProgramStatus = "RUNNING" | "STOPPED" | "FATAL" | "STARTING" | "UNKNOWN" export type ProgramStatus = "RUNNING" | "STOPPED" | "FATAL" | "STARTING" | "UNKNOWN"
export function getStreamStatus(id: string): Record<string, ProgramStatus> { export function getStreamStatus(id: string): Record<string, ProgramStatus> {
const programs = ["xvfb", "chrome", "autologin", "x11vnc", "novnc", "ffmpeg"] const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"]
if (IS_DEV) { if (IS_DEV) {
return Object.fromEntries(programs.map((p) => [p, "STOPPED" as ProgramStatus])) return Object.fromEntries(programs.map((p) => [p, "STOPPED" as ProgramStatus]))
} }
const result: Record<string, ProgramStatus> = {} const result: Record<string, ProgramStatus> = {}
for (const p of programs) { for (const p of programs) {
try { try {
const out = execSync( const out = execSync(
@@ -122,6 +141,5 @@ export function getStreamStatus(id: string): Record<string, ProgramStatus> {
result[p] = "UNKNOWN" result[p] = "UNKNOWN"
} }
} }
return result return result
} }
+18 -17
View File
@@ -1,44 +1,45 @@
export type StreamStatus = "running" | "stopped" | "error" | "starting" export type StreamStatus = "running" | "stopped" | "error" | "starting" | "restarting" | "stopping"
export interface Stream { export interface Stream {
id: string // slug definido pelo usuário: [a-z0-9-] id: string
name: string // nome amigável name: string
url: string url: string
user?: string user?: string
pass?: string pass?: string
// ffmpeg / xvfb delay: number // segundos antes do ffmpeg iniciar (delay de boot da stream)
delay: number // segundos antes do ffmpeg iniciar
resolution: string // tamanho do Xvfb/Chrome: "1920x1080" resolution: string // tamanho do Xvfb/Chrome: "1920x1080"
scale: string // scale do ffmpeg: "1280:720" scale: string // scale do ffmpeg output: "1280x720" (convertido para "1280:720" internamente)
fps: number fps: number
bitrate: string // "1500k" bitrate: string
bufsize: string // "1500k" bufsize: string
preset: string // ultrafast | superfast | veryfast | faster | fast preset: string
tune: string // stillimage | zerolatency | film tune: string
gop: number gop: number
threads: number
// alocado pelo sistema em runtime display: string
display: string // ":1", ":2"... vncPort: number
vncPort: number // 5901, 5902... debugPort: number
novncPort: number // 6081, 6082...
debugPort: number // 9222, 9223... desiredState: "running" | "stopped" // #19 — estado desejado persistente
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
export type StreamCreate = Omit<Stream, "display" | "vncPort" | "novncPort" | "debugPort" | "createdAt" | "updatedAt"> export type StreamCreate = Omit<Stream, "display" | "vncPort" | "debugPort" | "createdAt" | "updatedAt" | "desiredState">
export type StreamUpdate = Partial<StreamCreate> export type StreamUpdate = Partial<StreamCreate>
export const STREAM_DEFAULTS: Omit<StreamCreate, "id" | "name" | "url"> = { export const STREAM_DEFAULTS: Omit<StreamCreate, "id" | "name" | "url"> = {
delay: 15, delay: 15,
resolution: "1920x1080", resolution: "1920x1080",
scale: "1280:720", scale: "1280x720",
fps: 30, fps: 30,
bitrate: "1500k", bitrate: "1500k",
bufsize: "3000k", bufsize: "3000k",
preset: "ultrafast", preset: "ultrafast",
tune: "stillimage", tune: "stillimage",
gop: 60, gop: 60,
threads: 0,
} }