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:
@@ -1,20 +1,29 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getStream } from "@/lib/db"
|
||||
import { getStream, saveStream } from "@/lib/db"
|
||||
import { startStream, stopStream, restartStream } from "@/lib/supervisor"
|
||||
|
||||
type Ctx = { params: Promise<{ id: string; action: string }> }
|
||||
|
||||
export async function POST(_req: Request, { params }: Ctx) {
|
||||
const { id, action } = await params
|
||||
|
||||
if (!getStream(id)) return NextResponse.json({ error: "não encontrado" }, { status: 404 })
|
||||
const stream = getStream(id)
|
||||
if (!stream) return NextResponse.json({ error: "not found" }, { status: 404 })
|
||||
|
||||
switch (action) {
|
||||
case "start": startStream(id); break
|
||||
case "stop": stopStream(id); break
|
||||
case "restart": restartStream(id); break
|
||||
case "start":
|
||||
saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() }) // #19
|
||||
startStream(id)
|
||||
break
|
||||
case "stop":
|
||||
saveStream({ ...stream, desiredState: "stopped", updatedAt: new Date().toISOString() }) // #19
|
||||
stopStream(id)
|
||||
break
|
||||
case "restart":
|
||||
saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() }) // #19
|
||||
restartStream(id)
|
||||
break
|
||||
default:
|
||||
return NextResponse.json({ error: "ação inválida" }, { status: 400 })
|
||||
return NextResponse.json({ error: "invalid action" }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
|
||||
@@ -8,14 +8,14 @@ type Ctx = { params: Promise<{ id: string }> }
|
||||
export async function GET(_req: Request, { params }: Ctx) {
|
||||
const { id } = await params
|
||||
const stream = getStream(id)
|
||||
if (!stream) return NextResponse.json({ error: "não encontrado" }, { status: 404 })
|
||||
if (!stream) return NextResponse.json({ error: "not found" }, { status: 404 })
|
||||
return NextResponse.json(stream)
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request, { params }: Ctx) {
|
||||
const { id } = await params
|
||||
const stream = getStream(id)
|
||||
if (!stream) return NextResponse.json({ error: "não encontrado" }, { status: 404 })
|
||||
if (!stream) return NextResponse.json({ error: "not found" }, { status: 404 })
|
||||
|
||||
const body = (await req.json()) as StreamUpdate
|
||||
// id e portas não podem ser alterados via PATCH
|
||||
@@ -32,7 +32,7 @@ export async function PATCH(req: Request, { params }: Ctx) {
|
||||
|
||||
export async function DELETE(_req: Request, { params }: Ctx) {
|
||||
const { id } = await params
|
||||
if (!getStream(id)) return NextResponse.json({ error: "não encontrado" }, { status: 404 })
|
||||
if (!getStream(id)) return NextResponse.json({ error: "not found" }, { status: 404 })
|
||||
|
||||
removeStream(id)
|
||||
deleteStream(id)
|
||||
|
||||
@@ -6,6 +6,6 @@ type Ctx = { params: Promise<{ id: string }> }
|
||||
|
||||
export async function GET(_req: Request, { params }: Ctx) {
|
||||
const { id } = await params
|
||||
if (!getStream(id)) return NextResponse.json({ error: "não encontrado" }, { status: 404 })
|
||||
if (!getStream(id)) return NextResponse.json({ error: "not found" }, { status: 404 })
|
||||
return NextResponse.json(getStreamStatus(id))
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
for (const s of streams) {
|
||||
lines.push(`#EXTINF:-1,${s.name}`)
|
||||
lines.push(`http://${host}:${port}/live/${s.id}/index.m3u8`)
|
||||
lines.push(`#EXTINF:-1 tvg-id="${s.id}" tvg-name="${s.name}" group-title="DecapStream",${s.name} [${s.id}] ${s.resolution} ${s.fps}fps`)
|
||||
lines.push(`http://${host}:${port}/live/${s.id}`)
|
||||
}
|
||||
|
||||
return new Response(lines.join("\n"), {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { readStreams, saveStream, allocatePorts, getStream } from "@/lib/db"
|
||||
import { provisionStream, startStream } from "@/lib/supervisor"
|
||||
import { provisionStream, startStream, normalizeScale, captureThumb } from "@/lib/supervisor"
|
||||
import { STREAM_DEFAULTS, type StreamCreate } from "@/types/stream"
|
||||
|
||||
export async function GET() {
|
||||
@@ -13,13 +13,13 @@ export async function POST(req: Request) {
|
||||
const body = (await req.json()) as StreamCreate
|
||||
|
||||
if (!body.id || !SLUG_RE.test(body.id))
|
||||
return NextResponse.json({ error: "id inválido: use apenas letras minúsculas, números e hífen" }, { status: 400 })
|
||||
return NextResponse.json({ error: "invalid id: use only lowercase letters, numbers and hyphens" }, { status: 400 })
|
||||
|
||||
if (!body.name || !body.url)
|
||||
return NextResponse.json({ error: "name e url são obrigatórios" }, { status: 400 })
|
||||
return NextResponse.json({ error: "name and url are required" }, { status: 400 })
|
||||
|
||||
if (getStream(body.id))
|
||||
return NextResponse.json({ error: "já existe uma stream com esse id" }, { status: 409 })
|
||||
return NextResponse.json({ error: "a stream with this id already exists" }, { status: 409 })
|
||||
|
||||
const ports = allocatePorts()
|
||||
const now = new Date().toISOString()
|
||||
@@ -27,7 +27,9 @@ export async function POST(req: Request) {
|
||||
const stream = {
|
||||
...STREAM_DEFAULTS,
|
||||
...body,
|
||||
scale: normalizeScale(body.scale ?? STREAM_DEFAULTS.scale), // #13
|
||||
...ports,
|
||||
desiredState: "running" as const, // #19
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
@@ -35,6 +37,7 @@ export async function POST(req: Request) {
|
||||
saveStream(stream)
|
||||
provisionStream(stream)
|
||||
startStream(stream.id)
|
||||
captureThumb(stream.id, 60)
|
||||
|
||||
return NextResponse.json(stream, { status: 201 })
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -15,7 +15,8 @@
|
||||
--primary-foreground: #0a0a0a;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #fff;
|
||||
--accent: #1a1a1a;
|
||||
--accent: #2a2a2a;
|
||||
--accent-hover: #333333;
|
||||
--accent-foreground: #ededed;
|
||||
--ring: #444444;
|
||||
--radius: 0.5rem;
|
||||
@@ -30,4 +31,8 @@ body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
+121
-38
@@ -1,25 +1,96 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Plus, Download, RefreshCw } from "lucide-react"
|
||||
import { Plus, Download, RefreshCw, Settings, X } from "lucide-react"
|
||||
import { StreamCard } from "@/components/StreamCard"
|
||||
import type { Stream } from "@/types/stream"
|
||||
|
||||
type CardSize = "sm" | "md" | "lg"
|
||||
|
||||
function SkeletonCard({ size = "sm" }: { size?: CardSize }) {
|
||||
const widths = { sm: "max-w-[200px]", md: "max-w-[240px]", lg: "max-w-[300px]" }
|
||||
return (
|
||||
<div className={`rounded-lg border border-border bg-card p-3 flex flex-col gap-2.5 w-full ${widths[size]} animate-pulse`}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="h-4 w-28 bg-muted rounded" />
|
||||
<div className="h-3 w-16 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="h-3 w-12 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="h-3 w-full bg-muted rounded" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{[...Array(4)].map((_, i) => <div key={i} className="h-8 w-full bg-muted rounded" />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// #7 — settings popup
|
||||
function SettingsPopup({ cardSize, onCardSize, onClose }: {
|
||||
cardSize: CardSize
|
||||
onCardSize: (s: CardSize) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} />
|
||||
<div className="fixed top-16 right-6 z-50 w-64 rounded-lg border border-border shadow-2xl p-4 flex flex-col gap-4" style={{ background: "#1c1c1c" }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">Settings</p>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-[#2a2a2a] cursor-pointer transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-muted-foreground tracking-wider">Card size</p>
|
||||
<div className="flex gap-2">
|
||||
{(["sm", "md", "lg"] as CardSize[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onCardSize(s)}
|
||||
className={`flex-1 py-1.5 rounded border text-xs transition-colors cursor-pointer ${
|
||||
cardSize === s
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border hover:bg-[#2a2a2a]"
|
||||
}`}
|
||||
>
|
||||
{s === "sm" ? "Small" : s === "md" ? "Medium" : "Big"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GalleryPage() {
|
||||
const router = useRouter()
|
||||
const [streams, setStreams] = useState<Stream[]>([])
|
||||
const [statuses, setStatuses] = useState<Record<string, Record<string, string>>>({})
|
||||
const [localStatuses, setLocalStatuses] = useState<Record<string, string | null>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [cardSize, setCardSize] = useState<CardSize>("md")
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
const fetchStreams = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("cardSize") as CardSize | null
|
||||
if (saved) setCardSize(saved)
|
||||
}, [])
|
||||
|
||||
const fetchStreams = useCallback(async (manual = false) => {
|
||||
if (manual) setRefreshing(true)
|
||||
const res = await fetch("/api/streams")
|
||||
const data: Stream[] = await res.json()
|
||||
setStreams(data)
|
||||
setLoading(false)
|
||||
if (manual) setRefreshing(false)
|
||||
}, [])
|
||||
|
||||
const fetchStatuses = useCallback(async (list: Stream[]) => {
|
||||
if (list.length === 0) return
|
||||
const results = await Promise.all(
|
||||
list.map(async (s) => {
|
||||
const res = await fetch(`/api/streams/${s.id}/status`)
|
||||
@@ -30,9 +101,7 @@ export default function GalleryPage() {
|
||||
setStatuses(Object.fromEntries(results))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStreams()
|
||||
}, [fetchStreams])
|
||||
useEffect(() => { fetchStreams() }, [fetchStreams])
|
||||
|
||||
useEffect(() => {
|
||||
if (streams.length === 0) return
|
||||
@@ -41,62 +110,76 @@ export default function GalleryPage() {
|
||||
return () => clearInterval(interval)
|
||||
}, [streams, fetchStatuses])
|
||||
|
||||
const setLocalStatus = useCallback((id: string, s: string | null) => {
|
||||
setLocalStatuses((prev) => ({ ...prev, [id]: s }))
|
||||
}, [])
|
||||
|
||||
function downloadPlaylist() {
|
||||
const host = window.location.hostname
|
||||
window.location.href = `/api/streams/playlist?host=${host}&port=8888`
|
||||
window.location.href = `/api/streams/playlist?host=${window.location.hostname}&port=8888`
|
||||
}
|
||||
|
||||
const showSkeleton = loading || refreshing
|
||||
|
||||
// #6 — todos os botões do header com mesmo padding e tamanho
|
||||
const btnBase = "flex items-center gap-1.5 text-sm px-3 py-1.5 h-8 rounded border border-border hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer"
|
||||
const btnPrimary = "flex items-center gap-1.5 text-sm px-3 py-1.5 h-8 rounded border border-primary bg-primary text-primary-foreground hover:bg-[#2a2a2a] hover:text-foreground hover:border-border active:bg-[#333] transition-colors cursor-pointer"
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="border-b border-border px-6 py-4 flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold tracking-tight">DecapStream</h1>
|
||||
<h1 className="text-lg font-semibold tracking-tight">Decap Stream</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { fetchStreams() }}
|
||||
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded border border-border hover:bg-accent transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
{/* #6 — refresh com h-8 explícito igual aos outros */}
|
||||
<button onClick={() => fetchStreams(true)} className={btnBase} title="Atualizar">
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${refreshing ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={downloadPlaylist}
|
||||
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded border border-border hover:bg-accent transition-colors"
|
||||
>
|
||||
<button onClick={downloadPlaylist} className={btnBase}>
|
||||
<Download className="w-3.5 h-3.5" /> Playlist .m3u
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push("/streams/new")}
|
||||
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Nova stream
|
||||
{/* #7 — botão de config */}
|
||||
<button onClick={() => setSettingsOpen((v) => !v)} className={btnBase} title="Settings">
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => window.location.href = "/streams/new"} className={btnPrimary}>
|
||||
<Plus className="w-3.5 h-3.5" /> New stream
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Grid */}
|
||||
{/* #7 — popup de configurações */}
|
||||
{settingsOpen && (
|
||||
<SettingsPopup
|
||||
cardSize={cardSize}
|
||||
onCardSize={(s) => { setCardSize(s); localStorage.setItem("cardSize", s); setSettingsOpen(false) }}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<main className="flex-1 p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground text-sm">
|
||||
Carregando...
|
||||
{showSkeleton ? (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{[...Array(refreshing ? Math.max(streams.length, 1) : 4)].map((_, i) => (
|
||||
<SkeletonCard key={i} size={cardSize} />
|
||||
))}
|
||||
</div>
|
||||
) : streams.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-3 text-muted-foreground">
|
||||
<p className="text-sm">Nenhuma stream configurada.</p>
|
||||
<button
|
||||
onClick={() => router.push("/streams/new")}
|
||||
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Nova stream
|
||||
<p className="text-sm">No streams configured.</p>
|
||||
<button onClick={() => window.location.href = "/streams/new"} className={btnPrimary}>
|
||||
<Plus className="w-3.5 h-3.5" /> New stream
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{streams.map((s) => (
|
||||
<StreamCard
|
||||
key={s.id}
|
||||
stream={s}
|
||||
status={statuses[s.id]}
|
||||
onRefresh={fetchStreams}
|
||||
localStatus={localStatuses[s.id] ?? null}
|
||||
cardSize={cardSize}
|
||||
onRefresh={() => fetchStreams()}
|
||||
onLocalStatus={setLocalStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -104,4 +187,4 @@ export default function GalleryPage() {
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -45,13 +45,13 @@ export async function GET(req: NextRequest, { params }: Ctx) {
|
||||
hls.attachMedia(document.getElementById('v'));
|
||||
hls.on(Hls.Events.MANIFEST_PARSED,function(){document.getElementById('v').play();});
|
||||
hls.on(Hls.Events.ERROR,function(e,d){
|
||||
if(d.fatal){showMsg('Erro: '+d.type+' — reconectando...');setTimeout(load,3000);}
|
||||
if(d.fatal){showMsg('Error: '+d.type+' — reconnecting...');setTimeout(load,3000);}
|
||||
});
|
||||
}
|
||||
var last=0;
|
||||
setInterval(function(){
|
||||
var v=document.getElementById('v');
|
||||
if(v.currentTime===last&&!v.paused){showMsg('Stream travada — recarregando...');load();}
|
||||
if(v.currentTime===last&&!v.paused){showMsg('Stream stalled — reloading...');load();}
|
||||
last=v.currentTime;
|
||||
},10000);
|
||||
load();
|
||||
|
||||
@@ -2,22 +2,49 @@
|
||||
|
||||
import { Suspense } from "react"
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Script from "next/script"
|
||||
import { useEffect, useRef, useState, useCallback } from "react"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
type Mode = "hls" | "m3u8" | "html"
|
||||
type Mode = "hls" | "html"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Hls: any
|
||||
}
|
||||
interface Window { Hls: any }
|
||||
}
|
||||
|
||||
function HLSPlayer({ src }: { src: string }) {
|
||||
function BackButton({ onClick }: { onClick: () => void }) {
|
||||
const [visible, setVisible] = useState(true)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const show = useCallback(() => {
|
||||
setVisible(true)
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => setVisible(false), 5000)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
show()
|
||||
window.addEventListener("mousemove", show)
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", show)
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [show])
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{ opacity: visible ? 1 : 0, transition: "opacity 0.4s" }}
|
||||
className="absolute top-4 left-4 z-20 flex items-center gap-1.5 text-sm text-white bg-black/40 px-3 py-1.5 rounded-lg cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// HLS e M3U8 usam a mesma lógica — HLS.js carregado inline via fetch, não via <Script>
|
||||
function VideoPlayer({ src, controls }: { src: string; controls?: boolean }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hlsRef = useRef<unknown>(null)
|
||||
const hlsRef = useRef<any>(null)
|
||||
const [msg, setMsg] = useState("")
|
||||
|
||||
function showMsg(text: string) {
|
||||
@@ -25,102 +52,86 @@ function HLSPlayer({ src }: { src: string }) {
|
||||
setTimeout(() => setMsg(""), 4000)
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (!videoRef.current || !window.Hls) return
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Hls = window.Hls as any
|
||||
if (hlsRef.current) (hlsRef.current as { destroy: () => void }).destroy()
|
||||
const load = useCallback((Hls: any) => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
if (hlsRef.current) hlsRef.current.destroy()
|
||||
const hls = new Hls({
|
||||
liveSyncDurationCount: 2,
|
||||
liveMaxLatencyDurationCount: 4,
|
||||
manifestLoadingTimeOut: 10000,
|
||||
manifestLoadingMaxRetry: 10,
|
||||
fragLoadingTimeOut: 10000,
|
||||
liveSyncDurationCount: 3,
|
||||
liveMaxLatencyDurationCount: 6,
|
||||
manifestLoadingTimeOut: 15000,
|
||||
manifestLoadingMaxRetry: 20,
|
||||
manifestLoadingRetryDelay: 1000,
|
||||
fragLoadingTimeOut: 15000,
|
||||
fragLoadingMaxRetry: 10,
|
||||
})
|
||||
hlsRef.current = hls
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(videoRef.current)
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => videoRef.current?.play())
|
||||
hls.on(Hls.Events.ERROR, (_: unknown, d: { fatal: boolean; type: string }) => {
|
||||
if (d.fatal) {
|
||||
showMsg(`Erro: ${d.type} — reconectando...`)
|
||||
setTimeout(load, 3000)
|
||||
}
|
||||
hls.attachMedia(v)
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => v.play())
|
||||
hls.on(Hls.Events.ERROR, (_: any, d: any) => {
|
||||
if (d.fatal) { showMsg(`Error: ${d.type} — reconnecting...`); setTimeout(() => load(Hls), 3000) }
|
||||
})
|
||||
}
|
||||
}, [src])
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
|
||||
// Carrega HLS.js dinamicamente via import para evitar problemas com <Script>
|
||||
const script = document.createElement("script")
|
||||
script.src = "https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"
|
||||
script.onload = () => {
|
||||
const Hls = window.Hls
|
||||
if (Hls.isSupported()) {
|
||||
load(Hls)
|
||||
} else if (v.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
// Safari nativo
|
||||
v.src = src
|
||||
v.play()
|
||||
}
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
|
||||
// stall detection
|
||||
let last = 0
|
||||
const interval = setInterval(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
if (v.currentTime === last && !v.paused) {
|
||||
showMsg("Stream travada — recarregando...")
|
||||
load()
|
||||
}
|
||||
if (v.currentTime === last && !v.paused) { showMsg("Stream stalled — reloading..."); hlsRef.current && load(window.Hls) }
|
||||
last = v.currentTime
|
||||
}, 10000)
|
||||
return () => clearInterval(interval)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
hlsRef.current?.destroy()
|
||||
document.head.removeChild(script)
|
||||
}
|
||||
}, [load, src])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js" onLoad={load} />
|
||||
<video ref={videoRef} autoPlay muted playsInline className="w-screen h-screen object-contain bg-black" />
|
||||
<video ref={videoRef} autoPlay muted playsInline controls={controls} className="w-screen h-screen object-contain bg-black" />
|
||||
{msg && (
|
||||
<div className="fixed top-4 left-1/2 -translate-x-1/2 bg-black/75 text-white px-5 py-2 rounded-lg text-sm z-10">
|
||||
{msg}
|
||||
</div>
|
||||
<div className="fixed top-4 left-1/2 -translate-x-1/2 bg-black/75 text-white px-5 py-2 rounded-lg text-sm z-10">{msg}</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function M3U8Player({ src }: { src: string }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
useEffect(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
if (v.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
v.src = src
|
||||
v.play()
|
||||
}
|
||||
}, [src])
|
||||
return (
|
||||
<video ref={videoRef} src={src} autoPlay muted playsInline controls className="w-screen h-screen object-contain bg-black" />
|
||||
)
|
||||
}
|
||||
|
||||
// Componente interno que usa useSearchParams — precisa estar dentro de Suspense
|
||||
function PlayerInner() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const mode = (searchParams.get("mode") ?? "hls") as Mode
|
||||
|
||||
const host = typeof window !== "undefined" ? window.location.hostname : "localhost"
|
||||
const hlsSrc = `http://${host}:8888/live/${id}/index.m3u8`
|
||||
|
||||
const streamSrc = `http://${host}:8888/live/${id}/index.m3u8`
|
||||
|
||||
return (
|
||||
<div className="relative bg-black w-screen h-screen overflow-hidden">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="absolute top-4 left-4 z-20 flex items-center gap-1.5 text-sm text-white/70 hover:text-white bg-black/50 px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Voltar
|
||||
</button>
|
||||
|
||||
{mode === "hls" && <HLSPlayer src={hlsSrc} />}
|
||||
{mode === "m3u8" && <M3U8Player src={hlsSrc} />}
|
||||
{mode === "html" && (
|
||||
<iframe
|
||||
src={`/player-static/${id}`}
|
||||
className="w-screen h-screen border-0"
|
||||
allowFullScreen
|
||||
/>
|
||||
)}
|
||||
<BackButton onClick={() => router.push("/")} />
|
||||
{mode === "hls" && <VideoPlayer src={streamSrc} controls />}
|
||||
{mode === "html" && <iframe src={`/player-static/${id}`} className="w-screen h-screen border-0" allowFullScreen />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+130
-92
@@ -1,152 +1,190 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { MoreHorizontal, Play, Globe, FileVideo, Monitor, Pencil, RotateCcw, Square, Trash2, Circle } from "lucide-react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { MoreHorizontal, Play, Globe, Monitor, Pencil, RotateCcw, Square, Trash2, Circle, Copy, Check, Video, ImageUp } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Stream } from "@/types/stream"
|
||||
|
||||
interface Props {
|
||||
stream: Stream
|
||||
status?: Record<string, string>
|
||||
localStatus?: string | null
|
||||
cardSize?: "sm" | "md" | "lg"
|
||||
onRefresh: () => void
|
||||
onLocalStatus: (id: string, s: string | null) => void
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status?: Record<string, string> }) {
|
||||
if (!status) return null
|
||||
const ffmpeg = status.ffmpeg
|
||||
function StatusBadge({ status, localStatus }: { status?: Record<string, string>; localStatus?: string | null }) {
|
||||
const label = localStatus ?? (
|
||||
status?.ffmpeg === "RUNNING" ? "running" :
|
||||
status?.ffmpeg === "STARTING" ? "starting" :
|
||||
status?.ffmpeg === "FATAL" ? "error" :
|
||||
status?.ffmpeg === "STOPPED" ? "stopped" : "..."
|
||||
)
|
||||
const color =
|
||||
ffmpeg === "RUNNING" ? "bg-green-500" :
|
||||
ffmpeg === "STARTING" ? "bg-yellow-500" :
|
||||
ffmpeg === "FATAL" ? "bg-red-500" :
|
||||
"bg-zinc-500"
|
||||
const label =
|
||||
ffmpeg === "RUNNING" ? "running" :
|
||||
ffmpeg === "STARTING" ? "starting" :
|
||||
ffmpeg === "FATAL" ? "error" :
|
||||
"stopped"
|
||||
label === "running" ? "bg-green-500" :
|
||||
label === "starting" ? "bg-yellow-500" :
|
||||
label === "restarting" ? "bg-yellow-500" :
|
||||
label === "stopping" ? "bg-orange-500" :
|
||||
label === "error" ? "bg-red-500" : "bg-zinc-500"
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Circle className={cn("w-2 h-2 fill-current", color)} />
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground whitespace-nowrap">
|
||||
<Circle className={cn("w-2 h-2 fill-current shrink-0", color)} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function StreamCard({ stream, status, onRefresh }: Props) {
|
||||
const router = useRouter()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [loading, setLoading] = useState<string | null>(null)
|
||||
function copyToClipboard(text: string) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text)
|
||||
}
|
||||
const el = document.createElement("textarea")
|
||||
el.value = text
|
||||
el.style.position = "fixed"
|
||||
el.style.opacity = "0"
|
||||
document.body.appendChild(el)
|
||||
el.focus()
|
||||
el.select()
|
||||
document.execCommand("copy")
|
||||
document.body.removeChild(el)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
async function action(act: string) {
|
||||
setLoading(act)
|
||||
const CARD_WIDTHS = { sm: "max-w-[200px]", md: "max-w-[240px]", lg: "max-w-[300px]" }
|
||||
|
||||
export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRefresh, onLocalStatus }: Props) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [thumbKey, setThumbKey] = useState(0)
|
||||
const [thumbError, setThumbError] = useState(false)
|
||||
const [thumbCapturing, setThumbCapturing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!thumbError || thumbCapturing) return
|
||||
const interval = setInterval(() => setThumbKey((k) => k + 1), 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [thumbError, thumbCapturing])
|
||||
|
||||
async function action(act: string, optimisticStatus: string) {
|
||||
onLocalStatus(stream.id, optimisticStatus)
|
||||
setMenuOpen(false)
|
||||
await fetch(`/api/streams/${stream.id}/${act}`, { method: "POST" })
|
||||
setLoading(null)
|
||||
onRefresh()
|
||||
setTimeout(() => onLocalStatus(stream.id, null), 15000)
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm(`Deletar stream "${stream.name}"?`)) return
|
||||
if (!confirm(`Delete stream "${stream.name}"?`)) return
|
||||
await fetch(`/api/streams/${stream.id}`, { method: "DELETE" })
|
||||
onRefresh()
|
||||
}
|
||||
|
||||
function openVNC() {
|
||||
const host = window.location.hostname
|
||||
window.open(`http://${host}:${stream.novncPort}/vnc.html`, "_blank")
|
||||
const token = encodeURIComponent(`token=${stream.id}`)
|
||||
window.open(`http://${window.location.hostname}:6080/vnc.html?autoconnect=true&path=websockify%3F${token}`, "_blank")
|
||||
}
|
||||
|
||||
function copyRTMP() {
|
||||
const url = `rtmp://${window.location.hostname}:1935/live/${stream.id}`
|
||||
copyToClipboard(url).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshThumb() {
|
||||
setMenuOpen(false)
|
||||
setThumbCapturing(true)
|
||||
setThumbError(false)
|
||||
await fetch(`/api/streams/${stream.id}/thumb`, { method: "POST" })
|
||||
// 5s delay in backend + a few seconds for ffmpeg to process
|
||||
setTimeout(() => {
|
||||
setThumbKey((k) => k + 1)
|
||||
setThumbCapturing(false)
|
||||
}, 9000)
|
||||
}
|
||||
|
||||
function play(mode: string) {
|
||||
window.location.href = `/player/${stream.id}?mode=${mode}`
|
||||
}
|
||||
|
||||
const playBtn = "w-full flex items-center gap-2 text-xs px-3 py-2 rounded border border-border bg-muted hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer"
|
||||
const menuItem = "w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer"
|
||||
|
||||
return (
|
||||
<div className="relative rounded-lg border border-border bg-card p-4 flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className={cn("relative rounded-lg border border-border bg-card p-3 flex flex-col gap-2.5 w-full", CARD_WIDTHS[cardSize])}>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="w-full aspect-video rounded overflow-hidden bg-muted flex items-center justify-center">
|
||||
{thumbCapturing ? (
|
||||
<span className="text-xs text-muted-foreground animate-pulse">Capturing...</span>
|
||||
) : thumbError ? (
|
||||
<Video className="w-5 h-5 text-muted-foreground/25" />
|
||||
) : (
|
||||
<img
|
||||
key={thumbKey}
|
||||
src={`/api/streams/${stream.id}/thumb?t=${thumbKey}`}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setThumbError(true)}
|
||||
onLoad={() => setThumbError(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold truncate">{stream.name}</p>
|
||||
<p className="font-semibold text-sm truncate">{stream.name}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{stream.id}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<StatusBadge status={status} />
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="p-1 rounded hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<StatusBadge status={status} localStatus={localStatus} />
|
||||
<button onClick={() => setMenuOpen((v) => !v)} className="p-1 rounded hover:bg-[#2a2a2a] transition-colors cursor-pointer">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<p className="text-xs text-muted-foreground truncate" title={stream.url}>{stream.url}</p>
|
||||
|
||||
{/* Play buttons */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => router.push(`/player/${stream.id}?mode=hls`)}
|
||||
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
<Play className="w-3 h-3" /> Play HLS
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/player/${stream.id}?mode=html`)}
|
||||
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
<Globe className="w-3 h-3" /> Play HTML
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/player/${stream.id}?mode=m3u8`)}
|
||||
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
<FileVideo className="w-3 h-3" /> Play m3u8
|
||||
</button>
|
||||
<button
|
||||
onClick={openVNC}
|
||||
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
<Monitor className="w-3 h-3" /> Open VNC
|
||||
</button>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<button onClick={() => play("hls")} className={playBtn}><Play className="w-3 h-3 shrink-0" /> Play Stream</button>
|
||||
<button onClick={() => play("html")} className={playBtn}><Globe className="w-3 h-3 shrink-0" /> Run HTML</button>
|
||||
<button onClick={openVNC} className={playBtn}><Monitor className="w-3 h-3 shrink-0" /> Open VNC</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute top-10 right-4 z-20 min-w-[160px] rounded-lg border border-border bg-card shadow-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); router.push(`/streams/${stream.id}/edit`) }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" /> Editar
|
||||
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute top-10 right-2 z-50 min-w-[180px] rounded-lg border border-border shadow-2xl overflow-hidden"
|
||||
style={{ background: "#1c1c1c" }}>
|
||||
<button onClick={() => { setMenuOpen(false); window.location.href = `/streams/${stream.id}/edit` }} className={menuItem}>
|
||||
<Pencil className="w-3.5 h-3.5" /> Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); action("restart") }}
|
||||
disabled={!!loading}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<button onClick={() => action("restart", "restarting")} className={menuItem}>
|
||||
<RotateCcw className="w-3.5 h-3.5" /> Restart
|
||||
</button>
|
||||
{status?.ffmpeg === "RUNNING" ? (
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); action("stop") }}
|
||||
disabled={!!loading}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
{status?.ffmpeg === "RUNNING" || localStatus === "restarting" ? (
|
||||
<button onClick={() => action("stop", "stopping")} className={menuItem}>
|
||||
<Square className="w-3.5 h-3.5" /> Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); action("start") }}
|
||||
disabled={!!loading}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<button onClick={() => action("start", "starting")} className={menuItem}>
|
||||
<Play className="w-3.5 h-3.5" /> Start
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => { setMenuOpen(false); copyRTMP() }} className={menuItem}>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? "Copied!" : "Copy RTMP"}
|
||||
</button>
|
||||
<div className="border-t border-border" />
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); remove() }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-accent transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Deletar
|
||||
<button onClick={refreshThumb} disabled={thumbCapturing} className={cn(menuItem, thumbCapturing && "opacity-50")}>
|
||||
<ImageUp className="w-3.5 h-3.5" />
|
||||
{thumbCapturing ? "Capturing..." : "Refresh thumbnail"}
|
||||
</button>
|
||||
<div className="border-t border-border" />
|
||||
<button onClick={() => { setMenuOpen(false); remove() }} className={cn(menuItem, "text-destructive")}>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
+173
-93
@@ -2,51 +2,82 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronDown, ChevronRight, TriangleAlert } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { STREAM_DEFAULTS, type Stream, type StreamCreate, type StreamUpdate } from "@/types/stream"
|
||||
|
||||
interface Props {
|
||||
initial?: Stream
|
||||
}
|
||||
interface Props { initial?: Stream }
|
||||
|
||||
const SLUG_RE = /^[a-z0-9-]+$/
|
||||
|
||||
const PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium"]
|
||||
const TUNES = ["stillimage", "zerolatency", "film", "animation"]
|
||||
const TUNES = ["stillimage", "animation", "zerolatency", "film", "fastdecode"]
|
||||
|
||||
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
|
||||
const TOOLTIPS = {
|
||||
id: "Unique identifier used in URLs, RTMP path (/live/{id}) and HLS path. Lowercase letters, numbers and hyphens only.",
|
||||
name: "Display name shown in the interface. Does not affect the stream.",
|
||||
url: "The URL Chromium will open and capture as a video stream.",
|
||||
user: "Username for auto-login. Chromium will type this into the first form field after the page loads.",
|
||||
pass: "Password for auto-login. Typed after the username field.",
|
||||
resolution: "Virtual display (Xvfb) and Chromium window size. This is what ffmpeg captures. Format: WIDTHxHEIGHT.",
|
||||
scale: "Output video resolution. Can be lower than the capture resolution to reduce bandwidth. Format: WIDTHxHEIGHT.",
|
||||
fps: "Frames per second for capture and encoding. Higher values produce smoother video but increase CPU and bandwidth usage.",
|
||||
bitrate: "Target video bitrate. Higher values improve quality at the cost of bandwidth. Examples: 1500k, 3000k.",
|
||||
bufsize: "Encoder buffer size. Controls bitrate variance. Recommended: 2× bitrate.",
|
||||
preset: "Encoding speed vs compression trade-off. Faster presets use less CPU but produce larger files at the same quality. From fastest to slowest: ultrafast → superfast → veryfast → faster → fast → medium.",
|
||||
tune: "Optimizes the encoder for your content type.\n• stillimage — best for static or slow-changing content\n• animation — solid colors, UI, charts\n• zerolatency — minimizes encoding delay\n• film — natural video with grain\n• fastdecode — easier to decode on the client side",
|
||||
delay: "Seconds to wait after Chromium starts before ffmpeg begins capturing. Gives the page time to fully load and render.",
|
||||
gop: "Keyframe interval in frames. Recommended: 2× FPS. Affects HLS segment alignment and seek accuracy. Auto-calculated from FPS unless manually changed.",
|
||||
threads: "Number of ffmpeg encoding threads. 0 = auto-detect (recommended). Increasing this can reduce latency on multi-core systems at the cost of slightly reduced compression efficiency.",
|
||||
}
|
||||
|
||||
function Tooltip({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="relative group inline-flex items-center">
|
||||
<span className="w-3.5 h-3.5 rounded-full border border-muted-foreground/40 text-muted-foreground/70 text-[10px] flex items-center justify-center cursor-help select-none leading-none flex-shrink-0">?</span>
|
||||
<div className="absolute bottom-full left-0 mb-2 z-50 hidden group-hover:block w-56 rounded bg-[#1c1c1c] border border-border px-2.5 py-2 text-xs text-muted-foreground shadow-xl pointer-events-none whitespace-pre-line">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, tooltip, required, error, children }: {
|
||||
label: string
|
||||
tooltip?: string
|
||||
required?: boolean
|
||||
error?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{label}</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
{tooltip && <Tooltip text={tooltip} />}
|
||||
</div>
|
||||
{children}
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const inputClass = "w-full rounded border border-border bg-muted px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring transition-colors"
|
||||
const selectClass = cn(inputClass, "appearance-none bg-muted text-foreground")
|
||||
|
||||
function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input className={cn(inputClass, className)} {...props} />
|
||||
}
|
||||
|
||||
function Select({ className, children, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"w-full rounded border border-border bg-muted px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<select className={cn(selectClass, className)} {...props}>
|
||||
{children}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
"w-full rounded border border-border bg-muted px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
function normalizeScaleDisplay(s: string) { return s.replace(":", "x") }
|
||||
|
||||
export function StreamForm({ initial }: Props) {
|
||||
const router = useRouter()
|
||||
@@ -60,31 +91,58 @@ export function StreamForm({ initial }: Props) {
|
||||
pass: initial?.pass ?? "",
|
||||
...STREAM_DEFAULTS,
|
||||
...(initial ? {
|
||||
delay: initial.delay,
|
||||
delay: initial.delay,
|
||||
resolution: initial.resolution,
|
||||
scale: initial.scale,
|
||||
fps: initial.fps,
|
||||
bitrate: initial.bitrate,
|
||||
bufsize: initial.bufsize,
|
||||
preset: initial.preset,
|
||||
tune: initial.tune,
|
||||
gop: initial.gop,
|
||||
scale: normalizeScaleDisplay(initial.scale),
|
||||
fps: initial.fps,
|
||||
bitrate: initial.bitrate,
|
||||
bufsize: initial.bufsize,
|
||||
preset: initial.preset,
|
||||
tune: initial.tune,
|
||||
gop: initial.gop,
|
||||
threads: initial.threads ?? 0,
|
||||
} : {}),
|
||||
})
|
||||
|
||||
const [gopManuallyEdited, setGopManuallyEdited] = useState(isEdit)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function set(key: keyof StreamCreate, value: string | number) {
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
setErrors((e) => { const n = { ...e }; delete n[key]; return n })
|
||||
setErrors((e) => { const n = { ...e }; delete n[key as string]; return n })
|
||||
}
|
||||
|
||||
function setFps(value: number) {
|
||||
set("fps", value)
|
||||
if (!gopManuallyEdited) set("gop", value * 2)
|
||||
}
|
||||
|
||||
function setGop(value: number) {
|
||||
setGopManuallyEdited(true)
|
||||
set("gop", value)
|
||||
}
|
||||
|
||||
function setScale(value: string) {
|
||||
set("scale", value.replace(":", "x"))
|
||||
if (value && !/^\d+[x:]\d+$/.test(value)) {
|
||||
setErrors((e) => ({ ...e, scale: "Invalid format. Use 1280x720" }))
|
||||
} else {
|
||||
setErrors((e) => { const n = { ...e }; delete n.scale; return n })
|
||||
}
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
const e: Record<string, string> = {}
|
||||
if (!isEdit && !SLUG_RE.test(form.id)) e.id = "Apenas letras minúsculas, números e hífen"
|
||||
if (!form.name.trim()) e.name = "Obrigatório"
|
||||
if (!form.url.trim()) e.url = "Obrigatório"
|
||||
if (!isEdit && (!form.id || !SLUG_RE.test(form.id))) e.id = "Lowercase letters, numbers and hyphens only"
|
||||
if (!isEdit && !form.id) e.id = "Required"
|
||||
if (!form.name.trim()) e.name = "Required"
|
||||
if (!form.url.trim()) e.url = "Required"
|
||||
if (!form.resolution.trim()) e.resolution = "Required"
|
||||
if (!form.scale.trim() || !/^\d+[x:]\d+$/.test(form.scale)) e.scale = "Invalid format. Use 1280x720"
|
||||
if (!form.bitrate.trim()) e.bitrate = "Required"
|
||||
if (!form.bufsize.trim()) e.bufsize = "Required"
|
||||
setErrors(e)
|
||||
return Object.keys(e).length === 0
|
||||
}
|
||||
@@ -94,11 +152,10 @@ export function StreamForm({ initial }: Props) {
|
||||
setSaving(true)
|
||||
|
||||
if (isEdit) {
|
||||
const body: StreamUpdate = { ...form }
|
||||
await fetch(`/api/streams/${initial!.id}`, {
|
||||
fetch(`/api/streams/${initial!.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(form as StreamUpdate),
|
||||
})
|
||||
} else {
|
||||
await fetch("/api/streams", {
|
||||
@@ -108,105 +165,128 @@ export function StreamForm({ initial }: Props) {
|
||||
})
|
||||
}
|
||||
|
||||
setSaving(false)
|
||||
router.push("/")
|
||||
window.location.href = "/"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="border-b border-border px-6 py-4 flex items-center gap-4">
|
||||
<button onClick={() => router.push("/")} className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
← Voltar
|
||||
← Back
|
||||
</button>
|
||||
<h1 className="text-lg font-semibold">{isEdit ? `Editar — ${initial!.name}` : "Nova stream"}</h1>
|
||||
<h1 className="text-lg font-semibold">{isEdit ? `Edit — ${initial!.name}` : "New stream"}</h1>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-6 max-w-2xl mx-auto w-full flex flex-col gap-8">
|
||||
<main className="flex-1 p-6 max-w-2xl mx-auto w-full flex flex-col gap-6">
|
||||
|
||||
{/* Identificação */}
|
||||
{/* Identification */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Identificação</h2>
|
||||
<Field label="ID (slug)" error={errors.id}>
|
||||
<Input
|
||||
value={form.id}
|
||||
disabled={isEdit}
|
||||
placeholder="mapa-zabbix"
|
||||
onChange={(e) => set("id", e.target.value.toLowerCase())}
|
||||
/>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Identification</h2>
|
||||
<Field label="ID" tooltip={TOOLTIPS.id} required error={errors.id}>
|
||||
<Input value={form.id} disabled={isEdit} placeholder="my-stream" onChange={(e) => set("id", e.target.value.toLowerCase())} />
|
||||
</Field>
|
||||
<Field label="Nome" error={errors.name}>
|
||||
<Input value={form.name} placeholder="Mapa Zabbix" onChange={(e) => set("name", e.target.value)} />
|
||||
<Field label="Name" tooltip={TOOLTIPS.name} required error={errors.name}>
|
||||
<Input value={form.name} placeholder="My Stream" onChange={(e) => set("name", e.target.value)} />
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
{/* Fonte */}
|
||||
<hr className="border-border" />
|
||||
|
||||
{/* Source */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Fonte</h2>
|
||||
<Field label="URL" error={errors.url}>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Source</h2>
|
||||
<Field label="URL" tooltip={TOOLTIPS.url} required error={errors.url}>
|
||||
<Input value={form.url} placeholder="https://..." onChange={(e) => set("url", e.target.value)} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Usuário (opcional)">
|
||||
<Field label="Username" tooltip={TOOLTIPS.user}>
|
||||
<Input value={form.user ?? ""} onChange={(e) => set("user", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Senha (opcional)">
|
||||
<Field label="Password" tooltip={TOOLTIPS.pass}>
|
||||
<Input type="password" value={form.pass ?? ""} onChange={(e) => set("pass", e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FFmpeg */}
|
||||
<hr className="border-border" />
|
||||
|
||||
{/* Stream */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">FFmpeg / Xvfb</h2>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Stream</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Resolução (Xvfb/Chrome)">
|
||||
<Field label="Resolution" tooltip={TOOLTIPS.resolution} required error={errors.resolution}>
|
||||
<Input value={form.resolution} placeholder="1920x1080" onChange={(e) => set("resolution", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Scale (stream output)">
|
||||
<Input value={form.scale} placeholder="1280:720" onChange={(e) => set("scale", e.target.value)} />
|
||||
<Field label="Scale" tooltip={TOOLTIPS.scale} required error={errors.scale}>
|
||||
<Input value={form.scale} placeholder="1280x720" onChange={(e) => setScale(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="FPS">
|
||||
<Input type="number" value={form.fps} onChange={(e) => set("fps", Number(e.target.value))} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Field label="FPS" tooltip={TOOLTIPS.fps} required>
|
||||
<Input type="number" min={1} value={form.fps} onChange={(e) => setFps(Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="Delay (s)">
|
||||
<Input type="number" value={form.delay} onChange={(e) => set("delay", Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="Bitrate">
|
||||
<Field label="Bitrate" tooltip={TOOLTIPS.bitrate} required error={errors.bitrate}>
|
||||
<Input value={form.bitrate} placeholder="1500k" onChange={(e) => set("bitrate", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Bufsize">
|
||||
<Input value={form.bufsize} placeholder="1500k" onChange={(e) => set("bufsize", e.target.value)} />
|
||||
<Field label="Bufsize" tooltip={TOOLTIPS.bufsize} required error={errors.bufsize}>
|
||||
<Input value={form.bufsize} placeholder="3000k" onChange={(e) => set("bufsize", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="GOP">
|
||||
<Input type="number" value={form.gop} onChange={(e) => set("gop", Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="Preset">
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Preset" tooltip={TOOLTIPS.preset} required>
|
||||
<Select value={form.preset} onChange={(e) => set("preset", e.target.value)}>
|
||||
{PRESETS.map((p) => <option key={p}>{p}</option>)}
|
||||
{PRESETS.map((p) => <option key={p} value={p} style={{ background: "#1a1a1a", color: "#ededed" }}>{p}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Tune">
|
||||
<Field label="Tune" tooltip={TOOLTIPS.tune} required>
|
||||
<Select value={form.tune} onChange={(e) => set("tune", e.target.value)}>
|
||||
{TUNES.map((t) => <option key={t}>{t}</option>)}
|
||||
{TUNES.map((t) => <option key={t} value={t} style={{ background: "#1a1a1a", color: "#ededed" }}>{t}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* Advanced */}
|
||||
<div className="flex flex-col gap-3 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer w-fit"
|
||||
>
|
||||
{advancedOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
Advanced settings
|
||||
</button>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="flex flex-col gap-4 rounded border border-border bg-muted/30 p-4">
|
||||
<div className="flex items-start gap-2 text-xs text-yellow-500/80">
|
||||
<TriangleAlert className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<span>Changing these settings incorrectly may break the stream.</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Field label="Boot delay (s)" tooltip={TOOLTIPS.delay}>
|
||||
<Input type="number" min={0} value={form.delay} onChange={(e) => set("delay", Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="GOP" tooltip={TOOLTIPS.gop}>
|
||||
<Input type="number" min={1} value={form.gop} onChange={(e) => setGop(Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="Threads" tooltip={TOOLTIPS.threads}>
|
||||
<Input type="number" min={0} value={form.threads ?? 0} onChange={(e) => set("threads", Number(e.target.value))} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pb-8">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="px-4 py-2 rounded border border-border text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
<button onClick={() => router.push("/")} className="px-4 py-2 rounded border border-border text-sm hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded bg-primary text-primary-foreground text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Salvando..." : isEdit ? "Salvar alterações" : "Criar stream"}
|
||||
<button onClick={submit} disabled={saving} className="px-4 py-2 rounded border border-primary bg-primary text-primary-foreground text-sm hover:bg-[#2a2a2a] hover:text-foreground hover:border-border active:bg-[#333] transition-colors disabled:opacity-50 cursor-pointer">
|
||||
{saving ? "Saving..." : isEdit ? "Save changes" : "Create stream"}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -40,7 +40,6 @@ export function deleteStream(id: string): void {
|
||||
export function allocatePorts(): {
|
||||
display: string
|
||||
vncPort: number
|
||||
novncPort: number
|
||||
debugPort: number
|
||||
} {
|
||||
const streams = readStreams()
|
||||
@@ -52,7 +51,6 @@ export function allocatePorts(): {
|
||||
return {
|
||||
display: `:${n}`,
|
||||
vncPort: 5900 + n,
|
||||
novncPort: 6080 + n,
|
||||
debugPort: 9221 + n,
|
||||
}
|
||||
}
|
||||
+56
-38
@@ -1,10 +1,12 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { execSync } from "child_process"
|
||||
import { execSync, spawn } from "child_process"
|
||||
import type { Stream } from "@/types/stream"
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR ?? "/app/data"
|
||||
const STREAMS_DIR = path.join(DATA_DIR, "streams")
|
||||
const VNC_TOKENS_DIR = path.join(DATA_DIR, "vnc-tokens")
|
||||
const IS_DEV = process.env.NODE_ENV !== "production"
|
||||
|
||||
function streamDir(id: string) {
|
||||
return path.join(STREAMS_DIR, id)
|
||||
@@ -14,8 +16,6 @@ function render(template: string, vars: Record<string, string | number>): string
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k] ?? ""))
|
||||
}
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV !== "production"
|
||||
|
||||
function supervisorctl(cmd: string) {
|
||||
if (IS_DEV) {
|
||||
console.log(`[supervisor mock] supervisorctl ${cmd}`)
|
||||
@@ -24,61 +24,73 @@ function supervisorctl(cmd: string) {
|
||||
try {
|
||||
execSync(`supervisorctl -c /etc/supervisor/supervisord.conf ${cmd}`, { stdio: "pipe" })
|
||||
} catch {
|
||||
// supervisorctl retorna exit 1 em alguns casos não-fatais (ex: já parado)
|
||||
// supervisorctl retorna exit 1 em alguns casos não-fatais
|
||||
}
|
||||
}
|
||||
|
||||
// #6 — converte "1920x1080" → "1920,1080" para o Chrome
|
||||
function resolutionToChrome(res: string): string {
|
||||
return res.replace("x", ",")
|
||||
}
|
||||
|
||||
// #13 — normaliza scale: aceita "1280x720" ou "1280:720", sempre salva "1280:720"
|
||||
export function normalizeScale(scale: string): string {
|
||||
return scale.replace("x", ":")
|
||||
}
|
||||
|
||||
export function provisionStream(stream: Stream): void {
|
||||
const dir = streamDir(stream.id)
|
||||
fs.mkdirSync(path.join(dir, "chrome-profile"), { recursive: true })
|
||||
|
||||
const vars: Record<string, string | number> = {
|
||||
STREAM_ID: stream.id,
|
||||
DISPLAY: stream.display,
|
||||
RESOLUTION: stream.resolution,
|
||||
STREAM_URL: stream.url,
|
||||
DEBUG_PORT: stream.debugPort,
|
||||
VNC_PORT: stream.vncPort,
|
||||
NOVNC_PORT: stream.novncPort,
|
||||
STREAM_ID: stream.id,
|
||||
DISPLAY: stream.display,
|
||||
RESOLUTION: stream.resolution,
|
||||
CHROME_SIZE: resolutionToChrome(stream.resolution),
|
||||
STREAM_URL: stream.url,
|
||||
DEBUG_PORT: stream.debugPort,
|
||||
VNC_PORT: stream.vncPort,
|
||||
STREAM_DELAY: stream.delay,
|
||||
FPS: stream.fps,
|
||||
PRESET: stream.preset,
|
||||
TUNE: stream.tune,
|
||||
GOP: stream.gop,
|
||||
BITRATE: stream.bitrate,
|
||||
BUFSIZE: stream.bufsize,
|
||||
USER: stream.user ?? "",
|
||||
PASS: stream.pass ?? "",
|
||||
FPS: stream.fps,
|
||||
PRESET: stream.preset,
|
||||
TUNE: stream.tune,
|
||||
GOP: stream.gop,
|
||||
BITRATE: stream.bitrate,
|
||||
BUFSIZE: stream.bufsize,
|
||||
SCALE: normalizeScale(stream.scale),
|
||||
THREADS: stream.threads ?? 0,
|
||||
USER: stream.user ?? "",
|
||||
PASS: stream.pass ?? "",
|
||||
}
|
||||
|
||||
// autologin.sh
|
||||
const autologinTpl = fs.readFileSync("/opt/scripts/autologin.template.sh", "utf-8")
|
||||
const autologinPath = path.join(dir, "autologin.sh")
|
||||
fs.writeFileSync(autologinPath, render(autologinTpl, vars), "utf-8")
|
||||
fs.chmodSync(autologinPath, 0o755)
|
||||
|
||||
// stream.conf
|
||||
const confTpl = fs.readFileSync("/opt/scripts/stream.template.conf", "utf-8")
|
||||
const confPath = path.join(dir, "stream.conf")
|
||||
fs.writeFileSync(confPath, render(confTpl, vars), "utf-8")
|
||||
|
||||
// Recarrega supervisord para reconhecer o novo conf
|
||||
fs.mkdirSync(VNC_TOKENS_DIR, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(VNC_TOKENS_DIR, `${stream.id}.cfg`),
|
||||
`${stream.id}: localhost:${stream.vncPort}\n`,
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
supervisorctl("reread")
|
||||
supervisorctl("update")
|
||||
}
|
||||
|
||||
export function startStream(id: string): void {
|
||||
const programs = ["xvfb", "chrome", "autologin", "x11vnc", "novnc", "ffmpeg"]
|
||||
for (const p of programs) {
|
||||
supervisorctl(`start ${p}-${id}`)
|
||||
}
|
||||
const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"]
|
||||
for (const p of programs) supervisorctl(`start ${p}-${id}`)
|
||||
}
|
||||
|
||||
export function stopStream(id: string): void {
|
||||
const programs = ["ffmpeg", "novnc", "x11vnc", "autologin", "chrome", "xvfb"]
|
||||
for (const p of programs) {
|
||||
supervisorctl(`stop ${p}-${id}`)
|
||||
}
|
||||
const programs = ["ffmpeg", "x11vnc", "autologin", "chromium", "xvfb"]
|
||||
for (const p of programs) supervisorctl(`stop ${p}-${id}`)
|
||||
}
|
||||
|
||||
export function restartStream(id: string): void {
|
||||
@@ -88,28 +100,35 @@ export function restartStream(id: string): void {
|
||||
|
||||
export function removeStream(id: string): void {
|
||||
stopStream(id)
|
||||
|
||||
const confPath = path.join(streamDir(id), "stream.conf")
|
||||
if (fs.existsSync(confPath)) fs.unlinkSync(confPath)
|
||||
|
||||
const tokenPath = path.join(VNC_TOKENS_DIR, `${id}.cfg`)
|
||||
if (fs.existsSync(tokenPath)) fs.unlinkSync(tokenPath)
|
||||
supervisorctl("reread")
|
||||
supervisorctl("update")
|
||||
|
||||
// Remove pasta da stream
|
||||
fs.rmSync(streamDir(id), { recursive: true, force: true })
|
||||
}
|
||||
|
||||
export function captureThumb(streamId: string, delay = 60): void {
|
||||
if (IS_DEV) { console.log(`[thumb mock] captureThumb ${streamId} delay=${delay}s`); return }
|
||||
const thumbPath = path.join(STREAMS_DIR, streamId, "thumb.jpg")
|
||||
const tmpPath = `${thumbPath}.tmp`
|
||||
const child = spawn("bash", ["-c",
|
||||
`sleep ${delay} && ffmpeg -y -loglevel error -i http://localhost:8888/live/${streamId}/index.m3u8 -vframes 1 -q:v 2 "${tmpPath}" && mv "${tmpPath}" "${thumbPath}"`
|
||||
], { detached: true, stdio: "ignore" })
|
||||
child.unref()
|
||||
}
|
||||
|
||||
export type ProgramStatus = "RUNNING" | "STOPPED" | "FATAL" | "STARTING" | "UNKNOWN"
|
||||
|
||||
export function getStreamStatus(id: string): Record<string, ProgramStatus> {
|
||||
const programs = ["xvfb", "chrome", "autologin", "x11vnc", "novnc", "ffmpeg"]
|
||||
const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"]
|
||||
|
||||
if (IS_DEV) {
|
||||
return Object.fromEntries(programs.map((p) => [p, "STOPPED" as ProgramStatus]))
|
||||
}
|
||||
|
||||
const result: Record<string, ProgramStatus> = {}
|
||||
|
||||
for (const p of programs) {
|
||||
try {
|
||||
const out = execSync(
|
||||
@@ -122,6 +141,5 @@ export function getStreamStatus(id: string): Record<string, ProgramStatus> {
|
||||
result[p] = "UNKNOWN"
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
+19
-18
@@ -1,44 +1,45 @@
|
||||
export type StreamStatus = "running" | "stopped" | "error" | "starting"
|
||||
export type StreamStatus = "running" | "stopped" | "error" | "starting" | "restarting" | "stopping"
|
||||
|
||||
export interface Stream {
|
||||
id: string // slug definido pelo usuário: [a-z0-9-]
|
||||
name: string // nome amigável
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
user?: string
|
||||
pass?: string
|
||||
|
||||
// ffmpeg / xvfb
|
||||
delay: number // segundos antes do ffmpeg iniciar
|
||||
delay: number // segundos antes do ffmpeg iniciar (delay de boot da stream)
|
||||
resolution: string // tamanho do Xvfb/Chrome: "1920x1080"
|
||||
scale: string // scale do ffmpeg: "1280:720"
|
||||
scale: string // scale do ffmpeg output: "1280x720" (convertido para "1280:720" internamente)
|
||||
fps: number
|
||||
bitrate: string // "1500k"
|
||||
bufsize: string // "1500k"
|
||||
preset: string // ultrafast | superfast | veryfast | faster | fast
|
||||
tune: string // stillimage | zerolatency | film
|
||||
bitrate: string
|
||||
bufsize: string
|
||||
preset: string
|
||||
tune: string
|
||||
gop: number
|
||||
threads: number
|
||||
|
||||
// alocado pelo sistema em runtime
|
||||
display: string // ":1", ":2"...
|
||||
vncPort: number // 5901, 5902...
|
||||
novncPort: number // 6081, 6082...
|
||||
debugPort: number // 9222, 9223...
|
||||
display: string
|
||||
vncPort: number
|
||||
debugPort: number
|
||||
|
||||
desiredState: "running" | "stopped" // #19 — estado desejado persistente
|
||||
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type StreamCreate = Omit<Stream, "display" | "vncPort" | "novncPort" | "debugPort" | "createdAt" | "updatedAt">
|
||||
export type StreamCreate = Omit<Stream, "display" | "vncPort" | "debugPort" | "createdAt" | "updatedAt" | "desiredState">
|
||||
export type StreamUpdate = Partial<StreamCreate>
|
||||
|
||||
export const STREAM_DEFAULTS: Omit<StreamCreate, "id" | "name" | "url"> = {
|
||||
delay: 15,
|
||||
resolution: "1920x1080",
|
||||
scale: "1280:720",
|
||||
scale: "1280x720",
|
||||
fps: 30,
|
||||
bitrate: "1500k",
|
||||
bufsize: "3000k",
|
||||
preset: "ultrafast",
|
||||
tune: "stillimage",
|
||||
gop: 60,
|
||||
}
|
||||
threads: 0,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user