Media

Podcast Player

A podcast episode player with a seekable timeline, 15-second skip controls, a chapter list, and a cycling playback-speed control.

Install

npx shadcn@latest add @paragon/podcast-player

podcast-player.tsx

"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>
  );
}