Web InnoventixFreeCode

Media Mini Player

Original · free

compact sticky mini player

byWeb InnoventixReact + Tailwind
mediaminiplayer
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-mini-player.json
media-mini-player.tsx
"use client";

import type { CSSProperties } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Episode = {
  id: string;
  number: number;
  title: string;
  guest: string;
  role: string;
  duration: number;
  published: string;
  summary: string;
  art: string;
};

type CssVars = CSSProperties & Record<string, string | number>;

type IconProps = { className?: string };

const EPISODES: Episode[] = [
  {
    id: "ep-41",
    number: 41,
    title: "The Design System Nobody Asked For",
    guest: "Priya Raghunathan",
    role: "Staff engineer, Kestrel Health",
    duration: 2538,
    published: "14 Jul 2026",
    summary:
      "Three teams, four button components, and the quiet politics of getting anyone to adopt the fifth one.",
    art: "from-indigo-500 via-violet-500 to-sky-400",
  },
  {
    id: "ep-40",
    number: 40,
    title: "Shipping at 2am: A Postmortem",
    guest: "Tomas Lindqvist",
    role: "Platform lead, Northbeam",
    duration: 1974,
    published: "7 Jul 2026",
    summary:
      "A rollback that took eleven minutes and a retro that took eleven weeks. What the incident doc left out.",
    art: "from-rose-500 via-amber-400 to-orange-400",
  },
  {
    id: "ep-39",
    number: 39,
    title: "Your Tokens Are Lying to You",
    guest: "Amara Boateng",
    role: "Design systems architect, Lumen",
    duration: 2211,
    published: "30 Jun 2026",
    summary:
      "Semantic naming survives exactly until the first marketing page. A field guide to token drift.",
    art: "from-emerald-500 via-teal-400 to-sky-500",
  },
  {
    id: "ep-38",
    number: 38,
    title: "Why We Deleted 400 Components",
    guest: "Joon-ho Park",
    role: "Principal engineer, Farrow",
    duration: 2874,
    published: "23 Jun 2026",
    summary:
      "The audit found 612 components. Only 90 shipped to production twice. Here is how the cut went.",
    art: "from-sky-500 via-indigo-500 to-violet-500",
  },
  {
    id: "ep-37",
    number: 37,
    title: "The Accessibility Debt Ledger",
    guest: "Rafael Duarte",
    role: "Accessibility practice lead, Vantis",
    duration: 2405,
    published: "16 Jun 2026",
    summary:
      "Treating contrast failures and focus traps like interest-bearing debt, with a real repayment schedule.",
    art: "from-amber-400 via-rose-400 to-violet-500",
  },
  {
    id: "ep-36",
    number: 36,
    title: "Dark Mode Was a Mistake (We Did It Anyway)",
    guest: "Ingrid Sorensen",
    role: "Product designer, Halden Labs",
    duration: 1806,
    published: "9 Jun 2026",
    summary:
      "Two themes means two of every bug. A candid look at the maintenance bill nobody forecasts.",
    art: "from-violet-500 via-indigo-500 to-emerald-400",
  },
];

const SPEEDS = [0.75, 1, 1.25, 1.5, 2] as const;

