archive replay: add HLS DVR serve path and web mode

This commit is contained in:
every.channel 2026-02-24 03:19:56 -08:00
parent 656ec11c73
commit b35de70789
No known key found for this signature in database
9 changed files with 904 additions and 26 deletions

View file

@ -9,9 +9,16 @@ const MOQ_WATCH_MODULE_URLS = [
"https://cdn.jsdelivr.net/npm/@moq/watch@0.1.1/element/+esm",
"https://unpkg.com/@moq/watch@0.1.1/element.js?module",
];
const HLS_MODULE_URLS = [
"https://esm.sh/hls.js@1.6.2",
"https://cdn.jsdelivr.net/npm/hls.js@1.6.2/+esm",
"https://unpkg.com/hls.js@1.6.2/dist/hls.mjs",
];
const PUBLIC_STREAMS_PATH = "/api/public-streams";
let moqWatchModulePromise = null;
let hlsModulePromise = null;
let disposePlayerSignals = null;
let activeHlsPlayer = null;
function $(id) {
const el = document.getElementById(id);
@ -30,14 +37,16 @@ function normalizeName(s) {
return (s || "").trim();
}
function currentShareLink(relayUrl, name) {
function currentShareLink(relayUrl, name, mode) {
const u = new URL(window.location.href);
u.pathname = "/watch";
u.searchParams.set("url", relayUrl);
u.searchParams.set("name", name);
if (mode === "archive") u.searchParams.set("mode", "archive");
else u.searchParams.delete("mode");
// Avoid leaking other params.
for (const k of [...u.searchParams.keys()]) {
if (k !== "url" && k !== "name") u.searchParams.delete(k);
if (k !== "url" && k !== "name" && k !== "mode") u.searchParams.delete(k);
}
return u.toString();
}
@ -66,6 +75,17 @@ function clearPlayerSignals() {
disposePlayerSignals = null;
}
function destroyArchivePlayer() {
if (activeHlsPlayer && typeof activeHlsPlayer.destroy === "function") {
try {
activeHlsPlayer.destroy();
} catch (_) {
// Ignore teardown errors.
}
}
activeHlsPlayer = null;
}
function bindPlayerSignals(watch, name) {
const cleanup = [];
let offlineTimer = null;
@ -131,6 +151,7 @@ function bindPlayerSignals(watch, name) {
function mountPlayer(relayUrl, name) {
clearPlayerSignals();
destroyArchivePlayer();
const mount = $("playerMount");
mount.textContent = "";
@ -173,6 +194,74 @@ async function ensureMoqWatchElement() {
}
}
async function ensureHlsPlayerCtor() {
if (window.Hls) return window.Hls;
if (!hlsModulePromise) {
hlsModulePromise = (async () => {
let lastErr = null;
for (const moduleUrl of HLS_MODULE_URLS) {
try {
const mod = await import(moduleUrl);
if (mod?.default) {
window.Hls = mod.default;
} else if (mod?.Hls) {
window.Hls = mod.Hls;
}
} catch (err) {
lastErr = err;
continue;
}
if (window.Hls) return window.Hls;
}
throw lastErr || new Error("hls.js module is unavailable");
})();
}
return hlsModulePromise;
}
async function mountArchivePlayer(name) {
clearPlayerSignals();
destroyArchivePlayer();
const mount = $("playerMount");
mount.textContent = "";
const video = document.createElement("video");
video.className = "archiveVideo";
video.controls = true;
video.autoplay = true;
video.muted = false;
video.playsInline = true;
mount.appendChild(video);
const archiveUrl = `/api/archive/${encodeURIComponent(name)}/master.m3u8`;
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = archiveUrl;
void video.play().catch(() => {});
return;
}
const HlsCtor = await ensureHlsPlayerCtor();
if (!HlsCtor || typeof HlsCtor.isSupported !== "function" || !HlsCtor.isSupported()) {
throw new Error("HLS playback is unsupported in this browser");
}
const hls = new HlsCtor({
liveDurationInfinity: true,
lowLatencyMode: false,
backBufferLength: 120,
});
activeHlsPlayer = hls;
hls.on(HlsCtor.Events.ERROR, (_event, data) => {
if (data?.fatal) {
setHint(`Archive playback error: ${data.type || "fatal"}`, "warn");
}
});
hls.loadSource(archiveUrl);
hls.attachMedia(video);
void video.play().catch(() => {});
}
async function copyToClipboard(text) {
if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
@ -199,20 +288,24 @@ function readParams() {
u.searchParams.get("name") ||
u.searchParams.get("broadcast") ||
u.searchParams.get("path");
const mode = u.searchParams.get("mode") === "archive" ? "archive" : "live";
return {
relayUrl: normalizeRelayUrl(relay || DEFAULT_RELAY_URL),
name: normalizeName(name || ""),
mode,
};
}
function writeParams(relayUrl, name) {
function writeParams(relayUrl, name, mode) {
const u = new URL(window.location.href);
u.pathname = "/watch";
u.searchParams.set("url", relayUrl);
u.searchParams.set("name", name);
if (mode === "archive") u.searchParams.set("mode", "archive");
else u.searchParams.delete("mode");
// Canonicalize by dropping stale aliases/extra params.
for (const k of [...u.searchParams.keys()]) {
if (k !== "url" && k !== "name") u.searchParams.delete(k);
if (k !== "url" && k !== "name" && k !== "mode") u.searchParams.delete(k);
}
window.history.replaceState({}, "", u.toString());
}
@ -221,7 +314,7 @@ function hasWebTransport() {
return typeof window.WebTransport !== "undefined";
}
function renderLiveList(entries, onWatch) {
function renderLiveList(entries, onWatchLive, onWatchArchive) {
const mount = $("liveList");
mount.textContent = "";
if (!entries.length) {
@ -245,15 +338,28 @@ function renderLiveList(entries, onWatch) {
meta.textContent = `${entry.broadcast_name || ""} @ ${entry.relay_url || DEFAULT_RELAY_URL}`;
info.appendChild(meta);
const btn = document.createElement("button");
btn.className = "btn secondary";
btn.textContent = "Watch";
btn.addEventListener("click", () => {
onWatch(entry);
const actions = document.createElement("div");
actions.className = "liveActions";
const watchBtn = document.createElement("button");
watchBtn.className = "btn secondary";
watchBtn.textContent = "Live";
watchBtn.addEventListener("click", () => {
onWatchLive(entry);
});
const archiveBtn = document.createElement("button");
archiveBtn.className = "btn secondary";
archiveBtn.textContent = "Archive";
archiveBtn.addEventListener("click", () => {
onWatchArchive(entry);
});
actions.appendChild(watchBtn);
actions.appendChild(archiveBtn);
row.appendChild(info);
row.appendChild(btn);
row.appendChild(actions);
mount.appendChild(row);
}
}
@ -271,6 +377,7 @@ async function fetchLiveList() {
function main() {
const relayInput = $("relayUrl");
const nameInput = $("broadcastName");
const archiveModeInput = $("archiveMode");
const watchBtn = $("watchBtn");
const copyBtn = $("copyLinkBtn");
const refreshListBtn = $("refreshListBtn");
@ -278,20 +385,23 @@ function main() {
const initial = readParams();
relayInput.value = initial.relayUrl;
nameInput.value = initial.name;
archiveModeInput.checked = initial.mode === "archive";
function updateSharePreview() {
const relayUrl = normalizeRelayUrl(relayInput.value);
const name = normalizeName(nameInput.value);
const mode = archiveModeInput.checked ? "archive" : "live";
if (!name) {
setShareLink("");
return;
}
setShareLink(currentShareLink(relayUrl, name));
setShareLink(currentShareLink(relayUrl, name, mode));
}
async function start() {
const relayUrl = normalizeRelayUrl(relayInput.value);
const name = normalizeName(nameInput.value);
const mode = archiveModeInput.checked ? "archive" : "live";
updateSharePreview();
@ -300,6 +410,20 @@ function main() {
return;
}
if (mode === "archive") {
writeParams(relayUrl, name, mode);
setHint(`Loading archive DVR: ${name}`, "ok");
try {
await mountArchivePlayer(name);
} catch (e) {
setHint(
`Archive playback unavailable: ${String(e)}. Ensure /api/archive is configured.`,
"warn",
);
}
return;
}
if (!hasWebTransport()) {
setHint(
"WebTransport is not available in this browser. Try Chrome or Firefox Nightly. Safari support is still incomplete.",
@ -318,13 +442,14 @@ function main() {
return;
}
writeParams(relayUrl, name);
writeParams(relayUrl, name, mode);
setHint(`Connecting to relay and subscribing: ${name}`, "ok");
mountPlayer(relayUrl, name);
}
relayInput.addEventListener("input", updateSharePreview);
nameInput.addEventListener("input", updateSharePreview);
archiveModeInput.addEventListener("input", updateSharePreview);
watchBtn.addEventListener("click", () => {
void start();
@ -336,11 +461,12 @@ function main() {
copyBtn.addEventListener("click", async () => {
const relayUrl = normalizeRelayUrl(relayInput.value);
const name = normalizeName(nameInput.value);
const mode = archiveModeInput.checked ? "archive" : "live";
if (!name) {
setHint("Enter a broadcast name first.", "warn");
return;
}
const link = currentShareLink(relayUrl, name);
const link = currentShareLink(relayUrl, name, mode);
try {
await copyToClipboard(link);
setHint("Link copied.", "ok");
@ -355,12 +481,23 @@ function main() {
setListHint("Loading live streams...", "");
try {
const entries = await fetchLiveList();
renderLiveList(entries, (entry) => {
relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL);
nameInput.value = normalizeName(entry.broadcast_name || "");
updateSharePreview();
void start();
});
renderLiveList(
entries,
(entry) => {
archiveModeInput.checked = false;
relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL);
nameInput.value = normalizeName(entry.broadcast_name || "");
updateSharePreview();
void start();
},
(entry) => {
archiveModeInput.checked = true;
relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL);
nameInput.value = normalizeName(entry.broadcast_name || "");
updateSharePreview();
void start();
},
);
} catch (e) {
$("liveList").textContent = "";
setListHint(`Live list error: ${String(e)}`, "warn");