404 lines
26 KiB
HTML
404 lines
26 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Vanguard Stream</title>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<style>
|
||
:root { --bg:#1e1e1e; --fg:#e6e6e6; --mut:#9a9a9a; --acc:#3d8bfd; --ok:#2ecc71; --bad:#e74c3c; --warn:#f1c40f; --box:#2a2a2a; }
|
||
html,body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.4 system-ui,sans-serif}
|
||
main{padding:12px;max-width:440px}
|
||
h1{font-size:15px;margin:0 0 10px;color:var(--mut);font-weight:600}
|
||
h1 small{font-weight:400}
|
||
label{display:block;margin:8px 0 2px;color:var(--mut);font-size:12px}
|
||
input{width:100%;box-sizing:border-box;padding:8px;border:1px solid #444;border-radius:6px;background:var(--box);color:var(--fg);font-size:14px}
|
||
input[readonly]{color:var(--mut)}
|
||
button{width:100%;margin-top:10px;padding:11px;border:0;border-radius:6px;font-size:15px;font-weight:600;cursor:pointer;color:#fff;background:var(--acc)}
|
||
button.live{background:var(--ok);color:#000}
|
||
button.dump{background:var(--bad)}
|
||
button.small{width:auto;padding:6px 10px;font-size:13px;margin:6px 6px 0 0;background:#3a3a3a}
|
||
button:disabled{opacity:.5;cursor:default}
|
||
#status{margin-top:10px;padding:8px;border-radius:6px;background:var(--box);color:var(--mut);min-height:1.4em;white-space:pre-wrap}
|
||
details{margin-top:14px;border-top:1px solid #333;padding-top:8px}
|
||
summary{color:var(--mut);cursor:pointer;font-size:12px}
|
||
.row{display:flex;gap:8px}
|
||
.row button{margin-top:10px}
|
||
.preview{font-family:ui-monospace,monospace;font-size:12px;color:var(--mut);margin-top:4px;word-break:break-all}
|
||
.preview b{color:var(--fg)}
|
||
.rule{color:var(--bad);font-size:12px;margin-top:4px}
|
||
.kv{font-family:ui-monospace,monospace;font-size:12px;background:var(--box);border-radius:6px;padding:8px;margin-top:6px;word-break:break-all}
|
||
.kv .k{color:var(--mut)}
|
||
.tok{letter-spacing:1px}
|
||
.who{color:var(--mut);font-size:12px}
|
||
/* post-Go-Live health check: what the server is actually receiving */
|
||
#health{margin-top:10px;padding:8px;border-radius:6px;font-size:13px;white-space:pre-wrap;word-break:break-word}
|
||
#health.hidden{display:none}
|
||
#health.bad{background:#43242a;color:#ffd0d0;border:1px solid var(--bad)}
|
||
#health.bad code{background:#1a1a1a;padding:1px 5px;border-radius:4px;color:var(--fg)}
|
||
#health.ok{color:var(--ok)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<h1>Vanguard Stream <small id="who" class="who"></small>
|
||
<a id="signout" href="#" class="who" style="float:right;text-decoration:underline" title="sign out of the Discord login and come back to the dock (log in again — needed after a group change)">sign out</a></h1>
|
||
|
||
<label for="title">Stream title</label>
|
||
<input id="title" placeholder="VanguardTraining" autocomplete="off">
|
||
<div class="preview">stream name: <b id="preview">—</b></div>
|
||
<div class="rule" id="rule"></div>
|
||
<button id="golive" class="live">Go Live</button>
|
||
<div class="row">
|
||
<button id="stop">Stop Stream</button>
|
||
<button id="dump" class="dump">Dump tonight</button>
|
||
</div>
|
||
<div id="status">idle</div>
|
||
|
||
<details>
|
||
<summary>Settings (saved in this dock)</summary>
|
||
<label>Ingest host</label><input id="host" placeholder="media.example.com">
|
||
<label>OBS WebSocket password</label><input id="obspw" type="password">
|
||
<label>OBS WebSocket port</label><input id="obsport" value="4455">
|
||
<label>OBS profile to stream from</label><input id="profile" value="Vanguard">
|
||
<label style="display:flex;gap:8px;align-items:center;margin-top:8px"><input type="checkbox" id="enforce" style="width:auto;margin:0"> switch OBS to this profile when the dock loads</label>
|
||
<div class="who">Encoder settings (B-frames, keyframe interval) live in an OBS profile and obs-websocket cannot change them,
|
||
but it can switch profiles. Create one named <b>Vanguard</b> once: Profile → New, then Output → Advanced → your encoder
|
||
with <b>B-frames 0</b> and keyframe interval 2 s. Go Live always switches to it; this box also does it on startup.</div>
|
||
<label>Title prefix (optional, e.g. <code>HelmetCam-</code>)</label><input id="prefix" placeholder="">
|
||
<label>Title suffix (optional; <code>{date}</code> becomes YYYY-MM-DD)</label><input id="suffix" placeholder="-{date}">
|
||
<button id="save">Save settings</button>
|
||
</details>
|
||
|
||
<details id="tcband">
|
||
<summary>Timecode band (frame-accurate multicam sync)</summary>
|
||
<div class="who" style="margin-top:6px">Adds a thin "overscan" band below your canvas with the server clock encoded in it.
|
||
The server decodes it after each stream so editors get a frame-accurate timecode track; the live portal and
|
||
editors crop the band, Jellyfin shows it as a thin strip at the bottom. OBS must not be streaming or recording.</div>
|
||
<div class="kv" id="tc_state">canvas: — · band: —</div>
|
||
<button class="small" id="tc_add">Add band + clock source</button>
|
||
<button class="small" id="tc_scene">Add clock to current scene</button>
|
||
<button class="small" id="tc_remove">Remove band</button>
|
||
</details>
|
||
|
||
<div id="health" class="hidden"></div>
|
||
<details id="manual">
|
||
<summary>Manual OBS setup (endpoints + token)</summary>
|
||
<div class="kv"><span class="k">RTMP server </span><span id="m_rtmp">—</span></div>
|
||
<div class="kv"><span class="k">Stream key </span><span id="m_key">—</span></div>
|
||
<div class="kv"><span class="k">SRT URL </span><span id="m_srt">—</span></div>
|
||
<div class="kv"><span class="k">Token </span><span id="m_tok" class="tok">•••••••••••• (hidden)</span><br><span class="k" id="m_exp"></span></div>
|
||
<button class="small" id="m_show">Show token</button>
|
||
<button class="small" id="m_copy_key">Copy stream key</button>
|
||
<button class="small" id="m_copy_srt">Copy SRT URL</button>
|
||
<button class="small" id="m_fresh">Fresh token (12 h)</button>
|
||
<div class="who" style="margin-top:6px">Tokens are minted for your Discord login and expire; the Go Live button does all of this automatically.</div>
|
||
</details>
|
||
</main>
|
||
|
||
<script>
|
||
// ---- settings --------------------------------------------------------------
|
||
const KEYS = ["host","obspw","obsport","prefix","suffix","profile"];
|
||
const BOOLS = ["enforce"];
|
||
const $ = id => document.getElementById(id);
|
||
const status = (msg, cls) => { $("status").textContent = msg; $("status").style.color = cls==="bad"?"#e74c3c":cls==="ok"?"#2ecc71":"#9a9a9a"; };
|
||
function load(){ try{ KEYS.forEach(k => { const v = localStorage.getItem("vg."+k); if (v!==null) $(k).value = v; }); BOOLS.forEach(k => { $(k).checked = localStorage.getItem("vg."+k) === "1"; }); }catch(e){} if(!$("host").value) $("host").value = location.hostname; }
|
||
function save(){ try{ KEYS.forEach(k => localStorage.setItem("vg."+k, $(k).value.trim())); BOOLS.forEach(k => localStorage.setItem("vg."+k, $(k).checked ? "1" : "0")); status("settings saved"); }catch(e){ status("could not save settings (storage blocked)", "bad"); } renderManual(); }
|
||
load(); $("save").onclick = save;
|
||
const cfg = () => Object.fromEntries(KEYS.map(k => [k, $(k).value.trim()]));
|
||
|
||
// ---- naming rules (must match the server: the sweep publishes <Title>__<timestamp>.mp4) ----------------
|
||
const today = () => { const d = new Date(); return new Date(d.getTime() - d.getTimezoneOffset()*60000).toISOString().slice(0,10); };
|
||
function buildTitle(raw){
|
||
const c = cfg();
|
||
let t = (c.prefix||"") + raw.trim() + (c.suffix||"").replace("{date}", today());
|
||
t = t.replace(/\s+/g, "-") // spaces -> dashes
|
||
.replace(/[^A-Za-z0-9_-]/g, "") // allowed characters only; no dots: <title>.webrtc is the audio mirror's name
|
||
.replace(/_{2,}/g, "_") // "__" is reserved: it separates title from timestamp in filenames
|
||
.replace(/^[.-]+/, "") // no leading dot (hidden) or dash
|
||
.slice(0, 64);
|
||
return t;
|
||
}
|
||
function ruleText(raw){
|
||
const r = [];
|
||
if (/__/.test(raw)) r.push("double underscore is reserved (becomes single)");
|
||
if (/[^A-Za-z0-9_\- ]/.test(raw)) r.push("only letters, digits, space, _ - are kept");
|
||
if (raw.trim().startsWith(".")) r.push("cannot start with a dot");
|
||
if ((cfg().prefix + raw + cfg().suffix).length > 64) r.push("longer than 64 characters: trimmed");
|
||
return r.join(" · ");
|
||
}
|
||
let me = null; // {user, token, expires}
|
||
function renderPreview(){
|
||
const t = buildTitle($("title").value);
|
||
$("preview").textContent = t ? `${me ? me.user : "<you>"}/${t}` : "—";
|
||
$("rule").textContent = ruleText($("title").value);
|
||
renderManual(t);
|
||
}
|
||
$("title").addEventListener("input", renderPreview);
|
||
["prefix","suffix"].forEach(k => $(k).addEventListener("input", renderPreview));
|
||
|
||
// ---- identity + tokens (the dock is behind Authentik: same-origin fetch carries the Discord session) -----
|
||
async function whoami(){
|
||
try {
|
||
const r = await fetch("/whoami", {credentials:"same-origin"});
|
||
const m = (await r.text()).match(/user:\s*(\S+)/); if (m) $("who").textContent = "— " + m[1];
|
||
} catch(e){}
|
||
}
|
||
async function mint(){
|
||
const r = await fetch("/me/stream-token", {credentials:"same-origin"});
|
||
const b = await r.json().catch(()=>({}));
|
||
if (!r.ok) throw new Error(b.error || `token ${r.status}`);
|
||
me = b; renderPreview(); return b;
|
||
}
|
||
let showTok = false;
|
||
function renderManual(t){
|
||
const c = cfg(); const title = (t ?? buildTitle($("title").value)) || "TITLE";
|
||
const user = me ? me.user : "<you>", tok = me ? me.token : "<token>";
|
||
$("m_rtmp").textContent = `rtmp://${c.host||location.hostname}:1935/`;
|
||
$("m_key").textContent = `${user}/${title}?user=${user}&pass=${showTok ? tok : "••••••••"}`;
|
||
$("m_srt").textContent = `srt://${c.host||location.hostname}:8890?streamid=publish:${user}/${title}:${user}:${showTok ? tok : "••••••••"}`;
|
||
$("m_tok").textContent = me ? (showTok ? me.token : "•••••••••••• (hidden)") : "— click “Fresh token”";
|
||
$("m_exp").textContent = me ? `valid until ${new Date(me.expires*1000).toLocaleString()}` : "";
|
||
}
|
||
$("m_show").onclick = () => { showTok = !showTok; $("m_show").textContent = showTok ? "Hide token" : "Show token"; renderManual(); };
|
||
$("m_fresh").onclick = async () => { try { await mint(); status("fresh token minted (12 h)", "ok"); } catch(e){ status(e.message, "bad"); } };
|
||
async function copy(text, what){ try { await navigator.clipboard.writeText(text); status(`${what} copied`, "ok"); } catch(e){ status("clipboard blocked — select the text manually", "bad"); } }
|
||
$("m_copy_key").onclick = async () => { if (!me) await mint().catch(e=>status(e.message,"bad")); if (me) copy(`${me.user}/${buildTitle($("title").value)||"TITLE"}?user=${me.user}&pass=${me.token}`, "stream key"); };
|
||
$("m_copy_srt").onclick = async () => { if (!me) await mint().catch(e=>status(e.message,"bad")); if (me) copy(`srt://${cfg().host}:8890?streamid=publish:${me.user}/${buildTitle($("title").value)||"TITLE"}:${me.user}:${me.token}`, "SRT URL"); };
|
||
$("manual").addEventListener("toggle", () => { if ($("manual").open) renderManual(); });
|
||
|
||
// ---- obs-websocket v5 ------------------------------------------------------
|
||
async function sha256b64(s){ const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); return btoa(String.fromCharCode(...new Uint8Array(h))); }
|
||
function obs(c){
|
||
return new Promise((resolve, reject) => {
|
||
const ws = new WebSocket(`ws://127.0.0.1:${c.obsport||4455}`);
|
||
const pending = new Map(); let n = 0;
|
||
ws.onerror = () => reject(new Error("cannot reach OBS WebSocket (enable it in Tools > WebSocket Server Settings)"));
|
||
ws.onmessage = async ev => {
|
||
const m = JSON.parse(ev.data);
|
||
if (m.op === 0) {
|
||
let auth;
|
||
if (m.d.authentication) { const secret = await sha256b64(c.obspw + m.d.authentication.salt); auth = await sha256b64(secret + m.d.authentication.challenge); }
|
||
ws.send(JSON.stringify({op:1, d:{rpcVersion:1, authentication:auth, eventSubscriptions:0}}));
|
||
} else if (m.op === 2) {
|
||
resolve({ call(type, data){ return new Promise((res, rej) => { const id = String(++n); pending.set(id, {res, rej}); ws.send(JSON.stringify({op:6, d:{requestType:type, requestId:id, requestData:data||{}}})); }); }, close(){ ws.close(); } });
|
||
} else if (m.op === 7) {
|
||
const p = pending.get(m.d.requestId); pending.delete(m.d.requestId); if (!p) return;
|
||
m.d.requestStatus.result ? p.res(m.d.responseData) : p.rej(new Error(m.d.requestStatus.comment || m.d.requestType + " failed"));
|
||
}
|
||
};
|
||
ws.onclose = ev => { if (ev.code === 4009) reject(new Error("OBS WebSocket password wrong")); };
|
||
});
|
||
}
|
||
|
||
// ---- profile enforcement ----------------------------------------------------------------------------
|
||
// Encoder settings (B-frames above all: MediaMTX refuses them over WebRTC) cannot be set through
|
||
// obs-websocket in Advanced output mode — they live in the profile's streamEncoder.json, which the protocol
|
||
// does not expose. Profiles can be switched, though, so the setting is enforced by making sure OBS is on a
|
||
// profile that already has it. The post-Go-Live health check then verifies the actual encode.
|
||
// Returns true when OBS is on the wanted profile, false (with a status line saying why) otherwise.
|
||
async function ensureProfile(o, c) {
|
||
if (!c.profile) return true;
|
||
const { profiles, currentProfileName } = await o.call("GetProfileList");
|
||
if (!profiles.includes(c.profile)) {
|
||
status(`OBS has no profile named "${c.profile}". Create it once (Profile → New) with B-frames 0, or clear the name in settings. Streaming on "${currentProfileName}".`, "bad");
|
||
return false;
|
||
}
|
||
if (currentProfileName === c.profile) return true;
|
||
const st = await o.call("GetStreamStatus");
|
||
if (st.outputActive) { status(`OBS is on "${currentProfileName}" but streaming; not switching mid-stream`, "bad"); return false; }
|
||
await o.call("SetCurrentProfile", { profileName: c.profile });
|
||
await new Promise(r => setTimeout(r, 800)); // OBS reloads its outputs on a profile switch
|
||
status(`OBS switched to profile "${c.profile}"`, "ok");
|
||
return true;
|
||
}
|
||
|
||
// ---- timecode band (obs-websocket) -------------------------------------------------------------------
|
||
const CLOCK_INPUT = "Vanguard Clock";
|
||
const bandFor = w => { let b = Math.round(w / 160); return b + (b % 2); };
|
||
async function tcState() {
|
||
try {
|
||
const o = await obs(cfg());
|
||
const v = await o.call("GetVideoSettings");
|
||
const inputs = await o.call("GetInputList", { inputKind: "browser_source" });
|
||
const has = inputs.inputs.some(i => i.inputName === CLOCK_INPUT);
|
||
const band = bandFor(v.baseWidth);
|
||
const hasBand = (v.baseHeight % 2 === 0) && [720, 1080, 1440, 2160].some(h => v.baseHeight === h + bandFor(v.baseWidth));
|
||
o.close();
|
||
$("tc_state").textContent = `canvas: ${v.baseWidth}×${v.baseHeight} (output ${v.outputWidth}×${v.outputHeight}) · band: ${hasBand ? band + " px present" : "not set"} · clock source: ${has ? "present" : "absent"}`;
|
||
return { v, has, band, hasBand };
|
||
} catch (e) { $("tc_state").textContent = "OBS not reachable: " + e.message; return null; }
|
||
}
|
||
async function notStreaming(o) {
|
||
const s = await o.call("GetStreamStatus"), r = await o.call("GetRecordStatus");
|
||
if (s.outputActive || r.outputActive) throw new Error("stop streaming/recording first — the canvas size cannot change while an output is active");
|
||
}
|
||
async function ensureClockInput(o, width, band, scene) {
|
||
const url = `${location.origin}/dock/clock/`;
|
||
const inputs = await o.call("GetInputList", { inputKind: "browser_source" });
|
||
const settings = { url, width, height: band, fps_custom: true, fps: 60, shutdown: false, restart_when_active: false, reroute_audio: false };
|
||
if (!inputs.inputs.some(i => i.inputName === CLOCK_INPUT)) {
|
||
await o.call("CreateInput", { sceneName: scene, inputName: CLOCK_INPUT, inputKind: "browser_source", inputSettings: settings, sceneItemEnabled: true });
|
||
} else {
|
||
await o.call("SetInputSettings", { inputName: CLOCK_INPUT, inputSettings: settings, overlay: true });
|
||
const items = await o.call("GetSceneItemList", { sceneName: scene });
|
||
if (!items.sceneItems.some(i => i.sourceName === CLOCK_INPUT)) await o.call("CreateSceneItem", { sceneName: scene, sourceName: CLOCK_INPUT, sceneItemEnabled: true });
|
||
}
|
||
const items = await o.call("GetSceneItemList", { sceneName: scene });
|
||
const it = items.sceneItems.find(i => i.sourceName === CLOCK_INPUT);
|
||
if (it) {
|
||
const v = await o.call("GetVideoSettings");
|
||
await o.call("SetSceneItemTransform", { sceneName: scene, sceneItemId: it.sceneItemId,
|
||
sceneItemTransform: { positionX: 0, positionY: v.baseHeight - band, scaleX: 1, scaleY: 1, boundsType: "OBS_BOUNDS_NONE", cropLeft: 0, cropRight: 0, cropTop: 0, cropBottom: 0 } });
|
||
await o.call("SetSceneItemLocked", { sceneName: scene, sceneItemId: it.sceneItemId, sceneItemLocked: true });
|
||
}
|
||
}
|
||
$("tc_add").onclick = async () => {
|
||
try {
|
||
const o = await obs(cfg()); await notStreaming(o);
|
||
const v = await o.call("GetVideoSettings"); const band = bandFor(v.baseWidth);
|
||
const gameH = [720, 1080, 1440, 2160].find(h => v.baseHeight === h + band) ? v.baseHeight - band : v.baseHeight;
|
||
const scale = v.outputHeight / v.baseHeight;
|
||
await o.call("SetVideoSettings", { baseWidth: v.baseWidth, baseHeight: gameH + band, outputWidth: v.outputWidth, outputHeight: Math.round((gameH + band) * scale / 2) * 2 });
|
||
const scene = (await o.call("GetCurrentProgramScene")).currentProgramSceneName;
|
||
await ensureClockInput(o, v.baseWidth, band, scene); o.close();
|
||
status(`band added: canvas ${v.baseWidth}×${gameH + band}, clock source in "${scene}"`, "ok"); tcState();
|
||
} catch (e) { status(e.message, "bad"); }
|
||
};
|
||
$("tc_scene").onclick = async () => {
|
||
try {
|
||
const o = await obs(cfg()); const v = await o.call("GetVideoSettings"); const band = bandFor(v.baseWidth);
|
||
const scene = (await o.call("GetCurrentProgramScene")).currentProgramSceneName;
|
||
await ensureClockInput(o, v.baseWidth, band, scene); o.close(); status(`clock source placed in "${scene}"`, "ok"); tcState();
|
||
} catch (e) { status(e.message, "bad"); }
|
||
};
|
||
$("tc_remove").onclick = async () => {
|
||
try {
|
||
const o = await obs(cfg()); await notStreaming(o);
|
||
const v = await o.call("GetVideoSettings"); const band = bandFor(v.baseWidth);
|
||
const gameH = [720, 1080, 1440, 2160].find(h => v.baseHeight === h + band) ? v.baseHeight - band : v.baseHeight;
|
||
try { await o.call("RemoveInput", { inputName: CLOCK_INPUT }); } catch {}
|
||
const scale = v.outputHeight / v.baseHeight;
|
||
await o.call("SetVideoSettings", { baseWidth: v.baseWidth, baseHeight: gameH, outputWidth: v.outputWidth, outputHeight: Math.round(gameH * scale / 2) * 2 });
|
||
o.close(); status(`band removed: canvas ${v.baseWidth}×${gameH}`, "ok"); tcState();
|
||
} catch (e) { status(e.message, "bad"); }
|
||
};
|
||
$("tcband").addEventListener("toggle", () => { if ($("tcband").open) tcState(); });
|
||
|
||
// ---- actions ---------------------------------------------------------------
|
||
$("golive").onclick = async () => {
|
||
const c = cfg(), title = buildTitle($("title").value);
|
||
if (!title) return status("type a title first", "bad");
|
||
if (!c.host) return status("set the ingest host in settings", "bad");
|
||
$("golive").disabled = true;
|
||
try {
|
||
status("getting a stream token…");
|
||
const t = await mint(); // fresh token every Go Live; nothing stored
|
||
status("connecting to OBS…");
|
||
const o = await obs(c);
|
||
await ensureProfile(o, c); // the Vanguard profile carries B-frames 0; switch first
|
||
await o.call("SetStreamServiceSettings", { streamServiceType: "rtmp_custom",
|
||
streamServiceSettings: { server: `rtmp://${c.host}:1935/`, key: `${t.user}/${title}?user=${encodeURIComponent(t.user)}&pass=${encodeURIComponent(t.token)}`, use_auth: false } });
|
||
// B-frames make the stream unwatchable on the portal (MediaMTX refuses them over WebRTC). obs-websocket
|
||
// can only set encoder options in Simple output mode with x264, so do that much here; everything else is
|
||
// caught after start by checkHealth(), which reads the actual recording on the server.
|
||
try {
|
||
const mode = (await o.call("GetProfileParameter", { parameterCategory: "Output", parameterName: "Mode" })).parameterValue;
|
||
if (mode === "Simple") {
|
||
const enc = (await o.call("GetProfileParameter", { parameterCategory: "SimpleOutput", parameterName: "StreamEncoder" })).parameterValue || "";
|
||
if (/x264/i.test(enc)) {
|
||
const cur = (await o.call("GetProfileParameter", { parameterCategory: "SimpleOutput", parameterName: "x264Settings" })).parameterValue || "";
|
||
if (!/\b(bf|bframes)=0\b/.test(cur)) {
|
||
await o.call("SetProfileParameter", { parameterCategory: "SimpleOutput", parameterName: "x264Settings", parameterValue: (cur + " bf=0").trim() });
|
||
status("set x264 bf=0 in OBS (Simple output) so viewers can watch over WebRTC");
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { /* older obs-websocket, or a locked profile: the health check will say if it matters */ }
|
||
const st = await o.call("GetStreamStatus");
|
||
if (st.outputActive) { await o.call("StopStream"); await new Promise(r => setTimeout(r, 1500)); }
|
||
await o.call("StartStream"); o.close();
|
||
status(`LIVE → ${t.user}/${title}`, "ok");
|
||
checkHealth(90);
|
||
} catch (e) { status(e.message, "bad"); }
|
||
$("golive").disabled = false;
|
||
};
|
||
// After Go Live, ask the server what it is actually receiving. The recording's first fragments reveal the
|
||
// B-frame depth within seconds, and B-frames mean every WebRTC viewer is refused. Polls until the stream
|
||
// shows up and is judged, or for `seconds`, whichever comes first.
|
||
let healthTimer = null;
|
||
async function checkHealth(seconds) {
|
||
clearInterval(healthTimer);
|
||
const until = Date.now() + seconds * 1000, box = $("health");
|
||
box.className = "who"; box.textContent = "checking what the server is receiving…";
|
||
healthTimer = setInterval(async () => {
|
||
try {
|
||
const r = await fetch("/me/stream-health", { credentials: "same-origin" });
|
||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||
const d = await r.json();
|
||
const judged = (d.streams || []).filter(s => s.bframes != null);
|
||
if (judged.length) {
|
||
clearInterval(healthTimer);
|
||
const bad = judged.filter(s => !s.webrtc_ok);
|
||
if (bad.length) {
|
||
box.className = "bad";
|
||
box.innerHTML = `<b>Viewers cannot watch this stream over WebRTC.</b> Your encoder is sending B-frames `
|
||
+ `(${bad.map(s => `${s.profile || "H.264"} ${s.res || ""}, B-frame depth ${s.bframes}`).join("; ")}). `
|
||
+ `In OBS: Output → Advanced → <b>x264</b>: add <code>bf=0</code> to the options; <b>NVENC</b>: Max B-frames = 0; `
|
||
+ `<b>AMF/QSV</b>: B-frames = 0. Then stop and start the stream. The recording itself is fine.`;
|
||
} else {
|
||
box.className = "ok";
|
||
box.textContent = `server receiving ${judged.map(s => `${s.res || ""} ${s.fps ? s.fps + " fps" : ""}`.trim()).join(", ")} with no B-frames — WebRTC viewers are good`;
|
||
}
|
||
return;
|
||
}
|
||
} catch (e) { /* not up yet, or the dock is not behind the proxy; keep trying until the deadline */ }
|
||
if (Date.now() > until) { clearInterval(healthTimer); box.className = "who"; box.textContent = "could not confirm what the server is receiving (is the stream up?)"; }
|
||
}, 6000);
|
||
}
|
||
$("stop").onclick = async () => { try { const o = await obs(cfg()); await o.call("StopStream"); o.close(); status("stream stopped"); } catch (e) { status(e.message, "bad"); } };
|
||
|
||
let armed = 0;
|
||
$("dump").onclick = async () => {
|
||
try {
|
||
if (Date.now() - armed > 10000) {
|
||
status("checking what tonight contains…");
|
||
const dry = await (await fetch("/dump", {credentials:"same-origin"})).json();
|
||
if (dry.error) throw new Error(dry.error);
|
||
if (!dry.files.length) return status(`nothing recorded for ${dry.night}`);
|
||
armed = Date.now(); $("dump").textContent = "Click again to confirm";
|
||
setTimeout(() => { $("dump").textContent = "Dump tonight"; armed = 0; }, 10000);
|
||
return status(`${dry.files.length} file(s), ${dry.gb} GB from the night of ${dry.night}.\nClick Dump again within 10 s to confirm. Undo window: 3 days (ask an admin).`);
|
||
}
|
||
armed = 0; $("dump").textContent = "Dump tonight";
|
||
const r = await (await fetch("/dump", {method:"POST", headers:{"Content-Type":"application/json"}, body:"{}", credentials:"same-origin"})).json();
|
||
if (r.error) throw new Error(r.error);
|
||
status(`dumped ${r.moved} file(s), ${r.gb} GB (recoverable until ${r.undo_until})`, "ok");
|
||
} catch (e) { status("dump failed: " + e.message, "bad"); }
|
||
};
|
||
|
||
|
||
$("signout").onclick = async e => {
|
||
e.preventDefault(); status("signing out…");
|
||
// Authentik's sign-out redirects to the site root (copyparty); do it in the background, then reload the dock,
|
||
// which sends us through the Discord login and back here.
|
||
try { await fetch("/outpost.goauthentik.io/sign_out", {credentials:"same-origin", mode:"no-cors", redirect:"follow"}); } catch(e){}
|
||
// also end the Authentik login itself (otherwise the next visit re-authorises silently); the flow returns to this host
|
||
const auth = "https://" + location.hostname.replace(/^[^.]+\./, "auth.") + "/if/flow/default-invalidation-flow/?next=" + encodeURIComponent(location.origin + "/dock/");
|
||
setTimeout(() => location.replace(auth), 400);
|
||
};
|
||
// On load: who am I, and mint a token right away so the preview and the manual section show real values.
|
||
(async () => {
|
||
await whoami();
|
||
try { await mint(); status("ready"); }
|
||
catch (e) { status(e.message, "bad"); $("m_tok").textContent = "— " + e.message; }
|
||
// "switch OBS to this profile when the dock loads": inside OBS the dock loads with OBS, so this is startup
|
||
if ($("enforce").checked) {
|
||
try { const o = await obs(cfg()); await ensureProfile(o, cfg()); o.close(); }
|
||
catch (e) { status(`profile check on startup: ${e.message}`, "bad"); }
|
||
}
|
||
})();
|
||
renderPreview();
|
||
</script>
|
||
</body>
|
||
</html>
|