const MMP_CSS = `
.mmp-scope { --mmp-accent: #6366f1; --mmp-track: rgba(148,163,184,0.38); }
.mmp-range { -webkit-appearance: none; appearance: none; width: 100%; background: transparent; cursor: pointer; }
.mmp-range:focus { outline: none; }
.mmp-range::-webkit-slider-runnable-track { height: 6px; border-radius: 999px; background: linear-gradient(to right, var(--mmp-accent) 0 var(--mmp-pos, 0%), var(--mmp-track) var(--mmp-pos, 0%) 100%); }
.mmp-range::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; margin-top: -4px; border-radius: 999px; background: #ffffff; border: 2px solid var(--mmp-accent); box-shadow: 0 1px 3px rgba(2,6,23,0.45); transition: transform 140ms ease; }
.mmp-range:hover::-webkit-slider-thumb, .mmp-range:focus-visible::-webkit-slider-thumb, .mmp-range:active::-webkit-slider-thumb { transform: scale(1.24); }
.mmp-range::-moz-range-track { height: 6px; border-radius: 999px; background: var(--mmp-track); }
.mmp-range::-moz-range-progress { height: 6px; border-radius: 999px; background: var(--mmp-accent); }
.mmp-range::-moz-range-thumb { height: 12px; width: 12px; border-radius: 999px; background: #ffffff; border: 2px solid var(--mmp-accent); box-shadow: 0 1px 3px rgba(2,6,23,0.45); transition: transform 140ms ease; }
.mmp-range:hover::-moz-range-thumb, .mmp-range:focus-visible::-moz-range-thumb, .mmp-range:active::-moz-range-thumb { transform: scale(1.24); }
@keyframes mmp-eq-bounce { 0%, 100% { transform: scaleY(0.26); } 50% { transform: scaleY(1); } }
@keyframes mmp-art-sheen { 0% { transform: translateX(-140%) skewX(-18deg); } 60%, 100% { transform: translateX(260%) skewX(-18deg); } }
@keyframes mmp-dock-rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
.mmp-eq-bar { transform-origin: bottom; animation: mmp-eq-bounce 900ms ease-in-out infinite; }
.mmp-art-sheen { animation: mmp-art-sheen 2800ms ease-in-out infinite; }
.mmp-dock { animation: mmp-dock-rise 460ms cubic-bezier(0.22, 1, 0.36, 1) both; }
@media (prefers-reduced-motion: reduce) {
  .mmp-eq-bar, .mmp-art-sheen, .mmp-dock { animation: none !important; }
  .mmp-range::-webkit-slider-thumb, .mmp-range::-moz-range-thumb { transition: none !important; }
}
`;

function formatTime(seconds: number): string {
  const safe = Math.max(0, Math.floor(seconds));
  const m = Math.floor(safe / 60);
  const s = safe % 60;
  return `${m}:${s.toString().padStart(2, "0")}`;
}

function spokenTime(seconds: number): string {
  const safe = Math.max(0, Math.floor(seconds));
  const m = Math.floor(safe / 60);
  const s = safe % 60;
  return `${m} minute${m === 1 ? "" : "s"} ${s} second${s === 1 ? "" : "s"}`;
}

function PlayIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
      <path d="M8 5.14v13.72a1 1 0 0 0 1.53.85l10.72-6.86a1 1 0 0 0 0-1.7L9.53 4.29A1 1 0 0 0 8 5.14Z" />
    </svg>
  );
}

function PauseIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
      <rect x="6.5" y="4.5" width="4" height="15" rx="1.4" />
      <rect x="13.5" y="4.5" width="4" height="15" rx="1.4" />
    </svg>
  );
}

function SkipBackIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
      <path d="M19 5.7v12.6a1 1 0 0 1-1.55.83l-9.3-6.3a1 1 0 0 1 0-1.66l9.3-6.3A1 1 0 0 1 19 5.7Z" />
      <rect x="4" y="5" width="2.6" height="14" rx="1.3" />
    </svg>
  );
}

function SkipForwardIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
      <path d="M5 5.7v12.6a1 1 0 0 0 1.55.83l9.3-6.3a1 1 0 0 0 0-1.66l-9.3-6.3A1 1 0 0 0 5 5.7Z" />
      <rect x="17.4" y="5" width="2.6" height="14" rx="1.3" />
    </svg>
  );
}

function ChevronIcon({ className }: IconProps) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <path d="m6 9 6 6 6-6" />
    </svg>
  );
}

function VolumeIcon({ className, muted }: IconProps & { muted: boolean }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path
        d="M11 5.2 6.8 8.6H4a1 1 0 0 0-1 1v4.8a1 1 0 0 0 1 1h2.8L11 18.8a1 1 0 0 0 1.6-.8V6a1 1 0 0 0-1.6-.8Z"
        fill="currentColor"
      />
      {muted ? (
        <g stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
          <path d="m16.5 9.5 5 5" />
          <path d="m21.5 9.5-5 5" />
        </g>
      ) : (
        <g stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
          <path d="M15.8 9.4a3.8 3.8 0 0 1 0 5.2" />
          <path d="M18.6 6.9a7.6 7.6 0 0 1 0 10.2" />
        </g>
      )}
    </svg>
  );
}

