"use client" import { useState, useEffect, useRef } from "react" import { MoreHorizontal, Play, Globe, Monitor, Pencil, RotateCcw, Square, Trash2, Circle, Copy, Check, Video, ImageUp, GripVertical, Wrench, RefreshCw } from "lucide-react" import { cn } from "@/lib/utils" import { Toggle } from "@/components/Toggle" import type { Stream } from "@/types/stream" import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities" import type { DraggableAttributes } from "@dnd-kit/core" interface Props { stream: Stream status?: Record localStatus?: string | null cardSize?: "mini" | "sm" | "md" | "lg" onRefresh: () => void onLocalStatus: (id: string, s: string | null) => void dragHandleListeners?: SyntheticListenerMap dragHandleAttributes?: DraggableAttributes isDragging?: boolean globalPrefs: { pureMode: boolean; newTab: boolean } } function StatusBadge({ status, localStatus, cardSize = "md" }: { status?: Record; localStatus?: string | null; cardSize?: "mini" | "sm" | "md" | "lg" }) { const sc = SCALE[cardSize] const label = localStatus ?? ( status?.ffmpeg === "RUNNING" ? "running" : status?.ffmpeg === "STARTING" ? "starting" : status?.ffmpeg === "FATAL" ? "error" : status?.ffmpeg === "STOPPED" ? "stopped" : "..." ) const color = 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 ( {label} ) } 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() } const CARD_WIDTHS = { mini: "sm:max-w-[200px]", sm: "sm:max-w-[240px]", md: "sm:max-w-[300px]", lg: "sm:max-w-[380px]" } const SCALE = { mini: { card: "p-2 gap-2", name: "text-xs", meta: "text-[10px]", btn: "text-[10px] px-2 py-1 gap-1.5", btnIcon: "w-2.5 h-2.5", menuIcon: "w-3.5 h-3.5", dot: "w-1.5 h-1.5" }, sm: { card: "p-2.5 gap-2", name: "text-xs", meta: "text-[10px]", btn: "text-xs px-2.5 py-1.5 gap-1.5", btnIcon: "w-2.5 h-2.5", menuIcon: "w-3.5 h-3.5", dot: "w-1.5 h-1.5" }, md: { card: "p-3 gap-2.5", name: "text-sm", meta: "text-xs", btn: "text-xs px-3 py-2 gap-2", btnIcon: "w-3 h-3", menuIcon: "w-4 h-4", dot: "w-2 h-2" }, lg: { card: "p-4 gap-3", name: "text-base", meta: "text-sm", btn: "text-sm px-4 py-2.5 gap-2", btnIcon: "w-4 h-4", menuIcon: "w-4 h-4", dot: "w-2 h-2" }, } satisfies Record function ConfirmDeleteModal({ name, onConfirm, onCancel }: { name: string; onConfirm: () => void; onCancel: () => void }) { return (

Delete stream

Are you sure you want to delete {name}? This action cannot be undone.

) } export function StreamCard({ stream, status, localStatus, cardSize = "md", onRefresh, onLocalStatus, dragHandleListeners, dragHandleAttributes, isDragging, globalPrefs }: Props) { const sc = SCALE[cardSize] 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) const [confirmDelete, setConfirmDelete] = useState(false) const [autoReload, setAutoReload] = useState(stream.autoReload ?? false) const [autoReloadMins, setAutoReloadMins] = useState(Math.round((stream.autoReloadInterval ?? 3600) / 60)) const pollRef = useRef | null>(null) useEffect(() => { if (!thumbError || thumbCapturing) return const interval = setInterval(() => setThumbKey((k) => k + 1), 15000) return () => clearInterval(interval) }, [thumbError, thumbCapturing]) useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, []) function navigate(url: string) { if (globalPrefs.newTab) window.open(url, "_blank") else window.location.href = url } async function action(act: string, optimisticStatus: string) { onLocalStatus(stream.id, optimisticStatus) setMenuOpen(false) await fetch(`/api/streams/${stream.id}/${act}`, { method: "POST" }) onRefresh() setTimeout(() => onLocalStatus(stream.id, null), 15000) } async function remove() { setMenuOpen(false) setConfirmDelete(true) } async function confirmRemove() { setConfirmDelete(false) await fetch(`/api/streams/${stream.id}`, { method: "DELETE" }) onRefresh() } function openVNC() { navigate(`/vnc/${stream.id}`) } function handlePlayStream() { navigate(globalPrefs.pureMode ? `/api/hls/live/${stream.id}/index.m3u8` : `/player/${stream.id}?mode=hls`) } function handleRunHtml() { navigate(globalPrefs.pureMode ? `/player.html?id=${stream.id}` : `/static/${stream.id}`) } function copyRTMP() { const url = `rtmp://${window.location.hostname}:1935/live/${stream.id}` copyToClipboard(url).then(() => { setCopied(true) setTimeout(() => setCopied(false), 2000) }) } async function toggleAutoReload() { const next = !autoReload setAutoReload(next) await fetch(`/api/streams/${stream.id}/autoreload`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: next, interval: autoReloadMins * 60 }), }) } async function saveAutoReloadInterval(mins: number) { await fetch(`/api/streams/${stream.id}/autoreload`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: autoReload, interval: mins * 60 }), }) } async function refreshThumb() { setMenuOpen(false) setThumbError(false) setThumbCapturing(true) if (pollRef.current) clearInterval(pollRef.current) await fetch(`/api/streams/${stream.id}/thumb`, { method: "POST" }) const deadline = Date.now() + 30000 pollRef.current = setInterval(async () => { const res = await fetch(`/api/streams/${stream.id}/thumb?t=${Date.now()}`, { cache: "no-store" }) if (res.ok) { clearInterval(pollRef.current!); pollRef.current = null setThumbKey((k) => k + 1) setThumbCapturing(false) } else if (Date.now() >= deadline) { clearInterval(pollRef.current!); pollRef.current = null setThumbCapturing(false) } }, 2000) } const playBtn = `w-full flex items-center rounded border border-border bg-muted hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer ${sc.btn}` 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 ( <> {confirmDelete && ( setConfirmDelete(false)} /> )}
{/* Drag handle strip */} {(dragHandleListeners || dragHandleAttributes) && (
)} {/* Thumbnail */}
{thumbCapturing ? ( Capturing... ) : ( <> {thumbError && (
)} setThumbError(true)} onLoad={() => setThumbError(false)} /> )}

{stream.name}

{stream.id}

{menuOpen && ( <>
setMenuOpen(false)} />
{status?.ffmpeg === "RUNNING" || localStatus === "restarting" ? ( ) : ( )}
Auto-reload
{autoReload && (
Every setAutoReloadMins(Math.max(1, Number(e.target.value) || 1))} onBlur={() => saveAutoReloadInterval(autoReloadMins)} onKeyDown={e => { if (e.key === "Enter") saveAutoReloadInterval(autoReloadMins) }} className="w-16 text-xs bg-[#2a2a2a] border border-border rounded px-2 py-0.5 text-center" /> min
)}
)}

{stream.url}

) }