Adiciona env vars de defaults de UI e melhora compatibilidade mobile dos players

---

- Adicionado endpoint GET /api/config que expõe as env vars de defaults para o frontend;
- Adicionadas env vars DEFAULT_PURE_MODE, DEFAULT_OPEN_NEW_TAB, DEFAULT_RELOAD_CLIENT e DEFAULT_RELOAD_CLIENT_TIME ao docker-compose.yml e README.md;
- Na primeira sessão sem global-prefs em localStorage, a UI busca os defaults do servidor via /api/config;
- Adicionado fallback de HLS nativo para iOS Safari em todos os players HTML (Hls.isSupported() → v.src + v.play());
- Substituído AbortSignal.timeout() por AbortController + setTimeout para compatibilidade com iOS ≤ 15;
- Corrigido stall detection falso-positivo: verificação só dispara após currentTime > 0 (vídeo realmente iniciado);
- Adicionado height: 100dvh nos players para corrigir clipping do iOS causado pelo 100vh incluir a chrome bar do browser;
- Adicionado atributo muted no <video> do player /static/[id] para permitir autoplay mobile;
- Adicionado listener de touchstart nos botões flutuantes de Back e Mute para exibição em touch;
- Botão Back do player React ampliado no mobile (px-5 py-3 text-[1.1rem]) para paridade visual com o player HTML;
- Comentários do docker-compose.yml traduzidos para inglês e reorganizados inline;