function BroadcastIcon({ className }: IconProps) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.8"
      strokeLinecap="round"
      aria-hidden="true"
      className={className}
    >
      <circle cx="12" cy="12" r="2.2" fill="currentColor" stroke="none" />
      <path d="M8.4 8.4a5 5 0 0 0 0 7.2" />
      <path d="M15.6 8.4a5 5 0 0 1 0 7.2" />
      <path d="M5.6 5.6a9 9 0 0 0 0 12.8" />
      <path d="M18.4 5.6a9 9 0 0 1 0 12.8" />
    </svg>
  );
}

function Equalizer({ playing }: { playing: boolean }) {
  const bars = [0, 1, 2, 3];
  const delays = ["0ms", "180ms", "90ms", "260ms"];
  const heights = ["h-2.5", "h-4", "h-3", "h-3.5"];
  return (
    <span className="flex h-4 items-end gap-[2px]" aria-hidden="true">
      {bars.map((b) => (
        <span
          key={b}
          className={`${heights[b]} w-[2px] rounded-full bg-white ${playing ? "mmp-eq-bar" : ""}`}
          style={
            playing
              ? { animationDelay: delays[b] }
              : { transform: "scaleY(0.3)", transformOrigin: "bottom" }
          }
        />
      ))}
    </span>
  );
}

