Adiciona Pure mode, escala de cards e player HTML otimizado para TVs
---
- Adicionado `SCALE` token table em `StreamCard` para escalonamento proporcional de todos os elementos do card (padding, texto, botões, ícones) nas variantes mini/sm/md/lg;
- Adicionado toggle "Pure mode" por card (salvo em `localStorage`): Play Stream abre o `.m3u8` direto, Run HTML abre `/player/{id}.html` com extensão real;
- Adicionado toggle "Open in new tab" por card (salvo em `localStorage`): todos os botões do card passam a abrir em nova aba quando ativo;
- Criado `GET /api/player-html/[id]` que serve HTML mínimo sem interface (sem botões Back/Mute), equivalente ao HTML estático antigo; servido via rewrite `next.config.ts` em `/player/:id.html`;
- Criado `GET /static/[id]` com player HTML otimizado para TVs: botões Back e Mute que somem após 5s, autoplay com fallback muted, tenta conexão direta ao MediaMTX (`:8888`) antes do proxy;
- Removido `player-static/[id]/route.ts`; `player/[id]/page.tsx` atualizado para apontar iframe ao `/static/{id}`;
- Melhorado proxy HLS (`/api/hls/`): repassa `Content-Length` e `Accept-Ranges`; segmentos `.ts` cacheados com `max-age=300, immutable`, playlists `.m3u8` com `no-cache, no-store`;
- Adicionado `Translate` ao `--disable-features` do Chromium para suprimir o popup de tradução do Google;
---
This commit is contained in:
@@ -31,13 +31,16 @@ All processes are managed by Supervisord. The web UI is a Next.js app that contr
|
|||||||
|
|
||||||
- **Stream any URL** — if it loads in a browser, it streams
|
- **Stream any URL** — if it loads in a browser, it streams
|
||||||
- **Dashboard with live thumbnails** — captured directly from the Xvfb display, refreshable on demand
|
- **Dashboard with live thumbnails** — captured directly from the Xvfb display, refreshable on demand
|
||||||
|
- **Scalable card sizes** — mini/sm/md/lg sizes scale all card elements proportionally (buttons, text, icons, padding)
|
||||||
- **Inline VNC** — inspect any stream's virtual display without leaving the UI (`/vnc/{id}`)
|
- **Inline VNC** — inspect any stream's virtual display without leaving the UI (`/vnc/{id}`)
|
||||||
- **Autologin with CDP detection** — configure credentials per stream; on restart, queries Chrome DevTools Protocol to skip login if the session is still alive
|
- **Autologin with CDP detection** — configure credentials per stream; on restart, queries Chrome DevTools Protocol to skip login if the session is still alive
|
||||||
- **Persistent desired state** — streams remember if they were running or stopped and restore automatically on container restart
|
- **Persistent desired state** — streams remember if they were running or stopped and restore automatically on container restart
|
||||||
- **Optional authentication** — set `AUTH_USER` + `AUTH_PASS` to password-protect the entire UI; rolling 30-day session, no login required while active
|
- **Optional authentication** — set `AUTH_USER` + `AUTH_PASS` to password-protect the entire UI; rolling 30-day session, no login required while active
|
||||||
- **Fully configurable encoding** — resolution, scale, FPS, bitrate, preset, tune, GOP, threads, all per stream
|
- **Fully configurable encoding** — resolution, scale, FPS, bitrate, preset, tune, GOP, threads, all per stream
|
||||||
- **GPU acceleration** — optional per-stream Chromium GPU flag (disabled by default for container compatibility)
|
- **GPU acceleration** — optional per-stream Chromium GPU flag (disabled by default for container compatibility)
|
||||||
- **Built-in HLS player** — watch any stream in the browser; also serves a standalone embeddable HTML page per stream
|
- **Built-in HLS player** — watch any stream in the browser via a standalone HTML page optimized for TVs (Back + Mute buttons, reconnect on stall, direct MediaMTX connection when available)
|
||||||
|
- **Per-card Pure mode** — toggle in the card menu to open Play Stream as a raw `.m3u8` link or Run HTML as a minimal `.html` page with no UI; works with native players and TV browsers
|
||||||
|
- **Per-card new tab** — toggle to open any button in a new tab instead of navigating in place; both settings are per-card and saved in the browser
|
||||||
|
|
||||||
## Platform Support
|
## Platform Support
|
||||||
|
|
||||||
@@ -103,9 +106,13 @@ Each stream gets a slug ID you define (e.g. `grafana-prod`):
|
|||||||
| Protocol | URL |
|
| Protocol | URL |
|
||||||
|----------|-----|
|
|----------|-----|
|
||||||
| RTMP ingest | `rtmp://<host>:1935/live/<id>` |
|
| RTMP ingest | `rtmp://<host>:1935/live/<id>` |
|
||||||
| HLS manifest | `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 |
|
||||||
|
| HTML player | `http://<host>:3000/player/<id>.html` — 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.
|
||||||
|
|
||||||
## Stream Configuration
|
## Stream Configuration
|
||||||
|
|
||||||
| Field | Default | Description |
|
| Field | Default | Description |
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/player/:id.html",
|
||||||
|
destination: "/api/player-html/:id",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
@@ -27,7 +27,7 @@ command=bash -c "rm -rf \
|
|||||||
--disable-background-timer-throttling \
|
--disable-background-timer-throttling \
|
||||||
--remote-debugging-port={{DEBUG_PORT}} \
|
--remote-debugging-port={{DEBUG_PORT}} \
|
||||||
--password-store=basic \
|
--password-store=basic \
|
||||||
--disable-features=PasswordManagerRedesign,PasswordSuggestions \
|
--disable-features=PasswordManagerRedesign,PasswordSuggestions,Translate \
|
||||||
'{{STREAM_URL}}'"
|
'{{STREAM_URL}}'"
|
||||||
environment=DISPLAY={{DISPLAY}}
|
environment=DISPLAY={{DISPLAY}}
|
||||||
autorestart=true
|
autorestart=true
|
||||||
|
|||||||
@@ -16,7 +16,15 @@ export async function GET(req: NextRequest, { params }: Ctx) {
|
|||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
const ct = res.headers.get("content-type")
|
const ct = res.headers.get("content-type")
|
||||||
if (ct) headers.set("content-type", ct)
|
if (ct) headers.set("content-type", ct)
|
||||||
headers.set("cache-control", "no-cache")
|
|
||||||
|
const cl = res.headers.get("content-length")
|
||||||
|
if (cl) headers.set("content-length", cl)
|
||||||
|
const ar = res.headers.get("accept-ranges")
|
||||||
|
if (ar) headers.set("accept-ranges", ar)
|
||||||
|
|
||||||
|
// .ts segments are immutable — cache them; playlists must stay fresh
|
||||||
|
const isSegment = path[path.length - 1]?.endsWith(".ts")
|
||||||
|
headers.set("cache-control", isSegment ? "public, max-age=300, immutable" : "no-cache, no-store")
|
||||||
|
|
||||||
return new NextResponse(res.body, { status: 200, headers })
|
return new NextResponse(res.body, { status: 200, headers })
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -26,14 +26,20 @@ export async function GET(_req: NextRequest, { params }: Ctx) {
|
|||||||
<div id="msg"></div>
|
<div id="msg"></div>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
var src='/api/hls/live/${id}/index.m3u8';
|
var streamId='${id}';
|
||||||
|
var proxyUrl='/api/hls/live/'+streamId+'/index.m3u8';
|
||||||
|
var directUrl='http://'+window.location.hostname+':8888/live/'+streamId+'/index.m3u8';
|
||||||
|
var activeSrc=proxyUrl;
|
||||||
var hls;
|
var hls;
|
||||||
|
|
||||||
function showMsg(t){
|
function showMsg(t){
|
||||||
var m=document.getElementById('msg');
|
var m=document.getElementById('msg');
|
||||||
m.textContent=t;m.style.display='block';
|
m.textContent=t;m.style.display='block';
|
||||||
setTimeout(function(){m.style.display='none';},4000);
|
setTimeout(function(){m.style.display='none';},4000);
|
||||||
}
|
}
|
||||||
function load(){
|
|
||||||
|
function load(src){
|
||||||
|
activeSrc=src;
|
||||||
if(hls)hls.destroy();
|
if(hls)hls.destroy();
|
||||||
hls=new Hls({
|
hls=new Hls({
|
||||||
liveSyncDurationCount:2,liveMaxLatencyDurationCount:4,
|
liveSyncDurationCount:2,liveMaxLatencyDurationCount:4,
|
||||||
@@ -44,16 +50,20 @@ export async function GET(_req: NextRequest, { params }: Ctx) {
|
|||||||
hls.attachMedia(document.getElementById('v'));
|
hls.attachMedia(document.getElementById('v'));
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED,function(){document.getElementById('v').play();});
|
hls.on(Hls.Events.MANIFEST_PARSED,function(){document.getElementById('v').play();});
|
||||||
hls.on(Hls.Events.ERROR,function(e,d){
|
hls.on(Hls.Events.ERROR,function(e,d){
|
||||||
if(d.fatal){showMsg('Error: '+d.type+' — reconnecting...');setTimeout(load,3000);}
|
if(d.fatal){showMsg('Erro: '+d.type+' — reconectando...');setTimeout(function(){load(activeSrc);},3000);}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var last=0;
|
var last=0;
|
||||||
setInterval(function(){
|
setInterval(function(){
|
||||||
var v=document.getElementById('v');
|
var v=document.getElementById('v');
|
||||||
if(v.currentTime===last&&!v.paused){showMsg('Stream stalled — reloading...');load();}
|
if(v.currentTime===last&&!v.paused){showMsg('Stream travada — recarregando...');load(activeSrc);}
|
||||||
last=v.currentTime;
|
last=v.currentTime;
|
||||||
},10000);
|
},10000);
|
||||||
load();
|
|
||||||
|
fetch(directUrl,{method:'HEAD',signal:AbortSignal.timeout(2000)})
|
||||||
|
.then(function(){load(directUrl);})
|
||||||
|
.catch(function(){load(proxyUrl);});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
@@ -129,7 +129,7 @@ function PlayerInner() {
|
|||||||
<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("/")} />
|
||||||
{mode === "hls" && <VideoPlayer src={streamSrc} controls />}
|
{mode === "hls" && <VideoPlayer src={streamSrc} controls />}
|
||||||
{mode === "html" && <iframe src={`/player-static/${id}`} className="w-screen h-screen border-0" allowFullScreen />}
|
{mode === "html" && <iframe src={`/static/${id}`} className="w-screen h-screen border-0" allowFullScreen />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
|
type Ctx = { params: Promise<{ id: string }> }
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: Ctx) {
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{background:#000;overflow:hidden;width:100%;height:100%}
|
||||||
|
video{width:100vw;height:100vh;display:block;object-fit:contain}
|
||||||
|
#msg{
|
||||||
|
position:fixed;top:16px;left:50%;transform:translateX(-50%);
|
||||||
|
background:rgba(0,0,0,0.75);color:#fff;padding:8px 20px;
|
||||||
|
border-radius:8px;font-family:sans-serif;font-size:34px;
|
||||||
|
display:none;z-index:9
|
||||||
|
}
|
||||||
|
#back,#mute{
|
||||||
|
position:fixed;z-index:20;
|
||||||
|
display:flex;align-items:center;gap:8px;
|
||||||
|
font-size:1.1rem;color:#fff;background:rgba(0,0,0,0.4);
|
||||||
|
border:none;padding:12px 20px;border-radius:10px;
|
||||||
|
cursor:pointer;transition:opacity 0.4s;
|
||||||
|
}
|
||||||
|
#back{top:20px;left:20px;}
|
||||||
|
#mute{bottom:20px;right:20px;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<video id="v" autoplay playsinline></video>
|
||||||
|
<div id="msg"></div>
|
||||||
|
<button id="back" onclick="history.back()">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button id="mute" onclick="toggleMute()">
|
||||||
|
<span id="mute-icon"></span>
|
||||||
|
</button>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"></script>
|
||||||
|
<script>
|
||||||
|
var streamId='${id}';
|
||||||
|
var proxyUrl='/api/hls/live/'+streamId+'/index.m3u8';
|
||||||
|
var directUrl='http://'+window.location.hostname+':8888/live/'+streamId+'/index.m3u8';
|
||||||
|
var activeSrc=proxyUrl;
|
||||||
|
var hls;
|
||||||
|
|
||||||
|
var SVG_ON='<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>';
|
||||||
|
var SVG_OFF='<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>';
|
||||||
|
|
||||||
|
function updateMuteIcon(muted){
|
||||||
|
document.getElementById('mute-icon').innerHTML=muted?SVG_OFF:SVG_ON;
|
||||||
|
}
|
||||||
|
function toggleMute(){
|
||||||
|
var v=document.getElementById('v');
|
||||||
|
v.muted=!v.muted;
|
||||||
|
updateMuteIcon(v.muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMsg(t){
|
||||||
|
var m=document.getElementById('msg');
|
||||||
|
m.textContent=t;m.style.display='block';
|
||||||
|
setTimeout(function(){m.style.display='none';},4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both buttons fade out after 5s, reappear on any interaction
|
||||||
|
var backBtn=document.getElementById('back');
|
||||||
|
var muteBtn=document.getElementById('mute');
|
||||||
|
var uiTimer;
|
||||||
|
function showUI(){
|
||||||
|
backBtn.style.opacity='1';muteBtn.style.opacity='1';
|
||||||
|
clearTimeout(uiTimer);
|
||||||
|
uiTimer=setTimeout(function(){backBtn.style.opacity='0';muteBtn.style.opacity='0';},5000);
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove',showUI);
|
||||||
|
showUI();
|
||||||
|
|
||||||
|
function startHls(src){
|
||||||
|
activeSrc=src;
|
||||||
|
if(hls)hls.destroy();
|
||||||
|
hls=new Hls({
|
||||||
|
liveSyncDurationCount:2,liveMaxLatencyDurationCount:4,
|
||||||
|
manifestLoadingTimeOut:10000,manifestLoadingMaxRetry:10,
|
||||||
|
fragLoadingTimeOut:10000,fragLoadingMaxRetry:10
|
||||||
|
});
|
||||||
|
hls.loadSource(src);
|
||||||
|
hls.attachMedia(document.getElementById('v'));
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED,function(){
|
||||||
|
var v=document.getElementById('v');
|
||||||
|
var p=v.play();
|
||||||
|
if(p)p.catch(function(){
|
||||||
|
v.muted=true;
|
||||||
|
updateMuteIcon(true);
|
||||||
|
v.play();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
hls.on(Hls.Events.ERROR,function(e,d){
|
||||||
|
if(d.fatal){showMsg('Error: '+d.type+' — reconnecting...');setTimeout(function(){startHls(activeSrc);},3000);}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMuteIcon(false);
|
||||||
|
|
||||||
|
// Try direct MediaMTX first (lower latency, avoids proxy buffering).
|
||||||
|
// Falls back to proxy if port 8888 is not reachable from this client.
|
||||||
|
fetch(directUrl,{method:'HEAD',signal:AbortSignal.timeout(2000)})
|
||||||
|
.then(function(){startHls(directUrl);})
|
||||||
|
.catch(function(){startHls(proxyUrl);});
|
||||||
|
|
||||||
|
var last=0;
|
||||||
|
setInterval(function(){
|
||||||
|
var v=document.getElementById('v');
|
||||||
|
if(v.currentTime===last&&!v.paused){showMsg('Stream stalled — reloading...');startHls(activeSrc);}
|
||||||
|
last=v.currentTime;
|
||||||
|
},10000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
return new NextResponse(html, {
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -19,7 +19,8 @@ interface Props {
|
|||||||
isDragging?: boolean
|
isDragging?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status, localStatus }: { status?: Record<string, string>; localStatus?: string | null }) {
|
function StatusBadge({ status, localStatus, cardSize = "md" }: { status?: Record<string, string>; localStatus?: string | null; cardSize?: "mini" | "sm" | "md" | "lg" }) {
|
||||||
|
const sc = SCALE[cardSize]
|
||||||
const label = localStatus ?? (
|
const label = localStatus ?? (
|
||||||
status?.ffmpeg === "RUNNING" ? "running" :
|
status?.ffmpeg === "RUNNING" ? "running" :
|
||||||
status?.ffmpeg === "STARTING" ? "starting" :
|
status?.ffmpeg === "STARTING" ? "starting" :
|
||||||
@@ -33,8 +34,8 @@ function StatusBadge({ status, localStatus }: { status?: Record<string, string>;
|
|||||||
label === "stopping" ? "bg-orange-500" :
|
label === "stopping" ? "bg-orange-500" :
|
||||||
label === "error" ? "bg-red-500" : "bg-zinc-500"
|
label === "error" ? "bg-red-500" : "bg-zinc-500"
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground whitespace-nowrap">
|
<span className={cn("flex items-center gap-1.5 text-muted-foreground whitespace-nowrap", sc.meta)}>
|
||||||
<Circle className={cn("w-2 h-2 fill-current shrink-0", color)} />
|
<Circle className={cn("fill-current shrink-0", color, sc.dot)} />
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -58,6 +59,21 @@ function copyToClipboard(text: string) {
|
|||||||
|
|
||||||
const CARD_WIDTHS = { mini: "max-w-[200px]", sm: "max-w-[240px]", md: "max-w-[300px]", lg: "max-w-[380px]" }
|
const CARD_WIDTHS = { mini: "max-w-[200px]", sm: "max-w-[240px]", md: "max-w-[300px]", lg: "max-w-[380px]" }
|
||||||
|
|
||||||
|
const SCALE = {
|
||||||
|
mini: { card: "p-2 gap-2", name: "text-xs", meta: "text-[10px]", btn: "text-[10px] px-2 py-1 gap-1.5", btnIcon: "w-2.5 h-2.5", menuIcon: "w-3.5 h-3.5", dot: "w-1.5 h-1.5" },
|
||||||
|
sm: { card: "p-2.5 gap-2", name: "text-xs", meta: "text-[10px]", btn: "text-xs px-2.5 py-1.5 gap-1.5", btnIcon: "w-2.5 h-2.5", menuIcon: "w-3.5 h-3.5", dot: "w-1.5 h-1.5" },
|
||||||
|
md: { card: "p-3 gap-2.5", name: "text-sm", meta: "text-xs", btn: "text-xs px-3 py-2 gap-2", btnIcon: "w-3 h-3", 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 }>
|
||||||
|
|
||||||
|
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">
|
||||||
@@ -89,12 +105,14 @@ 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 }: Props) {
|
||||||
|
const sc = SCALE[cardSize]
|
||||||
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 [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(() => {
|
||||||
@@ -105,6 +123,26 @@ 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) {
|
||||||
|
if (prefs.newTab) window.open(url, "_blank")
|
||||||
|
else window.location.href = url
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -125,7 +163,19 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openVNC() {
|
function openVNC() {
|
||||||
window.location.href = `/vnc/${stream.id}`
|
navigate(`/vnc/${stream.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayStream() {
|
||||||
|
navigate(prefs.pureMode
|
||||||
|
? `/api/hls/live/${stream.id}/index.m3u8`
|
||||||
|
: `/player/${stream.id}?mode=hls`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRunHtml() {
|
||||||
|
navigate(prefs.pureMode
|
||||||
|
? `/player/${stream.id}.html`
|
||||||
|
: `/static/${stream.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyRTMP() {
|
function copyRTMP() {
|
||||||
@@ -156,11 +206,7 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef
|
|||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
function play(mode: string) {
|
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}`
|
||||||
window.location.href = `/player/${stream.id}?mode=${mode}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const playBtn = "w-full flex items-center gap-2 text-xs px-3 py-2 rounded border border-border bg-muted 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"
|
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 (
|
||||||
@@ -172,7 +218,7 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef
|
|||||||
onCancel={() => setConfirmDelete(false)}
|
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")}>
|
<div className={cn("relative rounded-lg border border-border bg-card flex flex-col w-full transition-opacity", sc.card, CARD_WIDTHS[cardSize], isDragging && "opacity-40")}>
|
||||||
|
|
||||||
{/* Drag handle strip */}
|
{/* Drag handle strip */}
|
||||||
{(dragHandleListeners || dragHandleAttributes) && (
|
{(dragHandleListeners || dragHandleAttributes) && (
|
||||||
@@ -209,14 +255,14 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef
|
|||||||
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-semibold text-sm truncate">{stream.name}</p>
|
<p className={cn("font-semibold truncate", sc.name)}>{stream.name}</p>
|
||||||
<p className="text-xs text-muted-foreground font-mono truncate">{stream.id}</p>
|
<p className={cn("text-muted-foreground font-mono truncate", sc.meta)}>{stream.id}</p>
|
||||||
</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} cardSize={cardSize} />
|
||||||
<div className="relative">
|
<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={sc.menuIcon} />
|
||||||
</button>
|
</button>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<>
|
<>
|
||||||
@@ -251,6 +297,15 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", 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={() => togglePref("pureMode")} className={menuItem}>
|
||||||
|
<span className="flex-1">Pure mode</span>
|
||||||
|
<Toggle on={prefs.pureMode} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => togglePref("newTab")} className={menuItem}>
|
||||||
|
<span className="flex-1">Open in new tab</span>
|
||||||
|
<Toggle on={prefs.newTab} />
|
||||||
|
</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>
|
||||||
@@ -261,12 +316,12 @@ export function StreamCard({ stream, status, localStatus, cardSize = "md", onRef
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground truncate" title={stream.url}>{stream.url}</p>
|
<p className={cn("text-muted-foreground truncate", sc.meta)} title={stream.url}>{stream.url}</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<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={handlePlayStream} className={playBtn}><Play className={cn("shrink-0", sc.btnIcon)} /> Play Stream</button>
|
||||||
<button onClick={() => play("html")} className={playBtn}><Globe className="w-3 h-3 shrink-0" /> Run HTML</button>
|
<button onClick={handleRunHtml} className={playBtn}><Globe className={cn("shrink-0", sc.btnIcon)} /> Run HTML</button>
|
||||||
<button onClick={openVNC} className={playBtn}><Monitor className="w-3 h-3 shrink-0" /> Open VNC</button>
|
<button onClick={openVNC} className={playBtn}><Monitor className={cn("shrink-0", sc.btnIcon)} /> Open VNC</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
|
||||||
|
export function WakeLock() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pathname === "/login") return
|
||||||
|
|
||||||
|
const id = setInterval(() => {
|
||||||
|
document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, cancelable: true, clientX: Math.random(), clientY: Math.random() }))
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user