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 0000000..c81ae87 Binary files /dev/null and b/public/web-app-manifest-192x192.png differ diff --git a/public/web-app-manifest-512x512.png b/public/web-app-manifest-512x512.png new file mode 100644 index 0000000..c284b62 Binary files /dev/null and b/public/web-app-manifest-512x512.png differ diff --git a/scripts/stream.template.conf b/scripts/stream.template.conf index 6beb4c6..73e3c85 100644 --- a/scripts/stream.template.conf +++ b/scripts/stream.template.conf @@ -27,6 +27,8 @@ command=bash -c "rm -rf \ --disable-sync \ --disable-background-timer-throttling \ --remote-debugging-port={{DEBUG_PORT}} \ + --password-store=basic \ + --disable-features=PasswordManagerRedesign,PasswordSuggestions \ '{{STREAM_URL}}'" environment=DISPLAY={{DISPLAY}} autorestart=true diff --git a/src/app/api/streams/playlist/route.ts b/src/app/api/streams/playlist/route.ts index 4c8d5a9..481d953 100644 --- a/src/app/api/streams/playlist/route.ts +++ b/src/app/api/streams/playlist/route.ts @@ -8,10 +8,11 @@ export async function GET(req: Request) { const streams = readStreams() const lines = ["#EXTM3U"] - for (const s of streams) { - lines.push(`#EXTINF:-1 tvg-id="${s.id}" tvg-name="${s.name}" group-title="DecapStream",${s.name} [${s.id}] ${s.resolution} ${s.fps}fps`) + streams.forEach((s, i) => { + 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 0000000..e098833 Binary files /dev/null and b/src/app/apple-touch-icon.png differ diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000..100bb12 Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/icon.png b/src/app/icon.png new file mode 100644 index 0000000..c050917 Binary files /dev/null and b/src/app/icon.png differ diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..800de8b --- /dev/null +++ b/src/app/manifest.ts @@ -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', + } +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 715397d..43947ea 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,11 +4,39 @@ import { useEffect, useState, useCallback } from "react" import { Plus, Download, RefreshCw, Settings, X } from "lucide-react" import { StreamCard } from "@/components/StreamCard" 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 = { 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 = {