Harden LA publishers and add multi-relay guide
Some checks are pending
ci-gates / checks (push) Waiting to run
deploy-cloudflare / checks (push) Waiting to run
deploy-cloudflare / deploy (push) Blocked by required conditions

This commit is contained in:
Conrad Kramer 2026-06-10 01:28:15 -07:00
parent 5d6f77f868
commit cfc4902016
No known key found for this signature in database
13 changed files with 1430 additions and 402 deletions

View file

@ -39,6 +39,41 @@ function normalizeName(s) {
return (s || "").trim();
}
function entryRelays(entry) {
const relays = [];
const addRelay = (relay) => {
if (!relay?.relay_url || !relay.broadcast_name) return;
const normalized = {
relay_url: normalizeRelayUrl(relay.relay_url),
broadcast_name: normalizeName(relay.broadcast_name),
track_name: normalizeName(relay.track_name || "video0.m4s"),
};
const key = `${normalized.relay_url}\n${normalized.broadcast_name}\n${normalized.track_name}`;
if (!relays.some((existing) => `${existing.relay_url}\n${existing.broadcast_name}\n${existing.track_name}` === key)) {
relays.push(normalized);
}
};
addRelay({
relay_url: entry?.relay_url,
broadcast_name: entry?.broadcast_name,
track_name: entry?.track_name,
});
if (Array.isArray(entry?.relays)) {
entry.relays.forEach(addRelay);
}
return relays;
}
function entryPrimaryRelay(entry) {
return entryRelays(entry)[0] || {
relay_url: DEFAULT_RELAY_URL,
broadcast_name: normalizeName(entry?.broadcast_name || ""),
track_name: "video0.m4s",
};
}
function currentShareLink(relayUrl, name, mode) {
const u = new URL(window.location.href);
u.pathname = "/watch";
@ -59,6 +94,11 @@ function setHint(text, kind) {
el.dataset.kind = kind || "";
}
function setNowTitle(text) {
const el = document.getElementById("nowTitle");
if (el) el.textContent = text || "Ready";
}
function setShareLink(text) {
const el = $("shareLink");
el.textContent = text || "";
@ -70,6 +110,84 @@ function setListHint(text, kind) {
el.dataset.kind = kind || "";
}
function openSignalDrawer() {
const drawer = $("signalDrawer");
drawer.open = true;
$("broadcastName").focus();
}
function renderManualTunePrompt(message) {
const mount = $("liveList");
mount.textContent = "";
const empty = document.createElement("div");
empty.className = "emptyState";
const title = document.createElement("div");
title.className = "emptyTitle";
title.textContent = message;
const action = document.createElement("button");
action.className = "secondaryButton";
action.type = "button";
action.textContent = "Add channel";
action.addEventListener("click", openSignalDrawer);
empty.appendChild(title);
empty.appendChild(action);
mount.appendChild(empty);
}
function setThumbPosition(el, index) {
const positions = [
["0%", "0%"],
["100%", "0%"],
["0%", "100%"],
["100%", "100%"],
];
const [x, y] = positions[index % positions.length];
el.style.setProperty("--thumb-x", x);
el.style.setProperty("--thumb-y", y);
}
function renderDefaultMultiview() {
const grid = document.getElementById("multiViewGrid");
if (!grid) return;
const labels = ["News", "Game", "Weather", "Kitchen"];
grid.textContent = "";
labels.forEach((label, index) => {
const button = document.createElement("button");
button.className = `miniScreen tile${String.fromCharCode(65 + index)}`;
button.type = "button";
button.textContent = label;
button.addEventListener("click", () => {
setHint("Add a channel key to tune this slot.", "warn");
openSignalDrawer();
});
grid.appendChild(button);
});
}
function renderMultiview(entries, onWatchLive) {
const grid = document.getElementById("multiViewGrid");
if (!grid) return;
if (!entries.length) {
renderDefaultMultiview();
return;
}
grid.textContent = "";
entries.slice(0, 4).forEach((entry, index) => {
const title = entry.title || entry.stream_id || entry.broadcast_name || "Live";
const button = document.createElement("button");
button.className = "miniScreen";
button.type = "button";
button.textContent = title;
setThumbPosition(button, index);
button.addEventListener("click", () => onWatchLive(entry));
grid.appendChild(button);
});
}
function clearPlayerSignals() {
if (typeof disposePlayerSignals === "function") {
disposePlayerSignals();
@ -110,11 +228,13 @@ function bindPlayerSignals(watch, name, extraCleanup) {
if (status === "loading") {
clearOfflineTimer();
sawLoading = true;
setNowTitle(name);
setHint(`Tuning ${name}...`, "ok");
return;
}
if (status === "live") {
clearOfflineTimer();
setNowTitle(name);
setHint(`On air: ${name}`, "ok");
return;
}
@ -133,6 +253,7 @@ function bindPlayerSignals(watch, name, extraCleanup) {
const hasVideo = Boolean(catalog.video && catalog.video.renditions);
const hasAudio = Boolean(catalog.audio && catalog.audio.renditions);
if (hasVideo || hasAudio) {
setNowTitle(name);
setHint(`On air: ${name}`, "ok");
}
});
@ -157,6 +278,7 @@ function mountPlayer(relayUrl, name) {
const mount = $("playerMount");
mount.textContent = "";
setNowTitle(name);
const watch = document.createElement("moq-watch");
watch.setAttribute("url", relayUrl);
@ -276,6 +398,7 @@ async function mountArchivePlayer(name) {
const mount = $("playerMount");
mount.textContent = "";
setNowTitle(name);
const video = document.createElement("video");
video.className = "archiveVideo";
@ -393,48 +516,60 @@ function renderLiveList(entries, onWatchLive, onWatchArchive) {
mount.textContent = "";
if (!entries.length) {
setListHint("No stations are on air yet.", "");
renderMultiview([], onWatchLive);
renderManualTunePrompt("No channels found");
return;
}
setListHint(`${entries.length} on air`, "ok");
renderMultiview(entries, onWatchLive);
for (const entry of entries) {
for (const [index, entry] of entries.entries()) {
const titleText = entry.title || entry.stream_id || entry.broadcast_name || "Live stream";
const row = document.createElement("div");
row.className = "liveItem";
const tuneButton = document.createElement("button");
tuneButton.className = "liveTune";
tuneButton.type = "button";
tuneButton.setAttribute("aria-label", `Watch ${titleText}`);
setThumbPosition(tuneButton, index);
tuneButton.addEventListener("click", () => {
onWatchLive(entry);
});
const info = document.createElement("div");
const title = document.createElement("div");
title.className = "liveTitle";
title.textContent = entry.title || entry.stream_id || entry.broadcast_name || "Live stream";
title.textContent = titleText;
info.appendChild(title);
const meta = document.createElement("div");
meta.className = "liveMeta";
meta.textContent = entry.broadcast_name
? `Channel key: ${entry.broadcast_name}`
: "Ready to tune";
const relayCount = entryRelays(entry).length;
const relayText = relayCount > 1 ? ` · ${relayCount} relays` : "";
meta.textContent = `${entry.broadcast_name || "Ready"}${relayText}`;
info.appendChild(meta);
const watchBadge = document.createElement("span");
watchBadge.className = "watchBadge";
watchBadge.textContent = "Watch";
const actions = document.createElement("div");
actions.className = "liveActions";
const watchBtn = document.createElement("button");
watchBtn.className = "btn secondary";
watchBtn.textContent = "Watch";
watchBtn.addEventListener("click", () => {
onWatchLive(entry);
});
const archiveBtn = document.createElement("button");
archiveBtn.className = "btn secondary";
archiveBtn.className = "secondaryButton";
archiveBtn.type = "button";
archiveBtn.textContent = "DVR";
archiveBtn.addEventListener("click", () => {
onWatchArchive(entry);
});
actions.appendChild(watchBtn);
actions.appendChild(archiveBtn);
row.appendChild(info);
tuneButton.appendChild(info);
tuneButton.appendChild(watchBadge);
row.appendChild(tuneButton);
row.appendChild(actions);
mount.appendChild(row);
}
@ -457,11 +592,30 @@ function main() {
const watchBtn = $("watchBtn");
const copyBtn = $("copyLinkBtn");
const refreshListBtn = $("refreshListBtn");
const liveModeBtn = $("liveModeBtn");
const dvrModeBtn = $("dvrModeBtn");
const scrubber = $("watchScrubber");
const clipStartBtn = $("clipStartBtn");
const clipEndBtn = $("clipEndBtn");
const copyClipBtn = $("copyClipBtn");
const multiViewBtn = $("multiViewBtn");
const multiView = document.querySelector(".multiview");
let clipStart = Number(scrubber.value || 0);
let clipEnd = Number(scrubber.value || 0);
const initial = readParams();
relayInput.value = initial.relayUrl;
nameInput.value = initial.name;
archiveModeInput.checked = initial.mode === "archive";
setNowTitle(initial.name || "Ready");
function updateModeButtons() {
const isArchive = archiveModeInput.checked;
liveModeBtn.classList.toggle("pressed", !isArchive);
dvrModeBtn.classList.toggle("pressed", isArchive);
liveModeBtn.setAttribute("aria-pressed", String(!isArchive));
dvrModeBtn.setAttribute("aria-pressed", String(isArchive));
}
function updateSharePreview() {
const relayUrl = normalizeRelayUrl(relayInput.value);
@ -472,6 +626,20 @@ function main() {
return;
}
setShareLink(currentShareLink(relayUrl, name, mode));
updateModeButtons();
}
function currentClipLink() {
const relayUrl = normalizeRelayUrl(relayInput.value);
const name = normalizeName(nameInput.value);
const mode = archiveModeInput.checked ? "archive" : "live";
if (!name) return "";
const u = new URL(currentShareLink(relayUrl, name, mode));
const start = Math.min(clipStart, clipEnd);
const end = Math.max(clipStart, clipEnd);
u.searchParams.set("clipStart", String(start));
u.searchParams.set("clipEnd", String(end));
return u.toString();
}
async function start() {
@ -483,11 +651,14 @@ function main() {
if (!name) {
setHint("Pick a station or enter a channel key.", "warn");
setNowTitle("Ready");
openSignalDrawer();
return;
}
if (mode === "archive") {
writeParams(relayUrl, name, mode);
setNowTitle(name);
setHint(`Loading replay: ${name}`, "ok");
try {
await mountArchivePlayer(name);
@ -502,7 +673,7 @@ function main() {
if (!hasWebTransport()) {
setHint(
"This browser cannot tune the live player yet. Try Chrome or Edge.",
"Live playback needs WebTransport here. Try DVR replay or another browser.",
"warn",
);
return;
@ -519,6 +690,7 @@ function main() {
}
writeParams(relayUrl, name, mode);
setNowTitle(name);
setHint(`Tuning ${name}...`, "ok");
mountPlayer(relayUrl, name);
}
@ -526,6 +698,53 @@ function main() {
relayInput.addEventListener("input", updateSharePreview);
nameInput.addEventListener("input", updateSharePreview);
archiveModeInput.addEventListener("input", updateSharePreview);
archiveModeInput.addEventListener("change", updateModeButtons);
liveModeBtn.addEventListener("click", () => {
archiveModeInput.checked = false;
updateSharePreview();
});
dvrModeBtn.addEventListener("click", () => {
archiveModeInput.checked = true;
updateSharePreview();
});
scrubber.addEventListener("input", () => {
setHint(`Marker ${scrubber.value}%`, "");
});
clipStartBtn.addEventListener("click", () => {
clipStart = Number(scrubber.value || 0);
setHint(`Clip starts at ${clipStart}%`, "ok");
});
clipEndBtn.addEventListener("click", () => {
clipEnd = Number(scrubber.value || 0);
setHint(`Clip ends at ${clipEnd}%`, "ok");
});
copyClipBtn.addEventListener("click", async () => {
const link = currentClipLink();
if (!link) {
setHint("Pick a channel first.", "warn");
return;
}
try {
await copyToClipboard(link);
setHint("Clip link copied.", "ok");
setShareLink(link);
} catch (e) {
setHint(`Copy failed: ${String(e)}`, "warn");
setShareLink(link);
}
});
multiViewBtn.addEventListener("click", () => {
const isHidden = multiView?.hasAttribute("hidden");
if (isHidden) {
multiView?.removeAttribute("hidden");
multiViewBtn.classList.add("pressed");
multiViewBtn.setAttribute("aria-pressed", "true");
} else {
multiView?.setAttribute("hidden", "");
multiViewBtn.classList.remove("pressed");
multiViewBtn.setAttribute("aria-pressed", "false");
}
});
watchBtn.addEventListener("click", () => {
void start();
@ -560,23 +779,27 @@ function main() {
renderLiveList(
entries,
(entry) => {
const relay = entryPrimaryRelay(entry);
archiveModeInput.checked = false;
relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL);
nameInput.value = normalizeName(entry.broadcast_name || "");
relayInput.value = relay.relay_url;
nameInput.value = relay.broadcast_name;
updateSharePreview();
void start();
},
(entry) => {
const relay = entryPrimaryRelay(entry);
archiveModeInput.checked = true;
relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL);
nameInput.value = normalizeName(entry.broadcast_name || "");
relayInput.value = relay.relay_url;
nameInput.value = relay.broadcast_name;
updateSharePreview();
void start();
},
);
} catch (e) {
$("liveList").textContent = "";
setListHint("Station guide is unavailable right now.", "warn");
setListHint("Station guide is unavailable.", "warn");
renderMultiview([], () => {});
renderManualTunePrompt("Have a channel key?");
}
}
refreshListBtn.addEventListener("click", () => {
@ -584,6 +807,8 @@ function main() {
});
updateSharePreview();
updateModeButtons();
renderDefaultMultiview();
void refreshLiveList();
window.setInterval(() => {
void refreshLiveList();