Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
26 changes: 25 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,30 @@ export default function Player() {
}
}, [isPlaying.value]);

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

const applySeek = () => {
player.currentTime = target;
isPlaying.value = true;
player.play();
seekTo.value = null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
};

// 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