@@ -46,17 +74,16 @@ function SettingsPopup({ cardSize, onCardSize, onClose }: {
Card size
- {(["sm", "md", "lg"] as CardSize[]).map((s) => (
+ {(["mini", "sm", "md", "lg"] as CardSize[]).map((s) => (
))}
@@ -72,9 +99,25 @@ export default function GalleryPage() {
const [localStatuses, setLocalStatuses] = useState
>({})
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
- const [cardSize, setCardSize] = useState("md")
+ const [cardSize, setCardSize] = useState("md") // md = Medium = antigo Big
const [settingsOpen, setSettingsOpen] = useState(false)
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
+
+ async function handleDragEnd(event: DragEndEvent) {
+ const { active, over } = event
+ if (!over || active.id === over.id) return
+ const oldIndex = streams.findIndex((s) => s.id === active.id)
+ const newIndex = streams.findIndex((s) => s.id === over.id)
+ const reordered = arrayMove(streams, oldIndex, newIndex)
+ setStreams(reordered)
+ await fetch("/api/streams/reorder", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ids: reordered.map((s) => s.id) }),
+ })
+ }
+
useEffect(() => {
const saved = localStorage.getItem("cardSize") as CardSize | null
if (saved) setCardSize(saved)
@@ -127,7 +170,10 @@ export default function GalleryPage() {
return (
- Decap Stream
+
+

+
Decap Stream
+
{/* #6 — refresh com h-8 explícito igual aos outros */}
) : (
-
- {streams.map((s) => (
- fetchStreams()}
- onLocalStatus={setLocalStatus}
- />
- ))}
-
+
+ s.id)} strategy={rectSortingStrategy}>
+
+ {streams.map((s) => (
+ fetchStreams()}
+ onLocalStatus={setLocalStatus}
+ />
+ ))}
+
+
+
)}
diff --git a/src/components/StreamCard.tsx b/src/components/StreamCard.tsx
index 41db319..dff6168 100644
--- a/src/components/StreamCard.tsx
+++ b/src/components/StreamCard.tsx
@@ -1,17 +1,22 @@
"use client"
-import { useState, useEffect } from "react"
-import { MoreHorizontal, Play, Globe, Monitor, Pencil, RotateCcw, Square, Trash2, Circle, Copy, Check, Video, ImageUp } from "lucide-react"
+import { useState, useEffect, useRef } from "react"
+import { MoreHorizontal, Play, Globe, Monitor, Pencil, RotateCcw, Square, Trash2, Circle, Copy, Check, Video, ImageUp, GripVertical } from "lucide-react"
import { cn } from "@/lib/utils"
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?: "sm" | "md" | "lg"
+ cardSize?: "mini" | "sm" | "md" | "lg"
onRefresh: () => void
onLocalStatus: (id: string, s: string | null) => void
+ dragHandleListeners?: SyntheticListenerMap
+ dragHandleAttributes?: DraggableAttributes
+ isDragging?: boolean
}
function StatusBadge({ status, localStatus }: { status?: Record; localStatus?: string | null }) {
@@ -51,14 +56,46 @@ function copyToClipboard(text: string) {
return Promise.resolve()
}
-const CARD_WIDTHS = { sm: "max-w-[200px]", md: "max-w-[240px]", lg: "max-w-[300px]" }
+const CARD_WIDTHS = { mini: "max-w-[200px]", sm: "max-w-[240px]", md: "max-w-[300px]", lg: "max-w-[380px]" }
-export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRefresh, onLocalStatus }: Props) {
+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 }: 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)
+ const [confirmDelete, setConfirmDelete] = useState(false)
+ const pollRef = useRef | null>(null)
useEffect(() => {
if (!thumbError || thumbCapturing) return
@@ -66,6 +103,8 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
return () => clearInterval(interval)
}, [thumbError, thumbCapturing])
+ useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
+
async function action(act: string, optimisticStatus: string) {
onLocalStatus(stream.id, optimisticStatus)
setMenuOpen(false)
@@ -75,7 +114,12 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
}
async function remove() {
- if (!confirm(`Delete stream "${stream.name}"?`)) return
+ setMenuOpen(false)
+ setConfirmDelete(true)
+ }
+
+ async function confirmRemove() {
+ setConfirmDelete(false)
await fetch(`/api/streams/${stream.id}`, { method: "DELETE" })
onRefresh()
}
@@ -95,14 +139,22 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
async function refreshThumb() {
setMenuOpen(false)
- setThumbCapturing(true)
setThumbError(false)
+ setThumbCapturing(true)
+ if (pollRef.current) clearInterval(pollRef.current)
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)
+ 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)
}
function play(mode: string) {
@@ -113,22 +165,46 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
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)}
- />
+ <>
+ {thumbError && (
+
+
+
+ )}
+

