A podcast episode player with a seekable timeline, 15-second skip controls, a chapter list, and a cycling playback-speed control.
npx shadcn@latest add @paragon/podcast-player"use client";
import * as React from "react";
import { Pause, Play, RotateCcw, RotateCw } from "lucide-react";
import { cn } from "@/lib/utils";
export interface PodcastChapter {
title: string;
/** Chapter start time in seconds. */
start: number;
}
export interface PodcastPlayerProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
show?: string;
episode?: string;
/** Total duration in seconds. */
duration?: number;
position?: number;
playing?: boolean;
chapters?: PodcastChapter[];
/** Playback speed, applied to props and controllable via the speed prop. */
speed?: number;
color?: string;
}
const SPEEDS = [0.75, 1, 1.25, 1.5, 2];
function fmt(seconds: number) {
const s = Math.max(0, Math.floor(seconds));
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${r.toString().padStart(2, "0")}`;
}
const DEFAULT_CHAPTERS: PodcastChapter[] = [
{ title: "Cold open", start: 0 },
{ title: "The hiring freeze", start: 320 },
{ title: "What the data showed", start: 940 },
{ title: "Listener questions", start: 1680 },
];
/**
* A podcast episode player with a chapter list, skip-back/forward (15s), a
* scrubbable timeline, and a cycling speed control. The active chapter is
* derived from the playhead; clicking a chapter seeks to it. The playhead
* advances on a 1s interval scaled by speed, paused on hidden tabs.
*/
export function PodcastPlayer({
show = "The Quarterly",
episode = "Ep. 142 — Rethinking the Runway",
duration = 2145,
position = 340,
playing = false,
chapters = DEFAULT_CHAPTERS,
speed = 1,
color = "#0ea5e9",
className,
...props
}: PodcastPlayerProps) {
const [isPlaying, setIsPlaying] = React.useState(playing);
const [pos, setPos] = React.useState(Math.min(position, duration));
const [rate, setRate] = React.useState(speed);
React.useEffect(() => setRate(speed), [speed]);
React.useEffect(() => {
if (!isPlaying) return;
const id = window.setInterval(() => {
if (document.hidden) return;
setPos((p) => {
const next = p + rate;
if (next >= duration) {
setIsPlaying(false);
return duration;
}
return next;
});
}, 1000);
return () => window.clearInterval(id);
}, [isPlaying, rate, duration]);
const activeChapter = React.useMemo(() => {
let idx = 0;
for (let i = 0; i < chapters.length; i++) {
if (pos >= chapters[i].start) idx = i;
}
return idx;
}, [pos, chapters]);
const pct = duration ? (pos / duration) * 100 : 0;
function skip(delta: number) {
setPos((p) => Math.max(0, Math.min(duration, p + delta)));
}
function cycleSpeed() {
const i = SPEEDS.indexOf(rate);
setRate(SPEEDS[(i + 1) % SPEEDS.length] ?? 1);
}
return (
<div
data-slot="podcast-player"
className={cn(
"flex w-full max-w-md flex-col gap-4 rounded-xl bg-card p-4 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center gap-3">
<span
aria-hidden
className="grid size-14 shrink-0 place-items-center rounded-lg text-white/90"
style={{
background: `linear-gradient(135deg, ${color}, color-mix(in oklch, ${color} 45%, black))`,
}}
>
<Play className="size-5 fill-current" />
</span>
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground">
{show}
</div>
<div className="truncate text-sm font-semibold">{episode}</div>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="relative">
<div className="pointer-events-none absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-muted" />
<div
className="pointer-events-none absolute top-1/2 left-0 h-1 -translate-y-1/2 rounded-full bg-foreground"
style={{ width: `${pct}%` }}
/>
<input
type="range"
aria-label="Seek"
min={0}
max={duration}
step={1}
value={pos}
onChange={(e) => setPos(Number(e.target.value))}
className="paragon-range relative z-10 h-4 w-full cursor-pointer"
/>
</div>
<div className="flex justify-between font-mono text-[11px] tabular-nums text-muted-foreground">
<span>{fmt(pos)}</span>
<span>-{fmt(duration - pos)}</span>
</div>
</div>
<div className="flex items-center justify-between">
<button
type="button"
aria-label="Rewind 15 seconds"
onClick={() => skip(-15)}
className="pressable relative grid size-9 place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<RotateCcw className="size-5" />
<span className="absolute text-[8px] font-bold tabular-nums">15</span>
</button>
<button
type="button"
aria-label={isPlaying ? "Pause" : "Play"}
onClick={() => setIsPlaying((p) => !p)}
className="pressable grid size-11 place-items-center rounded-full bg-foreground text-background"
>
{isPlaying ? (
<Pause className="size-5 fill-current" />
) : (
<Play className="size-5 translate-x-px fill-current" />
)}
</button>
<button
type="button"
aria-label="Forward 15 seconds"
onClick={() => skip(15)}
className="pressable relative grid size-9 place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<RotateCw className="size-5" />
<span className="absolute text-[8px] font-bold tabular-nums">15</span>
</button>
<button
type="button"
aria-label={`Playback speed ${rate}x, tap to change`}
onClick={cycleSpeed}
className="pressable grid h-9 min-w-12 place-items-center rounded-full bg-muted px-2 text-sm font-semibold tabular-nums text-foreground transition-colors duration-150 hover:bg-accent"
>
{rate}×
</button>
</div>
<ul className="flex flex-col gap-0.5 border-t border-border pt-2">
{chapters.map((ch, i) => {
const active = i === activeChapter;
return (
<li key={ch.title}>
<button
type="button"
onClick={() => setPos(ch.start)}
aria-current={active ? "true" : undefined}
className={cn(
"flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left transition-colors duration-150",
active ? "bg-accent" : "hover:bg-accent/60",
)}
>
<span
className={cn(
"truncate text-sm",
active
? "font-semibold text-foreground"
: "text-muted-foreground",
)}
>
{ch.title}
</span>
<span className="ml-2 shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
{fmt(ch.start)}
</span>
</button>
</li>
);
})}
</ul>
</div>
);
}