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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user