---
This commit is contained in:
2026-04-28 10:34:04 -03:00
parent 89ddf24021
commit 8bcc269594
8 changed files with 108 additions and 40 deletions
+7 -3
View File
@@ -72,10 +72,14 @@ services:
# - /dev/dri:/dev/dri # Uncomment for Intel/AMD (vaapi or qsv) # - /dev/dri:/dev/dri # Uncomment for Intel/AMD (vaapi or qsv)
environment: environment:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo
# FFMPEG_HWACCEL: nvenc # GPU encoding: nvenc (NVIDIA), vaapi (Intel/AMD), qsv (Intel QSV) / Requires: nvenc → gpus: all | vaapi/qsv → devices: /dev/dri # AUTH_USER: admin # If set (with AUTH_PASS), enables login
# LD_LIBRARY_PATH: /usr/lib/wsl/lib # WSL2 + nvenc only: injects NVENC libs not auto-mounted by Docker
# AUTH_USER: admin # Se definido (junto com AUTH_PASS), habilita login
# AUTH_PASS: secure_password # AUTH_PASS: secure_password
DEFAULT_PURE_MODE: false # Pure mode: raw .m3u8 / minimal player (no UI chrome)
DEFAULT_OPEN_NEW_TAB: false # Open player buttons in a new tab
DEFAULT_RELOAD_CLIENT: false # Auto-reload the client player page
DEFAULT_RELOAD_CLIENT_TIME: 2 # Client auto-reload interval in minutes
# FFMPEG_HWACCEL: nvenc # GPU encoding: nvenc (NVIDIA), vaapi / qsv (Intel/AMD)
# LD_LIBRARY_PATH: /usr/lib/wsl/lib # WSL2 + nvenc only
ports: ports:
- "3000:3000" # Web UI — main entry point - "3000:3000" # Web UI — main entry point
- "127.0.0.1:6080:6080" # VNC — localhost only; remote access via tunnel/VPN - "127.0.0.1:6080:6080" # VNC — localhost only; remote access via tunnel/VPN
+11 -7
View File
@@ -11,19 +11,23 @@ services:
# - /dev/dri:/dev/dri # Uncomment for Intel/AMD (vaapi or qsv) # - /dev/dri:/dev/dri # Uncomment for Intel/AMD (vaapi or qsv)
environment: environment:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo
# FFMPEG_HWACCEL: nvenc # GPU encoding: nvenc (NVIDIA), vaapi (Intel/AMD), qsv (Intel QSV) / Requires: nvenc → gpus: all | vaapi/qsv → devices: /dev/dri # AUTH_USER: admin # If set (with AUTH_PASS), enables login
# LD_LIBRARY_PATH: /usr/lib/wsl/lib # WSL2 + nvenc only: injects NVENC libs not auto-mounted by Docker
# AUTH_USER: admin # Se definido (junto com AUTH_PASS), habilita login
# AUTH_PASS: secure_password # AUTH_PASS: secure_password
DEFAULT_PURE_MODE: false # Pure mode: raw .m3u8 / minimal player (no UI chrome)
DEFAULT_OPEN_NEW_TAB: false # Open player buttons in a new tab
DEFAULT_RELOAD_CLIENT: false # Auto-reload the client player page
DEFAULT_RELOAD_CLIENT_TIME: 2 # Client auto-reload interval in minutes
# FFMPEG_HWACCEL: nvenc # GPU encoding: nvenc (NVIDIA), vaapi / qsv (Intel/AMD)
# LD_LIBRARY_PATH: /usr/lib/wsl/lib # WSL2 + nvenc only
ports: ports:
- "3000:3000" # Web UI — main entry point - "3000:3000" # Web UI — main entry point
- "127.0.0.1:6080:6080" # VNC — localhost only; remote access via tunnel/VPN - "127.0.0.1:6080:6080" # noVNC — localhost only; expose via tunnel/VPN for remote access
# - "1935:1935" # RTMP — internal only; expose only for external ingest (e.g. OBS) # - "1935:1935" # RTMP — expose only for external ingest (e.g. OBS)
# - "8888:8888" # HLS — internal only; proxied through Next.js at /api/hls/ # - "8888:8888" # HLS — internal only; proxied through Next.js at /api/hls/
volumes: volumes:
- streams:/app/data/streams # Persistent: streams.json, chrome profiles, thumbs - streams:/app/data/streams # Persistent: streams.json, chrome profiles, thumbnails
# - ./logs:/app/data/logs # Optional: mount for external log access
# - /usr/lib/wsl/lib:/usr/lib/wsl/lib:ro # WSL2 + nvenc: exposes libnvidia-encode.so.1 # - /usr/lib/wsl/lib:/usr/lib/wsl/lib:ro # WSL2 + nvenc: exposes libnvidia-encode.so.1
# - logs:/app/data/logs # Optional
volumes: volumes:
streams: streams:
+25 -8
View File
@@ -2,10 +2,11 @@
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style> <style>
*{margin:0;padding:0;box-sizing:border-box} *{margin:0;padding:0;box-sizing:border-box}
html,body{background:#000;overflow:hidden;width:100%;height:100%} html,body{background:#000;overflow:hidden;width:100%;height:100%}
video{width:100vw;height:100vh;display:block;object-fit:contain} video{width:100vw;height:100vh;height:100dvh;display:block;object-fit:contain}
#msg{ #msg{
position:fixed;top:16px;left:50%;transform:translateX(-50%); position:fixed;top:16px;left:50%;transform:translateX(-50%);
background:rgba(0,0,0,0.75);color:#fff;padding:8px 20px; background:rgba(0,0,0,0.75);color:#fff;padding:8px 20px;
@@ -34,30 +35,46 @@
function load(src){ function load(src){
activeSrc=src; activeSrc=src;
var v=document.getElementById('v');
if(hls)hls.destroy(); if(hls)hls.destroy();
if(!Hls.isSupported()){
if(v.canPlayType('application/vnd.apple.mpegurl')){
v.src=src;
var p=v.play();
if(p)p.catch(function(){v.muted=true;v.play();});
}
return;
}
hls=new Hls({ hls=new Hls({
liveSyncDurationCount:2,liveMaxLatencyDurationCount:4, liveSyncDurationCount:2,liveMaxLatencyDurationCount:4,
manifestLoadingTimeOut:10000,manifestLoadingMaxRetry:10, manifestLoadingTimeOut:10000,manifestLoadingMaxRetry:10,
fragLoadingTimeOut:10000,fragLoadingMaxRetry:10 fragLoadingTimeOut:10000,fragLoadingMaxRetry:10
}); });
hls.loadSource(src); hls.loadSource(src);
hls.attachMedia(document.getElementById('v')); hls.attachMedia(v);
hls.on(Hls.Events.MANIFEST_PARSED,function(){document.getElementById('v').play();}); hls.on(Hls.Events.MANIFEST_PARSED,function(){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(function(){load(activeSrc);},3000);} if(d.fatal){showMsg('Error: '+d.type+' — reconnecting...');setTimeout(function(){load(activeSrc);},3000);}
}); });
} }
var last=0; var last=0,started=false;
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(activeSrc);} if(!started&&v.currentTime>0)started=true;
if(started&&v.currentTime===last&&!v.paused){
showMsg('Stream stalled — reloading...');
if(hls)load(activeSrc);
else{var s=v.src;v.src='';v.src=s;v.play().catch(function(){});}
}
last=v.currentTime; last=v.currentTime;
},10000); },10000);
fetch(directUrl,{method:'HEAD',signal:AbortSignal.timeout(2000)}) var ctrl=new AbortController();
.then(function(){load(directUrl);}) var fetchTimer=setTimeout(function(){ctrl.abort();},2000);
.catch(function(){load(proxyUrl);}); fetch(directUrl,{method:'HEAD',signal:ctrl.signal})
.then(function(){clearTimeout(fetchTimer);load(directUrl);})
.catch(function(){clearTimeout(fetchTimer);load(proxyUrl);});
// Auto-reload — reads global-prefs written by the main UI // Auto-reload — reads global-prefs written by the main UI
try { try {
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server"
export async function GET() {
return NextResponse.json({
pureMode: process.env.DEFAULT_PURE_MODE === "true",
newTab: process.env.DEFAULT_OPEN_NEW_TAB === "true",
autoReload: process.env.DEFAULT_RELOAD_CLIENT === "true",
reloadInterval: Math.max(1, Number(process.env.DEFAULT_RELOAD_CLIENT_TIME) || 2),
})
}
+5 -3
View File
@@ -61,9 +61,11 @@ export async function GET(_req: NextRequest, { params }: Ctx) {
last=v.currentTime; last=v.currentTime;
},10000); },10000);
fetch(directUrl,{method:'HEAD',signal:AbortSignal.timeout(2000)}) var ctrl=new AbortController();
.then(function(){load(directUrl);}) var fetchTimer=setTimeout(function(){ctrl.abort();},2000);
.catch(function(){load(proxyUrl);}); fetch(directUrl,{method:'HEAD',signal:ctrl.signal})
.then(function(){clearTimeout(fetchTimer);load(directUrl);})
.catch(function(){clearTimeout(fetchTimer);load(proxyUrl);});
</script> </script>
</body> </body>
</html>` </html>`
+7 -1
View File
@@ -183,7 +183,13 @@ export default function GalleryPage() {
const savedSize = localStorage.getItem("cardSize") as CardSize | null const savedSize = localStorage.getItem("cardSize") as CardSize | null
if (savedSize) setCardSize(savedSize) if (savedSize) setCardSize(savedSize)
const savedPrefs = localStorage.getItem("global-prefs") const savedPrefs = localStorage.getItem("global-prefs")
if (savedPrefs) setGlobalPrefs({ ...DEFAULT_GLOBAL_PREFS, ...JSON.parse(savedPrefs) }) if (savedPrefs) {
setGlobalPrefs({ ...DEFAULT_GLOBAL_PREFS, ...JSON.parse(savedPrefs) })
} else {
fetch("/api/config").then(r => r.json()).then((d: Partial<GlobalPrefs>) => {
setGlobalPrefs(prev => ({ ...prev, ...d }))
}).catch(() => {})
}
} catch {} } catch {}
}, []) }, [])
+16 -8
View File
@@ -24,8 +24,10 @@ function BackButton({ onClick }: { onClick: () => void }) {
useEffect(() => { useEffect(() => {
show() show()
window.addEventListener("mousemove", show) window.addEventListener("mousemove", show)
window.addEventListener("touchstart", show)
return () => { return () => {
window.removeEventListener("mousemove", show) window.removeEventListener("mousemove", show)
window.removeEventListener("touchstart", show)
if (timerRef.current) clearTimeout(timerRef.current) if (timerRef.current) clearTimeout(timerRef.current)
} }
}, [show]) }, [show])
@@ -34,7 +36,7 @@ function BackButton({ onClick }: { onClick: () => void }) {
<button <button
onClick={onClick} onClick={onClick}
style={{ opacity: visible ? 1 : 0, transition: "opacity 0.4s" }} style={{ opacity: visible ? 1 : 0, transition: "opacity 0.4s" }}
className="absolute top-4 left-4 z-20 flex items-center gap-1.5 text-sm text-white bg-black/40 px-3 py-1.5 rounded-lg cursor-pointer" className="absolute top-4 left-4 z-20 flex items-center gap-2 sm:gap-1.5 text-[1.1rem] sm:text-sm text-white bg-black/40 px-5 py-3 sm:px-3 sm:py-1.5 rounded-[10px] sm:rounded-lg cursor-pointer"
> >
<ArrowLeft className="w-4 h-4" /> Back <ArrowLeft className="w-4 h-4" /> Back
</button> </button>
@@ -86,18 +88,24 @@ function VideoPlayer({ src, controls }: { src: string; controls?: boolean }) {
if (Hls.isSupported()) { if (Hls.isSupported()) {
load(Hls) load(Hls)
} else if (v.canPlayType("application/vnd.apple.mpegurl")) { } else if (v.canPlayType("application/vnd.apple.mpegurl")) {
// Safari nativo // iOS Safari native HLS
v.src = src v.src = src
v.play() v.play().catch(() => { v.muted = true; v.play().catch(() => {}) })
} }
} }
document.head.appendChild(script) document.head.appendChild(script)
// stall detection // stall detection — only fires after video has actually played once
let last = 0 let last = 0
let started = false
const interval = setInterval(() => { const interval = setInterval(() => {
if (!v) return if (!v) return
if (v.currentTime === last && !v.paused) { showMsg("Stream stalled — reloading..."); hlsRef.current && load(window.Hls) } if (!started && v.currentTime > 0) started = true
if (started && v.currentTime === last && !v.paused) {
showMsg("Stream stalled — reloading...")
if (hlsRef.current) load(window.Hls)
else { const s = v.src; v.src = ""; v.src = s; v.play().catch(() => {}) }
}
last = v.currentTime last = v.currentTime
}, 10000) }, 10000)
@@ -110,7 +118,7 @@ function VideoPlayer({ src, controls }: { src: string; controls?: boolean }) {
return ( return (
<> <>
<video ref={videoRef} autoPlay muted playsInline controls={controls} className="w-screen h-screen object-contain bg-black" /> <video ref={videoRef} autoPlay muted playsInline controls={controls} className="w-screen h-screen object-contain bg-black" style={{ height: "100dvh" }} />
{msg && ( {msg && (
<div className="fixed top-4 left-1/2 -translate-x-1/2 bg-black/75 text-white px-5 py-2 rounded-lg text-sm z-10">{msg}</div> <div className="fixed top-4 left-1/2 -translate-x-1/2 bg-black/75 text-white px-5 py-2 rounded-lg text-sm z-10">{msg}</div>
)} )}
@@ -137,10 +145,10 @@ function PlayerInner() {
}, []) }, [])
return ( return (
<div className="relative bg-black w-screen h-screen overflow-hidden"> <div className="relative bg-black w-screen h-screen overflow-hidden" style={{ height: "100dvh" }}>
<BackButton onClick={() => router.push("/")} /> <BackButton onClick={() => router.push("/")} />
{mode === "hls" && <VideoPlayer src={streamSrc} controls />} {mode === "hls" && <VideoPlayer src={streamSrc} controls />}
{mode === "html" && <iframe src={`/static/${id}`} className="w-screen h-screen border-0" allowFullScreen />} {mode === "html" && <iframe src={`/static/${id}`} className="w-screen border-0" style={{ height: "100dvh" }} allowFullScreen />}
</div> </div>
) )
} }
+26 -9
View File
@@ -9,10 +9,11 @@ export async function GET(_req: NextRequest, { params }: Ctx) {
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style> <style>
*{margin:0;padding:0;box-sizing:border-box} *{margin:0;padding:0;box-sizing:border-box}
html,body{background:#000;overflow:hidden;width:100%;height:100%} html,body{background:#000;overflow:hidden;width:100%;height:100%}
video{width:100vw;height:100vh;display:block;object-fit:contain} video{width:100vw;height:100vh;height:100dvh;display:block;object-fit:contain}
#msg{ #msg{
position:fixed;top:16px;left:50%;transform:translateX(-50%); position:fixed;top:16px;left:50%;transform:translateX(-50%);
background:rgba(0,0,0,0.75);color:#fff;padding:8px 20px; background:rgba(0,0,0,0.75);color:#fff;padding:8px 20px;
@@ -31,7 +32,7 @@ export async function GET(_req: NextRequest, { params }: Ctx) {
</style> </style>
</head> </head>
<body> <body>
<video id="v" autoplay playsinline></video> <video id="v" autoplay muted playsinline></video>
<div id="msg"></div> <div id="msg"></div>
<button id="back" onclick="history.back()"> <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> <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>
@@ -76,20 +77,29 @@ export async function GET(_req: NextRequest, { params }: Ctx) {
uiTimer=setTimeout(function(){backBtn.style.opacity='0';muteBtn.style.opacity='0';},5000); uiTimer=setTimeout(function(){backBtn.style.opacity='0';muteBtn.style.opacity='0';},5000);
} }
document.addEventListener('mousemove',showUI); document.addEventListener('mousemove',showUI);
document.addEventListener('touchstart',showUI);
showUI(); showUI();
function startHls(src){ function startHls(src){
activeSrc=src; activeSrc=src;
var v=document.getElementById('v');
if(hls)hls.destroy(); if(hls)hls.destroy();
if(!Hls.isSupported()){
if(v.canPlayType('application/vnd.apple.mpegurl')){
v.src=src;
var p=v.play();
if(p)p.catch(function(){v.muted=true;updateMuteIcon(true);v.play();});
}
return;
}
hls=new Hls({ hls=new Hls({
liveSyncDurationCount:2,liveMaxLatencyDurationCount:4, liveSyncDurationCount:2,liveMaxLatencyDurationCount:4,
manifestLoadingTimeOut:10000,manifestLoadingMaxRetry:10, manifestLoadingTimeOut:10000,manifestLoadingMaxRetry:10,
fragLoadingTimeOut:10000,fragLoadingMaxRetry:10 fragLoadingTimeOut:10000,fragLoadingMaxRetry:10
}); });
hls.loadSource(src); hls.loadSource(src);
hls.attachMedia(document.getElementById('v')); hls.attachMedia(v);
hls.on(Hls.Events.MANIFEST_PARSED,function(){ hls.on(Hls.Events.MANIFEST_PARSED,function(){
var v=document.getElementById('v');
var p=v.play(); var p=v.play();
if(p)p.catch(function(){ if(p)p.catch(function(){
v.muted=true; v.muted=true;
@@ -106,14 +116,21 @@ export async function GET(_req: NextRequest, { params }: Ctx) {
// Try direct MediaMTX first (lower latency, avoids proxy buffering). // Try direct MediaMTX first (lower latency, avoids proxy buffering).
// Falls back to proxy if port 8888 is not reachable from this client. // Falls back to proxy if port 8888 is not reachable from this client.
fetch(directUrl,{method:'HEAD',signal:AbortSignal.timeout(2000)}) var ctrl=new AbortController();
.then(function(){startHls(directUrl);}) var fetchTimer=setTimeout(function(){ctrl.abort();},2000);
.catch(function(){startHls(proxyUrl);}); fetch(directUrl,{method:'HEAD',signal:ctrl.signal})
.then(function(){clearTimeout(fetchTimer);startHls(directUrl);})
.catch(function(){clearTimeout(fetchTimer);startHls(proxyUrl);});
var last=0; var last=0,started=false;
setInterval(function(){ setInterval(function(){
var v=document.getElementById('v'); var v=document.getElementById('v');
if(v.currentTime===last&&!v.paused){showMsg('Stream stalled — reloading...');startHls(activeSrc);} if(!started&&v.currentTime>0)started=true;
if(started&&v.currentTime===last&&!v.paused){
showMsg('Stream stalled — reloading...');
if(hls)startHls(activeSrc);
else{var s=v.src;v.src='';v.src=s;v.play().catch(function(){});}
}
last=v.currentTime; last=v.currentTime;
},10000); },10000);