From f6879781c1245be74f9c83a23380e7793d369aea Mon Sep 17 00:00:00 2001 From: Kralot Date: Sat, 25 Apr 2026 03:24:20 -0300 Subject: [PATCH] =?UTF-8?q?Adiciona=20features=20de=20ordena=C3=A7=C3=A3o,?= =?UTF-8?q?=20drag-and-drop=20e=20melhorias=20de=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- - 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; --- --- docker/Dockerfile | 8 + package-lock.json | 56 +++++++ package.json | 3 + public/favicon.svg | 1 + public/web-app-manifest-192x192.png | Bin 0 -> 7030 bytes public/web-app-manifest-512x512.png | Bin 0 -> 24521 bytes scripts/stream.template.conf | 2 + src/app/api/streams/playlist/route.ts | 7 +- src/app/api/streams/reorder/route.ts | 20 +++ src/app/api/streams/route.ts | 3 + src/app/apple-touch-icon.png | Bin 0 -> 6004 bytes src/app/favicon.ico | Bin 0 -> 15086 bytes src/app/icon.png | Bin 0 -> 3392 bytes src/app/manifest.ts | 15 ++ src/app/page.tsx | 98 ++++++++++--- src/components/StreamCard.tsx | 204 ++++++++++++++++++-------- src/components/StreamForm.tsx | 6 +- src/lib/db.ts | 9 +- src/lib/supervisor.ts | 9 +- src/types/stream.ts | 4 +- 20 files changed, 348 insertions(+), 97 deletions(-) create mode 100644 public/favicon.svg create mode 100644 public/web-app-manifest-192x192.png create mode 100644 public/web-app-manifest-512x512.png create mode 100644 src/app/api/streams/reorder/route.ts create mode 100644 src/app/apple-touch-icon.png create mode 100644 src/app/favicon.ico create mode 100644 src/app/icon.png create mode 100644 src/app/manifest.ts diff --git a/docker/Dockerfile b/docker/Dockerfile index 9971f90..dd33d96 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -6,6 +6,7 @@ COPY package.json package-lock.json ./ RUN npm ci COPY src/ ./src/ +COPY public/ ./public/ COPY next.config.ts tsconfig.json postcss.config.mjs ./ RUN npm run build @@ -38,6 +39,12 @@ RUN apt-get update \ && apt-get autoremove -y \ && apt-get clean \ && 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 \ /var/lib/apt/lists/* \ /tmp/* /var/tmp/* \ @@ -48,6 +55,7 @@ RUN apt-get update \ COPY --from=builder /build/.next/standalone/ /app/ 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/mediamtx.yml /etc/mediamtx.yml diff --git a/package-lock.json b/package-lock.json index a6c7126..8245628 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "decap-stream", "version": "1.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "clsx": "^2.1.1", "lucide-react": "^0.577.0", "nanoid": "^5.1.7", @@ -283,6 +286,59 @@ "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": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", diff --git a/package.json b/package.json index 9f2c5e0..3a300d3 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "lint": "eslint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "clsx": "^2.1.1", "lucide-react": "^0.577.0", "nanoid": "^5.1.7", diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..3f92fb7 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ +RealFaviconGeneratorhttps://realfavicongenerator.net \ No newline at end of file diff --git a/public/web-app-manifest-192x192.png b/public/web-app-manifest-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..c81ae877692ba88f53de8c208954759edfae8c68 GIT binary patch literal 7030 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 UQi< { + 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}`) - } + }) return new Response(lines.join("\n"), { headers: { diff --git a/src/app/api/streams/reorder/route.ts b/src/app/api/streams/reorder/route.ts new file mode 100644 index 0000000..7455172 --- /dev/null +++ b/src/app/api/streams/reorder/route.ts @@ -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 }) +} diff --git a/src/app/api/streams/route.ts b/src/app/api/streams/route.ts index 970ff3b..ebf02e4 100644 --- a/src/app/api/streams/route.ts +++ b/src/app/api/streams/route.ts @@ -23,6 +23,8 @@ export async function POST(req: Request) { const ports = allocatePorts() const now = new Date().toISOString() + const existing = readStreams() + const nextOrder = existing.length > 0 ? Math.max(...existing.map((s) => s.order)) + 1 : 0 const stream = { ...STREAM_DEFAULTS, @@ -30,6 +32,7 @@ export async function POST(req: Request) { scale: normalizeScale(body.scale ?? STREAM_DEFAULTS.scale), // #13 ...ports, desiredState: "running" as const, // #19 + order: nextOrder, createdAt: now, updatedAt: now, } diff --git a/src/app/apple-touch-icon.png b/src/app/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e0988337dce66bdf7332a1f13e21d5b292ad5dfd GIT binary patch literal 6004 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 Wi?0l|+WKJt0000 = { mini: "max-w-[200px]", sm: "max-w-[240px]", md: "max-w-[300px]", lg: "max-w-[380px]" } + +function SortableStreamCard(props: { + stream: Stream + status?: Record + 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 ( +
+ +
+ ) +} 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 (
@@ -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 +

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 ? ( -
@@ -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 = {