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
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
set -e
DEFAULT_IMAGE="git.kralot.cloud/decap-stream"
DEFAULT_IMAGE="git.kralot.cloud/kralot/decap-stream"
DEFAULT_VERSION="0.0.0"
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
# 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]
files = /app/data/streams/*/stream.conf
+45 -39
View File
@@ -1,55 +1,61 @@
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
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 ────────────────────────────────────────────────────────────
# ── Stage 1: Next.js build ───────────────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY src/ ./src/
COPY next.config.ts tsconfig.json postcss.config.mjs ./
RUN npm run build
# ── Montar app standalone ────────────────────────────────────────────────────
RUN mkdir -p /app/.next && \
cp -r .next/standalone/. /app/ && \
cp -r .next/static /app/.next/static && \
mkdir -p /app/public && \
(cp -r public/. /app/public/ 2>/dev/null || true)
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
ARG MEDIAMTX_VERSION=1.17.1
# 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/mediamtx.yml /etc/mediamtx.yml
COPY scripts/ /opt/scripts/
RUN chmod +x /opt/scripts/*.sh
# ── Entrypoint ───────────────────────────────────────────────────────────────
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN chmod +x /opt/scripts/*.sh /entrypoint.sh
VOLUME ["/app/data"]
EXPOSE 3000 1935 8888 6081
EXPOSE 3000 1935 8888 6080
CMD ["/entrypoint.sh"]
+1 -1
View File
@@ -1,5 +1,5 @@
SHELL := /bin/bash
IMAGE ?= git.kralot.cloud/decap-stream
IMAGE ?= git.kralot.cloud/kralot/decap-stream
TAG ?= ""
.PHONY: build push
+4 -2
View File
@@ -1,16 +1,18 @@
services:
decap-stream:
image: git.kralot.cloud/decap-stream:latest
image: git.kralot.cloud/kralot/decap-stream:latest
container_name: decap-stream
restart: unless-stopped
shm_size: "2gb"
security_opt:
- seccomp:unconfined
environment:
TZ: America/Sao_Paulo
ports:
- "3000:3000" # Web UI
- "1935:1935" # RTMP (MediaMTX)
- "8888:8888" # HLS (MediaMTX)
- "6081:6081" # VNC (noVNC)
- "6080:6080" # VNC (noVNC)
volumes:
- decap-stream:/app/data
+12 -4
View File
@@ -1,13 +1,21 @@
#!/bin/bash
set -e
# Garante estrutura de dados persistente
mkdir -p /app/data/streams
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 {} \;
# #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
+101 -4958
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -9,17 +9,12 @@
"lint": "eslint"
},
"dependencies": {
"@tabler/icons-react": "^3.40.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"nanoid": "^5.1.7",
"next": "^16.2.2",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"shadcn": "^4.0.3",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
+29 -3
View File
@@ -1,11 +1,37 @@
#!/bin/bash
# Gerado automaticamente pela API — não editar manualmente
# Auto-generated by API — do not edit manually
# 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 key Tab
sleep 0.3
DISPLAY={{DISPLAY}} xdotool type --clearmodifiers --delay 50 "{{PASS}}"
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
stderr_logfile=/app/data/streams/{{STREAM_ID}}/xvfb.log
[program:chrome-{{STREAM_ID}}]
command=bash -c "rm -rf /app/data/streams/{{STREAM_ID}}/chrome-profile/Singleton* && google-chrome \
[program:chromium-{{STREAM_ID}}]
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 \
--disable-gpu \
--window-size={{RESOLUTION}} \
--start-maximized \
--user-data-dir=/app/data/streams/{{STREAM_ID}}/chrome-profile \
--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 \
--disable-extensions \
--disable-background-networking \
--disable-sync \
--disable-translate \
--disable-background-timer-throttling \
--remote-debugging-port={{DEBUG_PORT}} \
"{{STREAM_URL}}""
'{{STREAM_URL}}'"
environment=DISPLAY={{DISPLAY}}
autorestart=true
priority=20
startsecs=5
stdout_logfile=/app/data/streams/{{STREAM_ID}}/chrome.log
stderr_logfile=/app/data/streams/{{STREAM_ID}}/chrome.log
stdout_logfile=/app/data/streams/{{STREAM_ID}}/chromium.log
stderr_logfile=/app/data/streams/{{STREAM_ID}}/chromium.log
[program:autologin-{{STREAM_ID}}]
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}}]
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
priority=40
stdout_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}}]
command=bash -c "sleep {{STREAM_DELAY}} && ffmpeg \
-loglevel warning \
-threads {{THREADS}} \
-f x11grab \
-video_size {{RESOLUTION}} \
-framerate {{FPS}} \
+16 -7
View File
@@ -1,20 +1,29 @@
import { NextResponse } from "next/server"
import { getStream } from "@/lib/db"
import { getStream, saveStream } from "@/lib/db"
import { startStream, stopStream, restartStream } from "@/lib/supervisor"
type Ctx = { params: Promise<{ id: string; action: string }> }
export async function POST(_req: Request, { params }: Ctx) {
const { id, action } = await params
if (!getStream(id)) return NextResponse.json({ error: "não encontrado" }, { status: 404 })
const stream = getStream(id)
if (!stream) return NextResponse.json({ error: "not found" }, { status: 404 })
switch (action) {
case "start": startStream(id); break
case "stop": stopStream(id); break
case "restart": restartStream(id); break
case "start":
saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() }) // #19
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:
return NextResponse.json({ error: "ação inválida" }, { status: 400 })
return NextResponse.json({ error: "invalid action" }, { status: 400 })
}
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) {
const { id } = await params
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)
}
export async function PATCH(req: Request, { params }: Ctx) {
const { id } = await params
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
// 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) {
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)
deleteStream(id)
+1 -1
View File
@@ -6,6 +6,6 @@ type Ctx = { params: Promise<{ id: string }> }
export async function GET(_req: Request, { params }: Ctx) {
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))
}
+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"]
for (const s of streams) {
lines.push(`#EXTINF:-1,${s.name}`)
lines.push(`http://${host}:${port}/live/${s.id}/index.m3u8`)
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}`)
}
return new Response(lines.join("\n"), {
+7 -4
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"
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"
export async function GET() {
@@ -13,13 +13,13 @@ export async function POST(req: Request) {
const body = (await req.json()) as StreamCreate
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)
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))
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 now = new Date().toISOString()
@@ -27,7 +27,9 @@ export async function POST(req: Request) {
const stream = {
...STREAM_DEFAULTS,
...body,
scale: normalizeScale(body.scale ?? STREAM_DEFAULTS.scale), // #13
...ports,
desiredState: "running" as const, // #19
createdAt: now,
updatedAt: now,
}
@@ -35,6 +37,7 @@ export async function POST(req: Request) {
saveStream(stream)
provisionStream(stream)
startStream(stream.id)
captureThumb(stream.id, 60)
return NextResponse.json(stream, { status: 201 })
}
+6 -1
View File
@@ -15,7 +15,8 @@
--primary-foreground: #0a0a0a;
--destructive: #ef4444;
--destructive-foreground: #fff;
--accent: #1a1a1a;
--accent: #2a2a2a;
--accent-hover: #333333;
--accent-foreground: #ededed;
--ring: #444444;
--radius: 0.5rem;
@@ -31,3 +32,7 @@ body {
color: var(--foreground);
margin: 0;
}
button {
cursor: pointer;
}
+120 -37
View File
@@ -1,25 +1,96 @@
"use client"
import { useEffect, useState, useCallback } from "react"
import { useRouter } from "next/navigation"
import { Plus, Download, RefreshCw } from "lucide-react"
import { Plus, Download, RefreshCw, Settings, X } from "lucide-react"
import { StreamCard } from "@/components/StreamCard"
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() {
const router = useRouter()
const [streams, setStreams] = useState<Stream[]>([])
const [statuses, setStatuses] = useState<Record<string, Record<string, string>>>({})
const [localStatuses, setLocalStatuses] = useState<Record<string, string | null>>({})
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 data: Stream[] = await res.json()
setStreams(data)
setLoading(false)
if (manual) setRefreshing(false)
}, [])
const fetchStatuses = useCallback(async (list: Stream[]) => {
if (list.length === 0) return
const results = await Promise.all(
list.map(async (s) => {
const res = await fetch(`/api/streams/${s.id}/status`)
@@ -30,9 +101,7 @@ export default function GalleryPage() {
setStatuses(Object.fromEntries(results))
}, [])
useEffect(() => {
fetchStreams()
}, [fetchStreams])
useEffect(() => { fetchStreams() }, [fetchStreams])
useEffect(() => {
if (streams.length === 0) return
@@ -41,62 +110,76 @@ export default function GalleryPage() {
return () => clearInterval(interval)
}, [streams, fetchStatuses])
const setLocalStatus = useCallback((id: string, s: string | null) => {
setLocalStatuses((prev) => ({ ...prev, [id]: s }))
}, [])
function downloadPlaylist() {
const host = window.location.hostname
window.location.href = `/api/streams/playlist?host=${host}&port=8888`
window.location.href = `/api/streams/playlist?host=${window.location.hostname}&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 (
<div className="min-h-screen flex flex-col">
{/* Header */}
<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">
<button
onClick={() => { fetchStreams() }}
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" />
{/* #6 — refresh com h-8 explícito igual aos outros */}
<button onClick={() => fetchStreams(true)} className={btnBase} title="Atualizar">
<RefreshCw className={`w-3.5 h-3.5 ${refreshing ? "animate-spin" : ""}`} />
</button>
<button
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"
>
<button onClick={downloadPlaylist} className={btnBase}>
<Download className="w-3.5 h-3.5" /> Playlist .m3u
</button>
<button
onClick={() => router.push("/streams/new")}
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
{/* #7 — botão de config */}
<button onClick={() => setSettingsOpen((v) => !v)} className={btnBase} title="Settings">
<Settings className="w-3.5 h-3.5" />
</button>
<button onClick={() => window.location.href = "/streams/new"} className={btnPrimary}>
<Plus className="w-3.5 h-3.5" /> New stream
</button>
</div>
</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">
{loading ? (
<div className="flex items-center justify-center h-64 text-muted-foreground text-sm">
Carregando...
{showSkeleton ? (
<div className="flex flex-wrap gap-4">
{[...Array(refreshing ? Math.max(streams.length, 1) : 4)].map((_, i) => (
<SkeletonCard key={i} size={cardSize} />
))}
</div>
) : streams.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 gap-3 text-muted-foreground">
<p className="text-sm">Nenhuma stream configurada.</p>
<button
onClick={() => router.push("/streams/new")}
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
<p className="text-sm">No streams configured.</p>
<button onClick={() => window.location.href = "/streams/new"} className={btnPrimary}>
<Plus className="w-3.5 h-3.5" /> New stream
</button>
</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) => (
<StreamCard
key={s.id}
stream={s}
status={statuses[s.id]}
onRefresh={fetchStreams}
localStatus={localStatuses[s.id] ?? null}
cardSize={cardSize}
onRefresh={() => fetchStreams()}
onLocalStatus={setLocalStatus}
/>
))}
</div>
+2 -2
View File
@@ -45,13 +45,13 @@ export async function GET(req: NextRequest, { params }: Ctx) {
hls.attachMedia(document.getElementById('v'));
hls.on(Hls.Events.MANIFEST_PARSED,function(){document.getElementById('v').play();});
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;
setInterval(function(){
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;
},10000);
load();
+85 -74
View File
@@ -2,22 +2,49 @@
import { Suspense } from "react"
import { useParams, useSearchParams, useRouter } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import Script from "next/script"
import { useEffect, useRef, useState, useCallback } from "react"
import { ArrowLeft } from "lucide-react"
type Mode = "hls" | "m3u8" | "html"
type Mode = "hls" | "html"
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Hls: any
}
interface Window { 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 hlsRef = useRef<unknown>(null)
const hlsRef = useRef<any>(null)
const [msg, setMsg] = useState("")
function showMsg(text: string) {
@@ -25,102 +52,86 @@ function HLSPlayer({ src }: { src: string }) {
setTimeout(() => setMsg(""), 4000)
}
function load() {
if (!videoRef.current || !window.Hls) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Hls = window.Hls as any
if (hlsRef.current) (hlsRef.current as { destroy: () => void }).destroy()
const load = useCallback((Hls: any) => {
const v = videoRef.current
if (!v) return
if (hlsRef.current) hlsRef.current.destroy()
const hls = new Hls({
liveSyncDurationCount: 2,
liveMaxLatencyDurationCount: 4,
manifestLoadingTimeOut: 10000,
manifestLoadingMaxRetry: 10,
fragLoadingTimeOut: 10000,
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
manifestLoadingTimeOut: 15000,
manifestLoadingMaxRetry: 20,
manifestLoadingRetryDelay: 1000,
fragLoadingTimeOut: 15000,
fragLoadingMaxRetry: 10,
})
hlsRef.current = hls
hls.loadSource(src)
hls.attachMedia(videoRef.current)
hls.on(Hls.Events.MANIFEST_PARSED, () => videoRef.current?.play())
hls.on(Hls.Events.ERROR, (_: unknown, d: { fatal: boolean; type: string }) => {
if (d.fatal) {
showMsg(`Erro: ${d.type} — reconectando...`)
setTimeout(load, 3000)
}
hls.attachMedia(v)
hls.on(Hls.Events.MANIFEST_PARSED, () => v.play())
hls.on(Hls.Events.ERROR, (_: any, d: any) => {
if (d.fatal) { showMsg(`Error: ${d.type} — reconnecting...`); setTimeout(() => load(Hls), 3000) }
})
}
}, [src])
useEffect(() => {
const v = videoRef.current
if (!v) return
// Carrega HLS.js dinamicamente via import para evitar problemas com <Script>
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(() => {
const v = videoRef.current
if (!v) return
if (v.currentTime === last && !v.paused) {
showMsg("Stream travada — recarregando...")
load()
}
if (v.currentTime === last && !v.paused) { showMsg("Stream stalled — reloading..."); hlsRef.current && load(window.Hls) }
last = v.currentTime
}, 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 (
<>
<Script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js" onLoad={load} />
<video ref={videoRef} autoPlay muted playsInline className="w-screen h-screen object-contain bg-black" />
<video ref={videoRef} autoPlay muted playsInline controls={controls} className="w-screen h-screen object-contain bg-black" />
{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">
{msg}
</div>
<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>
)}
</>
)
}
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() {
const { id } = useParams<{ id: string }>()
const searchParams = useSearchParams()
const router = useRouter()
const mode = (searchParams.get("mode") ?? "hls") as Mode
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 (
<div className="relative bg-black w-screen h-screen overflow-hidden">
<button
onClick={() => router.push("/")}
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"
>
<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
/>
)}
<BackButton onClick={() => router.push("/")} />
{mode === "hls" && <VideoPlayer src={streamSrc} controls />}
{mode === "html" && <iframe src={`/player-static/${id}`} className="w-screen h-screen border-0" allowFullScreen />}
</div>
)
}
+130 -92
View File
@@ -1,152 +1,190 @@
"use client"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { MoreHorizontal, Play, Globe, FileVideo, Monitor, Pencil, RotateCcw, Square, Trash2, Circle } from "lucide-react"
import { useState, useEffect } from "react"
import { MoreHorizontal, Play, Globe, Monitor, Pencil, RotateCcw, Square, Trash2, Circle, Copy, Check, Video, ImageUp } from "lucide-react"
import { cn } from "@/lib/utils"
import type { Stream } from "@/types/stream"
interface Props {
stream: Stream
status?: Record<string, string>
localStatus?: string | null
cardSize?: "sm" | "md" | "lg"
onRefresh: () => void
onLocalStatus: (id: string, s: string | null) => void
}
function StatusBadge({ status }: { status?: Record<string, string> }) {
if (!status) return null
const ffmpeg = status.ffmpeg
function StatusBadge({ status, localStatus }: { status?: Record<string, string>; localStatus?: string | null }) {
const label = localStatus ?? (
status?.ffmpeg === "RUNNING" ? "running" :
status?.ffmpeg === "STARTING" ? "starting" :
status?.ffmpeg === "FATAL" ? "error" :
status?.ffmpeg === "STOPPED" ? "stopped" : "..."
)
const color =
ffmpeg === "RUNNING" ? "bg-green-500" :
ffmpeg === "STARTING" ? "bg-yellow-500" :
ffmpeg === "FATAL" ? "bg-red-500" :
"bg-zinc-500"
const label =
ffmpeg === "RUNNING" ? "running" :
ffmpeg === "STARTING" ? "starting" :
ffmpeg === "FATAL" ? "error" :
"stopped"
label === "running" ? "bg-green-500" :
label === "starting" ? "bg-yellow-500" :
label === "restarting" ? "bg-yellow-500" :
label === "stopping" ? "bg-orange-500" :
label === "error" ? "bg-red-500" : "bg-zinc-500"
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Circle className={cn("w-2 h-2 fill-current", color)} />
<span className="flex items-center gap-1.5 text-xs text-muted-foreground whitespace-nowrap">
<Circle className={cn("w-2 h-2 fill-current shrink-0", color)} />
{label}
</span>
)
}
export function StreamCard({ stream, status, onRefresh }: Props) {
const router = useRouter()
const [menuOpen, setMenuOpen] = useState(false)
const [loading, setLoading] = useState<string | null>(null)
function copyToClipboard(text: string) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text)
}
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) {
setLoading(act)
const CARD_WIDTHS = { sm: "max-w-[200px]", md: "max-w-[240px]", lg: "max-w-[300px]" }
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" })
setLoading(null)
onRefresh()
setTimeout(() => onLocalStatus(stream.id, null), 15000)
}
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" })
onRefresh()
}
function openVNC() {
const host = window.location.hostname
window.open(`http://${host}:${stream.novncPort}/vnc.html`, "_blank")
const token = encodeURIComponent(`token=${stream.id}`)
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 (
<div className="relative rounded-lg border border-border bg-card p-4 flex flex-col gap-3">
{/* Header */}
<div className={cn("relative rounded-lg border border-border bg-card p-3 flex flex-col gap-2.5 w-full", CARD_WIDTHS[cardSize])}>
{/* 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="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>
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={status} />
<button
onClick={() => setMenuOpen((v) => !v)}
className="p-1 rounded hover:bg-accent transition-colors"
>
<div className="flex items-center gap-1.5 shrink-0">
<StatusBadge status={status} localStatus={localStatus} />
<button onClick={() => setMenuOpen((v) => !v)} className="p-1 rounded hover:bg-[#2a2a2a] transition-colors cursor-pointer">
<MoreHorizontal className="w-4 h-4" />
</button>
</div>
</div>
{/* URL */}
<p className="text-xs text-muted-foreground truncate" title={stream.url}>{stream.url}</p>
{/* Play buttons */}
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => router.push(`/player/${stream.id}?mode=hls`)}
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 className="flex flex-col gap-1.5">
<button onClick={() => play("hls")} className={playBtn}><Play className="w-3 h-3 shrink-0" /> Play Stream</button>
<button onClick={() => play("html")} className={playBtn}><Globe className="w-3 h-3 shrink-0" /> Run HTML</button>
<button onClick={openVNC} className={playBtn}><Monitor className="w-3 h-3 shrink-0" /> Open VNC</button>
</div>
{/* Dropdown menu */}
{menuOpen && (
<>
<div className="fixed inset-0 z-10" 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">
<button
onClick={() => { setMenuOpen(false); router.push(`/streams/${stream.id}/edit`) }}
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" /> Editar
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute top-10 right-2 z-50 min-w-[180px] rounded-lg border border-border shadow-2xl overflow-hidden"
style={{ background: "#1c1c1c" }}>
<button onClick={() => { setMenuOpen(false); window.location.href = `/streams/${stream.id}/edit` }} className={menuItem}>
<Pencil className="w-3.5 h-3.5" /> Edit
</button>
<button
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"
>
<button onClick={() => action("restart", "restarting")} className={menuItem}>
<RotateCcw className="w-3.5 h-3.5" /> Restart
</button>
{status?.ffmpeg === "RUNNING" ? (
<button
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"
>
{status?.ffmpeg === "RUNNING" || localStatus === "restarting" ? (
<button onClick={() => action("stop", "stopping")} className={menuItem}>
<Square className="w-3.5 h-3.5" /> Stop
</button>
) : (
<button
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"
>
<button onClick={() => action("start", "starting")} className={menuItem}>
<Play className="w-3.5 h-3.5" /> Start
</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" />
<button
onClick={() => { setMenuOpen(false); remove() }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-accent transition-colors"
>
<Trash2 className="w-3.5 h-3.5" /> Deletar
<button onClick={refreshThumb} disabled={thumbCapturing} className={cn(menuItem, thumbCapturing && "opacity-50")}>
<ImageUp className="w-3.5 h-3.5" />
{thumbCapturing ? "Capturing..." : "Refresh thumbnail"}
</button>
<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>
</div>
</>
+173 -93
View File
@@ -2,51 +2,82 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import { ChevronDown, ChevronRight, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"
import { STREAM_DEFAULTS, type Stream, type StreamCreate, type StreamUpdate } from "@/types/stream"
interface Props {
initial?: Stream
}
interface Props { initial?: Stream }
const SLUG_RE = /^[a-z0-9-]+$/
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 (
<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}
{error && <p className="text-xs text-destructive">{error}</p>}
</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>) {
return <input className={cn(inputClass, className)} {...props} />
}
function Select({ className, children, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
return (
<input
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}
/>
<select className={cn(selectClass, className)} {...props}>
{children}
</select>
)
}
function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
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}
/>
)
}
function normalizeScaleDisplay(s: string) { return s.replace(":", "x") }
export function StreamForm({ initial }: Props) {
const router = useRouter()
@@ -60,31 +91,58 @@ export function StreamForm({ initial }: Props) {
pass: initial?.pass ?? "",
...STREAM_DEFAULTS,
...(initial ? {
delay: initial.delay,
delay: initial.delay,
resolution: initial.resolution,
scale: initial.scale,
fps: initial.fps,
bitrate: initial.bitrate,
bufsize: initial.bufsize,
preset: initial.preset,
tune: initial.tune,
gop: initial.gop,
scale: normalizeScaleDisplay(initial.scale),
fps: initial.fps,
bitrate: initial.bitrate,
bufsize: initial.bufsize,
preset: initial.preset,
tune: initial.tune,
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 [saving, setSaving] = useState(false)
function set(key: keyof StreamCreate, value: string | number) {
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 {
const e: Record<string, string> = {}
if (!isEdit && !SLUG_RE.test(form.id)) e.id = "Apenas letras minúsculas, números e hífen"
if (!form.name.trim()) e.name = "Obrigatório"
if (!form.url.trim()) e.url = "Obrigatório"
if (!isEdit && (!form.id || !SLUG_RE.test(form.id))) e.id = "Lowercase letters, numbers and hyphens only"
if (!isEdit && !form.id) e.id = "Required"
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)
return Object.keys(e).length === 0
}
@@ -94,11 +152,10 @@ export function StreamForm({ initial }: Props) {
setSaving(true)
if (isEdit) {
const body: StreamUpdate = { ...form }
await fetch(`/api/streams/${initial!.id}`, {
fetch(`/api/streams/${initial!.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
body: JSON.stringify(form as StreamUpdate),
})
} else {
await fetch("/api/streams", {
@@ -108,105 +165,128 @@ export function StreamForm({ initial }: Props) {
})
}
setSaving(false)
router.push("/")
window.location.href = "/"
}
return (
<div className="min-h-screen flex flex-col">
<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">
Voltar
Back
</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>
<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">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Identificação</h2>
<Field label="ID (slug)" error={errors.id}>
<Input
value={form.id}
disabled={isEdit}
placeholder="mapa-zabbix"
onChange={(e) => set("id", e.target.value.toLowerCase())}
/>
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Identification</h2>
<Field label="ID" tooltip={TOOLTIPS.id} required error={errors.id}>
<Input value={form.id} disabled={isEdit} placeholder="my-stream" onChange={(e) => set("id", e.target.value.toLowerCase())} />
</Field>
<Field label="Nome" error={errors.name}>
<Input value={form.name} placeholder="Mapa Zabbix" onChange={(e) => set("name", e.target.value)} />
<Field label="Name" tooltip={TOOLTIPS.name} required error={errors.name}>
<Input value={form.name} placeholder="My Stream" onChange={(e) => set("name", e.target.value)} />
</Field>
</section>
{/* Fonte */}
<hr className="border-border" />
{/* Source */}
<section className="flex flex-col gap-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Fonte</h2>
<Field label="URL" error={errors.url}>
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Source</h2>
<Field label="URL" tooltip={TOOLTIPS.url} required error={errors.url}>
<Input value={form.url} placeholder="https://..." onChange={(e) => set("url", e.target.value)} />
</Field>
<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)} />
</Field>
<Field label="Senha (opcional)">
<Field label="Password" tooltip={TOOLTIPS.pass}>
<Input type="password" value={form.pass ?? ""} onChange={(e) => set("pass", e.target.value)} />
</Field>
</div>
</section>
{/* FFmpeg */}
<hr className="border-border" />
{/* Stream */}
<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">
<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)} />
</Field>
<Field label="Scale (stream output)">
<Input value={form.scale} placeholder="1280:720" onChange={(e) => set("scale", e.target.value)} />
<Field label="Scale" tooltip={TOOLTIPS.scale} required error={errors.scale}>
<Input value={form.scale} placeholder="1280x720" onChange={(e) => setScale(e.target.value)} />
</Field>
<Field label="FPS">
<Input type="number" value={form.fps} onChange={(e) => set("fps", Number(e.target.value))} />
</div>
<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 label="Delay (s)">
<Input type="number" value={form.delay} onChange={(e) => set("delay", Number(e.target.value))} />
</Field>
<Field label="Bitrate">
<Field label="Bitrate" tooltip={TOOLTIPS.bitrate} required error={errors.bitrate}>
<Input value={form.bitrate} placeholder="1500k" onChange={(e) => set("bitrate", e.target.value)} />
</Field>
<Field label="Bufsize">
<Input value={form.bufsize} placeholder="1500k" onChange={(e) => set("bufsize", e.target.value)} />
<Field label="Bufsize" tooltip={TOOLTIPS.bufsize} required error={errors.bufsize}>
<Input value={form.bufsize} placeholder="3000k" onChange={(e) => set("bufsize", e.target.value)} />
</Field>
<Field label="GOP">
<Input type="number" value={form.gop} onChange={(e) => set("gop", Number(e.target.value))} />
</Field>
<Field label="Preset">
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="Preset" tooltip={TOOLTIPS.preset} required>
<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>
</Field>
<Field label="Tune">
<Field label="Tune" tooltip={TOOLTIPS.tune} required>
<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>
</Field>
</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>
{/* Actions */}
<div className="flex gap-3 pb-8">
<button
onClick={() => router.push("/")}
className="px-4 py-2 rounded border border-border text-sm hover:bg-accent transition-colors"
>
Cancelar
<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">
Cancel
</button>
<button
onClick={submit}
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 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">
{saving ? "Saving..." : isEdit ? "Save changes" : "Create stream"}
</button>
</div>
</main>
-2
View File
@@ -40,7 +40,6 @@ export function deleteStream(id: string): void {
export function allocatePorts(): {
display: string
vncPort: number
novncPort: number
debugPort: number
} {
const streams = readStreams()
@@ -52,7 +51,6 @@ export function allocatePorts(): {
return {
display: `:${n}`,
vncPort: 5900 + n,
novncPort: 6080 + n,
debugPort: 9221 + n,
}
}
+55 -37
View File
@@ -1,10 +1,12 @@
import fs from "fs"
import path from "path"
import { execSync } from "child_process"
import { execSync, spawn } from "child_process"
import type { Stream } from "@/types/stream"
const DATA_DIR = process.env.DATA_DIR ?? "/app/data"
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) {
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] ?? ""))
}
const IS_DEV = process.env.NODE_ENV !== "production"
function supervisorctl(cmd: string) {
if (IS_DEV) {
console.log(`[supervisor mock] supervisorctl ${cmd}`)
@@ -24,61 +24,73 @@ function supervisorctl(cmd: string) {
try {
execSync(`supervisorctl -c /etc/supervisor/supervisord.conf ${cmd}`, { stdio: "pipe" })
} 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 {
const dir = streamDir(stream.id)
fs.mkdirSync(path.join(dir, "chrome-profile"), { recursive: true })
const vars: Record<string, string | number> = {
STREAM_ID: stream.id,
DISPLAY: stream.display,
RESOLUTION: stream.resolution,
STREAM_URL: stream.url,
DEBUG_PORT: stream.debugPort,
VNC_PORT: stream.vncPort,
NOVNC_PORT: stream.novncPort,
STREAM_ID: stream.id,
DISPLAY: stream.display,
RESOLUTION: stream.resolution,
CHROME_SIZE: resolutionToChrome(stream.resolution),
STREAM_URL: stream.url,
DEBUG_PORT: stream.debugPort,
VNC_PORT: stream.vncPort,
STREAM_DELAY: stream.delay,
FPS: stream.fps,
PRESET: stream.preset,
TUNE: stream.tune,
GOP: stream.gop,
BITRATE: stream.bitrate,
BUFSIZE: stream.bufsize,
USER: stream.user ?? "",
PASS: stream.pass ?? "",
FPS: stream.fps,
PRESET: stream.preset,
TUNE: stream.tune,
GOP: stream.gop,
BITRATE: stream.bitrate,
BUFSIZE: stream.bufsize,
SCALE: normalizeScale(stream.scale),
THREADS: stream.threads ?? 0,
USER: stream.user ?? "",
PASS: stream.pass ?? "",
}
// autologin.sh
const autologinTpl = fs.readFileSync("/opt/scripts/autologin.template.sh", "utf-8")
const autologinPath = path.join(dir, "autologin.sh")
fs.writeFileSync(autologinPath, render(autologinTpl, vars), "utf-8")
fs.chmodSync(autologinPath, 0o755)
// stream.conf
const confTpl = fs.readFileSync("/opt/scripts/stream.template.conf", "utf-8")
const confPath = path.join(dir, "stream.conf")
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("update")
}
export function startStream(id: string): void {
const programs = ["xvfb", "chrome", "autologin", "x11vnc", "novnc", "ffmpeg"]
for (const p of programs) {
supervisorctl(`start ${p}-${id}`)
}
const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"]
for (const p of programs) supervisorctl(`start ${p}-${id}`)
}
export function stopStream(id: string): void {
const programs = ["ffmpeg", "novnc", "x11vnc", "autologin", "chrome", "xvfb"]
for (const p of programs) {
supervisorctl(`stop ${p}-${id}`)
}
const programs = ["ffmpeg", "x11vnc", "autologin", "chromium", "xvfb"]
for (const p of programs) supervisorctl(`stop ${p}-${id}`)
}
export function restartStream(id: string): void {
@@ -88,28 +100,35 @@ export function restartStream(id: string): void {
export function removeStream(id: string): void {
stopStream(id)
const confPath = path.join(streamDir(id), "stream.conf")
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("update")
// Remove pasta da stream
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 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) {
return Object.fromEntries(programs.map((p) => [p, "STOPPED" as ProgramStatus]))
}
const result: Record<string, ProgramStatus> = {}
for (const p of programs) {
try {
const out = execSync(
@@ -122,6 +141,5 @@ export function getStreamStatus(id: string): Record<string, ProgramStatus> {
result[p] = "UNKNOWN"
}
}
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 {
id: string // slug definido pelo usuário: [a-z0-9-]
name: string // nome amigável
id: string
name: string
url: string
user?: string
pass?: string
// ffmpeg / xvfb
delay: number // segundos antes do ffmpeg iniciar
delay: number // segundos antes do ffmpeg iniciar (delay de boot da stream)
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
bitrate: string // "1500k"
bufsize: string // "1500k"
preset: string // ultrafast | superfast | veryfast | faster | fast
tune: string // stillimage | zerolatency | film
bitrate: string
bufsize: string
preset: string
tune: string
gop: number
threads: number
// alocado pelo sistema em runtime
display: string // ":1", ":2"...
vncPort: number // 5901, 5902...
novncPort: number // 6081, 6082...
debugPort: number // 9222, 9223...
display: string
vncPort: number
debugPort: number
desiredState: "running" | "stopped" // #19 — estado desejado persistente
createdAt: 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 const STREAM_DEFAULTS: Omit<StreamCreate, "id" | "name" | "url"> = {
delay: 15,
resolution: "1920x1080",
scale: "1280:720",
scale: "1280x720",
fps: 30,
bitrate: "1500k",
bufsize: "3000k",
preset: "ultrafast",
tune: "stillimage",
gop: 60,
threads: 0,
}