Browse Source

Add the-ham.org live video panel

A live video panel alongside the audio, sourced from the-ham.org (nginx-rtmp fed
by OBS over RTMP, served as HLS).

  - /api/live (app.js) proxies the-ham's stat.xml -- which, unlike the .m3u8/.ts,
    has no CORS -- finds the publisher under the `live` app, and returns
    {online, name, hlsUrl, width, height}. Config via HAM_STAT_URL / HAM_HLS_BASE
    / HAM_APP. Gives the client a ready HLS URL and a clean offline state without
    hardcoding a stream key.
  - src/js/ham.js: a persistent "Video Stream" bar (dot grey idle / red-pulsing
    live, chevron collapse/expand). Going live auto-expands and autoplays muted
    (muted is the only autoplay browsers allow); the corner button unmutes;
    collapsing tears the video down.
  - hls.js is vendored from npm but emitted as its own app/js/hls.min.js and
    lazy-loaded by ham.js only when video first plays -- NOT in the main bundle,
    so the common idle case pays nothing for it. Safari plays HLS natively.
  - Low-latency player config (lowLatencyMode, liveSyncDurationCount: 2,
    maxLiveSyncPlaybackRate) to pair with short server segments.

Deploy note: this adds app/js/hls.min.js (a new file to upload) and hls.js as a
dependency (server needs npm ci --omit=dev again).
windhamdavid 3 weeks ago
parent
commit
7d4ec08deb
6 changed files with 309 additions and 3 deletions
  1. 6 0
      .env.example
  2. 72 0
      app.js
  3. 9 1
      build.mjs
  4. 9 1
      package-lock.json
  5. 2 1
      package.json
  6. 211 0
      src/js/ham.js

+ 6 - 0
.env.example

@@ -40,3 +40,9 @@ LASTFM_USER=windhamdavid
 # Apache in front of node, so point this at the main site to proxy it.
 # Leave UNSET in production.
 # DAW_ORIGIN=http://daw.stu
+
+# the-ham.org live video (nginx-rtmp -> HLS), proxied via /api/live. Defaults
+# point at the-ham.org; override only if the host/paths change.
+# HAM_STAT_URL=https://the-ham.org/stat.xml
+# HAM_HLS_BASE=https://the-ham.org/stream/hls/
+# HAM_APP=live

+ 72 - 0
app.js

@@ -34,6 +34,12 @@ const config = {
   streamStatusUrl:
     process.env.STREAM_STATUS_URL ?? 'https://stream.davidawindham.com/status-json.xsl',
   streamMount: process.env.STREAM_MOUNT ?? '/stream',
+  // the-ham.org video (nginx-rtmp -> HLS). /api/live proxies its stat.xml
+  // (which has no CORS, unlike the .m3u8/.ts) to discover the live stream and
+  // report online/offline, so the client never hardcodes a stream key.
+  hamStatUrl: process.env.HAM_STAT_URL ?? 'https://the-ham.org/stat.xml',
+  hamHlsBase: process.env.HAM_HLS_BASE ?? 'https://the-ham.org/stream/hls/',
+  hamApp: process.env.HAM_APP ?? 'live',
   // Proxied via /api/lastfm so the key stops shipping in the client bundle.
   // Unset => the sidebar lists just stay empty.
   lastfmKey: process.env.LASTFM_API_KEY ?? '',
@@ -96,6 +102,21 @@ router.get('/api/status', async (req, res) => {
   }
 });
 
+// the-ham.org video status, proxied from nginx-rtmp's stat.xml.
+//
+// stat.xml has no CORS header (the .m3u8/.ts do), so the browser can't read it
+// directly. This proxy discovers whatever is publishing under the `live` app
+// and hands back a ready-to-play HLS URL, so the client never hardcodes a
+// stream key and gets a clean offline state when nothing is broadcasting --
+// which, per the nginx logs, is most of the time.
+router.get('/api/live', async (req, res) => {
+  try {
+    res.json(await getHamStatus());
+  } catch {
+    res.status(502).json({ online: false, error: 'live status unavailable' });
+  }
+});
+
 // Last.fm sidebar data, proxied so the API key stays on the server.
 //
 // The key used to be hardcoded in src/js/radio.js (4x) and shipped in the
@@ -292,6 +313,57 @@ async function getStreamStatus() {
   return value;
 }
 
