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:
@@ -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
|
||||||
|
|||||||
Generated
+56
@@ -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",
|
||||||
|
|||||||
@@ -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 |
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -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
@@ -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
@@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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"> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user