Smart Links (Listen Pages)
The (listen) SvelteKit route group serves the public smart-link pages on listen.podcasterplus.com: a podcast landing page at /{podcastSlug} and an episode page at /{podcastSlug}/e/{episodeSlug} (the /e/ segment avoids a route-pattern conflict with the public booking page's /{slug}/{slug} shape). Canonical URL builders: getListenUrl() / getListenEpisodeUrl() in src/lib/constants/distribution-platforms.ts.
Host routing
src/routes/(listen)/+layout.server.ts gates the group on locals.isPublicListen (set by the subdomain handler in src/hooks.server.ts). Requests that are not on the listen subdomain redirect 302 to https://app.podcasterplus.com, except in development, where localhost hosts and a ?subdomain=listen query param are allowed through.
Data loading
Both pages load in +page.server.ts on the service-role admin client (createAdminClient), mirroring the public booking page: the tables are RLS-protected and there is no session on this surface. Missing admin configuration (no PUBLIC_SUPABASE_URL / SUPABASE_SECRET_KEY) fails closed with a 503, never a partial page.
Podcast page (src/routes/(listen)/[podcastSlug]/+page.server.ts)
- Podcast lookup 404s unless the show is
status IN ('active', 'paused')ANDhosting_type = 'podcasterplus'. Externally hosted and deleted/pending-deletion shows have no listen page. - Three secondary reads run in parallel (directory listings, episodes, funding links); a failure in any one degrades to an empty section while the hero and RSS badge still render.
- Episodes: published,
published_atin the past, not blocked, newest first. The page server-renders 5 (LISTEN_INITIAL_EPISODES) and over-fetches by one sohasMoreEpisodesneeds no count query; the client pages the rest through the public API (GET /v1/podcasts/{slug}/episodes, served by the public-api worker). - Funding links ("Support the show"):
podcast_fundingrows ordered bydisplay_order, filtered to rows with both a URL and a title. - Author-supplied HTML descriptions are stripped to plain text server-side (
toPlainText); the meta description is a single trimmed line (toMetaDescription).
Episode page (src/routes/(listen)/[podcastSlug]/e/[episodeSlug]/+page.server.ts)
Same posture: same podcast gate, then the episode must be published, unblocked, and not future-dated, otherwise 404. This page is the share target for self-hosted episodes and the episode-level oEmbed target. It returns the episode's audio fields, season/episode numbers, cover art (falling back to podcast art in the page component), and, since #297 item 4, the same confirmed directory listings as the landing page (a secondary read that degrades to an empty grid on failure).
Platform badges
Both pages' "Listen on" grids render only confirmed listed rows: the loads query podcast_directory_listings with status = 'listed' AND confirmed = true AND a non-null listing_url, so unconfirmed fuzzy Spotify detections and merely-submitted platforms never appear publicly. The grid itself is the shared BadgeLinkSection component (src/lib/components/listen/), reused by the landing page (listen-on + funding) and the episode page (listen-on), so the two surfaces cannot drift. Badge labels resolve client-side through the catalog: getPlatform(key)?.name ?? key, so a listing row whose platform key has left the catalog still renders (as the raw key) rather than crashing. An RSS badge linking to getFeedUrl(slug) always renders, and is the only badge when no listings are confirmed yet.
Page composition (2026-08-12 redesign)
Both pages are built from src/lib/components/listen/: SubscribeCard, PeopleRow + PersonAvatar, ShowFacts, ExpandableText, EpisodeCard, TranscriptReader and the older BadgeLinkSection. Per-show colour arrives as CSS custom properties from src/lib/listen/brand.ts, which resolves contrast with the SAME helpers the embed player uses (embed-player/contrast.ts) so a page and the player inside it cannot disagree about what is readable; brand ink is held to 4.5:1 because it is used for prose links, where the player only needs 3:1 for large marks. Both themes are emitted at once (--brand / --brand-d …) and a :global(.dark) rule promotes the dark set, so theme switching needs no JavaScript.
People come from episode_credits and nothing else. It is the only person-layer table that is closed-set (episode_credits_role_label_check) and carries no email, token or private-note column, and everything in it is already public because the feed emits it as <podcast:person>. episode_people sits one FK from episode_guests (magic-link access tokens, guest emails, host-private notes) and its role_label is unconstrained free text. Display order is host, co-host, guest, then crew (src/lib/listen/people.ts CREDIT_DISPLAY_ORDER), deliberately NOT the catalogue order, which puts Producer above Guest.
The subscribe card features four apps (Apple, Spotify, YouTube, Pocket Casts by listener share, ruled 2026-08-12) and collapses the rest; splitSubscribeLinks backfills empty featured slots from the remainder so a niche show still gets a full card.
Landing-page reads are one parallel wave: listings, episodes, funding, a head:true episode count, and loadShowPeople. That last one orders by the EMBEDDED episode's publish date (referencedTable: 'episodes') rather than the credit's own created_at, because an import writes every credit at the same instant and created_at would order a back catalogue arbitrarily. Verified against staging PostgREST before it was relied on.
episode_credits.avatar_url and external_url are a SNAPSHOT written when the credit row is created, not a live view of the person's account. Someone who fills in their website after an episode was credited has the link on later credits and NULL on earlier ones, and because this page only samples PUBLISHED episodes it can end up seeing nothing but the NULLs — the link is set in settings and the public page silently omits it. That is exactly what was reported on 2026-08-13: every published episode of the E2E show had NULL, while every credit carrying the URL sat on a draft.
Both loaders therefore fall back to the credited user's own user_profiles.website_url / avatar_url, which is what src/api/routes/episode-people/index.ts and the publish handoff already did. The credit's own value still wins where it has one, since that is the author's deliberate per-episode choice. user_id is selected purely to key that lookup: it is an auth UUID, it never appears in a ListenPerson, and it never reaches the browser. The profile URL goes through safeHttpUrl like any other author-supplied link.
The RSS feed applies the same fallback, so a person cannot be linked on the listen page and unlinked in <podcast:person>. It has two read paths and both carry it: the Hyperdrive query COALESCEs over a LEFT JOIN user_profiles, and the REST fallback does a second batched request and merges in JS. The merge cannot be a PostgREST embed because episode_credits.user_id references auth.users, not user_profiles, so there is no relationship to traverse. validateUrl still pins http/https before emitting href, so the profile value gets no special trust for arriving from another table.
Any change to that query requires an rss-feed redeploy — the feed is generated in the worker, not the app.
Page layout
Both pages are built from the Claude Design frames, and both have genuinely different desktop and mobile layouts rather than one column that stretches. The (listen) layout imposes NO width or padding — each page owns its measure, because the identity band is full-bleed while its contents are held to 1000px.
The reflows are done with CSS grid areas rather than duplicated markup, so there is exactly one of each control in the DOM at any width:
| Region | Phone (1a / 2a) | Desktop (1c / 2c) |
|---|---|---|
Landing .identity | art beside the name; chips and description full width beneath | art down the left, all three stacked beside it |
Landing .band-top | identity, subscribe card, then links | identity + links in one column, subscribe in a 264px rail |
Episode .body | single column, rail cards continue it | main column + 264px rail, footer under main |
Reader .reader-top | close + title, player, search | title + search + close on one row, player beneath |
Duplicating the reader's close button and search field per breakpoint would have been the easy version and would have doubled the tab stops and repeated the input id.
The transcript reader
TranscriptReader.svelte holds the episode page's ONLY PlayerCore. Closed, its shell is display: contents, so the player participates in the page's normal flow; open, the shell becomes position: fixed flex column and the SAME player is pinned to its top. This is why it is not built on ui/sheet: a dialog primitive owns its own subtree and would force the player to be re-rendered inside it, producing a second <audio> element and doubled playback. Dialog semantics, focus trap, Escape, restored focus, inert on the page's other sections and body scroll lock are therefore hand-rolled. The component test asserts the single-audio invariant by element identity, and it was re-confirmed in a real browser (document.querySelectorAll('audio') returns one element with the reader open).
TranscriptCard.svelte is a SEPARATE component for the collapsed state. The design puts the card after the description while the reader's shell has to sit where the player sits, and only two components can be in two places. The page wires them together: the card calls reader.openReader(), and the reader calls back onClosed so the page can return focus to the card's trigger. The trigger cannot be captured by the reader itself — the card re-renders independently, so a captured node would be detached by the time focus is handed back and .focus() would silently drop to <body>.
Following playback is inferred from visibility rather than tracked with a "programmatic scroll" flag: following scrolls the active cue to the centre, so a scroll event that follows one still finds the cue on screen and keeps following, while a scroll that takes the reader away from it does not. No timers, and nothing to get stuck. The "Back to now playing" chip appears exactly when following has stopped.
Word-level follow-along
renderVtt emits the artifact's word timings as inline WebVTT timestamp tags (<v Dan>Welcome <00:00:01.500>there …), which is the spec's own karaoke mechanism. parseVtt reads them back into cue.words, and the reader highlights the run covering currentTime.
The load-bearing guarantee is that this cannot change the published text. Word timings are only emitted for a cue when the words in range join back to exactly that cue's text; any mismatch (an edited row whose words drifted, a partial alignment) falls back to the untimed payload. A tag is also skipped when it would not strictly increase, because WebVTT requires monotonic timestamps and millisecond rounding can repeat on fast speech. Both the render tests and the publish route test assert that stripping the tags leaves the cue byte-identical.
Timestamps are compared in whole milliseconds, against the cue's own serialised bounds, because that is what gets written. Whisper reports word starts unrounded (workers/ai-processor/src/lib/whisper.ts keeps offset + w.start verbatim), so two words 0.4ms apart pass a raw start > previous test and then serialise to the SAME timestamp — a duplicate tag that breaks WebVTT's monotonic requirement.
Everything published before this, and every third-party imported file, has no tags — those cues highlight whole, which is what the design frames show anyway. Republishing an episode's transcript picks up the word timings. There is no backfill sweep: production holds five published transcripts in total, so the proportionate fix is republishing them rather than building a migration that rewrites RSS-referenced artifacts.
The runs carry their own trailing space ({run.text + ' '}). A literal space or a   entity between the spans is dropped by the compiler as insignificant whitespace, and the transcript then copies to the clipboard as "SoHLSchanged". Verified in a browser.
Cue data comes from src/lib/transcripts/vtt.ts, the read counterpart to render.ts. It tolerates third-party imported files (NOTE/STYLE/REGION blocks, cue settings, nested tags, no voice spans) and, critically, a TRUNCATED file: the episode page server-renders a bounded excerpt, so a trailing cue whose <v> span was cut is dropped rather than shown as half a sentence. activeCueIndex binary-searches because it runs on every timeupdate, and resolves a time inside a GAP to the preceding cue so the highlight holds through a pause.
The excerpt is read from R2, not over HTTPS
The server-rendered excerpt is what puts real transcript text in the HTML for crawlers. It reads the first 48KB of the VTT through the MEDIA_BUCKET binding, not with fetch('https://media.podcasterplus.com/…').
The app is a route-bound Worker on the same zone as media., and a same-zone subrequest bypasses Worker routes and lands on the origin — which for media. is the DR 100:: discard record. The fetch simply hangs until the timeout and the excerpt silently comes back empty. On staging this presented as a transcript card with counts but no preview text for the one episode whose transcript_url points at media.podcasterplus.dev, while an episode still pointing at the production host (a different zone from staging) rendered fine. In production every URL is same-zone, so it would have failed for every episode. See .claude/rules/backend/workers.md.
The URL must match this environment's own PUBLIC_MEDIA_URL, not merely be one of our media hosts. Staging and production share podcast and episode ids wherever staging was seeded from a production dump, so the same key exists in both buckets with different contents; matching on host alone would read the local object for a URL naming the other bucket and silently serve the wrong environment's transcript. PUBLIC_MEDIA_URL is therefore set in both [vars] and [env.staging.vars] — without the production value the guard would disable R2 reads in production and reintroduce the same-zone bug.
HTTP remains the fallback for everything the binding cannot serve: a transcript mirrored from a third-party host on import, a row naming the other environment (which is off-zone, so HTTP is correct for it), and an R2 miss.
The budget is 48KB rather than 16KB because word timestamps roughly triple a transcript's size for the same amount of speech.
Word and speaker counts come from episode_transcripts where the row exists, and are derived from the excerpt ONLY when the excerpt is provably the whole transcript. Imported episodes have transcript_url with no row at all (podcast-import-executor mirrors the source feed's file), which is why transcript_url is the presence signal and the row is an enhancement.
Structured data
Both pages emit JSON-LD (src/lib/listen/structured-data.ts): PodcastSeries on the landing page, PodcastEpisode on the episode page, with credits as actor, ISO-8601 duration, and the transcript URL on the AudioObject. Neither page had any structured data before. Optional fields are omitted rather than emitted empty, and < is escaped so a hostile episode title cannot close the <script> block.
Dark mode (#297 item 2)
The listen pages colour exclusively through the app.css theme tokens (--card, --border, --foreground, --muted-foreground) plus two page-level indirections: --brand-ink (brand text swaps from the dark brand shade to the base brand colour under .dark) and --badge-hover-bg (the light hover tint swaps to a color-mix of brand into card). The on-page PlayerCore follows the PAGE theme, not prefers-color-scheme: both pages read the .dark class through createDocumentDarkWatcher() (src/lib/stores/theme.svelte.ts) and pass theme={dark ? 'dark' : 'light'}: the player's own auto mode only watches the media query and can disagree with an explicitly stored theme on the shared-origin dev path. The players also receive the show's player_waveform setting.
oEmbed discovery and SEO meta
Both pages emit in <svelte:head>:
- a canonical link,
- Open Graph tags (
og:typewebsiteon the podcast page,articleon the episode page;og:site_name,og:title,og:description,og:url,og:image+ alt when artwork exists), - Twitter card tags (
summary_large_image), - an oEmbed discovery link:
<link
rel="alternate"
type="application/json+oembed"
href="https://api.podcasterplus.com/v1/oembed?url={encodeURIComponent(canonicalUrl)}"
/>The oEmbed endpoint itself lives in the public-api worker; the listen pages only advertise it, which is what lets platforms that consume oEmbed turn a pasted listen URL into an embedded player.
Related
- Listings & monitoring for how rows become
listed+confirmed - Platform catalog for badge names and URL builders