export default function MediaMiniPlayer() {
  const reduceMotion = useReducedMotion();
  const panelId = useId();

  const [index, setIndex] = useState<number>(0);
  const [isPlaying, setIsPlaying] = useState<boolean>(false);
  const [elapsed, setElapsed] = useState<number>(0);
  const [volume, setVolume] = useState<number>(65);
  const [lastVolume, setLastVolume] = useState<number>(65);
  const [speedIndex, setSpeedIndex] = useState<number>(1);
  const [expanded, setExpanded] = useState<boolean>(false);
  const [scrubbing, setScrubbing] = useState<boolean>(false);

  const episode = EPISODES[index];
  const speed = SPEEDS[speedIndex];
  const muted = volume === 0;
  const progress = episode.duration > 0 ? elapsed / episode.duration : 0;

  const endedRef = useRef<boolean>(false);

  const goTo = useCallback((next: number, autoplay: boolean) => {
    const total = EPISODES.length;
    setIndex(((next % total) + total) % total);
    setElapsed(0);
    endedRef.current = false;
    if (autoplay) setIsPlaying(true);
  }, []);

  const playNext = useCallback(() => {
    goTo(index + 1, true);
  }, [goTo, index]);

  const playPrev = useCallback(() => {
    if (elapsed > 3) {
      setElapsed(0);
      endedRef.current = false;
      return;
    }
    goTo(index - 1, isPlaying);
  }, [elapsed, goTo, index, isPlaying]);

  const selectEpisode = useCallback(
    (i: number) => {
      if (i === index) {
        setIsPlaying((p) => !p);
        return;
      }
      goTo(i, true);
    },
    [goTo, index]
  );

  const toggleMute = useCallback(() => {
    if (volume === 0) {
      setVolume(lastVolume === 0 ? 55 : lastVolume);
      return;
    }
    setLastVolume(volume);
    setVolume(0);
  }, [lastVolume, volume]);

  useEffect(() => {
    if (!isPlaying || scrubbing) return;
    let frame = 0;
    let last = performance.now();
    const tick = (now: number) => {
      const delta = (now - last) / 1000;
      last = now;
      setElapsed((current) => {
        const advanced = current + delta * speed;
        return advanced >= episode.duration ? episode.duration : advanced;
      });
      frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [isPlaying, scrubbing, speed, episode.duration]);

  useEffect(() => {
    if (!isPlaying) return;
    if (elapsed < episode.duration) {
      endedRef.current = false;
      return;
    }
    if (endedRef.current) return;
    endedRef.current = true;
    playNext();
  }, [elapsed, episode.duration, isPlaying, playNext]);

  const upNext = useMemo(
    () =>
      Array.from({ length: EPISODES.length - 1 }, (_, i) => {
        const at = (index + 1 + i) % EPISODES.length;
        return { at, episode: EPISODES[at] };
      }),
    [index]
  );

  const seekStyle: CssVars = { "--mmp-pos": `${Math.min(100, progress * 100)}%` };
  const volumeStyle: CssVars = { "--mmp-pos": `${volume}%` };

  const focusRing =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";

  return (
    <section className="mmp-scope relative w-full bg-slate-50 py-16 sm:py-24 dark:bg-slate-950">
      <style>{MMP_CSS}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 top-0 h-72 bg-[radial-gradient(60%_100%_at_50%_0%,rgba(99,102,241,0.14),transparent_70%)] dark:bg-[radial-gradient(60%_100%_at_50%_0%,rgba(99,102,241,0.22),transparent_70%)]"
      />

      <div className="relative mx-auto w-full max-w-4xl px-4 sm:px-6">
        <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-white px-3 py-1 text-xs font-semibold text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
          <BroadcastIcon className="h-3.5 w-3.5" />
          Season 4 · New episodes Tuesdays
        </span>

        <h2 className="mt-5 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-slate-50">
          Signal &amp; Noise
        </h2>
        <p className="mt-3 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-400">
          Long conversations with the people who maintain the boring parts of the
          web — design systems, incident channels, and the tickets nobody puts on
          a roadmap. Start an episode and the player docks to the bottom while you
          keep browsing the archive.
        </p>

        <ol className="mt-10 space-y-2.5">
          {EPISODES.map((ep, i) => {
            const active = i === index;
            const activePlaying = active && isPlaying;
            return (
              <li key={ep.id}>
                <button
                  type="button"
                  onClick={() => selectEpisode(i)}
                  aria-current={active ? "true" : undefined}
                  aria-label={`${activePlaying ? "Pause" : "Play"} episode ${ep.number}, ${ep.title}, ${spokenTime(ep.duration)}`}
                  className={`group flex w-full items-start gap-4 rounded-2xl border p-3 text-left transition-colors sm:p-4 ${focusRing} ${
                    active
                      ? "border-indigo-300 bg-white shadow-sm shadow-indigo-500/5 dark:border-indigo-500/40 dark:bg-slate-900"
                      : "border-slate-200 bg-white/60 hover:border-slate-300 hover:bg-white dark:border-slate-800 dark:bg-slate-900/40 dark:hover:border-slate-700 dark:hover:bg-slate-900"
                  }`}
                >
                  <span
                    className={`relative grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-xl bg-gradient-to-br ${ep.art}`}
                  >
                    {activePlaying ? (
                      <Equalizer playing />
                    ) : (
                      <PlayIcon className="h-4 w-4 translate-x-[1px] text-white/95" />
                    )}
                    {activePlaying ? (
                      <span className="mmp-art-sheen pointer-events-none absolute inset-y-0 -left-6 w-6 bg-white/35 blur-[2px]" />
                    ) : null}
                  </span>

                  <span className="min-w-0 flex-1">
                    <span className="flex flex-wrap items-baseline gap-x-2">
                      <span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
                        Ep {ep.number}
                      </span>
                      <span
                        className={`text-[15px] font-semibold leading-snug ${
                          active
                            ? "text-indigo-700 dark:text-indigo-300"
                            : "text-slate-900 dark:text-slate-100"
                        }`}
                      >
                        {ep.title}
                      </span>
                    </span>
                    <span className="mt-1 block text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                      {ep.summary}
                    </span>
                    <span className="mt-2 block text-xs text-slate-500 dark:text-slate-500">
                      {ep.guest} · {ep.role} · {ep.published} ·{" "}
                      <span className="tabular-nums">{formatTime(ep.duration)}</span>
                    </span>
                  </span>
                </button>
              </li>
            );
          })}
        </ol>
      </div>

      <div className="sticky bottom-4 z-30 mx-auto mt-10 w-full max-w-3xl px-4 sm:bottom-6 sm:px-6">
        <div className="mmp-dock overflow-hidden rounded-2xl border border-slate-200/90 bg-white/85 shadow-xl shadow-slate-900/10 backdrop-blur-xl dark:border-slate-800 dark:bg-slate-900/85 dark:shadow-black/40">
          <AnimatePresence initial={false}>
            {expanded ? (
              <motion.div
                key="panel"
                id={panelId}
                initial={reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}
                animate={reduceMotion ? { opacity: 1 } : { height: "auto", opacity: 1 }}
                exit={reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}
                transition={{
                  duration: reduceMotion ? 0.001 : 0.3,
                  ease: [0.22, 1, 0.36, 1],
                }}
                className="overflow-hidden"
              >
                <div className="border-b border-slate-200 px-3 py-3.5 sm:px-4 dark:border-slate-800">
                  <div className="flex flex-wrap items-center justify-between gap-4">
                    <div className="flex min-w-0 flex-1 items-center gap-2.5">
                      <button
                        type="button"
                        onClick={toggleMute}
                        aria-pressed={muted}
                        aria-label={muted ? "Unmute" : "Mute"}
                        className={`grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 ${focusRing}`}
                      >
                        <VolumeIcon className="h-4 w-4" muted={muted} />
                      </button>
                      <input
                        type="range"
                        min={0}
                        max={100}
                        step={1}
                        value={volume}
                        onChange={(e) => {
                          const v = Number(e.target.value);
                          setVolume(v);
                          if (v > 0) setLastVolume(v);
                        }}
                        aria-label="Volume"
                        aria-valuetext={`${volume} percent`}
                        style={volumeStyle}
                        className={`mmp-range h-4 max-w-[9rem] flex-1 rounded-full ${focusRing}`}
                      />
                      <span className="w-9 shrink-0 text-xs tabular-nums text-slate-500 dark:text-slate-500">
                        {volume}%
                      </span>
                    </div>

                    <div className="flex items-center gap-2">
                      <span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
                        Speed
                      </span>
                      <div className="flex items-center gap-0.5 rounded-lg bg-slate-100 p-0.5 dark:bg-slate-800">
                        {SPEEDS.map((s, i) => (
                          <button
                            key={s}
                            type="button"
                            onClick={() => setSpeedIndex(i)}
                            aria-pressed={i === speedIndex}
                            aria-label={`Playback speed ${s} times`}
                            className={`rounded-md px-2 py-1 text-xs font-semibold tabular-nums transition-colors ${focusRing} focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-800 ${
                              i === speedIndex
                                ? "bg-white text-indigo-700 shadow-sm dark:bg-slate-950 dark:text-indigo-300"
                                : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
                            }`}
                          >
                            {s}×
                          </button>
                        ))}
                      </div>
                    </div>
                  </div>

                  <p className="mt-4 text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
                    Up next
                  </p>
                  <ul className="mt-2 max-h-36 space-y-0.5 overflow-y-auto pr-1">
                    {upNext.map(({ at, episode: ep }) => (
                      <li key={ep.id}>
                        <button
                          type="button"
                          onClick={() => goTo(at, true)}
                          aria-label={`Play episode ${ep.number}, ${ep.title}`}
                          className={`flex w-full items-center gap-3 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-slate-100 dark:hover:bg-slate-800 ${focusRing}`}
                        >
                          <span
                            className={`h-6 w-6 shrink-0 rounded-md bg-gradient-to-br ${ep.art}`}
                            aria-hidden="true"
                          />
                          <span className="min-w-0 flex-1 truncate text-sm font-medium text-slate-700 dark:text-slate-300">
                            {ep.title}
                          </span>
                          <span className="shrink-0 text-xs tabular-nums text-slate-500 dark:text-slate-500">
                            {formatTime(ep.duration)}
                          </span>
                        </button>
                      </li>
                    ))}
                  </ul>
                </div>
              </motion.div>
            ) : null}
          </AnimatePresence>

          <div className="flex flex-wrap items-center gap-x-3 gap-y-2.5 p-2.5 sm:p-3">
            <div className="flex min-w-0 flex-1 items-center gap-3 sm:w-52 sm:flex-none">
              <span
                className={`relative grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-lg bg-gradient-to-br ${episode.art}`}
                aria-hidden="true"
              >
                <Equalizer playing={isPlaying} />
                {isPlaying ? (
                  <span className="mmp-art-sheen pointer-events-none absolute inset-y-0 -left-5 w-5 bg-white/35 blur-[2px]" />
                ) : null}
              </span>
              <span className="min-w-0">
                <span className="block truncate text-[13px] font-semibold text-slate-900 dark:text-slate-100">
                  {episode.title}
                </span>
                <span className="block truncate text-xs text-slate-500 dark:text-slate-500">
                  Ep {episode.number} · {episode.guest}
                </span>
              </span>
            </div>

            <div className="flex shrink-0 items-center gap-0.5">
              <button
                type="button"
                onClick={playPrev}
                aria-label="Previous episode"
                className={`grid h-9 w-9 place-items-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 ${focusRing}`}
              >
                <SkipBackIcon className="h-4 w-4" />
              </button>

              <motion.button
                type="button"
                onClick={() => setIsPlaying((p) => !p)}
                aria-label={isPlaying ? `Pause ${episode.title}` : `Play ${episode.title}`}
                whileTap={reduceMotion ? undefined : { scale: 0.92 }}
                transition={{ type: "spring", stiffness: 520, damping: 26 }}
                className={`grid h-10 w-10 place-items-center rounded-full bg-indigo-600 text-white transition-colors hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400 ${focusRing}`}
              >
                {isPlaying ? (
                  <PauseIcon className="h-4 w-4" />
                ) : (
                  <PlayIcon className="h-4 w-4 translate-x-[1px]" />
                )}
              </motion.button>

              <button
                type="button"
                onClick={playNext}
                aria-label="Next episode"
                className={`grid h-9 w-9 place-items-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 ${focusRing}`}
              >
                <SkipForwardIcon className="h-4 w-4" />
              </button>
            </div>

            <div className="order-last flex w-full items-center gap-2 sm:order-none sm:w-auto sm:flex-1">
              <span className="w-9 shrink-0 text-right text-[11px] tabular-nums text-slate-500 dark:text-slate-500">
                {formatTime(elapsed)}
              </span>
              <input
                type="range"
                min={0}
                max={episode.duration}
                step={1}
                value={Math.min(episode.duration, Math.round(elapsed))}
                onChange={(e) => {
                  setElapsed(Number(e.target.value));
                  endedRef.current = false;
                }}
                onPointerDown={() => setScrubbing(true)}
                onPointerUp={() => setScrubbing(false)}
                onPointerCancel={() => setScrubbing(false)}
                onKeyDown={() => setScrubbing(true)}
                onKeyUp={() => setScrubbing(false)}
                onBlur={() => setScrubbing(false)}
                aria-label={`Seek within ${episode.title}`}
                aria-valuetext={`${spokenTime(elapsed)} of ${spokenTime(episode.duration)}`}
                style={seekStyle}
                className={`mmp-range h-4 flex-1 rounded-full ${focusRing}`}
              />
              <span className="w-9 shrink-0 text-[11px] tabular-nums text-slate-500 dark:text-slate-500">
                {formatTime(episode.duration)}
              </span>
            </div>

            <div className="ml-auto flex shrink-0 items-center gap-1 sm:ml-0">
              <button
                type="button"
                onClick={() => setSpeedIndex((i) => (i + 1) % SPEEDS.length)}
                aria-label={`Playback speed ${speed} times. Change speed`}
                className={`hidden h-8 items-center rounded-lg px-2 text-xs font-semibold tabular-nums text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 sm:inline-flex dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 ${focusRing}`}
              >
                {speed}×
              </button>
              <button
                type="button"
                onClick={() => setExpanded((v) => !v)}
                aria-expanded={expanded}
                aria-controls={panelId}
                aria-label={expanded ? "Collapse player controls" : "Expand player controls"}
                className={`grid h-8 w-8 place-items-center rounded-lg text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 ${focusRing}`}
              >
                <ChevronIcon
                  className={`h-4 w-4 transition-transform duration-200 ${expanded ? "rotate-180" : ""}`}
                />
              </button>
            </div>
          </div>
        </div>

        <p className="sr-only" role="status" aria-live="polite">
          {isPlaying ? "Playing" : "Paused"} episode {episode.number}, {episode.title}, with{" "}
          {episode.guest}, at {speed} times speed.{" "}
          {muted ? "Audio muted." : `Volume ${volume} percent.`}
        </p>
      </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 →