setThumbError(true)}
+ onLoad={() => setThumbError(false)}
+ />
+ >
)}
@@ -139,9 +215,47 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
-
+
+
+ {menuOpen && (
+ <>
+
setMenuOpen(false)} />
+
+
+
+ {status?.ffmpeg === "RUNNING" || localStatus === "restarting" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ >
+ )}
+
@@ -152,43 +266,7 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
-
- {menuOpen && (
- <>
- setMenuOpen(false)} />
-
-
-
- {status?.ffmpeg === "RUNNING" || localStatus === "restarting" ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
- >
- )}
+ >
)
}
diff --git a/src/components/StreamForm.tsx b/src/components/StreamForm.tsx
index 22f3ae9..66070a7 100644
--- a/src/components/StreamForm.tsx
+++ b/src/components/StreamForm.tsx
@@ -57,7 +57,7 @@ function Field({ label, tooltip, required, error, children }: {
{tooltip && }
{children}
- {error && {error}
}
+ {error && {error}
}
)
}
@@ -200,10 +200,10 @@ export function StreamForm({ initial }: Props) {
- set("user", e.target.value)} />
+ set("user", e.target.value)} />
- set("pass", e.target.value)} />
+ set("pass", e.target.value)} />
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 7ff220f..7afc300 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -12,7 +12,14 @@ function ensureFile() {
export function readStreams(): Stream[] {
ensureFile()
- return JSON.parse(fs.readFileSync(STREAMS_FILE, "utf-8")) as Stream[]
+ const streams = JSON.parse(fs.readFileSync(STREAMS_FILE, "utf-8")) as Stream[]
+ // migrate: assign order to streams that don't have it yet
+ let dirty = false
+ streams.forEach((s, i) => {
+ if (s.order === undefined) { s.order = i; dirty = true }
+ })
+ if (dirty) fs.writeFileSync(STREAMS_FILE, JSON.stringify(streams, null, 2), "utf-8")
+ return streams.sort((a, b) => a.order - b.order)
}
export function writeStreams(streams: Stream[]): void {
diff --git a/src/lib/supervisor.ts b/src/lib/supervisor.ts
index 95744fb..6484446 100644
--- a/src/lib/supervisor.ts
+++ b/src/lib/supervisor.ts
@@ -2,6 +2,7 @@ import fs from "fs"
import path from "path"
import { execSync, spawn } from "child_process"
import type { Stream } from "@/types/stream"
+import { getStream } from "./db"
const DATA_DIR = process.env.DATA_DIR ?? "/app/data"
const STREAMS_DIR = path.join(DATA_DIR, "streams")
@@ -86,6 +87,7 @@ export function provisionStream(stream: Stream): void {
export function startStream(id: string): void {
const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"]
for (const p of programs) supervisorctl(`start ${p}-${id}`)
+ captureThumb(id, 60)
}
export function stopStream(id: string): void {
@@ -111,10 +113,13 @@ export function removeStream(id: string): void {
export function captureThumb(streamId: string, delay = 60): void {
if (IS_DEV) { console.log(`[thumb mock] captureThumb ${streamId} delay=${delay}s`); return }
+ const stream = getStream(streamId)
+ if (!stream) return
const thumbPath = path.join(STREAMS_DIR, streamId, "thumb.jpg")
- const tmpPath = `${thumbPath}.tmp`
+ const tmpPath = path.join(STREAMS_DIR, streamId, "thumb.tmp.jpg")
+ // capture directly from Xvfb — doesn't depend on RTMP/HLS being up
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}"`
+ `sleep ${delay} && ffmpeg -y -loglevel error -f x11grab -video_size ${stream.resolution} -i ${stream.display} -vframes 1 -q:v 2 "${tmpPath}" && mv "${tmpPath}" "${thumbPath}"`
], { detached: true, stdio: "ignore" })
child.unref()
}
diff --git a/src/types/stream.ts b/src/types/stream.ts
index 1901708..bfbcc24 100644
--- a/src/types/stream.ts
+++ b/src/types/stream.ts
@@ -24,11 +24,13 @@ export interface Stream {
desiredState: "running" | "stopped" // #19 — estado desejado persistente
+ order: number
+
createdAt: string
updatedAt: string
}
-export type StreamCreate = Omit
+export type StreamCreate = Omit
export type StreamUpdate = Partial
export const STREAM_DEFAULTS: Omit = {