Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,14 @@ connection is configured in `db/index.ts`.
- `src/lib/` — Core utilities: RSS fetching, image optimization, LLM content
generation.
- `src/content/transcripts/` — Markdown transcript files named by episode
number.
number. When one is absent, the site falls back to the transcript referenced
by the feed's `<podcast:transcript>` tag (fetched/parsed in
`src/lib/transcript.ts`). Both sources render with clickable timestamps that
seek the player: RSS paragraphs via the `episode/Transcript` island, and
markdown `[HH:MM:SS]` timestamps via the `rehype-transcript-timestamps`
plugin (registered in `astro.config.mjs`) plus the `episode/MarkdownTranscript`
island. Note: changing that rehype plugin needs a dev server restart to take
effect, since Astro's content render cache doesn't reload it on hot-reload.
- `src/layouts/Layout.astro` — Single shared layout.
- `db/` — Database schema (`schema.ts`), connection (`index.ts`), seed script
(`seed.ts`), and static data files (`data/`).
Expand Down
6 changes: 6 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';
import vercel from '@astrojs/vercel';

import rehypeTranscriptTimestamps from './src/lib/rehype-transcript-timestamps.mjs';

// https://astro.build/config
export default defineConfig({
output: 'static',
Expand All @@ -29,6 +31,10 @@ export default defineConfig({
build: {
inlineStylesheets: 'always'
},
markdown: {
// Makes bracketed timestamps in markdown transcripts clickable for seeking.
rehypePlugins: [rehypeTranscriptTimestamps]
},
experimental: {
clientPrerender: true
},
Expand Down
35 changes: 34 additions & 1 deletion src/components/Player.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from 'preact/hooks';

import { currentEpisode, isMuted, isPlaying } from '../components/state';
import { currentEpisode, isMuted, isPlaying, seekTo } from '../components/state';
import MuteButton from './player/MuteButton';
import PlayButton from './player/PlayButton';
import PlaybackRateButton from './player/PlaybackRateButton';
Expand Down Expand Up @@ -64,6 +64,39 @@ export default function Player() {
}
}, [isPlaying.value]);

useEffect(() => {
const target = seekTo.value;
const player = audioPlayer.current;
if (target === null || !player) {
return;
}

const applySeek = async () => {
const previousIsPlaying = isPlaying.value;
const previousSeekTo = seekTo.value;
player.currentTime = target;
try {
await player.play();
isPlaying.value = true;
seekTo.value = null;
} catch {
// Playback was blocked (e.g. autoplay policy) — keep the UI honest by
// restoring the pre-seek state instead of showing a false "playing".
isPlaying.value = previousIsPlaying;
seekTo.value = previousSeekTo;
}
};

// If the episode was just switched, its metadata isn't loaded yet and the
// seek wouldn't stick — wait for it. Otherwise seek immediately.
if (player.readyState >= 1 /* HAVE_METADATA */) {
applySeek();
} else {
player.addEventListener('loadedmetadata', applySeek, { once: true });
return () => player.removeEventListener('loadedmetadata', applySeek);
}
}, [seekTo.value]);

useEffect(() => {
const duration = audioPlayer.current?.duration ?? 0;
if (duration > 0 && currentTime >= duration - 0.01) {
Expand Down
39 changes: 39 additions & 0 deletions src/components/episode/MarkdownTranscript.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { ComponentChildren, JSX } from 'preact';

import { currentEpisode, seekToEpisode } from '../state';

type Props = {
episode: NonNullable<(typeof currentEpisode)['value']>;
children: ComponentChildren;
};

/**
* Wraps a server-rendered markdown transcript and makes its timestamp buttons
* (injected by the `rehype-transcript-timestamps` plugin) seek the player.
*
* The markdown HTML is rendered by Astro and passed in as children, so a single
* delegated click handler on the container drives every `[data-seek]` button
* rather than hydrating each one.
*/
export default function MarkdownTranscript({ episode, children }: Props) {
function handleClick(event: JSX.TargetedMouseEvent<HTMLElement>) {
const button = (event.target as HTMLElement).closest('[data-seek]');
if (!button) {
return;
}

const seconds = Number(button.getAttribute('data-seek'));
if (Number.isFinite(seconds)) {
seekToEpisode(episode, seconds);
}
}

return (
<article
class="transcript prose prose-neutral dark:prose-invert line-clamp-4"
onClick={handleClick}
>
{children}
</article>
);
}
54 changes: 54 additions & 0 deletions src/components/episode/Transcript.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { currentEpisode, seekToEpisode } from '../state';
import type { TranscriptParagraph } from '../../lib/transcript';

type Props = {
episode: NonNullable<(typeof currentEpisode)['value']>;
paragraphs: Array<TranscriptParagraph>;
};

function formatTimestamp(seconds: number): string {
const total = Math.floor(seconds);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
const pad = (value: number) => value.toString().padStart(2, '0');

return hours > 0
? `${hours}:${pad(minutes)}:${pad(secs)}`
: `${minutes}:${pad(secs)}`;
}

/**
* Renders an RSS-sourced transcript as paragraphs, each prefixed with a
* clickable timestamp that jumps the audio player to that moment (starting the
* episode first if it isn't the one currently loaded).
*/
export default function Transcript({ episode, paragraphs }: Props) {
function jumpTo(start: number) {
seekToEpisode(episode, start);
}

return (
<article class="transcript prose prose-neutral dark:prose-invert line-clamp-4">
{paragraphs.map((paragraph, index) => {
const start = paragraph.start;

return (
<p key={index}>
{start !== undefined && (
<button
type="button"
onClick={() => jumpTo(start)}
class="mr-2 align-baseline font-mono text-sm font-medium text-violet-600 tabular-nums no-underline transition-colors hover:text-violet-500 dark:text-cyan-400 dark:hover:text-cyan-300"
aria-label={`Play from ${formatTimestamp(start)}`}
>
{formatTimestamp(start)}
</button>
)}
{paragraph.text}
</p>
);
})}
</article>
);
}
17 changes: 17 additions & 0 deletions src/components/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,22 @@ export const currentEpisode = signal<Pick<
export const isPlaying = signal(false);
export const isMuted = signal(false);

// Set to a time (in seconds) to ask the player to seek there. The player
// consumes it and resets it back to null. Used by clickable transcript
// timestamps to jump to a moment in the current episode.
export const seekTo = signal<number | null>(null);

// Load the given episode (if it isn't already current) and ask the player to
// seek to `seconds`. Shared by the RSS and markdown transcript timestamps.
export function seekToEpisode(
episode: NonNullable<(typeof currentEpisode)['value']>,
seconds: number
) {
if (currentEpisode.value?.id !== episode.id) {
currentEpisode.value = { ...episode };
}
seekTo.value = seconds;
}

// Search state
export const isSearchOpen = signal(false);
101 changes: 101 additions & 0 deletions src/lib/rehype-transcript-timestamps.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Rehype plugin that turns bracketed transcript timestamps like `[00:01:23]`
// or `[04:12]` into clickable buttons that seek the audio player. Only the
// markdown transcripts in `src/content/transcripts` contain this pattern, so
// this plugin effectively only affects them.
//
// The button keeps the original bracketed label as its text and carries the
// resolved offset (in seconds) in `data-seek`; the client-side markdown
// transcript island reads that attribute to drive the player. The class list
// mirrors the RSS transcript timestamps (`Transcript.tsx`) so both look the
// same. Those utilities are already emitted by Tailwind from that component,
// so listing them here adds no new CSS.
const TIMESTAMP = /\[(\d{1,2}):(\d{2})(?::(\d{2}))?\]/g;

const TIMESTAMP_CLASS = [
'transcript-timestamp',
'mr-1',
'align-baseline',
'font-mono',
'text-sm',
'font-medium',
'text-violet-600',
'tabular-nums',
'no-underline',
'transition-colors',
'hover:text-violet-500',
'dark:text-cyan-400',
'dark:hover:text-cyan-300'
];

function toSeconds(hours, minutes, seconds) {
// `[MM:SS]` arrives as (minutes, seconds) with `seconds` undefined.
return seconds === undefined
? Number(hours) * 60 + Number(minutes)
: Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
}

function timestampButton(label, seconds) {
return {
type: 'element',
tagName: 'button',
properties: {
type: 'button',
className: [...TIMESTAMP_CLASS],
'data-seek': String(seconds),
'aria-label': `Play from ${label}`
},
children: [{ type: 'text', value: label }]
};
}

function splitTextNode(value) {
const nodes = [];
let lastIndex = 0;
let match;

TIMESTAMP.lastIndex = 0;
while ((match = TIMESTAMP.exec(value)) !== null) {
const [label, a, b, c] = match;
if (match.index > lastIndex) {
nodes.push({ type: 'text', value: value.slice(lastIndex, match.index) });
}
nodes.push(timestampButton(label, toSeconds(a, b, c)));
lastIndex = match.index + label.length;
}

if (lastIndex < value.length) {
nodes.push({ type: 'text', value: value.slice(lastIndex) });
}

return nodes;
}

function transform(node) {
if (!node.children || node.children.length === 0) {
return;
}

const nextChildren = [];
for (const child of node.children) {
if (
child.type === 'text' &&
// Cheap guard so we only rebuild text nodes that actually contain a
// timestamp.
child.value.includes('[') &&
TIMESTAMP.test(child.value)
) {
nextChildren.push(...splitTextNode(child.value));
} else {
transform(child);
nextChildren.push(child);
}
}

node.children = nextChildren;
}

export default function rehypeTranscriptTimestamps() {
return (tree) => {
transform(tree);
};
}
Loading
Loading