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:
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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,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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
Generated
+101
-4958
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
@@ -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}} \
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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"), {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user