+// Parse nginx-rtmp stat.xml for a live publisher under `app`.
+//
+// The XML is small and machine-generated, so targeted regex is enough (Node has
+// no built-in XML parser and this doesn't warrant a dependency). While nothing
+// is publishing there's no <stream> node at all under the app's <live>; while
+// publishing there's <stream><name>…</name>…<publishing/> plus a <meta><video>.
+// The <publishing/> marker is the definitive "a source is connected" signal.
+function parseHamStat(xml, app) {
+  // Narrow to the target application's block.
+  const appRe = new RegExp(
+    `<application>\\s*<name>\\s*${app}\\s*</name>([\\s\\S]*?)</application>`,
+    'i',
+  );
+  const appBlock = appRe.exec(xml)?.[1];
+  if (!appBlock) return { online: false };
+
+  // Only streams with an active publisher count as live.
+  for (const m of appBlock.matchAll(/<stream>([\s\S]*?)<\/stream>/g)) {
+    const s = m[1];
+    if (!/<publishing\/>/.test(s)) continue;
+    const name = /<name>([\s\S]*?)<\/name>/.exec(s)?.[1]?.trim();
+    if (!name) continue;
+    const width = Number(/<width>(\d+)<\/width>/.exec(s)?.[1]) || null;
+    const height = Number(/<height>(\d+)<\/height>/.exec(s)?.[1]) || null;
+    return {
+      online: true,
+      name,
+      // .m3u8/.ts carry CORS, so the client plays this URL directly.
+      hlsUrl: new URL(`${encodeURIComponent(name)}.m3u8`, config.hamHlsBase).href,
+      width,
+      height,
+    };
+  }
+  return { online: false };
+}
+
+const HAM_CACHE_MS = 8000;
+let hamCache = { at: 0, value: null };
+
+async function getHamStatus() {
+  const now = Date.now();
+  if (hamCache.value && now - hamCache.at < HAM_CACHE_MS) return hamCache.value;
+
+  const upstream = await fetch(config.hamStatUrl, { signal: AbortSignal.timeout(5000) });
+  if (!upstream.ok) throw new Error(`the-ham stat responded ${upstream.status}`);
+
+  const value = parseHamStat(await upstream.text(), config.hamApp);
+  hamCache = { at: now, value };
+  return value;
+}
+
 function sendBroadcast(text) {
   for (const room of publicRooms()) {
     io.to(room).emit('newMessage', {

+ 9 - 1
build.mjs

@@ -48,13 +48,19 @@ const VENDOR_JS = [
 
 const RADIO_JS = [
   // base.js must come first: it defines window.RADIO, the shared socket and
-  // mount-path helper that the other two depend on.
+  // mount-path helper that the others depend on.
   path.join(SRC, 'js/base.js'),
   path.join(SRC, 'js/chat.js'),
   path.join(SRC, 'js/amplitude-v2.2.0.js'),
   path.join(SRC, 'js/radio.js'),
+  path.join(SRC, 'js/ham.js'),
 ];
 
+// hls.js is emitted as its own file (app/js/hls.min.js) and loaded on demand by
+// ham.js only when the-ham goes live — NOT bundled into radio.min.js, so the
+// common case (nothing broadcasting) never pays its ~530KB.
+const HLS_SRC = path.join(NODE_MODULES, 'hls.js/dist/hls.min.js');
+
 // Bootstrap's CSS ships with its JS, so it tracks the same 3.4.1 package. Its
 // glyphicon @font-face rules point at ../fonts/, which is what src/fonts/ is
 // copied to (those files are byte-identical between 3.3.6 and 3.4.1).
@@ -113,6 +119,8 @@ async function build() {
   await bundleCss(CSS, 'css/style.min.css');
   await bundleJs(VENDOR_JS, 'js/vendor.min.js');
   await bundleJs(RADIO_JS, 'js/radio.min.js', { define: DEFINE });
+  // hls.js is already minified; copy as-is (lazy-loaded, not bundled).
+  await cp(HLS_SRC, path.join(OUT, 'js/hls.min.js'));
 
   console.log(`BUILD: complete in ${Date.now() - started}ms -> ${path.relative(__dirname, OUT)}/`);
 }

+ 9 - 1
package-lock.json

@@ -17,7 +17,8 @@
       "devDependencies": {
         "bootstrap": "^3.4.1",
         "esbuild": "^0.28.1",
-        "handlebars": "^4.7.9"
+        "handlebars": "^4.7.9",
+        "hls.js": "^1.6.16"
       },
       "engines": {
         "node": ">=20"
@@ -1231,6 +1232,13 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/hls.js": {
+      "version": "1.6.16",
+      "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
+      "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
     "node_modules/http-errors": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",

+ 2 - 1
package.json

@@ -24,6 +24,7 @@
   "devDependencies": {
     "bootstrap": "^3.4.1",
     "esbuild": "^0.28.1",
-    "handlebars": "^4.7.9"
+    "handlebars": "^4.7.9",
+    "hls.js": "^1.6.16"
   }
 }

+ 211 - 0
src/js/ham.js

@@ -0,0 +1,211 @@
+/* the-ham.org live video panel.
+ *
+ * A persistent "Video Stream" bar with a status dot and a chevron. The video
+ * collapses/expands beneath it:
+ *   - dot: dim when nothing's broadcasting, red + pulsing when a source is live
+ *   - chevron: down when collapsed, up when expanded; click the bar to toggle
+ *   - when a stream goes live it auto-expands and autoplays MUTED (the only
+ *     autoplay browsers allow); the corner button unmutes
+ *   - collapsing tears the video down (no wasted bandwidth); expanding replays
+ *
+ * Live state comes from /api/live (which proxies the-ham's nginx-rtmp stat.xml).
+ * hls.js is loaded lazily the first time video actually plays, so the idle case
+ * costs zero bytes beyond this small file.
+ */
+(function () {
+  var POLL_MS = 15000;
+
+  var panel, head, dot, chevron, body, wrap, video, muteBtn, errBox, offlineMsg;
+  var live = false;         // is a source publishing?
+  var expanded = false;     // is the body open?
+  var userCollapsed = false; // did the user collapse it while live?
+  var currentUrl = null, currentName = null;
+  var playingUrl = null;    // guards against restarting hls on every poll
+  var hls = null, hlsLoading = false, hlsWaiters = [];
+
+  function ready(fn) {
+    if (document.readyState !== 'loading') fn();
+    else document.addEventListener('DOMContentLoaded', fn);
+  }
+
+  // ----- hls.js, loaded on demand -------------------------------------------
+  function loadHls(cb) {
+    if (window.Hls) return cb();
+    hlsWaiters.push(cb);
+    if (hlsLoading) return;
+    hlsLoading = true;
+    var s = document.createElement('script');
+    s.src = window.RADIO.url('js/hls.min.js');
+    s.onload = function () {
+      var fns = hlsWaiters; hlsWaiters = [];
+      fns.forEach(function (f) { f(); });
+    };
+    s.onerror = function () { showError('Could not load the video player.'); };
+    document.head.appendChild(s);
+  }
+
+  function showError(msg) { if (errBox) { errBox.textContent = msg; errBox.hidden = false; } }
+  function clearError() { if (errBox) errBox.hidden = true; }
+
+  function syncMuteBtn() {
+    if (!muteBtn) return;
+    var icon = video.muted ? 'glyphicon-volume-off' : 'glyphicon-volume-up';
+    muteBtn.innerHTML = '<span class="glyphicon ' + icon + '" aria-hidden="true"></span>';
+    muteBtn.setAttribute('aria-label', video.muted ? 'Unmute video' : 'Mute video');
+  }
+
+  // ----- render the bar/body from state -------------------------------------
+  function render() {
+    dot.classList.toggle('live', live);
+    head.setAttribute('aria-expanded', expanded ? 'true' : 'false');
+    body.hidden = !expanded;
+    wrap.hidden = !(expanded && live);
+    offlineMsg.hidden = !(expanded && !live);
+  }
+
+  // ----- playback ------------------------------------------------------------
+  function play() {
+    if (!currentUrl || playingUrl === currentUrl) return; // already on it
+    clearError();
+    loadHls(function () {
+      if (!currentUrl) return;
+      if (video.canPlayType('application/vnd.apple.mpegurl')) {
+        video.src = currentUrl; // Safari/iOS native HLS
+      } else if (window.Hls && window.Hls.isSupported()) {
+        teardownHls();
+        // Low-latency live tuning. Pairs with the short server segments so the
+        // player hugs the live edge instead of the default ~3-segment lag:
+        //  - lowLatencyMode: use LL-HLS parts if the server emits them
+        //  - liveSyncDurationCount: aim ~2 segments behind live
+        //  - liveMaxLatencyDurationCount: if we fall further behind, catch up
+        //  - maxLiveSyncPlaybackRate: nudge playback slightly faster to close
+        //    the gap smoothly rather than seeking (which visibly jumps)
+        //  - backBufferLength: don't hoard old segments in memory
+        hls = new window.Hls({
+          liveDurationInfinity: true,
+          lowLatencyMode: true,
+          liveSyncDurationCount: 2,
+          liveMaxLatencyDurationCount: 6,
+          maxLiveSyncPlaybackRate: 1.5,
+          backBufferLength: 10,
+        });
+        hls.loadSource(currentUrl);
+        hls.attachMedia(video);
+        hls.on(window.Hls.Events.ERROR, onHlsError);
+      } else {
+        showError('This browser can’t play the stream.');
+        return;
+      }
+      video.muted = true; // muted so autoplay is allowed
+      syncMuteBtn();
+      var p = video.play();
+      if (p && p.catch) p.catch(function () { /* autoplay quirks; stays muted */ });
+      playingUrl = currentUrl;
+    });
+  }
+
+  function onHlsError(evt, data) {
+    if (!data || !data.fatal) return;
+    var T = window.Hls.ErrorTypes;
+    if (data.type === T.NETWORK_ERROR) {
+      showError('Stream interrupted — retrying…');
+      try { hls.startLoad(); } catch (e) { /* noop */ }
+    } else if (data.type === T.MEDIA_ERROR) {
+      showError('Recovering…');
+      try { hls.recoverMediaError(); } catch (e) { /* noop */ }
+    } else {
+      teardownHls();
+      showError('Stream unavailable.');
+    }
+  }
+
+  function teardownHls() {
+    if (hls) { try { hls.destroy(); } catch (e) { /* noop */ } hls = null; }
+  }
+  function stopVideo() {
+    teardownHls();
+    try { video.pause(); } catch (e) { /* noop */ }
+    video.removeAttribute('src');
+    try { video.load(); } catch (e) { /* noop */ }
+    playingUrl = null;
+    clearError();
+  }
+
+  // ----- expand / collapse ---------------------------------------------------
+  function expand() {
+    expanded = true;
+    userCollapsed = false;
+    render();
+    if (live) play();
+  }
+  function collapse() {
+    expanded = false;
+    userCollapsed = true; // respect the choice; don't auto-reopen this session
+    stopVideo();
+    render();
+  }
+  function toggle() { if (expanded) collapse(); else expand(); }
+
+  // ----- poll ----------------------------------------------------------------
+  function applyStatus(st) {
+    if (st && st.online && st.hlsUrl) {
+      currentUrl = st.hlsUrl;
+      currentName = st.name;
+      if (!live) {                       // just went live
+        live = true;
+        if (!userCollapsed) expanded = true; // auto-open unless user collapsed it
+      }
+      render();
+      if (expanded) play();
+    } else {
+      if (live) {                        // just went offline
+        live = false;
+        userCollapsed = false;           // a fresh broadcast may auto-open again
+        stopVideo();
+      }
+      currentUrl = null;
+      currentName = null;
+      render();
+    }
+  }
+
+  function poll() {
+    fetch(window.RADIO.url('api/live'), { cache: 'no-store' })
+      .then(function (r) { return r.json(); })
+      .then(applyStatus)
+      .catch(function () { /* transient blip: leave state as-is */ });
+  }
+
+  ready(function () {
+    panel = document.getElementById('ham-panel');
+    if (!panel) return;
+    head = document.getElementById('ham-toggle');
+    dot = panel.querySelector('.ham-dot');
+    chevron = panel.querySelector('.ham-chevron');
+    body = document.getElementById('ham-body');
+    wrap = panel.querySelector('.ham-video-wrap');
+    video = document.getElementById('ham-video');
+    muteBtn = document.getElementById('ham-mute');
+    errBox = document.getElementById('ham-error');
+    offlineMsg = document.getElementById('ham-offline');
+
+    video.muted = true;
+    video.setAttribute('playsinline', '');
+    syncMuteBtn();
+    render();
+
+    head.addEventListener('click', toggle);
+
+    if (muteBtn) {
+      muteBtn.addEventListener('click', function (e) {
+        e.stopPropagation(); // the bar toggles; the mute button shouldn't
+        video.muted = !video.muted;
+        if (!video.muted && video.volume === 0) video.volume = 1;
+        syncMuteBtn();
+      });
+    }
+
+    poll();
+    setInterval(poll, POLL_MS);
+  });
+})();