EdTech

Lesson Player

A lesson player with a scrub bar, a segment list showing completed, current, and upcoming states, a live playhead pulse, and a next-up footer.

Install

npx shadcn@latest add @paragon/lesson-player

lesson-player.tsx

"use client";

import * as React from "react";
import { Play, Pause, CheckCircle2, Circle, PlayCircle } from "lucide-react";
import { cn } from "@/lib/utils";

export interface LessonSegment {
  title: string;
  /** Length in seconds. */
  duration: number;
}

export interface LessonPlayerProps extends React.ComponentProps<"div"> {
  course?: string;
  lesson?: string;
  segments?: LessonSegment[];
  /** Index of the active segment. */
  current?: number;
  /** Elapsed seconds within the current segment. */
  elapsed?: number;
  nextUp?: string;
}

const DEFAULT_SEGMENTS: LessonSegment[] = [
  { title: "What is a pure function?", duration: 210 },
  { title: "Side effects & referential transparency", duration: 330 },
  { title: "Composing functions", duration: 285 },
  { title: "Live coding: refactor to pure", duration: 420 },
];

function fmt(sec: number) {
  const m = Math.floor(sec / 60);
  const s = Math.floor(sec % 60);
  return `${m}:${s.toString().padStart(2, "0")}`;
}

/**
 * A lesson player: a playhead card with a scrub bar (progress driven by a
 * scaleX transform, no layout thrash), a segment list with completed / current
 * / upcoming states, and a next-up footer. The play/pause control is a real
 * button with a spring icon swap and reduced-motion-safe pulse on the playhead.
 */
export function LessonPlayer({
  course = "Functional Programming in JavaScript",
  lesson = "Pure functions",
  segments = DEFAULT_SEGMENTS,
  current = 1,
  elapsed = 120,
  nextUp = "Immutability patterns",
  className,
  ...props
}: LessonPlayerProps) {
  const [playing, setPlaying] = React.useState(true);
  const total = segments.reduce((a, s) => a + s.duration, 0);
  const before = segments
    .slice(0, current)
    .reduce((a, s) => a + s.duration, 0);
  const clampedElapsed = Math.min(elapsed, segments[current]?.duration ?? 0);
  const overallElapsed = before + clampedElapsed;
  const frac = total > 0 ? overallElapsed / total : 0;

  return (
    <div
      data-slot="lesson-player"
      className={cn(
        "w-full max-w-md overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <style href="paragon-lesson-player" precedence="paragon">{`
        @keyframes paragon-lesson-pulse { 50% { opacity: 0.4; } }
        @media (prefers-reduced-motion: reduce) {
          [data-lesson-live] { animation: none !important; }
        }
      `}</style>

      <div className="flex aspect-[16/7] items-center justify-center bg-secondary/60">
        <button
          type="button"
          onClick={() => setPlaying((p) => !p)}
          aria-label={playing ? "Pause lesson" : "Play lesson"}
          className="pressable flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-border"
        >
          {playing ? (
            <Pause className="size-6" />
          ) : (
            <Play className="size-6 translate-x-0.5" />
          )}
        </button>
      </div>

      <div className="px-5 pt-4">
        <p className="text-xs text-muted-foreground">{course}</p>
        <h3 className="text-sm font-medium">{lesson}</h3>

        <div className="mt-3 h-1.5 overflow-hidden rounded-full bg-secondary">
          <div
            className="h-full origin-left rounded-full bg-primary transition-transform duration-300 ease-out"
            style={{ transform: `scaleX(${frac})` }}
          />
        </div>
        <div className="mt-1.5 flex items-center justify-between text-xs text-muted-foreground tabular-nums">
          <span className="flex items-center gap-1.5">
            {playing && (
              <span
                data-lesson-live
                aria-hidden
                className="size-1.5 rounded-full bg-primary"
                style={{
                  animation: "paragon-lesson-pulse 1.6s var(--ease-in-out) infinite",
                }}
              />
            )}
            {fmt(overallElapsed)}
          </span>
          <span>{fmt(total)}</span>
        </div>
      </div>

      <ul className="mt-3 px-2 pb-2">
        {segments.map((seg, i) => {
          const done = i < current;
          const active = i === current;
          const Icon = done ? CheckCircle2 : active ? PlayCircle : Circle;
          return (
            <li key={seg.title}>
              <div
                className={cn(
                  "flex items-center gap-3 rounded-lg px-3 py-2",
                  active && "bg-secondary/60",
                )}
              >
                <Icon
                  className={cn(
                    "size-4 shrink-0",
                    done && "text-success",
                    active && "text-primary",
                    !done && !active && "text-muted-foreground",
                  )}
                />
                <span
                  className={cn(
                    "min-w-0 flex-1 truncate text-sm",
                    active ? "font-medium" : "text-muted-foreground",
                  )}
                >
                  {seg.title}
                </span>
                <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
                  {fmt(seg.duration)}
                </span>
              </div>
            </li>
          );
        })}
      </ul>

      <div className="flex items-center gap-2 border-t border-border px-5 py-3 text-sm">
        <span className="text-muted-foreground">Next up</span>
        <span className="min-w-0 flex-1 truncate font-medium">{nextUp}</span>
      </div>
    </div>
  );
}