From 6315cd13124daabbfd855857fe93fa37fba53460 Mon Sep 17 00:00:00 2001 From: Kralot Date: Mon, 27 Apr 2026 17:56:10 -0300 Subject: [PATCH] =?UTF-8?q?Migra=20prefer=C3=AAncias=20para=20config=20glo?= =?UTF-8?q?bal=20e=20adiciona=20auto-reload=20no=20player=20client=20side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- - Extraído `Toggle` para componente compartilhado em `src/components/Toggle.tsx`; - Preferências "Pure mode" e "Open in new tab" migradas de por-card (localStorage por stream) para configuração global (`global-prefs` no localStorage), refletidas em todos os cards simultaneamente; - Adicionada seção "Player" no Settings com toggle "Auto-reload" e campo de intervalo em minutos; configuração lida pelo `player.html`, `/static/{id}` e `/player/{id}?mode=hls` para cobrir todos os modos de reprodução; - Header simplificado: apenas 3 botões com ícone (Refresh, Settings, New Stream); botões de Download playlist e Sign out movidos para dentro do painel de Settings; --- --- CHANGELOG.md | 44 +++++++++++++ README.md | 2 +- public/player.html | 8 +++ src/app/page.tsx | 112 ++++++++++++++++++++++++++-------- src/app/player/[id]/page.tsx | 11 ++++ src/app/static/[id]/route.ts | 7 +++ src/components/StreamCard.tsx | 45 +++----------- src/components/Toggle.tsx | 9 +++ 8 files changed, 172 insertions(+), 66 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/components/Toggle.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..32c66f9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +## Decap Stream v1.0.0 + +Turn any web page into an RTMP/HLS stream. Chromium renders the page in a virtual display, ffmpeg captures it, and MediaMTX publishes it, all managed through a web UI. + +### What's included + +- **Dashboard with live thumbnails and drag-and-drop ordering** +- **Per-stream configuration** — resolution, scale, FPS, bitrate, x264 preset/tune, GPU flag +- **Scalable card sizes** — mini / sm / md / lg with proportional scaling across all elements +- **Inline VNC** — inspect any stream's virtual display without leaving the UI +- **Autologin with CDP detection** — skips login if the session is still alive on container restart +- **Built-in HLS player** — with controls; static standalone page optimized for TV browsers (`/player.html?id=`) +- **Per-card Pure mode** — open streams as a raw `.m3u8` link or a zero-dependency `.html` page, usable in VLC or any HLS-capable player +- **Per-card new-tab toggle** — open any action button in a new tab; settings are per-card and saved in the browser +- **Optional UI authentication** — set `AUTH_USER` + `AUTH_PASS` to password-protect the entire UI +- **Persistent desired state** — streams restore automatically on container restart + +### Quick start + +```yaml +services: + decap-stream: + image: ghcr.io/riguettodev/decap-stream:latest + restart: unless-stopped + shm_size: "2gb" + security_opt: + - seccomp:unconfined + ports: + - "3000:3000" + - "127.0.0.1:6080:6080" + volumes: + - streams:/app/data/streams + +volumes: + streams: +``` + +```bash +docker compose up -d +``` + +See the [README](README.md) for the full configuration reference. diff --git a/README.md b/README.md index e52bdbd..1bb0254 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Each stream gets a slug ID you define (e.g. `grafana-prod`): | RTMP ingest | `rtmp://:1935/live/` | | HLS manifest (proxied) | `http://:3000/api/hls/live//index.m3u8` | | HLS manifest (direct) | `http://:8888/live//index.m3u8` — requires port 8888 exposed | -| HTML player | `http://:3000/player/.html` — minimal page, no UI chrome | +| HTML player | `http://:3000/player.html?id=` — static minimal page, no UI chrome | | VNC (inline) | `http://:3000/vnc/` | > **Pure mode** (toggle per card): Play Stream opens the proxied HLS `.m3u8` directly; Run HTML opens the `.html` player. Both can be pasted into VLC or any HLS-capable player, or loaded natively on TV browsers that support HLS. diff --git a/public/player.html b/public/player.html index c7147ea..f299ac6 100644 --- a/public/player.html +++ b/public/player.html @@ -59,6 +59,14 @@ .then(function(){load(directUrl);}) .catch(function(){load(proxyUrl);}); + // Auto-reload — reads global-prefs written by the main UI + try { + var gp=JSON.parse(localStorage.getItem('global-prefs')||'{}'); + if(gp.autoReload){ + setTimeout(function(){location.reload();},Math.max(1,gp.reloadInterval||2)*60*1000); + } + }catch(e){} + // WebOS screensaver suppression — fails silently if not available if(typeof WebOSServiceBridge!=='undefined'){ var bridge=new WebOSServiceBridge(); diff --git a/src/app/page.tsx b/src/app/page.tsx index 48cd392..90e6b39 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, useCallback } from "react" import { Plus, Download, RefreshCw, Settings, X, LogOut } from "lucide-react" import { StreamCard } from "@/components/StreamCard" +import { Toggle } from "@/components/Toggle" import type { Stream } from "@/types/stream" import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core" import type { DragEndEvent } from "@dnd-kit/core" @@ -10,6 +11,9 @@ import { SortableContext, useSortable, rectSortingStrategy, arrayMove } from "@d import { CSS } from "@dnd-kit/utilities" type CardSize = "mini" | "sm" | "md" | "lg" +type GlobalPrefs = { pureMode: boolean; newTab: boolean; autoReload: boolean; reloadInterval: number } + +const DEFAULT_GLOBAL_PREFS: GlobalPrefs = { pureMode: false, newTab: false, autoReload: false, reloadInterval: 2 } const CARD_WIDTHS: Record = { mini: "max-w-[200px]", sm: "max-w-[240px]", md: "max-w-[300px]", lg: "max-w-[380px]" } @@ -20,6 +24,7 @@ function SortableStreamCard(props: { cardSize: CardSize onRefresh: () => void onLocalStatus: (id: string, s: string | null) => void + globalPrefs: GlobalPrefs }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.stream.id }) const style = { transform: CSS.Transform.toString(transform), transition } @@ -54,12 +59,18 @@ function SkeletonCard({ size = "sm" }: { size?: CardSize }) { ) } -// settings popup -function SettingsPopup({ cardSize, onCardSize, onClose }: { +function SettingsPopup({ cardSize, onCardSize, globalPrefs, onGlobalPrefs, authEnabled, onDownloadPlaylist, onLogout, onClose }: { cardSize: CardSize onCardSize: (s: CardSize) => void + globalPrefs: GlobalPrefs + onGlobalPrefs: (patch: Partial) => void + authEnabled: boolean + onDownloadPlaylist: () => void + onLogout: () => void onClose: () => void }) { + const toggleRow = "flex items-center gap-2 w-full px-1 py-1.5 rounded hover:bg-[#2a2a2a] transition-colors cursor-pointer text-sm" + return ( <>
@@ -72,7 +83,7 @@ function SettingsPopup({ cardSize, onCardSize, onClose }: {
-

Card size

+

Card size

{(["mini", "sm", "md", "lg"] as CardSize[]).map((s) => (
+ +
+

Cards

+ + +
+ +
+

Player

+ + {globalPrefs.autoReload && ( +
+ Interval + onGlobalPrefs({ reloadInterval: Math.max(1, Number(e.target.value) || 1) })} + className="w-14 px-2 py-1 text-xs rounded border border-border bg-muted text-center" + /> + min +
+ )} +
+ +
+ + {authEnabled && ( + + )} +
) @@ -99,9 +154,10 @@ export default function GalleryPage() { const [localStatuses, setLocalStatuses] = useState>({}) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) - const [cardSize, setCardSize] = useState("md") // md = Medium = antigo Big + const [cardSize, setCardSize] = useState("md") const [settingsOpen, setSettingsOpen] = useState(false) const [authEnabled, setAuthEnabled] = useState(false) + const [globalPrefs, setGlobalPrefs] = useState(DEFAULT_GLOBAL_PREFS) const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })) @@ -120,10 +176,22 @@ export default function GalleryPage() { } useEffect(() => { - const saved = localStorage.getItem("cardSize") as CardSize | null - if (saved) setCardSize(saved) + try { + const savedSize = localStorage.getItem("cardSize") as CardSize | null + if (savedSize) setCardSize(savedSize) + const savedPrefs = localStorage.getItem("global-prefs") + if (savedPrefs) setGlobalPrefs({ ...DEFAULT_GLOBAL_PREFS, ...JSON.parse(savedPrefs) }) + } catch {} }, []) + function updateGlobalPrefs(patch: Partial) { + setGlobalPrefs(prev => { + const next = { ...prev, ...patch } + localStorage.setItem("global-prefs", JSON.stringify(next)) + return next + }) + } + const fetchStreams = useCallback(async (manual = false) => { if (manual) setRefreshing(true) const res = await fetch("/api/streams") @@ -162,7 +230,6 @@ export default function GalleryPage() { const showSkeleton = loading || refreshing - // all header buttons share the same padding and height const btnBase = "flex items-center gap-1.5 text-sm px-3 py-1.5 h-8 rounded border border-border hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer" const btnPrimary = "flex items-center gap-1.5 text-sm px-3 py-1.5 h-8 rounded border border-primary bg-primary text-primary-foreground hover:bg-[#2a2a2a] hover:text-foreground hover:border-border active:bg-[#333] transition-colors cursor-pointer" @@ -176,37 +243,27 @@ export default function GalleryPage() { riguetto.dev
- {/* explicit h-8 to match other header buttons */} - - - {/* #7 — botão de config */} - - {authEnabled && ( - - )}
- {/* settings popup */} {settingsOpen && ( { setCardSize(s); localStorage.setItem("cardSize", s); setSettingsOpen(false) }} + onCardSize={(s) => { setCardSize(s); localStorage.setItem("cardSize", s) }} + globalPrefs={globalPrefs} + onGlobalPrefs={updateGlobalPrefs} + authEnabled={authEnabled} + onDownloadPlaylist={downloadPlaylist} + onLogout={async () => { await fetch("/api/auth/logout", { method: "POST" }); window.location.href = "/login" }} onClose={() => setSettingsOpen(false)} /> )} @@ -238,6 +295,7 @@ export default function GalleryPage() { cardSize={cardSize} onRefresh={() => fetchStreams()} onLocalStatus={setLocalStatus} + globalPrefs={globalPrefs} /> ))} @@ -247,4 +305,4 @@ export default function GalleryPage() { ) -} \ No newline at end of file +} diff --git a/src/app/player/[id]/page.tsx b/src/app/player/[id]/page.tsx index 2eef0e6..c32539f 100644 --- a/src/app/player/[id]/page.tsx +++ b/src/app/player/[id]/page.tsx @@ -125,6 +125,17 @@ function PlayerInner() { const mode = (searchParams.get("mode") ?? "hls") as Mode const streamSrc = `/api/hls/live/${id}/index.m3u8` + useEffect(() => { + try { + const gp = JSON.parse(localStorage.getItem("global-prefs") ?? "{}") + if (gp.autoReload) { + const ms = Math.max(1, gp.reloadInterval ?? 2) * 60 * 1000 + const t = setTimeout(() => location.reload(), ms) + return () => clearTimeout(t) + } + } catch {} + }, []) + return (
router.push("/")} /> diff --git a/src/app/static/[id]/route.ts b/src/app/static/[id]/route.ts index 1a50f19..303f780 100644 --- a/src/app/static/[id]/route.ts +++ b/src/app/static/[id]/route.ts @@ -116,6 +116,13 @@ export async function GET(_req: NextRequest, { params }: Ctx) { if(v.currentTime===last&&!v.paused){showMsg('Stream stalled — reloading...');startHls(activeSrc);} last=v.currentTime; },10000); + + try{ + var gp=JSON.parse(localStorage.getItem('global-prefs')||'{}'); + if(gp.autoReload){ + setTimeout(function(){location.reload();},Math.max(1,gp.reloadInterval||2)*60*1000); + } + }catch(e){} ` diff --git a/src/components/StreamCard.tsx b/src/components/StreamCard.tsx index 79e4f1a..e9695fd 100644 --- a/src/components/StreamCard.tsx +++ b/src/components/StreamCard.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react" import { MoreHorizontal, Play, Globe, Monitor, Pencil, RotateCcw, Square, Trash2, Circle, Copy, Check, Video, ImageUp, GripVertical, Wrench } from "lucide-react" import { cn } from "@/lib/utils" +import { Toggle } from "@/components/Toggle" import type { Stream } from "@/types/stream" import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities" import type { DraggableAttributes } from "@dnd-kit/core" @@ -17,6 +18,7 @@ interface Props { dragHandleListeners?: SyntheticListenerMap dragHandleAttributes?: DraggableAttributes isDragging?: boolean + globalPrefs: { pureMode: boolean; newTab: boolean } } function StatusBadge({ status, localStatus, cardSize = "md" }: { status?: Record; localStatus?: string | null; cardSize?: "mini" | "sm" | "md" | "lg" }) { @@ -66,14 +68,6 @@ const SCALE = { lg: { card: "p-4 gap-3", name: "text-base", meta: "text-sm", btn: "text-sm px-4 py-2.5 gap-2", btnIcon: "w-4 h-4", menuIcon: "w-4 h-4", dot: "w-2 h-2" }, } satisfies Record -function Toggle({ on }: { on: boolean }) { - return ( - - - - ) -} - function ConfirmDeleteModal({ name, onConfirm, onCancel }: { name: string; onConfirm: () => void; onCancel: () => void }) { return (
@@ -104,7 +98,7 @@ function ConfirmDeleteModal({ name, onConfirm, onCancel }: { name: string; onCon ) } -export function StreamCard({ stream, status, localStatus, cardSize = "md", onRefresh, onLocalStatus, dragHandleListeners, dragHandleAttributes, isDragging }: Props) { +export function StreamCard({ stream, status, localStatus, cardSize = "md", onRefresh, onLocalStatus, dragHandleListeners, dragHandleAttributes, isDragging, globalPrefs }: Props) { const sc = SCALE[cardSize] const [menuOpen, setMenuOpen] = useState(false) const [copied, setCopied] = useState(false) @@ -112,7 +106,6 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef const [thumbError, setThumbError] = useState(false) const [thumbCapturing, setThumbCapturing] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) - const [prefs, setPrefs] = useState({ pureMode: false, newTab: false }) const pollRef = useRef | null>(null) useEffect(() => { @@ -123,23 +116,8 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, []) - useEffect(() => { - try { - const saved = localStorage.getItem(`stream-prefs-${stream.id}`) - if (saved) setPrefs(JSON.parse(saved)) - } catch {} - }, [stream.id]) - - function togglePref(key: "pureMode" | "newTab") { - setPrefs(prev => { - const next = { ...prev, [key]: !prev[key] } - localStorage.setItem(`stream-prefs-${stream.id}`, JSON.stringify(next)) - return next - }) - } - function navigate(url: string) { - if (prefs.newTab) window.open(url, "_blank") + if (globalPrefs.newTab) window.open(url, "_blank") else window.location.href = url } @@ -167,13 +145,13 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef } function handlePlayStream() { - navigate(prefs.pureMode + navigate(globalPrefs.pureMode ? `/api/hls/live/${stream.id}/index.m3u8` : `/player/${stream.id}?mode=hls`) } function handleRunHtml() { - navigate(prefs.pureMode + navigate(globalPrefs.pureMode ? `/player.html?id=${stream.id}` : `/static/${stream.id}`) } @@ -206,7 +184,7 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef }, 2000) } -const playBtn = `w-full flex items-center rounded border border-border bg-muted hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer ${sc.btn}` + const playBtn = `w-full flex items-center rounded border border-border bg-muted hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer ${sc.btn}` 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 ( @@ -297,15 +275,6 @@ const playBtn = `w-full flex items-center rounded border border-border bg-muted {thumbCapturing ? "Capturing..." : "Refresh thumbnail"}
- - -
diff --git a/src/components/Toggle.tsx b/src/components/Toggle.tsx new file mode 100644 index 0000000..91ec348 --- /dev/null +++ b/src/components/Toggle.tsx @@ -0,0 +1,9 @@ +import { cn } from "@/lib/utils" + +export function Toggle({ on }: { on: boolean }) { + return ( + + + + ) +}