Migra preferências para config global e adiciona auto-reload no player client side

---

- 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;

---
This commit is contained in:
2026-04-27 17:56:10 -03:00
parent e0fd0af02e
commit 6315cd1312
8 changed files with 172 additions and 66 deletions
+44
View File
@@ -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=<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.
+1 -1
View File
@@ -108,7 +108,7 @@ Each stream gets a slug ID you define (e.g. `grafana-prod`):
| RTMP ingest | `rtmp://<host>:1935/live/<id>` | | RTMP ingest | `rtmp://<host>:1935/live/<id>` |
| HLS manifest (proxied) | `http://<host>:3000/api/hls/live/<id>/index.m3u8` | | HLS manifest (proxied) | `http://<host>:3000/api/hls/live/<id>/index.m3u8` |
| HLS manifest (direct) | `http://<host>:8888/live/<id>/index.m3u8` — requires port 8888 exposed | | HLS manifest (direct) | `http://<host>:8888/live/<id>/index.m3u8` — requires port 8888 exposed |
| HTML player | `http://<host>:3000/player/<id>.html` minimal page, no UI chrome | | HTML player | `http://<host>:3000/player.html?id=<id>` — static minimal page, no UI chrome |
| VNC (inline) | `http://<host>:3000/vnc/<id>` | | VNC (inline) | `http://<host>:3000/vnc/<id>` |
> **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. > **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.
+8
View File
@@ -59,6 +59,14 @@
.then(function(){load(directUrl);}) .then(function(){load(directUrl);})
.catch(function(){load(proxyUrl);}); .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 // WebOS screensaver suppression — fails silently if not available
if(typeof WebOSServiceBridge!=='undefined'){ if(typeof WebOSServiceBridge!=='undefined'){
var bridge=new WebOSServiceBridge(); var bridge=new WebOSServiceBridge();
+84 -26
View File
@@ -3,6 +3,7 @@
import { useEffect, useState, useCallback } from "react" import { useEffect, useState, useCallback } from "react"
import { Plus, Download, RefreshCw, Settings, X, LogOut } from "lucide-react" import { Plus, Download, RefreshCw, Settings, X, LogOut } from "lucide-react"
import { StreamCard } from "@/components/StreamCard" import { StreamCard } from "@/components/StreamCard"
import { Toggle } from "@/components/Toggle"
import type { Stream } from "@/types/stream" import type { Stream } from "@/types/stream"
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core" import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"
import type { DragEndEvent } 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" import { CSS } from "@dnd-kit/utilities"
type CardSize = "mini" | "sm" | "md" | "lg" 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<CardSize, string> = { mini: "max-w-[200px]", sm: "max-w-[240px]", md: "max-w-[300px]", lg: "max-w-[380px]" } const CARD_WIDTHS: Record<CardSize, string> = { 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 cardSize: CardSize
onRefresh: () => void onRefresh: () => void
onLocalStatus: (id: string, s: string | null) => void onLocalStatus: (id: string, s: string | null) => void
globalPrefs: GlobalPrefs
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.stream.id }) const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.stream.id })
const style = { transform: CSS.Transform.toString(transform), transition } const style = { transform: CSS.Transform.toString(transform), transition }
@@ -54,12 +59,18 @@ function SkeletonCard({ size = "sm" }: { size?: CardSize }) {
) )
} }
// settings popup function SettingsPopup({ cardSize, onCardSize, globalPrefs, onGlobalPrefs, authEnabled, onDownloadPlaylist, onLogout, onClose }: {
function SettingsPopup({ cardSize, onCardSize, onClose }: {
cardSize: CardSize cardSize: CardSize
onCardSize: (s: CardSize) => void onCardSize: (s: CardSize) => void
globalPrefs: GlobalPrefs
onGlobalPrefs: (patch: Partial<GlobalPrefs>) => void
authEnabled: boolean
onDownloadPlaylist: () => void
onLogout: () => void
onClose: () => 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 ( return (
<> <>
<div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} /> <div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} />
@@ -72,7 +83,7 @@ function SettingsPopup({ cardSize, onCardSize, onClose }: {
</div> </div>
<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 uppercase">Card size</p>
<div className="flex gap-2"> <div className="flex gap-2">
{(["mini", "sm", "md", "lg"] as CardSize[]).map((s) => ( {(["mini", "sm", "md", "lg"] as CardSize[]).map((s) => (
<button <button
@@ -88,6 +99,50 @@ function SettingsPopup({ cardSize, onCardSize, onClose }: {
))} ))}
</div> </div>
</div> </div>
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground tracking-wider uppercase">Cards</p>
<button onClick={() => onGlobalPrefs({ pureMode: !globalPrefs.pureMode })} className={toggleRow}>
<Toggle on={globalPrefs.pureMode} />
<span>Pure mode</span>
</button>
<button onClick={() => onGlobalPrefs({ newTab: !globalPrefs.newTab })} className={toggleRow}>
<Toggle on={globalPrefs.newTab} />
<span>Open in new tab</span>
</button>
</div>
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground tracking-wider uppercase">Player</p>
<button onClick={() => onGlobalPrefs({ autoReload: !globalPrefs.autoReload })} className={toggleRow}>
<Toggle on={globalPrefs.autoReload} />
<span>Auto-reload</span>
</button>
{globalPrefs.autoReload && (
<div className="flex items-center gap-2 px-1 py-1">
<span className="text-xs text-muted-foreground">Interval</span>
<input
type="number"
min={1}
value={globalPrefs.reloadInterval}
onChange={(e) => 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"
/>
<span className="text-xs text-muted-foreground">min</span>
</div>
)}
</div>
<div className="border-t border-border pt-3 flex flex-col gap-1">
<button onClick={onDownloadPlaylist} className="flex items-center gap-2 w-full px-1 py-1.5 rounded hover:bg-[#2a2a2a] transition-colors cursor-pointer text-sm">
<Download className="w-3.5 h-3.5" /> Download playlist
</button>
{authEnabled && (
<button onClick={onLogout} className="flex items-center gap-2 w-full px-1 py-1.5 rounded hover:bg-[#2a2a2a] transition-colors cursor-pointer text-sm text-muted-foreground">
<LogOut className="w-3.5 h-3.5" /> Sign out
</button>
)}
</div>
</div> </div>
</> </>
) )
@@ -99,9 +154,10 @@ 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") // md = Medium = antigo Big const [cardSize, setCardSize] = useState<CardSize>("md")
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
const [authEnabled, setAuthEnabled] = useState(false) const [authEnabled, setAuthEnabled] = useState(false)
const [globalPrefs, setGlobalPrefs] = useState<GlobalPrefs>(DEFAULT_GLOBAL_PREFS)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })) const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
@@ -120,10 +176,22 @@ export default function GalleryPage() {
} }
useEffect(() => { useEffect(() => {
const saved = localStorage.getItem("cardSize") as CardSize | null try {
if (saved) setCardSize(saved) 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<GlobalPrefs>) {
setGlobalPrefs(prev => {
const next = { ...prev, ...patch }
localStorage.setItem("global-prefs", JSON.stringify(next))
return next
})
}
const fetchStreams = useCallback(async (manual = false) => { const fetchStreams = useCallback(async (manual = false) => {
if (manual) setRefreshing(true) if (manual) setRefreshing(true)
const res = await fetch("/api/streams") const res = await fetch("/api/streams")
@@ -162,7 +230,6 @@ export default function GalleryPage() {
const showSkeleton = loading || refreshing 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 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" 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() {
<a href="https://riguetto.dev" target="_blank" rel="noopener noreferrer" className="text-xs text-[#888] hover:text-[#ededed] hover:underline transition-colors">riguetto.dev</a> <a href="https://riguetto.dev" target="_blank" rel="noopener noreferrer" className="text-xs text-[#888] hover:text-[#ededed] hover:underline transition-colors">riguetto.dev</a>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* explicit h-8 to match other header buttons */} <button onClick={() => fetchStreams(true)} className={btnBase} title="Refresh">
<button onClick={() => fetchStreams(true)} className={btnBase} title="Atualizar">
<RefreshCw className={`w-3.5 h-3.5 ${refreshing ? "animate-spin" : ""}`} /> <RefreshCw className={`w-3.5 h-3.5 ${refreshing ? "animate-spin" : ""}`} />
</button> </button>
<button onClick={downloadPlaylist} className={btnBase}>
<Download className="w-3.5 h-3.5" /> Playlist .m3u
</button>
{/* #7 — botão de config */}
<button onClick={() => setSettingsOpen((v) => !v)} className={btnBase} title="Settings"> <button onClick={() => setSettingsOpen((v) => !v)} className={btnBase} title="Settings">
<Settings className="w-3.5 h-3.5" /> <Settings className="w-3.5 h-3.5" />
</button> </button>
<button onClick={() => window.location.href = "/streams/new"} className={btnPrimary}> <button onClick={() => window.location.href = "/streams/new"} className={btnPrimary} title="New stream">
<Plus className="w-3.5 h-3.5" /> New stream <Plus className="w-3.5 h-3.5" />
</button> </button>
{authEnabled && (
<button
onClick={async () => { await fetch("/api/auth/logout", { method: "POST" }); window.location.href = "/login" }}
className={btnBase}
title="Sign out"
>
<LogOut className="w-3.5 h-3.5" />
</button>
)}
</div> </div>
</header> </header>
{/* settings popup */}
{settingsOpen && ( {settingsOpen && (
<SettingsPopup <SettingsPopup
cardSize={cardSize} cardSize={cardSize}
onCardSize={(s) => { 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)} onClose={() => setSettingsOpen(false)}
/> />
)} )}
@@ -238,6 +295,7 @@ export default function GalleryPage() {
cardSize={cardSize} cardSize={cardSize}
onRefresh={() => fetchStreams()} onRefresh={() => fetchStreams()}
onLocalStatus={setLocalStatus} onLocalStatus={setLocalStatus}
globalPrefs={globalPrefs}
/> />
))} ))}
</div> </div>
+11
View File
@@ -125,6 +125,17 @@ function PlayerInner() {
const mode = (searchParams.get("mode") ?? "hls") as Mode const mode = (searchParams.get("mode") ?? "hls") as Mode
const streamSrc = `/api/hls/live/${id}/index.m3u8` 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 ( return (
<div className="relative bg-black w-screen h-screen overflow-hidden"> <div className="relative bg-black w-screen h-screen overflow-hidden">
<BackButton onClick={() => router.push("/")} /> <BackButton onClick={() => router.push("/")} />
+7
View File
@@ -116,6 +116,13 @@ export async function GET(_req: NextRequest, { params }: Ctx) {
if(v.currentTime===last&&!v.paused){showMsg('Stream stalled — reloading...');startHls(activeSrc);} if(v.currentTime===last&&!v.paused){showMsg('Stream stalled — reloading...');startHls(activeSrc);}
last=v.currentTime; last=v.currentTime;
},10000); },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){}
</script> </script>
</body> </body>
</html>` </html>`
+6 -37
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from "react" 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 { MoreHorizontal, Play, Globe, Monitor, Pencil, RotateCcw, Square, Trash2, Circle, Copy, Check, Video, ImageUp, GripVertical, Wrench } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Toggle } from "@/components/Toggle"
import type { Stream } from "@/types/stream" import type { Stream } from "@/types/stream"
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities" import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities"
import type { DraggableAttributes } from "@dnd-kit/core" import type { DraggableAttributes } from "@dnd-kit/core"
@@ -17,6 +18,7 @@ interface Props {
dragHandleListeners?: SyntheticListenerMap dragHandleListeners?: SyntheticListenerMap
dragHandleAttributes?: DraggableAttributes dragHandleAttributes?: DraggableAttributes
isDragging?: boolean isDragging?: boolean
globalPrefs: { pureMode: boolean; newTab: boolean }
} }
function StatusBadge({ status, localStatus, cardSize = "md" }: { status?: Record<string, string>; localStatus?: string | null; cardSize?: "mini" | "sm" | "md" | "lg" }) { function StatusBadge({ status, localStatus, cardSize = "md" }: { status?: Record<string, string>; 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" }, 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<string, { card: string; name: string; meta: string; btn: string; btnIcon: string; menuIcon: string; dot: string }> } satisfies Record<string, { card: string; name: string; meta: string; btn: string; btnIcon: string; menuIcon: string; dot: string }>
function Toggle({ on }: { on: boolean }) {
return (
<span className={cn("w-8 h-4 rounded-full flex items-center px-0.5 transition-colors shrink-0", on ? "bg-blue-500" : "bg-zinc-600")}>
<span className={cn("w-3 h-3 rounded-full bg-white transition-transform", on ? "translate-x-4" : "translate-x-0")} />
</span>
)
}
function ConfirmDeleteModal({ name, onConfirm, onCancel }: { name: string; onConfirm: () => void; onCancel: () => void }) { function ConfirmDeleteModal({ name, onConfirm, onCancel }: { name: string; onConfirm: () => void; onCancel: () => void }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -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 sc = SCALE[cardSize]
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [copied, setCopied] = 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 [thumbError, setThumbError] = useState(false)
const [thumbCapturing, setThumbCapturing] = useState(false) const [thumbCapturing, setThumbCapturing] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false)
const [prefs, setPrefs] = useState({ pureMode: false, newTab: false })
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null) const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => { useEffect(() => {
@@ -123,23 +116,8 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, []) 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) { function navigate(url: string) {
if (prefs.newTab) window.open(url, "_blank") if (globalPrefs.newTab) window.open(url, "_blank")
else window.location.href = url else window.location.href = url
} }
@@ -167,13 +145,13 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef
} }
function handlePlayStream() { function handlePlayStream() {
navigate(prefs.pureMode navigate(globalPrefs.pureMode
? `/api/hls/live/${stream.id}/index.m3u8` ? `/api/hls/live/${stream.id}/index.m3u8`
: `/player/${stream.id}?mode=hls`) : `/player/${stream.id}?mode=hls`)
} }
function handleRunHtml() { function handleRunHtml() {
navigate(prefs.pureMode navigate(globalPrefs.pureMode
? `/player.html?id=${stream.id}` ? `/player.html?id=${stream.id}`
: `/static/${stream.id}`) : `/static/${stream.id}`)
} }
@@ -297,15 +275,6 @@ const playBtn = `w-full flex items-center rounded border border-border bg-muted
{thumbCapturing ? "Capturing..." : "Refresh thumbnail"} {thumbCapturing ? "Capturing..." : "Refresh thumbnail"}
</button> </button>
<div className="border-t border-border" /> <div className="border-t border-border" />
<button onClick={() => togglePref("pureMode")} className={menuItem}>
<Toggle on={prefs.pureMode} />
<span>Pure mode</span>
</button>
<button onClick={() => togglePref("newTab")} className={menuItem}>
<Toggle on={prefs.newTab} />
<span>Open in new tab</span>
</button>
<div className="border-t border-border" />
<button onClick={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>
+9
View File
@@ -0,0 +1,9 @@
import { cn } from "@/lib/utils"
export function Toggle({ on }: { on: boolean }) {
return (
<span className={cn("w-8 h-4 rounded-full flex items-center px-0.5 transition-colors shrink-0", on ? "bg-blue-500" : "bg-zinc-600")}>
<span className={cn("w-3 h-3 rounded-full bg-white transition-transform", on ? "translate-x-4" : "translate-x-0")} />
</span>
)
}