Media Podcast
Original · freepodcast episode player card
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/media-podcast.json"use client";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Chapter = { at: number; label: string };
type Episode = {
id: string;
number: number;
title: string;
guest: string;
guestRole: string;
date: string;
duration: number;
blurb: string;
c1: string;
c2: string;
chapters: Chapter[];
notes: string[];
};
function makeWave(seed: number, count: number): number[] {
const out: number[] = [];
let s = seed;
for (let i = 0; i < count; i += 1) {
s = (s * 1103515245 + 12345) % 2147483648;
const r = s / 2147483648;
const env =
0.5 + 0.34 * Math.sin((i / count) * Math.PI * 2.4 + seed * 0.0007);
out.push(Math.min(1, Math.max(0.14, env * (0.55 + r * 0.8))));
}
return out;
}
const EPISODES: Episode[] = [
{
id: "e47",
number: 47,
title: "The on-call rotation nobody wanted",
guest: "Priya Raghunathan",
guestRole: "Staff SRE, Kestrel Logistics",
date: "12 Mar 2026",
duration: 2538,
blurb:
"Priya rebuilt on-call at Kestrel after a March outage burned through four engineers in six weeks. We get into alert budgets, the two-pager rule, and why she deleted 40% of the paging alerts before hiring anyone.",
c1: "#6366f1",
c2: "#8b5cf6",
chapters: [
{ at: 0, label: "Cold open: the 3am page" },
{ at: 160, label: "Why rotations rot after six weeks" },
{ at: 665, label: "The two-pager-or-it-didn't-happen rule" },
{ at: 1170, label: "Alert budgets vs. alert fatigue" },
{ at: 1695, label: "What Kestrel changed after March" },
{ at: 2210, label: "Listener: solo on-call at five people" },
{ at: 2420, label: "Close and next week" },
],
notes: [
"Kestrel cut paging alerts from 214 to 129 before touching the schedule — the rotation wasn't broken, the signal was.",
"The two-pager rule: any alert that fires twice in one shift gets a written owner and a deadline, or it gets deleted.",
"Priya's alert budget: 2 pages per on-call week. Blow the budget twice in a quarter and reliability work outranks roadmap work.",
"Correction from ep. 45: the Kestrel outage was 71 minutes, not 90. Thanks to Devin H. for the timeline.",
],
},
{
id: "e46",
number: 46,
title: "Your design system is a product, not a folder",
guest: "Tobias Lindqvist",
guestRole: "Design systems lead, Northbeam",
date: "5 Mar 2026",
duration: 2285,
blurb:
"Tobias has shipped three design systems and killed two of them. He explains why adoption is a distribution problem, not a documentation problem, and what he tracks instead of component count.",
c1: "#0ea5e9",
c2: "#6366f1",
chapters: [
{ at: 0, label: "The two systems he killed" },
{ at: 240, label: "Adoption is distribution, not docs" },
{ at: 820, label: "Metrics that aren't component count" },
{ at: 1380, label: "Saying no to the one-off request" },
{ at: 1910, label: "Close and next week" },
],
notes: [
"Northbeam tracks 'percentage of shipped screens using system primitives' — currently 78%, up from 31% in a year.",
"Every one-off request gets built in the product repo first. It only enters the system after a second team asks for it.",
"Tobias's rule: if the docs need a diagram to explain the prop, the API is wrong.",
],
},
{
id: "e45",
number: 45,
title: "We deleted 60% of our tests and shipped faster",
guest: "Marisol Okafor",
guestRole: "Principal engineer, Halden Systems",
date: "26 Feb 2026",
duration: 3093,
blurb:
"A 41-minute test suite, a 12% flake rate, and nobody trusted a red build. Marisol walks through the audit that removed 4,100 tests and why the escaped-defect rate went down afterwards.",
c1: "#f43f5e",
c2: "#f59e0b",
chapters: [
{ at: 0, label: "41 minutes and nobody trusts it" },
{ at: 300, label: "Auditing 6,800 tests in a fortnight" },
{ at: 1010, label: "What actually caught bugs" },
{ at: 1720, label: "The flake quarantine" },
{ at: 2450, label: "Escaped defects, six months on" },
{ at: 2930, label: "Close and next week" },
],
notes: [
"6,800 tests audited. 4,100 deleted. Suite went from 41 minutes to 9. Escaped defects fell from 7 per quarter to 4.",
"The keep test: does this test fail for exactly one reason, and would that reason reach a customer?",
"Quarantine, don't skip. Flaky tests move to a nightly job with an owner and a two-week clock.",
],
},
];
const WAVES: Record<string, number[]> = {
e47: makeWave(4712, 72),
e46: makeWave(9931, 72),
e45: makeWave(2087, 72),
};
const SPEEDS = [1, 1.25, 1.5, 1.75, 2, 0.75] as const;
function fmt(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
return `${h > 0 ? `${h}:` : ""}${mm}:${String(sec).padStart(2, "0")}`;
}
function spoken(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m} minute${m === 1 ? "" : "s"} ${sec} second${sec === 1 ? "" : "s"}`;
}
export default function MediaPodcast() {
const reduced = useReducedMotion();
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const [currentId, setCurrentId] = useState<string>("e47");
const [playing, setPlaying] = useState(false);
const [speedIdx, setSpeedIdx] = useState(0);
const [notesOpen, setNotesOpen] = useState(false);
const [dragging, setDragging] = useState(false);
const [saved, setSaved] = useState<Record<string, boolean>>({ e45: true });
const [times, setTimes] = useState<Record<string, number>>({
e47: 0,
e46: 1204,
e45: 3093,
});
const trackRef = useRef<HTMLDivElement | null>(null);
const episode = useMemo(
() => EPISODES.find((e) => e.id === currentId) ?? EPISODES[0],
[currentId],
);
const dur = episode.duration;
const time = times[currentId] ?? 0;
const speed = SPEEDS[speedIdx];
const wave = WAVES[currentId] ?? WAVES.e47;
const pct = dur > 0 ? time / dur : 0;
const seek = useCallback(
(next: number | ((prev: number) => number)) => {
setTimes((prev) => {
const cur = prev[currentId] ?? 0;
const raw = typeof next === "function" ? next(cur) : next;
return { ...prev, [currentId]: Math.min(dur, Math.max(0, raw)) };
});
},
[currentId, dur],
);
useEffect(() => {
if (!playing) return;
let last = performance.now();
const id = window.setInterval(() => {
const now = performance.now();
const dt = (now - last) / 1000;
last = now;
seek((t) => t + dt * speed);
}, 180);
return () => window.clearInterval(id);
}, [playing, speed, seek]);
useEffect(() => {
if (playing && time >= dur) setPlaying(false);
}, [playing, time, dur]);
const activeChapterIdx = useMemo(() => {
let idx = 0;
episode.chapters.forEach((c, i) => {
if (time + 0.001 >= c.at) idx = i;
});
return idx;
}, [episode.chapters, time]);
const activeChapter = episode.chapters[activeChapterIdx];
const loadEpisode = useCallback(
(id: string) => {
if (id === currentId) return;
setCurrentId(id);
setNotesOpen(false);
setPlaying(true);
},
[currentId],
);
const seekFromClientX = useCallback(
(clientX: number) => {
const el = trackRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.width === 0) return;
const ratio = Math.min(
1,
Math.max(0, (clientX - rect.left) / rect.width),
);
seek(ratio * dur);
},
[dur, seek],
);
const onSliderKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
const keys = [
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
];
if (!keys.includes(event.key)) return;
event.preventDefault();
if (event.key === "Home") return seek(0);
if (event.key === "End") return seek(dur);
const step =
event.key === "PageUp" || event.key === "PageDown" ? 30 : 5;
const back =
event.key === "ArrowLeft" ||
event.key === "ArrowDown" ||
event.key === "PageDown";
seek((t) => t + (back ? -step : step));
},
[dur, seek],
);
const springy = reduced
? { duration: 0 }
: { type: "spring" as const, stiffness: 380, damping: 34 };
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 lg:py-28 dark:bg-zinc-950">
<style>{`
@keyframes mpod-eq {
0%, 100% { transform: scaleY(0.28); }
50% { transform: scaleY(1); }
}
@keyframes mpod-ring {
0% { transform: scale(1); opacity: 0.55; }
100% { transform: scale(1.9); opacity: 0; }
}
@keyframes mpod-drift {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(3%, -4%, 0) scale(1.08); }
}
.mpod-eq-bar { transform-origin: 50% 100%; animation: mpod-eq 1.05s ease-in-out infinite; }
.mpod-ring { animation: mpod-ring 2.1s ease-out infinite; }
.mpod-drift { animation: mpod-drift 22s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.mpod-eq-bar, .mpod-ring, .mpod-drift {
animation: none !important;
transform: none !important;
}
.mpod-ring { opacity: 0 !important; }
.mpod-eq-bar { transform: scaleY(0.6) !important; }
}
`}</style>
<div
aria-hidden="true"
className="mpod-drift pointer-events-none absolute -top-32 left-1/2 h-80 w-[42rem] -translate-x-1/2 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/15"
/>
<div className="relative mx-auto w-full max-w-3xl">
<header className="mb-8 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-900 text-white dark:bg-zinc-50 dark:text-zinc-900">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
>
<path d="M12 3a4 4 0 0 0-4 4v5a4 4 0 0 0 8 0V7a4 4 0 0 0-4-4Z" />
<path d="M5 11v1a7 7 0 0 0 14 0v-1M12 19v2" />
</svg>
</span>
<div>
<p className="text-sm font-semibold tracking-tight text-slate-900 dark:text-zinc-50">
Signal & Noise
</p>
<p className="text-xs text-slate-500 dark:text-zinc-400">
How software teams actually ship · Thursdays
</p>
</div>
</div>
<p className="text-xs font-medium text-slate-500 dark:text-zinc-400">
Season 4 · 47 episodes
</p>
</header>
<article className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex flex-col gap-5 p-5 sm:flex-row sm:gap-6 sm:p-7">
<div className="relative mx-auto h-32 w-32 shrink-0 sm:mx-0 sm:h-36 sm:w-36">
<svg
viewBox="0 0 120 120"
role="img"
aria-label={`Cover art for episode ${episode.number}, ${episode.title}`}
className="h-full w-full rounded-xl shadow-md"
>
<defs>
<linearGradient
id={`mpod-${uid}-${episode.id}`}
x1="0"
y1="0"
x2="1"
y2="1"
>
<stop offset="0%" stopColor={episode.c1} />
<stop offset="100%" stopColor={episode.c2} />
</linearGradient>
</defs>
<rect
width="120"
height="120"
rx="14"
fill={`url(#mpod-${uid}-${episode.id})`}
/>
<g
fill="none"
stroke="#ffffff"
strokeOpacity="0.28"
strokeWidth="1.5"
>
<circle cx="96" cy="24" r="34" />
<circle cx="96" cy="24" r="52" />
<circle cx="24" cy="104" r="30" />
</g>
<text
x="12"
y="28"
fill="#ffffff"
fillOpacity="0.9"
fontSize="9"
fontWeight="700"
letterSpacing="1.4"
>
EP {episode.number}
</text>
<g transform="translate(12, 74)">
{[0, 1, 2, 3].map((i) => (
<rect
key={i}
className={playing ? "mpod-eq-bar" : undefined}
x={i * 9}
y={0}
width="5"
height="26"
rx="2.5"
fill="#ffffff"
fillOpacity="0.92"
style={
playing
? {
animationDelay: `${i * 0.14}s`,
transformBox: "fill-box",
}
: {
transform: "scaleY(0.45)",
transformBox: "fill-box",
transformOrigin: "50% 100%",
}
}
/>
))}
</g>
</svg>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-indigo-50 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-indigo-700 ring-1 ring-inset ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/30">
Episode {episode.number}
</span>
{episode.id === "e47" ? (
<span className="rounded-full bg-amber-50 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-amber-800 ring-1 ring-inset ring-amber-200 dark:bg-amber-400/10 dark:text-amber-300 dark:ring-amber-400/30">
New
</span>
) : null}
<span className="text-xs text-slate-500 dark:text-zinc-400">
{episode.date} · {fmt(dur)}
</span>
</div>
<h2 className="mt-2 text-balance text-xl font-semibold leading-snug tracking-tight text-slate-900 sm:text-2xl dark:text-zinc-50">
{episode.title}
</h2>
<p className="mt-1 text-sm font-medium text-slate-600 dark:text-zinc-300">
with {episode.guest}
<span className="font-normal text-slate-500 dark:text-zinc-400">
{" "}
— {episode.guestRole}
</span>
</p>
<p className="mt-3 text-sm leading-relaxed text-slate-600 dark:text-zinc-400">
{episode.blurb}
</p>
</div>
</div>
<div className="px-5 pb-5 sm:px-7 sm:pb-7">
<div
ref={trackRef}
role="slider"
tabIndex={0}
aria-label="Seek within episode"
aria-valuemin={0}
aria-valuemax={dur}
aria-valuenow={Math.round(time)}
aria-valuetext={`${spoken(time)} of ${spoken(dur)}. Chapter: ${activeChapter.label}`}
onKeyDown={onSliderKeyDown}
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
seekFromClientX(event.clientX);
}}
onPointerMove={(event) => {
if (dragging) seekFromClientX(event.clientX);
}}
onPointerUp={(event) => {
event.currentTarget.releasePointerCapture(event.pointerId);
setDragging(false);
}}
onPointerCancel={() => setDragging(false)}
className="relative flex h-20 w-full cursor-pointer touch-none select-none items-end gap-[2px] rounded-lg outline-none ring-offset-2 ring-offset-white focus-visible:ring-2 focus-visible:ring-indigo-500 dark:ring-offset-zinc-900 dark:focus-visible:ring-indigo-400"
>
{wave.map((h, i) => {
const played = i / wave.length < pct;
return (
<span
key={i}
aria-hidden="true"
style={{ height: `${Math.round(h * 100)}%` }}
className={`w-full rounded-sm transition-colors duration-150 ${
played
? "bg-indigo-500 dark:bg-indigo-400"
: "bg-slate-200 dark:bg-zinc-700"
}`}
/>
);
})}
{episode.chapters.slice(1).map((c) => (
<span
key={c.at}
aria-hidden="true"
style={{ left: `${(c.at / dur) * 100}%` }}
className="pointer-events-none absolute bottom-0 top-0 w-px bg-slate-400/50 dark:bg-zinc-500/50"
/>
))}
<span
aria-hidden="true"
style={{ left: `${pct * 100}%` }}
className="pointer-events-none absolute -top-1 bottom-[-4px] w-0.5 -translate-x-1/2 rounded-full bg-slate-900 dark:bg-zinc-50"
>
<span className="absolute -left-[3px] -top-[3px] h-2 w-2 rounded-full bg-slate-900 dark:bg-zinc-50" />
</span>
</div>
<div className="mt-2 flex items-center justify-between text-xs font-medium tabular-nums text-slate-500 dark:text-zinc-400">
<span>{fmt(time)}</span>
<span>-{fmt(dur - time)}</span>
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => seek((t) => t - 15)}
className="flex h-10 w-10 items-center justify-center rounded-full text-slate-600 outline-none transition-colors hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-300 dark:hover:bg-zinc-800 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
aria-label="Rewind 15 seconds"
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11.5 5V2L7 5.5 11.5 9V6a6 6 0 1 1-6 6" />
<text
x="12"
y="18.5"
fontSize="7.5"
fontWeight="700"
stroke="none"
fill="currentColor"
textAnchor="middle"
>
15
</text>
</svg>
</button>
<div className="relative">
{playing && !reduced ? (
<span
aria-hidden="true"
className="mpod-ring absolute inset-0 rounded-full bg-indigo-500/40 dark:bg-indigo-400/40"
/>
) : null}
<button
type="button"
onClick={() => setPlaying((p) => !p)}
aria-pressed={playing}
aria-label={
playing
? `Pause episode ${episode.number}, ${episode.title}`
: `Play episode ${episode.number}, ${episode.title}`
}
className="relative flex h-14 w-14 items-center justify-center rounded-full bg-slate-900 text-white outline-none transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:scale-95 dark:bg-zinc-50 dark:text-zinc-900 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
>
{playing ? (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-6 w-6"
fill="currentColor"
>
<rect x="7" y="5" width="3.5" height="14" rx="1.2" />
<rect x="13.5" y="5" width="3.5" height="14" rx="1.2" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="ml-0.5 h-6 w-6"
fill="currentColor"
>
<path d="M8 5.3c0-.9 1-1.5 1.8-1l9 6.2c.7.5.7 1.5 0 2l-9 6.2c-.8.5-1.8-.1-1.8-1V5.3Z" />
</svg>
)}
</button>
</div>
<button
type="button"
onClick={() => seek((t) => t + 30)}
className="flex h-10 w-10 items-center justify-center rounded-full text-slate-600 outline-none transition-colors hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-300 dark:hover:bg-zinc-800 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
aria-label="Skip forward 30 seconds"
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12.5 5V2L17 5.5 12.5 9V6a6 6 0 1 0 6 6" />
<text
x="12"
y="18.5"
fontSize="7.5"
fontWeight="700"
stroke="none"
fill="currentColor"
textAnchor="middle"
>
30
</text>
</svg>
</button>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setSpeedIdx((i) => (i + 1) % SPEEDS.length)}
aria-label={`Playback speed ${speed} times. Activate to change.`}
className="rounded-full border border-slate-200 px-3 py-1.5 text-xs font-semibold tabular-nums text-slate-700 outline-none transition-colors hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
>
{speed}×
</button>
<button
type="button"
onClick={() =>
setSaved((prev) => ({
...prev,
[episode.id]: !prev[episode.id],
}))
}
aria-pressed={Boolean(saved[episode.id])}
aria-label={`Save episode ${episode.number} for later`}
className={`flex h-9 w-9 items-center justify-center rounded-full border outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900 ${
saved[episode.id]
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 text-slate-500 hover:bg-slate-100 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
}`}
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-4 w-4"
fill={saved[episode.id] ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 4h12a1 1 0 0 1 1 1v15l-7-4.2L5 20V5a1 1 0 0 1 1-1Z" />
</svg>
</button>
</div>
</div>
</div>
<div className="border-t border-slate-200 px-5 py-5 sm:px-7 dark:border-zinc-800">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-zinc-400">
Chapters
</h3>
<ul className="space-y-0.5">
{episode.chapters.map((chapter, i) => {
const isActive = i === activeChapterIdx;
return (
<li key={chapter.at}>
<button
type="button"
onClick={() => seek(chapter.at)}
aria-current={isActive ? "true" : undefined}
className="group relative flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left outline-none transition-colors hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:hover:bg-zinc-800 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
>
{isActive ? (
<motion.span
layoutId={`mpod-${uid}-chapter`}
transition={springy}
aria-hidden="true"
className="absolute inset-y-1 left-0 w-0.5 rounded-full bg-indigo-500 dark:bg-indigo-400"
/>
) : null}
<span
className={`shrink-0 text-xs font-semibold tabular-nums ${
isActive
? "text-indigo-600 dark:text-indigo-400"
: "text-slate-400 dark:text-zinc-500"
}`}
>
{fmt(chapter.at)}
</span>
<span
className={`min-w-0 flex-1 truncate text-sm ${
isActive
? "font-semibold text-slate-900 dark:text-zinc-50"
: "text-slate-600 dark:text-zinc-300"
}`}
>
{chapter.label}
</span>
{isActive && playing ? (
<span
aria-hidden="true"
className="flex h-3.5 shrink-0 items-end gap-[2px]"
>
{[0, 1, 2].map((b) => (
<span
key={b}
className="mpod-eq-bar block h-full w-[2px] rounded-full bg-indigo-500 dark:bg-indigo-400"
style={{ animationDelay: `${b * 0.16}s` }}
/>
))}
</span>
) : null}
</button>
</li>
);
})}
</ul>
</div>
<div className="border-t border-slate-200 dark:border-zinc-800">
<h3>
<button
type="button"
onClick={() => setNotesOpen((o) => !o)}
aria-expanded={notesOpen}
aria-controls={`mpod-${uid}-notes`}
className="flex w-full items-center justify-between gap-3 px-5 py-4 text-left outline-none transition-colors hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 sm:px-7 dark:hover:bg-zinc-800/50 dark:focus-visible:ring-indigo-400"
>
<span className="text-sm font-semibold text-slate-900 dark:text-zinc-50">
Show notes & corrections
</span>
<span className="flex items-center gap-2 text-xs text-slate-500 dark:text-zinc-400">
{episode.notes.length} items
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={`h-4 w-4 transition-transform duration-200 ${
notesOpen ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</span>
</button>
</h3>
<AnimatePresence initial={false}>
{notesOpen ? (
<motion.div
key="notes"
id={`mpod-${uid}-notes`}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={
reduced
? { duration: 0 }
: { duration: 0.28, ease: "easeOut" as const }
}
className="overflow-hidden"
>
<ul className="space-y-3 px-5 pb-5 sm:px-7 sm:pb-6">
{episode.notes.map((note) => (
<li
key={note}
className="flex gap-3 text-sm leading-relaxed text-slate-600 dark:text-zinc-400"
>
<span
aria-hidden="true"
className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-indigo-500 dark:bg-indigo-400"
/>
{note}
</li>
))}
</ul>
</motion.div>
) : null}
</AnimatePresence>
</div>
</article>
<section aria-labelledby={`mpod-${uid}-queue`} className="mt-10">
<h3
id={`mpod-${uid}-queue`}
className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-zinc-400"
>
More from season 4
</h3>
<ul className="space-y-2">
{EPISODES.map((ep) => {
const isCurrent = ep.id === currentId;
const progress = (times[ep.id] ?? 0) / ep.duration;
const finished = progress >= 0.999;
return (
<li key={ep.id}>
<button
type="button"
onClick={() => loadEpisode(ep.id)}
aria-current={isCurrent ? "true" : undefined}
className={`flex w-full items-center gap-4 rounded-xl border px-4 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-950 ${
isCurrent
? "border-indigo-300 bg-indigo-50/60 dark:border-indigo-500/40 dark:bg-indigo-500/10"
: "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700 dark:hover:bg-zinc-800/60"
}`}
>
<span
aria-hidden="true"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-xs font-bold text-white"
style={{
backgroundImage: `linear-gradient(135deg, ${ep.c1}, ${ep.c2})`,
}}
>
{ep.number}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-900 dark:text-zinc-50">
{ep.title}
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500 dark:text-zinc-400">
{ep.guest} · {fmt(ep.duration)} ·{" "}
{finished
? "Played"
: progress > 0.01
? `${fmt(ep.duration - (times[ep.id] ?? 0))} left`
: ep.date}
</span>
{progress > 0.01 && !finished ? (
<span
aria-hidden="true"
className="mt-1.5 block h-1 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-zinc-700"
>
<span
className="block h-full rounded-full bg-indigo-500 dark:bg-indigo-400"
style={{ width: `${Math.round(progress * 100)}%` }}
/>
</span>
) : null}
</span>
<span className="shrink-0 text-slate-400 dark:text-zinc-500">
{isCurrent && playing ? (
<span
aria-hidden="true"
className="flex h-4 w-4 items-end justify-center gap-[2px]"
>
{[0, 1, 2].map((b) => (
<span
key={b}
className="mpod-eq-bar block h-full w-[2px] rounded-full bg-indigo-500 dark:bg-indigo-400"
style={{ animationDelay: `${b * 0.16}s` }}
/>
))}
</span>
) : (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-4 w-4"
fill="currentColor"
>
<path d="M8 5.3c0-.9 1-1.5 1.8-1l9 6.2c.7.5.7 1.5 0 2l-9 6.2c-.8.5-1.8-.1-1.8-1V5.3Z" />
</svg>
)}
</span>
</button>
</li>
);
})}
</ul>
</section>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Media Video Player
Originalcustom video player controls UI

Media Audio Player
Originalaudio player with progress and volume

Media Playlist
Originalmusic playlist with now-playing

Media Mini Player
Originalcompact sticky mini player

Media Video Card
Originalvideo thumbnail card with play overlay

Media Waveform
Originalaudio waveform scrubber

Media Video Hero
Originalhero with a video-style backdrop

Media Gallery Player
Originalmedia gallery with a lightbox player

Media Live Badge
Originallive stream card with viewer count

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

