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.