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 "&": "&", "<": "<", ">": ">", '"': """, "'": "'", 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.