Repo init
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getStream } 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 })
|
||||
|
||||
switch (action) {
|
||||
case "start": startStream(id); break
|
||||
case "stop": stopStream(id); break
|
||||
case "restart": restartStream(id); break
|
||||
default:
|
||||
return NextResponse.json({ error: "ação inválida" }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getStream, saveStream, deleteStream } from "@/lib/db"
|
||||
import { provisionStream, restartStream, removeStream } from "@/lib/supervisor"
|
||||
import type { StreamUpdate } from "@/types/stream"
|
||||
|
||||
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 })
|
||||
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 })
|
||||
|
||||
const body = (await req.json()) as StreamUpdate
|
||||
// id e portas não podem ser alterados via PATCH
|
||||
const { id: _id, ...safe } = body as StreamUpdate & { id?: string }
|
||||
void _id
|
||||
|
||||
const updated = { ...stream, ...safe, updatedAt: new Date().toISOString() }
|
||||
saveStream(updated)
|
||||
provisionStream(updated)
|
||||
restartStream(id)
|
||||
|
||||
return NextResponse.json(updated)
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: Ctx) {
|
||||
const { id } = await params
|
||||
if (!getStream(id)) return NextResponse.json({ error: "não encontrado" }, { status: 404 })
|
||||
|
||||
removeStream(id)
|
||||
deleteStream(id)
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getStream } from "@/lib/db"
|
||||
import { getStreamStatus } from "@/lib/supervisor"
|
||||
|
||||
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 })
|
||||
return NextResponse.json(getStreamStatus(id))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readStreams } from "@/lib/db"
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const host = searchParams.get("host") ?? "localhost"
|
||||
const port = searchParams.get("port") ?? "8888"
|
||||
|
||||
const streams = readStreams()
|
||||
|
||||
const lines = ["#EXTM3U"]
|
||||
for (const s of streams) {
|
||||
lines.push(`#EXTINF:-1,${s.name}`)
|
||||
lines.push(`http://${host}:${port}/live/${s.id}/index.m3u8`)
|
||||
}
|
||||
|
||||
return new Response(lines.join("\n"), {
|
||||
headers: {
|
||||
"Content-Type": "application/x-mpegurl",
|
||||
"Content-Disposition": 'attachment; filename="decap-stream.m3u"',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { readStreams, saveStream, allocatePorts, getStream } from "@/lib/db"
|
||||
import { provisionStream, startStream } from "@/lib/supervisor"
|
||||
import { STREAM_DEFAULTS, type StreamCreate } from "@/types/stream"
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(readStreams())
|
||||
}
|
||||
|
||||
const SLUG_RE = /^[a-z0-9-]+$/
|
||||
|
||||
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 })
|
||||
|
||||
if (!body.name || !body.url)
|
||||
return NextResponse.json({ error: "name e url são obrigatórios" }, { status: 400 })
|
||||
|
||||
if (getStream(body.id))
|
||||
return NextResponse.json({ error: "já existe uma stream com esse id" }, { status: 409 })
|
||||
|
||||
const ports = allocatePorts()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const stream = {
|
||||
...STREAM_DEFAULTS,
|
||||
...body,
|
||||
...ports,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
saveStream(stream)
|
||||
provisionStream(stream)
|
||||
startStream(stream.id)
|
||||
|
||||
return NextResponse.json(stream, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--card: #111111;
|
||||
--card-foreground: #ededed;
|
||||
--border: #222222;
|
||||
--muted: #1a1a1a;
|
||||
--muted-foreground: #888888;
|
||||
--primary: #ededed;
|
||||
--primary-foreground: #0a0a0a;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #fff;
|
||||
--accent: #1a1a1a;
|
||||
--accent-foreground: #ededed;
|
||||
--ring: #444444;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next"
|
||||
import { Geist, Geist_Mono } from "next/font/google"
|
||||
import "./globals.css"
|
||||
|
||||
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] })
|
||||
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "DecapStream",
|
||||
description: "Headless stream manager",
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="pt-BR" suppressHydrationWarning>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Plus, Download, RefreshCw } from "lucide-react"
|
||||
import { StreamCard } from "@/components/StreamCard"
|
||||
import type { Stream } from "@/types/stream"
|
||||
|
||||
export default function GalleryPage() {
|
||||
const router = useRouter()
|
||||
const [streams, setStreams] = useState<Stream[]>([])
|
||||
const [statuses, setStatuses] = useState<Record<string, Record<string, string>>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchStreams = useCallback(async () => {
|
||||
const res = await fetch("/api/streams")
|
||||
const data: Stream[] = await res.json()
|
||||
setStreams(data)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
const fetchStatuses = useCallback(async (list: Stream[]) => {
|
||||
const results = await Promise.all(
|
||||
list.map(async (s) => {
|
||||
const res = await fetch(`/api/streams/${s.id}/status`)
|
||||
const data = await res.json()
|
||||
return [s.id, data] as const
|
||||
})
|
||||
)
|
||||
setStatuses(Object.fromEntries(results))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStreams()
|
||||
}, [fetchStreams])
|
||||
|
||||
useEffect(() => {
|
||||
if (streams.length === 0) return
|
||||
fetchStatuses(streams)
|
||||
const interval = setInterval(() => fetchStatuses(streams), 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [streams, fetchStatuses])
|
||||
|
||||
function downloadPlaylist() {
|
||||
const host = window.location.hostname
|
||||
window.location.href = `/api/streams/playlist?host=${host}&port=8888`
|
||||
}
|
||||
|
||||
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>
|
||||
<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" />
|
||||
</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"
|
||||
>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Grid */}
|
||||
<main className="flex-1 p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground text-sm">
|
||||
Carregando...
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{streams.map((s) => (
|
||||
<StreamCard
|
||||
key={s.id}
|
||||
stream={s}
|
||||
status={statuses[s.id]}
|
||||
onRefresh={fetchStreams}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { type NextRequest, NextResponse } from "next/server"
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> }
|
||||
|
||||
export async function GET(req: NextRequest, { params }: Ctx) {
|
||||
const { id } = await params
|
||||
const host = req.headers.get("host")?.split(":")[0] ?? "localhost"
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
html,body{background:#000;overflow:hidden;width:100%;height:100%}
|
||||
video{width:100vw;height:100vh;display:block;object-fit:contain}
|
||||
#msg{
|
||||
position:fixed;top:16px;left:50%;transform:translateX(-50%);
|
||||
background:rgba(0,0,0,0.75);color:#fff;padding:8px 20px;
|
||||
border-radius:8px;font-family:sans-serif;font-size:34px;
|
||||
display:none;z-index:9
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<video id="v" autoplay muted playsinline></video>
|
||||
<div id="msg"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"></script>
|
||||
<script>
|
||||
var src='http://${host}:8888/live/${id}/index.m3u8';
|
||||
var hls;
|
||||
function showMsg(t){
|
||||
var m=document.getElementById('msg');
|
||||
m.textContent=t;m.style.display='block';
|
||||
setTimeout(function(){m.style.display='none';},4000);
|
||||
}
|
||||
function load(){
|
||||
if(hls)hls.destroy();
|
||||
hls=new Hls({
|
||||
liveSyncDurationCount:2,liveMaxLatencyDurationCount:4,
|
||||
manifestLoadingTimeOut:10000,manifestLoadingMaxRetry:10,
|
||||
fragLoadingTimeOut:10000,fragLoadingMaxRetry:10
|
||||
});
|
||||
hls.loadSource(src);
|
||||
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);}
|
||||
});
|
||||
}
|
||||
var last=0;
|
||||
setInterval(function(){
|
||||
var v=document.getElementById('v');
|
||||
if(v.currentTime===last&&!v.paused){showMsg('Stream travada — recarregando...');load();}
|
||||
last=v.currentTime;
|
||||
},10000);
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense } from "react"
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Script from "next/script"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
type Mode = "hls" | "m3u8" | "html"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Hls: any
|
||||
}
|
||||
}
|
||||
|
||||
function HLSPlayer({ src }: { src: string }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hlsRef = useRef<unknown>(null)
|
||||
const [msg, setMsg] = useState("")
|
||||
|
||||
function showMsg(text: string) {
|
||||
setMsg(text)
|
||||
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 hls = new Hls({
|
||||
liveSyncDurationCount: 2,
|
||||
liveMaxLatencyDurationCount: 4,
|
||||
manifestLoadingTimeOut: 10000,
|
||||
manifestLoadingMaxRetry: 10,
|
||||
fragLoadingTimeOut: 10000,
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let last = 0
|
||||
const interval = setInterval(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
if (v.currentTime === last && !v.paused) {
|
||||
showMsg("Stream travada — recarregando...")
|
||||
load()
|
||||
}
|
||||
last = v.currentTime
|
||||
}, 10000)
|
||||
return () => clearInterval(interval)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
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" />
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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`
|
||||
|
||||
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
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PlayerPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="bg-black w-screen h-screen" />}>
|
||||
<PlayerInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { getStream } from "@/lib/db"
|
||||
import { StreamForm } from "@/components/StreamForm"
|
||||
|
||||
export default async function EditStreamPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const stream = getStream(id)
|
||||
if (!stream) notFound()
|
||||
return <StreamForm initial={stream} />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { StreamForm } from "@/components/StreamForm"
|
||||
|
||||
export default function NewStreamPage() {
|
||||
return <StreamForm />
|
||||
}
|
||||
Reference in New Issue
Block a user