PageSourceSearch

https://abovethenoisepodcast.netlify.app/js/app.js

js abovethenoisepodcast.netlify.app collected 2026-10-03 10:24:24 UTC 22,509 bytes, 674 lines download raw bytes

1/* ============================================================
2   Above the Noise — site behavior
3   Feed-driven content + noise/signal canvas + interactions
4   ============================================================ */
5
6"use strict";
7
8// Optional platform links. Apple Podcasts and Spotify have dedicated branded
9// buttons hardcoded in index.html (#platform-links); entries here render as
10// additional outline buttons. Empty strings are skipped.
11const PLATFORMS = {
12  "YouTube Music": "",
13  "Overcast": "",
14  "Pocket Casts": "",
15};
16
17const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
18const COARSE = window.matchMedia("(pointer: coarse)").matches;
19
20const $ = (sel) => document.querySelector(sel);
21
22// ---------------------------------------------------------------- feed
23
24let FEED = null;
25
26async function loadFeed() {
27  const sources = ["/api/feed", "data/fallback.json"];
28  for (const url of sources) {
29    try {
30      const res = await fetch(url);
31      if (!res.ok) continue;
32      const data = await res.json();
33      if (data && Array.isArray(data.episodes)) return data;
34    } catch (_) { /* try next source */ }
35  }
36  return null;
37}
38
39function renderEpisodes(feed) {
40  const list = $("#episode-list");
41
42  if (!feed || !feed.episodes.length) {
43    // keep whatever was prerendered; only show an error if there's nothing
44    if (!list.querySelector(".ep-card")) {
45      list.innerHTML = '<div class="episodes__loading mono">Feed unavailable — listen on <a href="https://shows.acast.com/above-the-noise-the-unbiased-ai-brief-for-enterprise-leaders">Acast</a>.</div>';
46    }
47    return;
48  }
49
50  // If the prerendered cards already match the live feed, hydrate them in
51  // place — no re-render, no animation flash.
52  const existing = Array.from(list.querySelectorAll(".ep-card"));
53  const matches = existing.length === feed.episodes.length &&
54    existing.every((el, i) => el.dataset.guid === feed.episodes[i].guid);
55  if (matches) {
56    existing.forEach((card, i) => {
57      const ep = feed.episodes[i];
58      const play = card.querySelector(".ep-card__play");
59      play.addEventListener("click", (e) => {
60        e.preventDefault();
61        player.toggleEpisode(ep);
62      });
63    });
64    bindMagnetic(list);
65    return;
66  }
67
68  list.innerHTML = "";
69  feed.episodes.forEach((ep, i) => {
70    const card = document.createElement("article");
71    card.className = "ep-card" + (i === 0 ? " ep-card--latest" : "");
72    card.setAttribute("data-reveal", "");
73    card.dataset.guid = ep.guid;
74
75    const epLabel = ep.season != null && ep.episode != null
76      ? `S${ep.season} · E${ep.episode}`
77      : ep.episode != null ? `Episode ${ep.episode}` : "Episode";
78
79    card.innerHTML = `
80      <img class="ep-card__art" src="${ep.image}" alt="" loading="lazy" />
81      <div class="ep-card__body">
82        <div class="ep-card__meta mono">
83          <span class="ep-num">${epLabel}</span>
84          <span>${formatDate(ep.pubDate)}</span>
85          <span>${ep.duration || ""}</span>
86        </div>
87        <h3 class="ep-card__title">${ep.slug ? `<a href="/episodes/${escapeHtml(ep.slug)}">${escapeHtml(ep.title)}</a>` : escapeHtml(ep.title)}</h3>
88        <p class="ep-card__excerpt">${escapeHtml(ep.excerpt)}</p>
89        <details class="ep-card__notes">
90          <summary>Show notes</summary>
91          <div class="ep-card__notes-body">${ep.descriptionHtml}</div>
92        </details>
93      </div>
94      <button class="ep-card__play" data-magnetic aria-label="Play ${escapeHtml(ep.title)}">
95        <svg viewBox="0 0 24 24" width="22" height="22"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>
96      </button>
97    `;
98
99    card.querySelector(".ep-card__play").addEventListener("click", () => {
100      player.toggleEpisode(ep);
101    });
102
103    list.appendChild(card);
104  });
105
106  observeReveals(list);
107  bindMagnetic(list);
108}
109
110function renderPlatforms(feed) {
111  const wrap = $("#platform-links");
112
113  const links = { ...PLATFORMS };
114  if (feed?.channel?.link) links["Acast"] = feed.channel.link;
115
116  for (const [name, url] of Object.entries(links)) {
117    if (!url) continue;
118    // skip platforms already present as static branded buttons
119    if (wrap.querySelector(`[data-platform="${CSS.escape(name)}"]`)) continue;
120    const a = document.createElement("a");
121    a.className = "btn btn--outline";
122    a.href = url;
123    a.target = "_blank";
124    a.rel = "noopener";
125    a.dataset.platform = name;
126    a.setAttribute("data-magnetic", "");
127    a.textContent = name;
128    wrap.appendChild(a);
129  }
130  bindMagnetic(wrap);
131}
132
133function injectSchema(feed) {
134  // The prerendered JSON-LD (scripts/prerender.mjs) is richer — only inject
135  // a fallback if it's somehow missing.
136  if (document.getElementById("podcast-schema") || !feed?.channel) return;
137  const schema = {
138    "@context": "https://schema.org",
139    "@type": "PodcastSeries",
140    name: feed.channel.title,
141    description: feed.channel.subtitle,
142    url: feed.channel.link,
143    image: feed.channel.image,
144    webFeed: feed.channel.feedUrl,
145    author: { "@type": "Person", name: feed.channel.author },
146  };
147  const s = document.createElement("script");
148  s.type = "application/ld+json";
149  s.id = "podcast-schema";
150  s.textContent = JSON.stringify(schema);
151  document.head.appendChild(s);
152}
153
154function setCanonical() {
155  // Derived from the live origin so it's correct on any domain. Hardcode the
156  // final URL here once a custom domain is settled, if preferred.
157  if (document.querySelector("link[rel='canonical']")) return;
158  const link = document.createElement("link");
159  link.rel = "canonical";
160  link.href = location.origin + "/";
161  document.head.appendChild(link);
162}
163
164function formatDate(d) {
165  const date = new Date(d);
166  if (isNaN(date)) return "";
167  return date.toLocaleDateString("en-US", { month: "short", day: "
167numeric", year: "numeric" });
168}
169
170function escapeHtml(s) {
171  return String(s ?? "").replace(/[&<>"']/g, (c) => ({
172    "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
173  })[c]);
174}
175
176// ---------------------------------------------------------------- player
177
178const player = (() => {
179  const dock = $("#player");
180  const audio = $("#audio");
181  const art = $("#player-art");
182  const title = $("#player-title");
183  const playBtn = $("#player-play");
184  const playIcon = $("#player-play-icon");
185  const back = $("#player-back");
186  const fwd = $("#player-fwd");
187  const cur = $("#player-current");
188  const dur = $("#player-duration");
189  const scrub = $("#player-scrub");
190  const fill = $("#player-scrub-fill");
191  const knob = $("#player-scrub-knob");
192  const rateBtn = $("#player-rate");
193  const closeBtn = $("#player-close");
194  const vizBars = Array.from(document.querySelectorAll("#player-viz i"));
195
196  const RATES = [1, 1.25, 1.5, 1.75, 2];
197  let rateIdx = 0;
198  let current = null;
199  let vizFrame = null;
200
201  const ICON_PLAY = "M8 5v14l11-7z";
202  const ICON_PAUSE = "M6 5h4v14H6zM14 5h4v14h-4z";
203
204  function toggleEpisode(ep) {
205    if (current && current.guid === ep.guid) {
206      audio.paused ? audio.play() : audio.pause();
207      return;
208    }
209    current = ep;
210    audio.src = ep.audioUrl;
211    art.src = ep.image;
212    title.textContent = ep.title;
213    dock.hidden = false;
214    audio.play().catch(() => {});
215    setMediaSession(ep);
216  }
217
218  function setMediaSession(ep) {
219    if (!("mediaSession" in navigator)) return;
220    navigator.mediaSession.metadata = new MediaMetadata({
221      title: ep.title,
222      artist: "Above the Noise — Shaun Gehring",
223      artwork: [{ src: ep.image, sizes: "512x512", type: "image/jpeg" }],
224    });
225    navigator.mediaSession.setActionHandler("seekbackward", () => skip(-15));
226    navigator.mediaSession.setActionHandler("seekforward", () => skip(15));
227  }
228
229  function skip(s) {
230    audio.currentTime = Math.max(0, Math.min(audio.duration || Infinity, audio.currentTime + s));
231  }
232
233  function fmt(t) {
234    if (!isFinite(t)) return "0:00";
235    const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = Math.floor(t % 60);
236    return (h ? h + ":" + String(m).padStart(2, "0") : m) + ":" + String(s).padStart(2, "0");
237  }
238
239  function syncUI() {
240    const pct = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0;
241    fill.style.width = pct + "%";
242    knob.style.left = pct + "%";
243    cur.textContent = fmt(audio.currentTime);
244    dur.textContent = fmt(audio.duration);
245    scrub.setAttribute("aria-valuenow", Math.round(pct));
246  }
247
248  function syncCards() {
249    document.querySelectorAll(".ep-card").forEach((card) => {
250      const btn = card.querySelector(".ep-card__play");
251      if (!btn) return;
252      const isCurrent = current && card.dataset.guid === current.guid;
253      btn.classList.toggle("playing", Boolean(isCurrent && !audio.paused));
254      btn.querySelector("path").setAttribute("d", isCurrent && !audio.paused ? ICON_PAUSE : ICON_PLAY);
255    });
256  }
257
258  // Procedural visualizer — bars dance while playing. (The media host
259  // doesn't send CORS headers, so a real WebAudio analyser would mute
260  // playback; layered sines look right and cost nothing.)
261  function vizLoop(ts) {
262    vizBars.forEach((bar, i) => {
263      const v = Math.abs(
264        Math.sin(ts / 220 + i * 0.9) * 0.5 +
265        Math.sin(ts / 97 + i * 1.7) * 0.3 +
266        Math.sin(ts / 41 + i * 2.3) * 0.2
267      );
268      bar.style.height = 3 + v * 11 + "px";
269    });
270    vizFrame = requestAnimationFrame(vizLoop);
271  }
272
273  audio.addEventListener("play", () => {
274    playIcon.querySelector("path").setAttribute("d", ICON_PAUSE);
275    if (!REDUCED && vizFrame == null) vizFrame = requestAnimationFrame(vizLoop);
276    syncCards();
277    noiseField.setEnergy(1);
278  });
279  audio.addEventListener("pause", () => {
280    playIcon.querySelector("path").setAttribute("d", ICON_PLAY);
281    if (vizFrame != null) { cancelAnimationFrame(vizFrame); vizFrame = null; }
282    vizBars.forEach((b) => (b.style.height = "3px"));
283    syncCards();
284    noiseField.setEnergy(0);
285  });
286  audio.addEventListener("timeupdate", syncUI);
287  audio.addEventListener("loadedmetadata", syncUI);
288  audio.addEventListener("ended", () => syncCards());
289
290  playBtn.addEventListener("click", () => (audio.paused ? audio.play() : audio.pause()));
291  back.addEventListener("click", () => skip(-15));
292  fwd.addEventListener("click", () => skip(15));
293
294  rateBtn.addEventListener("click", () => {
295    rateIdx = (rateIdx + 1) % RATES.length;
296    audio.playbackRate = RATES[rateIdx];
297    rateBtn.textContent = RATES[rateIdx] + "×";
298  });
299
300  closeBtn.addEventListener("click", () => {
301    audio.pause();
302    audio.removeAttribute("src");
303    audio.load();
304    current = null;
305    dock.hidden = true;
306    syncCards();
307  });
308
309  // scrubbing (pointer + keyboard)
310  function seekFromEvent(e) {
311    const rect = scrub.getBoundingClientRect();
312    const pct = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
313    if (audio.duration) audio.currentTime = pct * audio.duration;
314    syncUI();
315  }
316  scrub.addEventListener("pointerdown", (e) => {
317    scrub.setPointerCapture(e.pointerId);
318    seekFromEvent(e);
319    const move = (ev) => seekFromEvent(ev);
320    const up = () => {
321      scrub.removeEventListener("pointermove", move);
322      scrub.removeEventListener("pointerup", up);
323    };
324    scrub.addEventListener("pointermove", move);
325    scrub.addEventListener("pointerup", up);
326  });
327  scrub.addEventListener("keydown", (e) => {
328    if (e.key === "ArrowLeft") { skip(-5); e.preventDefault(); }
329    if (e.key === "ArrowRight") { skip(5); e.preventDefault(); }
330  });
331
332  // spacebar play/pause when the dock is open and focus isn't in a control
333  document.addEventListener("keydown", (e) => {
334    if (e.code !== "Space" || dock.hidden) return;
335    const t = e.target;
336    if (t instanceof HTMLElement && (t.isContentEditable || /^(input|textarea|
336select|button|summary)$/i.test(t.tagName))) return;
337    e.preventDefault();
338    audio.paused ? audio.play() : audio.pause();
339  });
340
341  return { toggleEpisode };
342})();
343
344// ------------------------------------------------------- noise canvas
345
346const noiseField = (() => {
347  const canvas = $("#noise-canvas");
348  const ctx = canvas.getContext("2d");
349  let W = 0, H = 0, dpr = 1;
350  let particles = [];
351  const mouse = { x: -9999, y: -9999 };
352  let energy = 0;        // 1 while audio plays — the signal wave swells
353  let energyTarget = 0;
354
355  function resize() {
356    dpr = Math.min(window.devicePixelRatio || 1, 2);
357    W = window.innerWidth;
358    H = window.innerHeight;
359    canvas.width = W * dpr;
360    canvas.height = H * dpr;
361    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
362    seed();
363  }
364
365  function seed() {
366    const count = Math.min(380, Math.round((W * H) / 7000));
367    particles = Array.from({ length: count }, () => ({
368      x: Math.random() * W,
369      y: Math.random() * H,
370      r: 0.4 + Math.random() * 1.3,
371      phase: Math.random() * Math.PI * 2,
372      gold: Math.random() < 0.22,
373      vx: 0, vy: 0,
374    }));
375  }
376
377  function frame(ts) {
378    ctx.clearRect(0, 0, W, H);
379    energy += (energyTarget - energy) * 0.04;
380
381    const scrollFade = Math.max(0, 1 - window.scrollY / (H * 1.2));
382
383    // --- static specks: jitter, flee the cursor ---
384    for (const p of particles) {
385      const jx = (Math.random() - 0.5) * 0.7;
386      const jy = (Math.random() - 0.5) * 0.7;
387
388      const dx = p.x - mouse.x;
389      const dy = p.y - mouse.y;
390      const d2 = dx * dx + dy * dy;
391      const R = 150;
392      if (d2 < R * R && d2 > 0.01) {
393        const d = Math.sqrt(d2);
394        const f = ((R - d) / R) * 2.4;
395        p.vx += (dx / d) * f;
396        p.vy += (dy / d) * f;
397      }
398      p.vx *= 0.86;
399      p.vy *= 0.86;
400      p.x += jx + p.vx;
401      p.y += jy + p.vy;
402
403      if (p.x < -10) p.x = W + 10; else if (p.x > W + 10) p.x = -10;
404      if (p.y < -10) p.y = H + 10; else if (p.y > H + 10) p.y = -10;
405
406      const flicker = 0.35 + 0.65 * Math.abs(Math.sin(ts / 700 + p.phase));
407      ctx.globalAlpha = flicker * (p.gold ? 0.5 : 0.22);
408      ctx.fillStyle = p.gold ? "#c9a44a" : "#ece5d3";
409      ctx.fillRect(p.x, p.y, p.r, p.r);
410    }
411
412    // --- the signal line: noisy by default, clean near the cursor ---
413    const waveAlpha = (0.1 + 0.3 * energy) * Math.max(scrollFade, energy * 0.5);
414    if (waveAlpha > 0.01) {
415      const baseY = H * 0.56;
416      const amp = 10 + energy * 16;
417      ctx.beginPath();
418      for (let x = 0; x <= W; x += 5) {
419        // clarity: 1 near cursor x → noise suppressed, pure tone remains
420        const clarity = Math.exp(-Math.pow((x - mouse.x) / 170, 2));
421        const tone = Math.sin(x * 0.012 + ts / 480) * amp;
422        const noise = (Math.random() - 0.5) * 18 * (1 - clarity) * (0.4 + 0.6 * (1 - energy));
423        const y = baseY + tone * (0.55 + 0.45 * clarity) + noise;
424        x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
425      }
426      ctx.globalAlpha = waveAlpha;
427      ctx.strokeStyle = "#c9a44a";
428      ctx.lineWidth = 1.1;
429      ctx.shadowColor = "rgba(201,164,74,0.8)";
430      ctx.shadowBlur = 8;
431      ctx.stroke();
432      ctx.shadowBlur = 0;
433    }
434
435    // --- soft gold glow under the cursor ---
436    if (mouse.x > -100) {
437      const g = ctx.createRadialGradient(mouse.x, mouse.y, 0, mouse.x, mouse.y, 220);
438      g.addColorStop(0, "rgba(201,164,74,0.07)");
439      g.addColorStop(1, "rgba(201,164,74,0)");
440      ctx.globalAlpha = 1;
441      ctx.fillStyle = g;
442      ctx.fillRect(mouse.x - 220, mouse.y - 220, 440, 440);
443    }
444
445    ctx.globalAlpha = 1;
446    requestAnimationFrame(frame);
447  }
448
449  function staticFrame() {
450    // reduced-motion: one quiet, motionless render
451    ctx.clearRect(0, 0, W, H);
452    for (const p of particles) {
453      ctx.globalAlpha = p.gold ? 0.4 : 0.16;
454      ctx.fillStyle = p.gold ? "#c9a44a" : "#ece5d3";
455      ctx.fillRect(p.x, p.y, p.r, p.r);
456    }
457    ctx.globalAlpha = 1;
458  }
459
460  window.addEventListener("resize", () => {
461    resize();
462    if (REDUCED) staticFrame();
463  });
464  window.addEventListener("pointermove", (e) => {
465    mouse.x = e.clientX;
466    mouse.y = e.clientY;
467  });
468  window.addEventListener("pointerleave", () => {
469    mouse.x = -9999;
470    mouse.y = -9999;
471  });
472
473  resize();
474  if (REDUCED) staticFrame();
475  else requestAnimationFrame(frame);
476
477  return { setEnergy: (v) => { energyTarget = v; } };
478})();
479
480// ----------------------------------------------------- custom cursor
481
482(() => {
483  if (COARSE || REDUCED) return;
484  const dot = $("#cursor-dot");
485  const ring = $("#cursor-ring");
486  let rx = 0, ry = 0, tx = 0, ty = 0;
487
488  window.addEventListener("pointermove", (e) => {
489    tx = e.clientX;
490    ty = e.clientY;
491    dot.style.transform = `translate(${tx}px, ${ty}px) translate(-50%, -50%)`;
492    document.body.classList.add("cursor-active");
493    const hot = e.target instanceof Element &&
494      e.target.closest("a, button, summary, [role='slider'], .ep-card");
495    document.body.classList.toggle("cursor-hot", Boolean(hot));
496  });
497  document.addEventListener("mouseleave", () => document.body.classList.remove("cursor-active"));
498
499  (function follow() {
500    rx += (tx - rx) * 0.16;
501    ry += (ty - ry) * 0.16;
502    ring.style.transform = `translate(${rx}px, ${ry}px) translate(-50%, -50%)`;
503    requestAnimationFrame(follow);
504  })();
505})();
506
507// -------------------------------------------------- magnetic buttons
508
509function bindMagnetic(root = document) {
510  if (COARSE || REDUCED) return;
511  root.querySelectorAll("[data-magnetic]").forEach((el) => {
512    if (el.dataset.magneticBound) return;
513    el.dataset.magneticBound = "1";
514    el.addEventListener("mousemove", (e) => {
515      const r = el.getBoundingClientRect();
516      const mx = e.clientX - (r.left + r.width / 2);
517      const my = e.clientY - (r.top + r.height / 2);
518      el.style.transform = `translate(${mx * 0.22}px, ${my * 0.28}px)`;
519    });
520    el.addEventListener("mouseleave", () => {
521      el.style.transition = "transform 0.45s cubic-bezier(0.22, 1, 0.36, 1)";
522      el.style.transform = "";
523      setTimeout(() => (el.style.transition = ""), 450);
524    });
525  });
526}
527
528// ------------------------------------------------------- tilt cards
529
530(() => {
531  if (COARSE || REDUCED) return;
532  document.addEventListener("mousemove", (e) => {
533    const card = e.target instanceof Element && e.target.closest("[data-tilt]");
534    document.querySelectorAll("[data-tilt]").forEach((el) => {
535      if (el !== card) el.style.transform = "";
536    });
537    if (!card) return;
538    const r = card.getBoundingClientRect();
539    const px = (e.clientX - r.left) / r.width;
540    const py = (e.clientY - r.top) / r.height;
541    const rotX = (0.5 - py) * 7;
542    const rotY = (px - 0.5) * 7;
543    card.style.transform = `perspective(800px) rotateX(${rotX}deg) rotateY(${rotY}deg)`;
544    card.style.setProperty("--gx", px * 100 + "%");
545    card.style.setProperty("--gy", py * 100 + "%");
546  });
547})();
548
549// ---------------------------------------------------- scroll reveals
550
551const revealObserver = new IntersectionObserver(
552  (entries) => {
553    entries.forEach((en) => {
554      if (en.isIntersecting) {
555        en.target.classList.add("revealed");
556        revealObserver.unobserve(en.target);
557      }
558    });
559  },
560  { threshold: 0.12 }
561);
562
563function observeReveals(root = document) {
564  root.querySelectorAll("[data-reveal]:not(.revealed)").forEach((el) => revealObserver.observe(el));
565}
566
567// -------------------------------------------------- decode headline
568
569(() => {
570  const el = $("#decode-kicker");
571  const finalText = el.textContent;
572  if (REDUCED) return;
573  const CHARS = "!<>-_\\/[]{}—=+*^?#";
574  const DURATION = 1700;
575  let start = null;
576
577  el.textContent = "";
578  function tick(ts) {
579    if (start == null) start = ts;
580    const progress = Math.min(1, (ts - start) / DURATION);
581    const settled = Math.floor(progress * finalText.length);
582    let out = finalText.slice(0, settled);
583    for (let i = settled; i < finalText.length; i++) {
584      out += finalText[i] === " " ? " " : CHARS[(Math.random() * CHARS.length) | 0];
585    }
586    el.textContent = out;
587    if (progress < 1) requestAnimationFrame(tick);
588    else el.textContent = finalText;
589  }
590  requestAnimationFrame(tick);
591})();
592
593// ------------------------------------------- "NOISE" letter jitter
594
595(() => {
596  if (REDUCED) return;
597  const spans = document.querySelectorAll("#noise-word span");
598  setInterval(() => {
599    spans.forEach((s) => {
600      const x = (Math.random() - 0.5) * 5;
601      const y = (Math.random() - 0.5) * 5;
602      const r = (Math.random() - 0.5) * 2;
603      s.style.transform = `translate(${x}px, ${y}px) rotate(${r}deg)`;
604    });
605  }, 110);
606})();
607
608// ----------------------------------------------------------- ticker
609
610(() => {
611  const phrases = [
612    "No sponsors", "No affiliates", "No agenda", "Cited evidence",
613    "Built by a practitioner", "Claims, never people", "Director to CIO",
614    "Biweekly", "~30 minutes", "The unbiased signal",
615  ];
616  const chunk = phrases.map((p) => `${p}<b>✦</b>`).join("");
617  $("#ticker-track").innerHTML = chunk.repeat(2) + chunk.repeat(2); // 2× for the seamless -50% loop
618})();
619
620// ------------------------------------------------------------- misc
621
622(() => {
623  $("#year").textContent = new Date().getFullYear();
624
625  const nav = document.querySelector(".nav");
626  window.addEventListener("scroll", () => {
627    nav.classList.toggle("scrolled", window.scrollY > 40);
628  }, { passive: true });
629
630  // Playbook download → GA event (the download itself is a plain href)
631  const playbookBtn = $("#playbook-download");
632  if (playbookBtn && typeof gtag === "function") {
633    playbookBtn.addEventListener("click", () => {
634      gtag("event", "file_download", {
635        file_name: "the-enterprise-leaders-ai-playbook.pdf",
636        link_text: "Download the Playbook",
637      });
638    });
639  }
640
641  $("#copy-rss").addEventListener("click", async (e) => {
642    const btn = e.currentTarget;
643    try {
644      await navigator.clipboard.writeText($("#rss-url").textContent.trim());
645      btn.textContent = "Copied ✓";
646    } catch {
647      btn.textContent = "Select it →";
648    }
649    setTimeout(() => (btn.textContent = "Copy"), 2000);
650  });
651})();
652
653// ------------------------------------------------------------- init
654
655(async () => {
656  observeReveals();
657  bindMagnetic();
658  setCanonical();
659
660  FEED = await loadFeed();
661  renderEpisodes(FEED);
662  renderPlatforms(FEED);
663  injectSchema(FEED);
664
665  if (FEED?.channel?.link) $("#about-link").href = FEED.channel.link;
666  if (FEED?.channel?.image) $("#about-cover").src = FEED.channel.image;
667
668  $("#hero-play").addEventListener("click", () => {
669    if (FEED?.episodes?.length) {
670      player.toggleEpisode(FEED.episodes[0]);
671      $("#episodes").scrollIntoView({ behavior: REDUCED ? "auto" : "smooth" });
672    }
673  });
674})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.