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:
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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("/")} />
|
||||||
|
|||||||
@@ -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>`
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
@@ -206,7 +184,7 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef
|
|||||||
}, 2000)
|
}, 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"
|
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 (
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user