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.
npx shadcn@latest add @paragon/lesson-player"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>
);
}