Harden LA publishers and add multi-relay guide
This commit is contained in:
parent
5d6f77f868
commit
cfc4902016
13 changed files with 1430 additions and 402 deletions
267
apps/web/app.js
267
apps/web/app.js
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue