Web InnoventixFreeCode

Media Podcast

Original · free

podcast episode player card

byWeb InnoventixReact + Tailwind
mediapodcast
Open

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
media-podcast.tsx
"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 &amp; Noise
              </p>
              <p className="text-xs text-slate-500 dark:text-zinc-400">
                How software teams actually ship &middot; Thursdays
              </p>
            </div>
          </div>
          <p className="text-xs font-medium text-slate-500 dark:text-zinc-400">
            Season 4 &middot; 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} &middot; {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">
                  {" "}
                  &mdash; {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}&times;
                </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 &amp; 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} &middot; {fmt(ep.duration)} &middot;{" "}
                        {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

motion

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 quote

Similar components

Browse all →