archive replay: add HLS DVR serve path and web mode
This commit is contained in:
parent
656ec11c73
commit
b35de70789
9 changed files with 904 additions and 26 deletions
177
apps/web/app.js
177
apps/web/app.js
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@
|
|||
<div class="label">Broadcast name</div>
|
||||
<input id="broadcastName" class="input" type="text" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field checkField">
|
||||
<div class="label">Mode</div>
|
||||
<div class="checkRow">
|
||||
<input id="archiveMode" type="checkbox" />
|
||||
<span>Archive DVR</span>
|
||||
</div>
|
||||
</label>
|
||||
<button id="watchBtn" class="btn">Watch</button>
|
||||
</div>
|
||||
<div class="hint" id="hint"></div>
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ body {
|
|||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 1fr auto;
|
||||
grid-template-columns: 1.15fr 1fr auto auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
|
@ -133,6 +133,23 @@ body {
|
|||
box-shadow: 0 0 0 3px rgba(255, 184, 108, 0.16);
|
||||
}
|
||||
|
||||
.checkField .checkRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.checkField input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 11px 14px;
|
||||
border-radius: 12px;
|
||||
|
|
@ -221,6 +238,11 @@ body {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.liveActions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.player {
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -272,6 +294,13 @@ body {
|
|||
display: block;
|
||||
}
|
||||
|
||||
.archiveVideo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.tv-scanlines {
|
||||
position: absolute;
|
||||
inset: 12px;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue