PageSourceSearch

https://www.stephanlivera.com/episode-page.js?v=25

js stephanlivera.com collected 2026-10-02 03:04:56 UTC 26,362 bytes, 769 lines download raw bytes

1const MONTHS = [
2  'January', 'February', 'March', 'April', 'May', 'June',
3  'July', 'August', 'September', 'October', 'November', 'December',
4];
5
6function formatDate(iso) {
7  const [y, m, d] = iso.split('-').map(Number);
8  return `${d} ${MONTHS[m - 1]} ${y}`;
9}
10
11function episodeUrl(number) {
12  return `/${Number(number)}`;
13}
14
15/** Parse "MM:SS" or "HH:MM:SS" (also "00:MM:SS") into seconds for audio seek. */
16function parseTimestampToSeconds(time) {
17  if (!time || typeof time !== 'string') return NaN;
18  const parts = time.trim().split(':').map((p) => Number(p));
19  if (parts.some((n) => Number.isNaN(n))) return NaN;
20  if (parts.length === 2) return parts[0] * 60 + parts[1];
21  if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
22  return NaN;
23}
24
25function setEpisodePlayingUi(playing) {
26  const playBtn = document.getElementById('ep-play');
27  if (!playBtn) return;
28  playBtn.classList.toggle('playing', playing);
29  const label = playBtn.querySelector('span:last-child');
30  if (label) label.textContent = playing ? 'Pause Stream' : 'Play Transmission';
31}
32
33function wireChapterSeek(listEl, audio) {
34  if (!listEl || !audio) return;
35  listEl.querySelectorAll('[data-seek]').forEach((button) => {
36    button.addEventListener('click', () => {
37      const seconds = Number(button.dataset.seek);
38      if (Number.isNaN(seconds)) return;
39      const seek = () => {
40        audio.currentTime = seconds;
41        audio.play().catch(() => {});
42        setEpisodePlayingUi(true);
43      };
44      if (audio.readyState >= 1) {
45        seek();
46      } else {
47        audio.addEventListener('loadedmetadata', seek, { once: true });
48        audio.load();
49      }
50    });
51  });
52}
53
54function getEpisodeNumber() {
55  const pathMatch = window.location.pathname.match(/(?:^|\/)(\d+)\/?$/);
56  if (pathMatch) return pathMatch[1];
57  const params = new URLSearchParams(window.location.search);
58  return params.get('n');
59}
60
61function episodeDataUrl(number) {
62  return `data/episodes/${Number(number)}.json`;
63}
64
65function absoluteUrl(path) {
66  if (!path) return window.location.origin + '/';
67  if (/^https?:\/\//i.test(path)) return path;
68  const base = window.location.origin;
69  return `${base}${path.startsWith('/') ? path : `/${path}`}`;
70}
71
72/** Convert "MM:SS" or "HH:MM:SS" to ISO 8601 duration (e.g. PT1H26M35S). */
73function durationToIso8601(duration) {
74  if (!duration || typeof duration !== 'string') return undefined;
75  const parts = duration.trim().split(':').map((p) => Number(p));
76  if (parts.some((n) => Number.isNaN(n))) return undefined;
77  let h = 0;
78  let m = 0;
79  let s = 0;
80  if (parts.length === 3) [h, m, s] = parts;
81  else if (parts.length === 2) [m, s] = parts;
82  else return undefined;
83  let out = 'PT';
84  if (h) out += `${h}H`;
85  if (m || h) out += `${m}M`;
86  out += `${s}S`;
87  return out;
88}
89
90/** Split guest line into Person nodes for schema.org. */
91function guestPersons(guest) {
92  if (!guest || typeof guest !== 'string') return [];
93  return guest
94    .split(/\s*[,&]\s*|\s+and\s+/i)
95    .map((name) => name.trim())
96    .filter((name) => name && !/^more$/i.test(name))
97    .map((name) => ({ '@type': 'Person', name }));
98}
99
100/**
101 * Inject or update PodcastEpisode JSON-LD for search engines and AI crawlers.
102 * When a transcript is available, attach it on AudioObject.transcript (schema.org).
103 */
104function injectEpisodeJsonLd(ep, transcriptPlainText = null) {
105  const pageUrl = absoluteUrl(episodeUrl(ep.number));
106  const withdrawn = Boolean(ep.withdrawn);
107  const audioObject = !withdrawn && ep.audio
108    ? {
109        '@type': 'AudioObject',
110        contentUrl: ep.audio,
111        encodingFormat: 'audio/mpeg',
112        name: `${ep.id} — ${ep.title}`,
113      }
114    : null;
115  const isoDuration = withdrawn ? undefined : durationToIso8601(ep.duration);
116  if (audioObject && isoDuration) audioObject.duration = isoDuration;
117  if (audioObject && transcriptPlainText) {
118    audioObject.transcript = transcriptPlainText;
119  }
120
121  const guests = withdrawn ? [] : guestPersons(ep.guest);
122  const schema = {
123    '@context': 'https://schema.org',
124    '@type': 'PodcastEpisode',
125    '@id': pageUrl,
126    url: pageUrl,
127    name: ep.title,
128    headline: `${ep.id} — ${ep.title}`,
129    description: ep.desc || ep.summary || undefined,
130    datePublished: ep.date || undefined,
131    episodeNumber: Number(ep.number),
132    duration: isoDuration,
133    inLanguage: 'en',
134    isAccessibleForFree: true,
135    creativeWorkStatus: withdrawn ? 'Unavailable' : undefined,
136    partOfSeries: {
137      '@type': 'PodcastSeries',
138      name: 'Stephan Livera Podcast',
139      url: absoluteUrl('/'),
140    },
141    author: {
142      '@type': 'Person',
143      name: 'Stephan Livera',
144      url: absoluteUrl('/'),
145    },
146    publisher: {
147      '@type': 'Person',
148      name: 'Stephan Livera',
149      url: absoluteUrl('/'),
150    },
151    associatedMedia: audioObject || undefined,
152    audio: audioObject || undefined,
153  };
154
155  if (guests.length) {
156    schema.actor = guests;
157    schema.contributor = guests;
158  }
159
160  if (transcriptPlainText) {
161    // Machine-readable pointer to the dialogue JSON (internal format under /schema/).
162    schema.workExample = {
163      '@type': 'MediaObject',
164      encodingFormat: 'application/json',
165      contentUrl: absoluteUrl(transcriptUrl(ep)),
166      name: `${ep.id} transcript (JSON)`,
167      description:
168        'Timestamped speaker-segment transcript used by the episode page UI.',
169    };
170  }
171
172  // Drop undefined keys so validators stay clean.
173  const prune = (value) => {
174    if (Array.isArray(value)) return value.map(prune).filter((v) => v !== undefined);
175    if (value && typeof value === 'object') {
176      const out = {};
177      for (const [k, v] of Object.entries(value)) {
178        const cleaned = prune(v);
179        if (cleaned !== undefined) out[k] = cleaned;
180      }
181      return out;
182    }
183    return value === undefined ? undefined : value;
184  };
185
186  let el = document.getElementById('episode-json-ld');
187  if (!el) {
188    el = document.createElement('script');
189    el.type = 'application/ld+json';
190    el.id = 'episode-json-ld';
191    document.head.appendChild(el);
192  }
193  el.textContent = JSON.stringify(prune(schema));
194}
195
196/** Canonical + Open Graph tags. Middleware already sets these for crawlers; keep the DOM in sync. */
197function injectEpisodeHeadMeta(ep) {
198  const pageUrl = absoluteUrl(episodeUrl(ep.number));
199  const title = `${ep.id} — ${ep.title} · Stephan Livera Podcast`;
200  let description = (ep.desc || ep.summary || 'Stephan Livera Podcast episode').replace(/\s+/g, ' ').trim();
201  const guest = (ep.guest || '').trim();
202  if (guest && !ep.withdrawn && !description.toLowerCase().startsWith('with ')) {
203    description = `with ${guest}. ${description}`;
204  }
205  const image = absoluteUrl(`/assets/og/${ep.number}.jpg`);
206  const imageAlt = `${ep.id} — ${ep.title}`;
207
208  document.title = title;
209
210  const descMeta = document.querySelector('meta[name="description"]');
211  if (descMeta) descMeta.content = description;
212
213  const setMeta = (attr, key, content) => {
214    let el = document.querySelector(`meta[${attr}="${key}"]`);
215    if (!el) {
216      el = document.createElement('meta');
217      el.setAttribute(attr, key);
218      document.head.appendChild(el);
219    }
220    el.setAttribute('content', content);
221  };
222
223  setMeta('property', 'og:type', 'website');
224  setMeta('property', 'og:site_name', 'Stephan Livera Podcast');
225  setMeta('property', 'og:title', title);
226  setMeta('property', 'og:description', description);
227  setMeta('property', 'og:url', pageUrl);
228  setMeta('property', 'og:image', image);
229  setMeta('property', 'og:image:width', '1200');
230  setMeta('property', 'og:image:height', '630');
231  setMeta('property', 'og:image:type', 'image/jpeg');
232  setMeta('property', 'og:image:alt', imageAlt);
233  setMeta('name', 'twitter:card', 'summary_large_image');
234  setMeta('name', 'twitter:site', '@stephanlivera');
235  setMeta('name', 'twitter:title', title);
236  setMeta('name', 'twitter:description', description);
237  setMeta('name', 'twitter:image', image);
238  setMeta('name', 'twitter:image:alt', imageAlt);
239
240  let canonical = document.querySelector('link[rel="canonical"]');
241  if (!canonical) {
242    canonical = document.createElement('link');
243    canonical.rel = 'canonical';
244    document.head.appendChild(canonical);
245  }
246  canonical.href = pageUrl;
247}
248
249/** Advertise the transcript JSON as an alternate representation for machine agents. */
250function injectTranscriptAlternateLink(ep, present) {
251  let link = document.getElementById('transcript-alternate');
252  if (!present) {
253    if (link) link.remove();
254    return;
255  }
256  if (!link) {
257    link = document.createElement('link');
258    link.id = 'transcript-alternate';
259    link.rel = 'alternate';
260    link.type = 'application/json';
261    link.title = 'Episode transcript';
262    document.head.appendChild(link);
263  }
264  link.href = absoluteUrl(transcriptUrl(ep));
265}
266
267async function fetchEpisode(number) {
268  try {
269    const response = await fetch(episodeDataUrl(number));
270    if (!response.ok) return null;
271    return await response.json();
272  } catch {
273    return null;
274  }
275}
276
277/** Update a .cmd-type prompt. If the typewriter already ran, rewrite the visible text. */
278function setTypedCommand(el, command) {
279  if (!el) return;
280  el.dataset.cmd = command;
281  if (el.dataset.typed === 'true') {
282    el.textContent = command;
283  }
284}
285
286function renderNotFound() {
287  // Production serves static e/N.html via rewrite (or branded 404.html).
288  // This is a client fallback for /episode.html?n=… without the rewrite.
289  document.title = '404 — This page doesn’t exist · Stephan Livera Podcast';
290  document.body.classList.add('episode-not-found');
291
292  const panelTitle = document.querySelector('.episode-panel .panel-title');
293  if (panelTitle) panelTitle.textContent = 'ROUTE_ERROR';
294
295  const fetchCmd = document.getElementById('fetch-cmd');
296  if (fetchCmd) {
297    const path = `${location.pathname}${location.search}` || '/';
298    fetchCmd.dataset.cmd = `open --path ${path}`;
299    fetchCmd.textContent = fetchCmd.dataset.cmd;
300  }
301
302  document.getElementById('ep-id').textContent = 'TRANSMISSION LOST';
303  document.getElementById('ep-title').textContent = 'This page doesn’t exist';
304
305  const summary = document.getElementById('ep-summary');
306  if (summary) {
307    summary.textContent = '';
308    summary.hidden = true;
309  }
310
311  const guest = document.getElementById('ep-guest');
312  if (guest) {
313    guest.textContent = '';
314    guest.hidden = true;
315  }
316
317  const checksum = document.getElementById('ep-checksum');
318  if (checksum) checksum.hidden = true;
319
320  const stats = document.querySelector('.episode-stats');
321  if (stats) stats.hidden = true;
322
323  const playerWrap = document.querySelector('.episode-player-wrap');
324  if (playerWrap) {
325    playerWrap.hidden = true;
326    playerWrap.setAttribute('aria-hidden', 'true');
327  }
328  const playBtn = document.getElementById('ep-play');
329  if (playBtn) {
330    playBtn.hidden = true;
331    playBtn.disabled = true;
332  }
333  const audioEl = document.getElementById('audio-element');
334  if (audioEl) {
335    audioEl.hidden = true;
336    audioEl.removeAttribute('src');
337    audioEl.load?.();
338  }
339
340  document.getElementById('nav-prev').style.display = 'none';
341  document.getElementById('nav-next').style.display = 'none';
342  const columns = document.querySelector('.episode-columns');
343  if (columns) columns.hidden = true;
344  const transcriptSection = document.querySelector('.episode-transcript-section');
345  if (transcriptSection) transcriptSection.hidden = true;
346
347  // Single exit: return home (no play controls on a missing episode)
348  let actions = document.getElementById('ep-not-found-actions');
349  if (!actions && summary?.parentElement) {
350    actions = document.createElement('div');
351    actions.id = 'ep-not-found-actions';
352    actions.className = 'hero-actions error-actions';
353    actions.innerHTML = `
354      <a href="/" class="btn-play error-home-btn">
355        <span class="play-icon">◀</span>
356        <span>Return Home</span>
357      </a>
358    `;
359    summary.insertAdjacentElement('afterend', actions);
360  }
361
362  // Soft signal to crawlers if this shell ever loads as 200
363  if (!document.querySelector('meta[name="robots"][content*="noindex"]')) {
364    const robots = document.createElement('meta');
365    robots.name = 'robots';
366    robots.content = 'noindex';
367    document.head.appendChild(robots);
368  }
369}
370
371function renderLoading() {
372  document.getElementById('ep-title').textContent = 'Loading transmission…';
373  document.getElementById('ep-summary').textContent = 'Fetching episode packet…';
374}
375
376function hidePlaybackSurfaces() {
377  const playerWrap = document.querySelector('.episode-player-wrap');
378  if (playerWrap) {
379    playerWrap.hidden = true;
380    playerWrap.setAttribute('aria-hidden', 'true');
381  }
382  const playBtn = document.getElementById('ep-play');
383  if (playBtn) {
384    playBtn.hidden = true;
385    playBtn.disabled = true;
386  }
387  const audioEl = document.getElementById('audio-element');
388  if (audioEl) {
389    audioEl.hidden = true;
390    audioEl.removeAttribute('src');
391    audioEl.load?.();
392  }
393}
394
395function renderWithdrawn(ep) {
396  document.body.classList.add('episode-withdrawn');
397
398  injectEpisodeHeadMeta(ep);
399  injectEpisodeJsonLd(ep);
400  injectTranscriptAlternateLink(ep, false);
401
402  const panelTitle = document.querySelector('.episode-panel .panel-title');
403  if (panelTitle) panelTitle.textContent = 'TRANSMISSION_REMOVED';
404
405  document.getElementById('header-ep-num').textContent = ep.number;
406  document.getElementById('nav-command').textContent = `cat ${ep.id.toLowerCase()}.log`;
407  const fetchCmd = document.getElementById('fetch-cmd');
408  fetchCmd.dataset.cmd = `open --transmission ${ep.id}`;
409  fetchCmd.textContent = fetchCmd.dataset.cmd;
410
411  document.getElementById('ep-id').textContent = `TRANSMISSION ${ep.id}`;
412  const checksumEl = document.getElementById('ep-checksum');
413  if (checksumEl) checksumEl.textContent = episodeChecksum(ep.number, ep.title);
414
415  document.getElementById('ep-title').textContent = ep.title;
416
417  const guest = document.getElementById('ep-guest');
418  if (guest) {
419    guest.textContent = '';
420    guest.hidden = true;
421  }
422  const guestShort = document.getElementById('ep-guest-short');
423  if (guestShort) {
424    guestShort.textContent = '';
425    guestShort.closest('.stat-cell')?.setAttribute('hidden', '');
426  }
427  const durationEl = document.getElementById('ep-duration');
428  if (durationEl) {
429    durationEl.textContent = '';
430    durationEl.closest('.stat-cell')?.setAttribute('hidden', '');
431  }
432
433  document.getElementById('ep-date').textContent = ep.date ? formatDate(ep.date) : '—';
434  document.getElementById('ep-summary').textContent =
435    ep.summary || ep.desc || 'This episode has been removed.';
436
437  hidePlaybackSurfaces();
438
439  const timestampsList = document.getElementById('ep-timestamps');
440  const timestampsSection = timestampsList?.closest('.episode-section');
441  if (timestampsList) timestampsList.innerHTML = '';
442  if (timestampsSection) timestampsSection.hidden = true;
443
444  const linksList = document.getElementById('ep-links');
445  const linksSection = linksList?.closest('.episode-section');
446  if (linksList) linksList.innerHTML = '';
447  if (linksSection) linksSection.hidden = true;
448
449  const transcriptSection = document.querySelector('.episode-transcript-section');
450  if (transcriptSection) transcriptSection.hidden = true;
451
452  const prev = ep.prev || null;
453  const next = ep.next || null;
454  const prevLink = document.getElementById('nav-prev');
455  const nextLink = document.getElementById('nav-next');
456
457  if (prev) {
458    prevLink.href = episodeUrl(prev.number);
459  } else {
460    prevLink.style.opacity = '0.35';
461    prevLink.style.pointerEvents = 'none';
462  }
463
464  if (next) {
465    nextLink.href = episodeUrl(next.number);
466  } else {
467    nextLink.style.opacity = '0.35';
468    nextLink.style.pointerEvents = 'none';
469  }
470
471  document.getElementById('ep-adjacent').innerHTML = [prev, next]
472    .filter(Boolean)
473    .map(
474      (adj) => `
475        <a href="${escapeHtml(episodeUrl(adj.number))}" class="adjacent-card holo-tilt">
476          <span class="adjacent-id">${escapeHtml(adj.id)}</span>
477          <span class="adjacent-title">${escapeHtml(adj.title)}</span>
478          <span class="adjacent-meta">${escapeHtml([adj.duration, adj.date ? formatDate(adj.date) : ''].filter(Boolean).join(' · '))}</span>
479        </a>
480      `
481    )
482    .join('');
483
484  if (typeof refreshHoloTilt === 'function') refreshHoloTilt();
485}
486
487function renderEpisode(ep) {
488  if (ep.withdrawn) {
489    renderWithdrawn(ep);
490    return;
491  }
492
493  const prev = ep.prev || null;
494  const next = ep.next || null;
495
496  injectEpisodeHeadMeta(ep);
497  // PodcastEpisode without transcript text first; enriched once dialogue JSON loads.
498  // Leave a build-stamped transcript link in place until that fetch finishes.
499  injectEpisodeJsonLd(ep);
500
501  document.getElementById('header-ep-num').textContent = ep.number;
502  document.getElementById('nav-command').textContent = `cat ${ep.id.toLowerCase()}.log`;
503  const fetchCmd = document.getElementById('fetch-cmd');
504  fetchCmd.dataset.cmd = `open --transmission ${ep.id}`;
505  fetchCmd.textContent = fetchCmd.dataset.cmd;
506
507  setTypedCommand(document.getElementById('decode-cmd'), `decode --transmission ${ep.id}`);
508
509  document.getElementById('ep-id').textContent = `TRANSMISSION ${ep.id}`;
510  const checksumEl = document.getElementById('ep-checksum');
511  if (checksumEl) checksumEl.textContent = episodeChecksum(ep.number, ep.title);
512  document.getElementById('ep-title').textContent = ep.title;
513  document.getElementById('ep-guest').textContent =
514    `with ${ep.guest}${ep.guestRole ? ` · ${ep.guestRole}` : ''}`;
515  document.getElementById('ep-date').textContent = formatDate(ep.date);
516  document.getElementById('ep-duration').textContent = ep.duration;
517  document.getElementById('ep-guest-short').textContent = ep.guest.split(',')[0].split('&')[0].trim();
518  document.getElementById('ep-summary').textContent = ep.summary;
519
520  const audio = document.getElementById('audio-element');
521  audio.src = ep.audio;
522
523  const playBtn = document.getElementById('ep-play');
524  playBtn.addEventListener('click', () => {
525    if (!audio.paused) {
526      audio.pause();
527      setEpisodePlayingUi(false);
528      return;
529    }
530    audio.play().catch(() => {});
531    setEpisodePlayingUi(true);
532  });
533
534  audio.addEventListener('play', () => setEpisodePlayingUi(true));
535  audio.addEventListener('pause', () => {
536    if (!audio.ended && audio.currentTime > 0) {
537      setEpisodePlayingUi(false);
538      return;
539    }
540    setEpisodePlayingUi(false);
541  });
542  audio.addEventListener('ended', () => setEpisodePlayingUi(false));
543
544  const timestamps = ep.timestamps || [];
545  const timestampsList = document.getElementById('ep-timestamps');
546  const timestampsSection = timestampsList?.closest('.episode-section');
547  if (timestamps.length) {
548    timestampsList.innerHTML = timestamps
549      .map(([time, label]) => {
550        const seconds = parseTimestampToSeconds(time);
551        const seekAttr = Number.isFinite(seconds) ? ` data-seek="${seconds}"` : '';
552        const clickable = Number.isFinite(seconds);
553        const safeTime = escapeHtml(time);
554        const safeLabel = escapeHtml(label);
555        return `
556        <li class="timestamp-item${clickable ? ' timestamp-item--seek' : ''}">
557          <button type="button" class="timestamp-seek"${seekAttr}${clickable ? '' : ' disabled'} aria-label="Seek to ${safeTime}: ${safeLabel}">
558            <span class="timestamp-time">${safeTime}</span>
559            <span class="timestamp-label">${safeLabel}</span>
560          </button>
561        </li>
562      `;
563      })
564      .join('');
565    if (timestampsSection) timestampsSection.hidden = false;
566    wireChapterSeek(timestampsList, audio);
567  } else {
568    timestampsList.innerHTML = '';
569    if (timestampsSection) timestampsSection.hidden = true;
570  }
571
572  const links = ep.links || [];
573  const linksSection = document.getElementById('ep-links')?.closest('.episode-section');
574  if (links.length) {
575    document.getElementById('ep-links').innerHTML = links
576      .map(
577        (link) => `
578        <li>
579          <a href="${escapeHtml(link.url)}" class="reference-link" target="_blank" rel="noopener">
580            <span class="sub-icon">◇</span> ${escapeHtml(link.label)}
581          </a>
582        </li>
583      `
584      )
585      .join('');
586    if (linksSection) linksSection.hidden = false;
587  } else {
588    document.getElementById('ep-links').innerHTML = '';
589    if (linksSection) linksSection.hidden = true;
590  }
591
592  const prevLink = document.getElementById('nav-prev');
593  const nextLink = document.getElementById('nav-next');
594
595  if (prev) {
596    prevLink.href = episodeUrl(prev.number);
597  } else {
598    prevLink.style.opacity = '0.35';
599    prevLink.style.pointerEvents = 'none';
600  }
601
602  if (next) {
603    nextLink.href = episodeUrl(next.number);
604  } else {
605    nextLink.style.opacity = '0.35';
606    nextLink.style.pointerEvents = 'none';
607  }
608
609  document.getElementById('ep-adjacent').innerHTML = [prev, next]
610    .filter(Boolean)
611    .map(
612      (adj) => `
613        <a href="${escapeHtml(episodeUrl(adj.number))}" class="adjacent-card holo-tilt">
614          <span class="adjacent-id">${escapeHtml(adj.id)}</span>
615          <span class="adjacent-title">${escapeHtml(adj.title)}</span>
616          <span class="adjacent-meta">${escapeHtml([adj.duration, adj.date ? formatDate(adj.date) : ''].filter(Boolean).join(' · '))}</span>
617        </a>
618      `
619    )
620    .join('');
621
622  // Load transcript by default (show notes + chapters stay above).
623  renderTranscript(ep, audio);
624  if (typeof refreshHoloTilt === 'function') refreshHoloTilt();
625}
626
627function renderTranscriptUnavailable(ep) {
628  const statusEl = document.getElementById('transcript-status');
629  statusEl.textContent = 'NOT AVAILABLE';
630  statusEl.classList.remove('decoded');
631  statusEl.classList.add('unavailable');
632
633  const toolbar = document.querySelector('.transcript-toolbar');
634  if (toolbar) toolbar.hidden = true;
635
636  injectTranscriptAlternateLink(ep, false);
637  // Keep PodcastEpisode schema; omit transcript property when none exists.
638  injectEpisodeJsonLd(ep);
639
640  const container = document.getElementById('transcript-content');
641  container.innerHTML = `
642    <div class="transcript-unavailable">
643      <span class="transcript-unavailable-icon" aria-hidden="true">◇</span>
644      <p class="transcript-unavailable-title">Transcript not available</p>
645      <p class="transcript-unavailable-desc">No transcript has been indexed for ${escapeHtml(ep.id)} yet. Listen to the episode above — transcripts will be added here in a future update.</p>
646    </div>
647  `;
648}
649
650/** Show a button instead of auto-fetching ~50–100KB transcript JSON. */
651function setupTranscriptLazy(ep, audio) {
652  const statusEl = document.getElementById('transcript-status');
653  const toolbar = document.querySelector('.transcript-toolbar');
654  const container = document.getElementById('transcript-content');
655  const searchInput = document.getElementById('transcript-search');
656
657  statusEl.textContent = 'STANDBY';
658  statusEl.classList.remove('decoded', 'unavailable');
659  if (toolbar) toolbar.hidden = true;
660  if (searchInput) searchInput.value = '';
661
662  container.innerHTML = `
663    <div class="transcript-unavailable">
664      <span class="transcript-unavailable-icon" aria-hidden="true">◇</span>
665      <p class="transcript-unavailable-title">Transcript on demand</p>
666      <p class="transcript-unavailable-desc">Decode the full transcript buffer when you need it — saves data on slower connections.</p>
667      <button type="button" class="btn-transcript" id="btn-load-transcript">
668        <span class="sub-icon">◇</span> Decode transcript
669      </button>
670    </div>
671  `;
672
673  const btn = document.getElementById('btn-load-transcript');
674  btn?.addEventListener('click', () => {
675    btn.disabled = true;
676    renderTranscript(ep, audio);
677  });
678}
679
680async function renderTranscript(ep, audio) {
681  const statusEl = document.getElementById('transcript-status');
682  const toolbar = document.querySelector('.transcript-toolbar');
683  const container = document.getElementById('transcript-content');
684  const searchInput = document.getElementById('transcript-search');
685
686  statusEl.textContent = 'DECODING…';
687  statusEl.classList.remove('decoded', 'unavailable');
688  if (toolbar) toolbar.hidden = true;
689  if (searchInput) searchInput.value = '';
690  container.innerHTML = `
691    <div class="transcript-unavailable">
692      <span class="transcript-unavailable-icon" aria-hidden="true">◇</span>
693      <p class="transcript-unavailable-title">Loading transcript</p>
694      <p class="transcript-unavailable-desc">Indexing ${escapeHtml(ep.id)} transcript buffer…</p>
695    </div>
696  `;
697
698  try {
699    const response = await fetch(transcriptUrl(ep));
700    if (!response.ok) throw new Error('Transcript not found');
701
702    const transcript = await response.json();
703    if (!transcript.segments?.length) throw new Error('Transcript empty');
704
705    statusEl.textContent = 'DECODED';
706    statusEl.classList.remove('unavailable');
707    statusEl.classList.add('decoded');
708    if (toolbar) toolbar.hidden = false;
709
710    container.innerHTML = renderTranscriptEntries(transcript);
711    wireTranscriptToolbar(ep, transcript, audio);
712
713    // schema.org PodcastEpisode + AudioObject.transcript for crawlers / AI.
714    const plain = transcriptToPlainText(ep, transcript);
715    injectEpisodeJsonLd(ep, plain);
716    injectTranscriptAlternateLink(ep, true);
717  } catch {
718    renderTranscriptUnavailable(ep);
719  }
720}
721
722function startClock() {
723  const clockEl = document.getElementById('clock');
724  if (!clockEl) return;
725  function tick() {
726    const now = new Date();
727    const h = String(now.getHours()).padStart(2, '0');
728    const m = String(now.getMinutes()).padStart(2, '0');
729    const s = String(now.getSeconds()).padStart(2, '0');
730    clockEl.textContent = `${h}:${m}:${s} · NODE ACTIVE`;
731  }
732  tick();
733  setInterval(tick, 1000);
734}
735
736function resetEpisodeScroll() {
737  if ('scrollRestoration' in history) {
738    history.scrollRestoration = 'manual';
739  }
740  window.scrollTo(0, 0);
741}
742
743async function init() {
744  resetEpisodeScroll();
745  startClock();
746  initTier1Effects();
747  scheduleDeferredEffects();
748
749  const number = getEpisodeNumber();
750  if (!number) {
751    renderNotFound();
752    return;
753  }
754
755  renderLoading();
756  const ep = await fetchEpisode(number);
757
758  if (!ep) {
759    renderNotFound();
760    return;
761  }
762
763  renderEpisode(ep);
764  if (!ep.withdrawn) {
765    wireAudioTransmission(document.getElementById('audio-element'));
766  }
767}
768
769document.addEventListener('DOMContentLoaded', init);

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.