Adiciona features de ordenação, drag-and-drop e melhorias de UX

---

- Adicionado campo order em Stream e migração automática em readStreams() para ordenação persistida no streams.json;
- Implementado drag-and-drop de cards com @dnd-kit/core + @dnd-kit/sortable, com faixa de drag dedicada no topo de cada card;
- Adicionado endpoint PUT /api/streams/reorder para persistir a nova ordem no servidor;
- Atualizada playlist M3U para respeitar a ordem dos cards e incluir tvg-chno com número de canal;
- Corrigida geração de thumbnail para capturar via ffmpeg -f x11grab direto do Xvfb, usando arquivo temporário thumb.tmp.jpg;
- Adicionada política gerenciada do Chromium no Dockerfile para suprimir diálogo de salvar senha;
- Adicionadas flags --password-store=basic e --disable-features=PasswordManagerRedesign no template do Chromium;
- Substituído confirm() nativo por modal de confirmação customizado no delete de stream;
- Adicionado tamanho mini e redefinidos os tamanhos de card; padrão alterado para md (300px);
- Adicionado logo do projeto no header e ícone GripVertical na faixa de drag;
- Erros de validação do formulário agora exibidos em vermelho negrito;

---
This commit is contained in:
2026-04-25 03:24:20 -03:00
parent 1f8385e450
commit f6879781c1
20 changed files with 348 additions and 97 deletions
+8
View File
@@ -6,6 +6,7 @@ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY src/ ./src/ COPY src/ ./src/
COPY public/ ./public/
COPY next.config.ts tsconfig.json postcss.config.mjs ./ COPY next.config.ts tsconfig.json postcss.config.mjs ./
RUN npm run build RUN npm run build
@@ -38,6 +39,12 @@ RUN apt-get update \
&& apt-get autoremove -y \ && apt-get autoremove -y \
&& apt-get clean \ && apt-get clean \
&& find /usr/lib/chromium/locales -name '*.pak' ! -name 'en-US.pak' -delete \ && find /usr/lib/chromium/locales -name '*.pak' ! -name 'en-US.pak' -delete \
\
# Chromium managed policy: disable password manager and autofill save prompts
&& mkdir -p /etc/chromium/policies/managed \
&& printf '{"PasswordManagerEnabled":false,"AutofillAddressEnabled":false,"AutofillCreditCardEnabled":false}' \
> /etc/chromium/policies/managed/policy.json \
\
&& rm -rf \ && rm -rf \
/var/lib/apt/lists/* \ /var/lib/apt/lists/* \
/tmp/* /var/tmp/* \ /tmp/* /var/tmp/* \
@@ -48,6 +55,7 @@ RUN apt-get update \
COPY --from=builder /build/.next/standalone/ /app/ COPY --from=builder /build/.next/standalone/ /app/
COPY --from=builder /build/.next/static/ /app/.next/static/ COPY --from=builder /build/.next/static/ /app/.next/static/
COPY --from=builder /build/public/ /app/public/
COPY config/supervisord.conf /etc/supervisor/supervisord.conf COPY config/supervisord.conf /etc/supervisor/supervisord.conf
COPY config/mediamtx.yml /etc/mediamtx.yml COPY config/mediamtx.yml /etc/mediamtx.yml
+56
View File
@@ -8,6 +8,9 @@
"name": "decap-stream", "name": "decap-stream",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"nanoid": "^5.1.7", "nanoid": "^5.1.7",
@@ -283,6 +286,59 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+3
View File
@@ -9,6 +9,9 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"nanoid": "^5.1.7", "nanoid": "^5.1.7",
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+2
View File
@@ -27,6 +27,8 @@ command=bash -c "rm -rf \
--disable-sync \ --disable-sync \
--disable-background-timer-throttling \ --disable-background-timer-throttling \
--remote-debugging-port={{DEBUG_PORT}} \ --remote-debugging-port={{DEBUG_PORT}} \
--password-store=basic \
--disable-features=PasswordManagerRedesign,PasswordSuggestions \
'{{STREAM_URL}}'" '{{STREAM_URL}}'"
environment=DISPLAY={{DISPLAY}} environment=DISPLAY={{DISPLAY}}
autorestart=true autorestart=true
+4 -3
View File
@@ -8,10 +8,11 @@ export async function GET(req: Request) {
const streams = readStreams() const streams = readStreams()
const lines = ["#EXTM3U"] const lines = ["#EXTM3U"]
for (const s of streams) { streams.forEach((s, i) => {
lines.push(`#EXTINF:-1 tvg-id="${s.id}" tvg-name="${s.name}" group-title="DecapStream",${s.name} [${s.id}] ${s.resolution} ${s.fps}fps`) const chno = i + 1
lines.push(`#EXTINF:-1 tvg-id="${s.id}" tvg-name="${s.name}" tvg-chno="${chno}" group-title="DecapStream",${chno}. ${s.name} [${s.id}] ${s.resolution} ${s.fps}fps`)
lines.push(`http://${host}:${port}/live/${s.id}`) lines.push(`http://${host}:${port}/live/${s.id}`)
} })
return new Response(lines.join("\n"), { return new Response(lines.join("\n"), {
headers: { headers: {
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server"
import { readStreams, writeStreams } from "@/lib/db"
export async function PUT(req: Request) {
const { ids } = (await req.json()) as { ids: string[] }
if (!Array.isArray(ids))
return NextResponse.json({ error: "ids must be an array" }, { status: 400 })
const streams = readStreams()
const streamMap = new Map(streams.map((s) => [s.id, s]))
ids.forEach((id, i) => {
const s = streamMap.get(id)
if (s) s.order = i
})
writeStreams(streams.sort((a, b) => a.order - b.order))
return NextResponse.json({ ok: true })
}
+3
View File
@@ -23,6 +23,8 @@ export async function POST(req: Request) {
const ports = allocatePorts() const ports = allocatePorts()
const now = new Date().toISOString() const now = new Date().toISOString()
const existing = readStreams()
const nextOrder = existing.length > 0 ? Math.max(...existing.map((s) => s.order)) + 1 : 0
const stream = { const stream = {
...STREAM_DEFAULTS, ...STREAM_DEFAULTS,
@@ -30,6 +32,7 @@ export async function POST(req: Request) {
scale: normalizeScale(body.scale ?? STREAM_DEFAULTS.scale), // #13 scale: normalizeScale(body.scale ?? STREAM_DEFAULTS.scale), // #13
...ports, ...ports,
desiredState: "running" as const, // #19 desiredState: "running" as const, // #19
order: nextOrder,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+15
View File
@@ -0,0 +1,15 @@
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Decap Stream',
short_name: 'DecapStream',
icons: [
{ src: '/web-app-manifest-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: '/web-app-manifest-512x512.png', sizes: '512x512', type: 'image/png' },
],
theme_color: '#040f14',
background_color: '#040f14',
display: 'standalone',
}
}
+61 -11
View File
@@ -4,11 +4,39 @@ import { useEffect, useState, useCallback } from "react"
import { Plus, Download, RefreshCw, Settings, X } from "lucide-react" import { Plus, Download, RefreshCw, Settings, X } from "lucide-react"
import { StreamCard } from "@/components/StreamCard" import { StreamCard } from "@/components/StreamCard"
import type { Stream } from "@/types/stream" import type { Stream } from "@/types/stream"
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"
import type { DragEndEvent } from "@dnd-kit/core"
import { SortableContext, useSortable, rectSortingStrategy, arrayMove } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
type CardSize = "sm" | "md" | "lg" type CardSize = "mini" | "sm" | "md" | "lg"
const CARD_WIDTHS: Record<CardSize, string> = { mini: "max-w-[200px]", sm: "max-w-[240px]", md: "max-w-[300px]", lg: "max-w-[380px]" }
function SortableStreamCard(props: {
stream: Stream
status?: Record<string, string>
localStatus?: string | null
cardSize: CardSize
onRefresh: () => void
onLocalStatus: (id: string, s: string | null) => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.stream.id })
const style = { transform: CSS.Transform.toString(transform), transition }
return (
<div ref={setNodeRef} style={style} className={`w-full ${CARD_WIDTHS[props.cardSize]}`}>
<StreamCard
{...props}
dragHandleListeners={listeners}
dragHandleAttributes={attributes}
isDragging={isDragging}
/>
</div>
)
}
function SkeletonCard({ size = "sm" }: { size?: CardSize }) { function SkeletonCard({ size = "sm" }: { size?: CardSize }) {
const widths = { sm: "max-w-[200px]", md: "max-w-[240px]", lg: "max-w-[300px]" } const widths = { mini: "max-w-[200px]", sm: "max-w-[240px]", md: "max-w-[300px]", lg: "max-w-[380px]" }
return ( 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={`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 justify-between items-start">
@@ -46,17 +74,16 @@ function SettingsPopup({ cardSize, onCardSize, onClose }: {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p className="text-xs text-muted-foreground tracking-wider">Card size</p> <p className="text-xs text-muted-foreground tracking-wider">Card size</p>
<div className="flex gap-2"> <div className="flex gap-2">
{(["sm", "md", "lg"] as CardSize[]).map((s) => ( {(["mini", "sm", "md", "lg"] as CardSize[]).map((s) => (
<button <button
key={s} key={s}
onClick={() => onCardSize(s)} onClick={() => onCardSize(s)}
className={`flex-1 py-1.5 rounded border text-xs transition-colors cursor-pointer ${ className="flex-1 py-1.5 rounded border text-xs transition-colors cursor-pointer"
cardSize === s style={cardSize === s
? "border-primary bg-primary text-primary-foreground" ? { background: "#ededed", color: "#0a0a0a", borderColor: "#ededed" }
: "border-border hover:bg-[#2a2a2a]" : {}}
}`}
> >
{s === "sm" ? "Small" : s === "md" ? "Medium" : "Big"} {s === "mini" ? "Mini" : s === "sm" ? "Small" : s === "md" ? "Medium" : "Big"}
</button> </button>
))} ))}
</div> </div>
@@ -72,9 +99,25 @@ export default function GalleryPage() {
const [localStatuses, setLocalStatuses] = useState<Record<string, string | null>>({}) const [localStatuses, setLocalStatuses] = useState<Record<string, string | null>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [cardSize, setCardSize] = useState<CardSize>("md") const [cardSize, setCardSize] = useState<CardSize>("md") // md = Medium = antigo Big
const [settingsOpen, setSettingsOpen] = useState(false) 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(() => { useEffect(() => {
const saved = localStorage.getItem("cardSize") as CardSize | null const saved = localStorage.getItem("cardSize") as CardSize | null
if (saved) setCardSize(saved) if (saved) setCardSize(saved)
@@ -127,7 +170,10 @@ export default function GalleryPage() {
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
<header className="border-b border-border px-6 py-4 flex items-center justify-between"> <header className="border-b border-border px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<img src="/web-app-manifest-192x192.png" alt="Decap Stream" className="w-6 h-6 rounded" />
<h1 className="text-lg font-semibold tracking-tight">Decap Stream</h1> <h1 className="text-lg font-semibold tracking-tight">Decap Stream</h1>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* #6 — refresh com h-8 explícito igual aos outros */} {/* #6 — refresh com h-8 explícito igual aos outros */}
<button onClick={() => fetchStreams(true)} className={btnBase} title="Atualizar"> <button onClick={() => fetchStreams(true)} className={btnBase} title="Atualizar">
@@ -170,9 +216,11 @@ export default function GalleryPage() {
</button> </button>
</div> </div>
) : ( ) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={streams.map((s) => s.id)} strategy={rectSortingStrategy}>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
{streams.map((s) => ( {streams.map((s) => (
<StreamCard <SortableStreamCard
key={s.id} key={s.id}
stream={s} stream={s}
status={statuses[s.id]} status={statuses[s.id]}
@@ -183,6 +231,8 @@ export default function GalleryPage() {
/> />
))} ))}
</div> </div>
</SortableContext>
</DndContext>
)} )}
</main> </main>
</div> </div>
+106 -28
View File
@@ -1,17 +1,22 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect, useRef } from "react"
import { MoreHorizontal, Play, Globe, Monitor, Pencil, RotateCcw, Square, Trash2, Circle, Copy, Check, Video, ImageUp } from "lucide-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 { cn } from "@/lib/utils"
import type { Stream } from "@/types/stream" 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 { interface Props {
stream: Stream stream: Stream
status?: Record<string, string> status?: Record<string, string>
localStatus?: string | null localStatus?: string | null
cardSize?: "sm" | "md" | "lg" cardSize?: "mini" | "sm" | "md" | "lg"
onRefresh: () => void onRefresh: () => void
onLocalStatus: (id: string, s: string | null) => void onLocalStatus: (id: string, s: string | null) => void
dragHandleListeners?: SyntheticListenerMap
dragHandleAttributes?: DraggableAttributes
isDragging?: boolean
} }
function StatusBadge({ status, localStatus }: { status?: Record<string, string>; localStatus?: string | null }) { function StatusBadge({ status, localStatus }: { status?: Record<string, string>; localStatus?: string | null }) {
@@ -51,14 +56,46 @@ function copyToClipboard(text: string) {
return Promise.resolve() 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onCancel} />
<div className="relative z-10 w-80 rounded-xl border border-border shadow-2xl p-6 flex flex-col gap-4" style={{ background: "#1c1c1c" }}>
<div className="flex flex-col gap-1">
<p className="font-semibold text-sm">Delete stream</p>
<p className="text-sm text-muted-foreground">
Are you sure you want to delete <span className="text-foreground font-medium">{name}</span>? This action cannot be undone.
</p>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={onCancel}
className="px-4 py-1.5 rounded border border-border text-sm hover:bg-[#2a2a2a] transition-colors cursor-pointer"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-1.5 rounded border border-destructive bg-destructive/10 text-destructive text-sm hover:bg-destructive hover:text-white transition-colors cursor-pointer"
>
Delete
</button>
</div>
</div>
</div>
)
}
export function StreamCard({ stream, status, localStatus, cardSize = "md", onRefresh, onLocalStatus, dragHandleListeners, dragHandleAttributes, isDragging }: Props) {
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [thumbKey, setThumbKey] = useState(0) const [thumbKey, setThumbKey] = useState(0)
const [thumbError, setThumbError] = useState(false) const [thumbError, setThumbError] = useState(false)
const [thumbCapturing, setThumbCapturing] = useState(false) const [thumbCapturing, setThumbCapturing] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => { useEffect(() => {
if (!thumbError || thumbCapturing) return if (!thumbError || thumbCapturing) return
@@ -66,6 +103,8 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
return () => clearInterval(interval) return () => clearInterval(interval)
}, [thumbError, thumbCapturing]) }, [thumbError, thumbCapturing])
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
async function action(act: string, optimisticStatus: string) { async function action(act: string, optimisticStatus: string) {
onLocalStatus(stream.id, optimisticStatus) onLocalStatus(stream.id, optimisticStatus)
setMenuOpen(false) setMenuOpen(false)
@@ -75,7 +114,12 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
} }
async function remove() { 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" }) await fetch(`/api/streams/${stream.id}`, { method: "DELETE" })
onRefresh() onRefresh()
} }
@@ -95,14 +139,22 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
async function refreshThumb() { async function refreshThumb() {
setMenuOpen(false) setMenuOpen(false)
setThumbCapturing(true)
setThumbError(false) setThumbError(false)
setThumbCapturing(true)
if (pollRef.current) clearInterval(pollRef.current)
await fetch(`/api/streams/${stream.id}/thumb`, { method: "POST" }) await fetch(`/api/streams/${stream.id}/thumb`, { method: "POST" })
// 5s delay in backend + a few seconds for ffmpeg to process const deadline = Date.now() + 30000
setTimeout(() => { 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) setThumbKey((k) => k + 1)
setThumbCapturing(false) setThumbCapturing(false)
}, 9000) } else if (Date.now() >= deadline) {
clearInterval(pollRef.current!); pollRef.current = null
setThumbCapturing(false)
}
}, 2000)
} }
function play(mode: string) { 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" 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 ( return (
<div className={cn("relative rounded-lg border border-border bg-card p-3 flex flex-col gap-2.5 w-full", CARD_WIDTHS[cardSize])}> <>
{confirmDelete && (
<ConfirmDeleteModal
name={stream.name}
onConfirm={confirmRemove}
onCancel={() => setConfirmDelete(false)}
/>
)}
<div className={cn("relative rounded-lg border border-border bg-card p-3 flex flex-col gap-2.5 w-full transition-opacity", CARD_WIDTHS[cardSize], isDragging && "opacity-40")}>
{/* Drag handle strip */}
{(dragHandleListeners || dragHandleAttributes) && (
<div
{...dragHandleListeners}
{...dragHandleAttributes}
className="-mx-3 -mt-3 h-7 flex items-center justify-center rounded-t-lg cursor-grab active:cursor-grabbing hover:bg-white/[0.05] transition-colors border-b border-border/40 group"
>
<GripVertical className="w-4 h-4 text-muted-foreground/30 group-hover:text-muted-foreground/65 transition-colors" />
</div>
)}
{/* Thumbnail */} {/* Thumbnail */}
<div className="w-full aspect-video rounded overflow-hidden bg-muted flex items-center justify-center"> <div className="w-full aspect-video rounded overflow-hidden bg-muted flex items-center justify-center relative">
{thumbCapturing ? ( {thumbCapturing ? (
<span className="text-xs text-muted-foreground animate-pulse">Capturing...</span> <span className="text-xs text-muted-foreground animate-pulse">Capturing...</span>
) : thumbError ? (
<Video className="w-5 h-5 text-muted-foreground/25" />
) : ( ) : (
<>
{thumbError && (
<div className="absolute inset-0 flex items-center justify-center">
<Video className="w-5 h-5 text-muted-foreground/25" />
</div>
)}
<img <img
key={thumbKey} key={thumbKey}
src={`/api/streams/${stream.id}/thumb?t=${thumbKey}`} src={`/api/streams/${stream.id}/thumb?t=${thumbKey}`}
className="w-full h-full object-cover" className={cn("w-full h-full object-cover", thumbError && "invisible")}
onError={() => setThumbError(true)} onError={() => setThumbError(true)}
onLoad={() => setThumbError(false)} onLoad={() => setThumbError(false)}
/> />
</>
)} )}
</div> </div>
@@ -139,24 +215,14 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
</div> </div>
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
<StatusBadge status={status} localStatus={localStatus} /> <StatusBadge status={status} localStatus={localStatus} />
<div className="relative">
<button onClick={() => setMenuOpen((v) => !v)} className="p-1 rounded hover:bg-[#2a2a2a] transition-colors cursor-pointer"> <button onClick={() => setMenuOpen((v) => !v)} className="p-1 rounded hover:bg-[#2a2a2a] transition-colors cursor-pointer">
<MoreHorizontal className="w-4 h-4" /> <MoreHorizontal className="w-4 h-4" />
</button> </button>
</div>
</div>
<p className="text-xs text-muted-foreground truncate" title={stream.url}>{stream.url}</p>
<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>
{menuOpen && ( {menuOpen && (
<> <>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} /> <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" <div className="absolute top-full right-0 mt-1 z-50 min-w-[180px] rounded-lg border border-border shadow-2xl overflow-hidden"
style={{ background: "#1c1c1c" }}> style={{ background: "#1c1c1c" }}>
<button onClick={() => { setMenuOpen(false); window.location.href = `/streams/${stream.id}/edit` }} className={menuItem}> <button onClick={() => { setMenuOpen(false); window.location.href = `/streams/${stream.id}/edit` }} className={menuItem}>
<Pencil className="w-3.5 h-3.5" /> Edit <Pencil className="w-3.5 h-3.5" /> Edit
@@ -183,12 +249,24 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
{thumbCapturing ? "Capturing..." : "Refresh thumbnail"} {thumbCapturing ? "Capturing..." : "Refresh thumbnail"}
</button> </button>
<div className="border-t border-border" /> <div className="border-t border-border" />
<button onClick={() => { setMenuOpen(false); remove() }} className={cn(menuItem, "text-destructive")}> <button onClick={remove} className={cn(menuItem, "text-destructive")}>
<Trash2 className="w-3.5 h-3.5" /> Delete <Trash2 className="w-3.5 h-3.5" /> Delete
</button> </button>
</div> </div>
</> </>
)} )}
</div> </div>
</div>
</div>
<p className="text-xs text-muted-foreground truncate" title={stream.url}>{stream.url}</p>
<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>
</div>
</>
) )
} }
+3 -3
View File
@@ -57,7 +57,7 @@ function Field({ label, tooltip, required, error, children }: {
{tooltip && <Tooltip text={tooltip} />} {tooltip && <Tooltip text={tooltip} />}
</div> </div>
{children} {children}
{error && <p className="text-xs text-destructive">{error}</p>} {error && <p className="text-xs font-bold text-red-500">{error}</p>}
</div> </div>
) )
} }
@@ -200,10 +200,10 @@ export function StreamForm({ initial }: Props) {
</Field> </Field>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Field label="Username" tooltip={TOOLTIPS.user}> <Field label="Username" tooltip={TOOLTIPS.user}>
<Input value={form.user ?? ""} onChange={(e) => set("user", e.target.value)} /> <Input autoComplete="off" value={form.user ?? ""} onChange={(e) => set("user", e.target.value)} />
</Field> </Field>
<Field label="Password" tooltip={TOOLTIPS.pass}> <Field label="Password" tooltip={TOOLTIPS.pass}>
<Input type="password" value={form.pass ?? ""} onChange={(e) => set("pass", e.target.value)} /> <Input type="password" autoComplete="new-password" value={form.pass ?? ""} onChange={(e) => set("pass", e.target.value)} />
</Field> </Field>
</div> </div>
</section> </section>
+8 -1
View File
@@ -12,7 +12,14 @@ function ensureFile() {
export function readStreams(): Stream[] { export function readStreams(): Stream[] {
ensureFile() 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 { export function writeStreams(streams: Stream[]): void {
+7 -2
View File
@@ -2,6 +2,7 @@ import fs from "fs"
import path from "path" import path from "path"
import { execSync, spawn } from "child_process" import { execSync, spawn } from "child_process"
import type { Stream } from "@/types/stream" import type { Stream } from "@/types/stream"
import { getStream } from "./db"
const DATA_DIR = process.env.DATA_DIR ?? "/app/data" const DATA_DIR = process.env.DATA_DIR ?? "/app/data"
const STREAMS_DIR = path.join(DATA_DIR, "streams") const STREAMS_DIR = path.join(DATA_DIR, "streams")
@@ -86,6 +87,7 @@ export function provisionStream(stream: Stream): void {
export function startStream(id: string): void { export function startStream(id: string): void {
const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"] const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"]
for (const p of programs) supervisorctl(`start ${p}-${id}`) for (const p of programs) supervisorctl(`start ${p}-${id}`)
captureThumb(id, 60)
} }
export function stopStream(id: string): void { export function stopStream(id: string): void {
@@ -111,10 +113,13 @@ export function removeStream(id: string): void {
export function captureThumb(streamId: string, delay = 60): void { export function captureThumb(streamId: string, delay = 60): void {
if (IS_DEV) { console.log(`[thumb mock] captureThumb ${streamId} delay=${delay}s`); return } 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 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", 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" }) ], { detached: true, stdio: "ignore" })
child.unref() child.unref()
} }
+3 -1
View File
@@ -24,11 +24,13 @@ export interface Stream {
desiredState: "running" | "stopped" // #19 — estado desejado persistente desiredState: "running" | "stopped" // #19 — estado desejado persistente
order: number
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
export type StreamCreate = Omit<Stream, "display" | "vncPort" | "debugPort" | "createdAt" | "updatedAt" | "desiredState"> export type StreamCreate = Omit<Stream, "display" | "vncPort" | "debugPort" | "createdAt" | "updatedAt" | "desiredState" | "order">
export type StreamUpdate = Partial<StreamCreate> export type StreamUpdate = Partial<StreamCreate>
export const STREAM_DEFAULTS: Omit<StreamCreate, "id" | "name" | "url"> = { export const STREAM_DEFAULTS: Omit<StreamCreate, "id" | "name" | "url"> = {