every.channel/apps/web/app.js

213 lines
5.7 KiB
JavaScript

// every.channel web watcher
//
// This uses the upstream hang web component (WebTransport + WebCodecs).
// It is intentionally dependency-light: no framework, no bundler.
const DEFAULT_RELAY_URL = "https://cdn.moq.dev/anon";
const HANG_WATCH_MODULE_URL = "https://esm.sh/@kixelated/hang@0.7.0/watch/element.js";
let hangWatchModulePromise = null;
function $(id) {
const el = document.getElementById(id);
if (!el) throw new Error(`missing element: ${id}`);
return el;
}
function normalizeRelayUrl(s) {
const trimmed = (s || "").trim();
if (!trimmed) return DEFAULT_RELAY_URL;
// Ensure trailing slash so relative fetches behave consistently.
return trimmed.endsWith("/") ? trimmed : `${trimmed}/`;
}
function normalizeName(s) {
return (s || "").trim();
}
function currentShareLink(relayUrl, name) {
const u = new URL(window.location.href);
u.pathname = "/watch";
u.searchParams.set("url", relayUrl);
u.searchParams.set("name", name);
// Avoid leaking other params.
for (const k of [...u.searchParams.keys()]) {
if (k !== "url" && k !== "name") u.searchParams.delete(k);
}
return u.toString();
}
function setHint(text, kind) {
const el = $("hint");
el.textContent = text || "";
el.dataset.kind = kind || "";
}
function setShareLink(text) {
const el = $("shareLink");
el.textContent = text || "";
}
function mountPlayer(relayUrl, name) {
const mount = $("playerMount");
mount.textContent = "";
const watch = document.createElement("hang-watch");
watch.setAttribute("url", relayUrl);
watch.setAttribute("name", name);
watch.setAttribute("controls", "");
// A canvas enables video rendering. Without it, only audio is played.
const canvas = document.createElement("canvas");
canvas.className = "canvas";
watch.appendChild(canvas);
mount.appendChild(watch);
}
async function ensureHangWatchElement() {
if (window.customElements && window.customElements.get("hang-watch")) return;
if (!hangWatchModulePromise) {
hangWatchModulePromise = import(HANG_WATCH_MODULE_URL);
}
await hangWatchModulePromise;
if (!(window.customElements && window.customElements.get("hang-watch"))) {
throw new Error("hang-watch custom element is unavailable");
}
}
async function copyToClipboard(text) {
if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return;
}
// Fallback: best-effort.
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
function readParams() {
const u = new URL(window.location.href);
// Accept legacy/share-link aliases for compatibility.
const relay =
u.searchParams.get("url") ||
u.searchParams.get("relay") ||
u.searchParams.get("relayUrl");
const name =
u.searchParams.get("name") ||
u.searchParams.get("broadcast");
return {
relayUrl: normalizeRelayUrl(relay || DEFAULT_RELAY_URL),
name: normalizeName(name || ""),
};
}
function writeParams(relayUrl, name) {
const u = new URL(window.location.href);
u.pathname = "/watch";
u.searchParams.set("url", relayUrl);
u.searchParams.set("name", name);
// Canonicalize by dropping stale aliases/extra params.
for (const k of [...u.searchParams.keys()]) {
if (k !== "url" && k !== "name") u.searchParams.delete(k);
}
window.history.replaceState({}, "", u.toString());
}
function hasWebTransport() {
return typeof window.WebTransport !== "undefined";
}
function main() {
const relayInput = $("relayUrl");
const nameInput = $("broadcastName");
const watchBtn = $("watchBtn");
const copyBtn = $("copyLinkBtn");
const initial = readParams();
relayInput.value = initial.relayUrl;
nameInput.value = initial.name;
function updateSharePreview() {
const relayUrl = normalizeRelayUrl(relayInput.value);
const name = normalizeName(nameInput.value);
if (!name) {
setShareLink("");
return;
}
setShareLink(currentShareLink(relayUrl, name));
}
async function start() {
const relayUrl = normalizeRelayUrl(relayInput.value);
const name = normalizeName(nameInput.value);
updateSharePreview();
if (!name) {
setHint("Enter a broadcast name to watch.", "warn");
return;
}
if (!hasWebTransport()) {
setHint(
"WebTransport is not available in this browser. Try Chrome or Firefox Nightly. Safari support is still incomplete.",
"warn",
);
return;
}
try {
await ensureHangWatchElement();
} catch (e) {
setHint(
`Failed to load web player dependency: ${String(e)}. Disable script blockers for esm.sh and retry.`,
"warn",
);
return;
}
writeParams(relayUrl, name);
setHint(`Connecting to relay and subscribing: ${name}`, "ok");
mountPlayer(relayUrl, name);
}
relayInput.addEventListener("input", updateSharePreview);
nameInput.addEventListener("input", updateSharePreview);
watchBtn.addEventListener("click", () => {
void start();
});
nameInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") void start();
});
copyBtn.addEventListener("click", async () => {
const relayUrl = normalizeRelayUrl(relayInput.value);
const name = normalizeName(nameInput.value);
if (!name) {
setHint("Enter a broadcast name first.", "warn");
return;
}
const link = currentShareLink(relayUrl, name);
try {
await copyToClipboard(link);
setHint("Link copied.", "ok");
setShareLink(link);
} catch (e) {
setHint(`Copy failed: ${String(e)}`, "warn");
setShareLink(link);
}
});
updateSharePreview();
// Auto-start if a name was provided.
if (initial.name) void start();
}
main();