DocDoeAI / src /components /learn-anything /LearnLessonPlayer.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
17.7 kB
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import {
Play,
Pause,
SkipBack,
SkipForward,
NotebookPen,
Layers,
GraduationCap,
RotateCcw,
Download,
Target,
type LucideIcon,
} from "lucide-react";
import type { LessonManifest } from "@/lib/api/learn-lesson";
import { printLessonAsPdf } from "@/lib/learn-anything/lessonPdf";
/**
* Plays a Learn Anything lesson in the browser: sequential per-beat audio with
* a board that changes in sync, so it feels like a continuous class rather
* than a slideshow. No video file is streamed — the browser is the renderer.
*
* The whole class (class + notes + flashcards) lives here; the student never
* leaves for a chat. A Download PDF action turns the same lesson into a clean
* printable handout. `variant` themes the player for a light or dark surface.
*/
type Tab = "class" | "notes" | "cards";
type Variant = "light" | "dark";
function formatClock(seconds: number): string {
const total = Math.max(0, Math.round(seconds));
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function tokens(variant: Variant) {
if (variant === "light") {
return {
eyebrow: "text-[#9CA3AF]",
title: "text-[#111217]",
subtle: "text-[#6B7280]",
faint: "text-[#9CA3AF]",
tabBar: "bg-[#f3f3f6]",
tabActive: "bg-[#111217] text-white",
tabIdle: "text-[#6B7280] hover:text-[#111217]",
board: "border-[#e5e7ed] bg-white",
boardHeading: "text-[#111217]",
boardLine: "text-[#374151]",
boardMark: "text-[#C4C4CC]",
hint: "text-[#9CA3AF]",
narration: "bg-[#f7f7fa] text-[#25262c]",
track: "bg-[#e9e9ef]",
fill: "bg-[#111217]",
ctrl: "text-[#9CA3AF] hover:text-[#111217]",
playBtn: "bg-[#111217] text-white hover:bg-black",
panel: "border-[#e5e7ed] bg-white",
panelText: "text-[#374151]",
pill: "border-[#e5e7ed] text-[#6B7280] hover:border-[#cfc9f4] hover:text-[#5f45d8]",
card: "border-[#e5e7ed] bg-white text-[#111217]",
objective: "text-[#374151]",
};
}
return {
eyebrow: "text-neutral-500",
title: "text-neutral-100",
subtle: "text-neutral-400",
faint: "text-neutral-500",
tabBar: "bg-neutral-900",
tabActive: "bg-neutral-100 text-neutral-900",
tabIdle: "text-neutral-400 hover:text-neutral-200",
board: "border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950",
boardHeading: "text-neutral-100",
boardLine: "text-neutral-300",
boardMark: "text-neutral-600",
hint: "text-neutral-500",
narration: "bg-neutral-900 text-neutral-200",
track: "bg-neutral-800",
fill: "bg-neutral-100",
ctrl: "text-neutral-400 hover:text-neutral-100",
playBtn: "bg-neutral-100 text-neutral-900 hover:bg-white",
panel: "border-neutral-800 bg-neutral-950",
panelText: "text-neutral-300",
pill: "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:text-neutral-100",
card: "border-neutral-800 bg-neutral-900 text-neutral-100",
objective: "text-neutral-300",
};
}
export function LearnLessonPlayer({
lesson,
variant = "dark",
completed = false,
onComplete,
}: {
lesson: LessonManifest;
variant?: Variant;
/** Persisted completion from the roadmap, if this class was already finished. */
completed?: boolean;
/** Called only after the learner reaches the final section and confirms completion. */
onComplete?: () => void;
}) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [tab, setTab] = useState<Tab>("class");
const [cardIndex, setCardIndex] = useState(0);
const [cardFlipped, setCardFlipped] = useState(false);
const [completionRequested, setCompletionRequested] = useState(false);
const t = tokens(variant);
const beats = lesson.beats;
const activeBeat = beats[activeIndex] ?? beats[0];
const hasAudio =
lesson.deliveryMode !== "reading" &&
beats.length > 0 &&
beats.every((beat) => beat.audio_src.trim().length > 0);
const objectives = useMemo(
() => (lesson.objectives ?? []).filter((item) => item.trim()),
[lesson.objectives],
);
const lessonTabs = useMemo<Array<{ key: Tab; label: string; Icon: LucideIcon }>>(
() => [
{ key: "class", label: "Class", Icon: GraduationCap },
{ key: "notes", label: "Notes", Icon: NotebookPen },
...(lesson.flashcards.length > 0
? [{ key: "cards" as const, label: "Flashcards", Icon: Layers }]
: []),
],
[lesson.flashcards.length],
);
const elapsedBefore = useMemo(
() => beats.slice(0, activeIndex).reduce((sum, beat) => sum + beat.duration_seconds, 0),
[beats, activeIndex],
);
// Swap the audio source when the active beat changes, and keep playing if we
// were already playing (so beats run back-to-back like one continuous class).
useEffect(() => {
const audio = audioRef.current;
if (!audio || !activeBeat || !hasAudio) return;
audio.src = activeBeat.audio_src;
if (isPlaying) {
void audio.play().catch(() => setIsPlaying(false));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeIndex, activeBeat?.audio_src, hasAudio]);
const handleEnded = useCallback(() => {
setActiveIndex((current) => {
if (current < beats.length - 1) return current + 1;
setIsPlaying(false);
return current;
});
}, [beats.length]);
const togglePlay = useCallback(() => {
const audio = audioRef.current;
if (!audio || !hasAudio) return;
if (isPlaying) {
audio.pause();
setIsPlaying(false);
} else {
void audio.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false));
}
}, [hasAudio, isPlaying]);
const goTo = useCallback((index: number) => {
setActiveIndex(Math.max(0, Math.min(beats.length - 1, index)));
}, [beats.length]);
const totalSeconds = lesson.totalSeconds || beats.reduce((sum, beat) => sum + beat.duration_seconds, 0);
const progressPct = totalSeconds > 0 ? Math.min(100, (elapsedBefore / totalSeconds) * 100) : 0;
const canFinish = beats.length > 0 && activeIndex === beats.length - 1;
const isComplete = completed || completionRequested;
const finishLesson = useCallback(() => {
if (!canFinish || isComplete) return;
setCompletionRequested(true);
onComplete?.();
}, [canFinish, isComplete, onComplete]);
const handleTabKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>, currentTab: Tab) => {
const currentIndex = lessonTabs.findIndex((item) => item.key === currentTab);
const nextIndex =
event.key === "ArrowRight"
? (currentIndex + 1) % lessonTabs.length
: event.key === "ArrowLeft"
? (currentIndex - 1 + lessonTabs.length) % lessonTabs.length
: event.key === "Home"
? 0
: event.key === "End"
? lessonTabs.length - 1
: null;
if (nextIndex === null) return;
event.preventDefault();
const nextTab = lessonTabs[nextIndex].key;
setTab(nextTab);
event.currentTarget.parentElement
?.querySelector<HTMLButtonElement>(`[data-lesson-tab="${nextTab}"]`)
?.focus();
}, [lessonTabs]);
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
{hasAudio ? <audio ref={audioRef} onEnded={handleEnded} preload="auto" /> : null}
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className={`text-xs uppercase tracking-widest ${t.eyebrow}`}>{lesson.topic}</p>
<h1 className={`text-xl font-semibold ${t.title}`}>{lesson.lessonTitle}</h1>
{lesson.summary ? (
<p className={`mt-1 max-w-[60ch] text-sm ${t.subtle}`}>{lesson.summary}</p>
) : null}
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<span className={`text-sm ${t.faint}`}>
~{Math.round(lesson.totalMinutes)} min {hasAudio ? "class" : "reading"}
</span>
<button
type="button"
onClick={() => printLessonAsPdf(lesson)}
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-semibold transition ${t.pill}`}
>
<Download size={14} /> Download PDF
</button>
</div>
</div>
{!hasAudio && lesson.deliveryNotice ? (
<p className={`rounded-xl border px-4 py-3 text-sm ${t.panel} ${t.panelText}`} role="status">
{lesson.deliveryNotice}
</p>
) : null}
{/* Objectives — what this class delivers */}
{objectives.length > 0 ? (
<div className={`rounded-xl border p-4 ${t.panel}`}>
<p className={`mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-widest ${t.eyebrow}`}>
<Target size={13} /> What you&apos;ll be able to do
</p>
<ul className="space-y-1.5">
{objectives.map((objective, index) => (
<li key={index} className={`flex gap-2 text-sm ${t.objective}`}>
<span className="text-[#7b5bf0]">✓</span>
<span>{objective}</span>
</li>
))}
</ul>
</div>
) : null}
{/* Tabs */}
<div
className={`flex gap-1 rounded-lg p-1 text-sm ${t.tabBar}`}
role="tablist"
aria-label="Lesson materials"
>
{lessonTabs.map(({ key, label, Icon }) => (
<button
key={key}
id={`learn-lesson-tab-${key}`}
data-lesson-tab={key}
type="button"
onClick={() => setTab(key)}
onKeyDown={(event) => handleTabKeyDown(event, key)}
role="tab"
aria-selected={tab === key}
aria-controls={`learn-lesson-${key}`}
className={`flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 transition ${
tab === key ? t.tabActive : t.tabIdle
}`}
>
<Icon size={16} /> {label}
</button>
))}
</div>
{tab === "class" && (
<div
id="learn-lesson-class"
role="tabpanel"
aria-labelledby="learn-lesson-tab-class"
className="flex flex-col gap-4"
>
{/* Board */}
<div className={`min-h-[300px] rounded-2xl border p-8 ${t.board}`}>
{activeBeat?.board_heading ? (
<h2 className={`mb-5 text-2xl font-semibold ${t.boardHeading}`}>{activeBeat.board_heading}</h2>
) : null}
<ul className="space-y-3">
{(activeBeat?.board_lines ?? []).map((line, index) => (
<li key={index} className={`flex gap-3 text-lg ${t.boardLine}`}>
<span className={t.boardMark}>—</span>
<span>{line}</span>
</li>
))}
</ul>
{activeBeat?.visual_hint ? (
<p className={`mt-6 text-sm italic ${t.hint}`}>✎ {activeBeat.visual_hint}</p>
) : null}
</div>
{/* Live narration line */}
<p className={`rounded-xl px-5 py-4 text-center text-base leading-relaxed ${t.narration}`}>
{activeBeat?.narration}
</p>
{/* Progress */}
<div className={`h-1.5 w-full overflow-hidden rounded-full ${t.track}`}>
<div className={`h-full rounded-full transition-all ${t.fill}`} style={{ width: `${progressPct}%` }} />
</div>
{/* Controls */}
<div className="flex items-center justify-center gap-6">
<button
type="button"
onClick={() => goTo(activeIndex - 1)}
disabled={activeIndex === 0}
aria-label="Previous lesson section"
className={`inline-flex size-11 items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/40 ${t.ctrl} disabled:opacity-30`}
>
<SkipBack size={22} />
</button>
{hasAudio ? (
<button
type="button"
onClick={togglePlay}
aria-label={isPlaying ? "Pause lesson audio" : "Play lesson audio"}
className={`flex h-14 w-14 items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/40 ${t.playBtn}`}
>
{isPlaying ? <Pause size={24} /> : <Play size={24} className="ml-0.5" />}
</button>
) : (
<span className={`inline-flex min-h-11 items-center rounded-full border px-4 text-sm font-semibold ${t.pill}`}>
Reading section
</span>
)}
<button
type="button"
onClick={() => goTo(activeIndex + 1)}
disabled={activeIndex === beats.length - 1}
aria-label="Next lesson section"
className={`inline-flex size-11 items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/40 ${t.ctrl} disabled:opacity-30`}
>
<SkipForward size={22} />
</button>
</div>
<p className={`text-center text-xs ${t.faint}`}>
Beat {activeIndex + 1} of {beats.length} · {formatClock(elapsedBefore)} / {formatClock(totalSeconds)}
</p>
{onComplete ? (
<div className={`rounded-xl border p-4 ${t.panel}`} aria-live="polite">
<p className={`text-sm ${t.panelText}`}>
{isComplete
? "Lesson completion saved. Your roadmap and Progress now reflect this class."
: canFinish
? "You have reached the final section. Save this completed lesson when you are ready."
: "Completion becomes available after the final section, so your roadmap reflects finished study."}
</p>
{!isComplete ? (
<button
type="button"
onClick={finishLesson}
disabled={!canFinish}
className={`mt-3 inline-flex min-h-11 items-center rounded-lg border px-4 text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/40 ${t.pill} disabled:cursor-not-allowed disabled:opacity-45`}
>
Finish and save lesson
</button>
) : null}
</div>
) : null}
</div>
)}
{tab === "notes" && (
<div
id="learn-lesson-notes"
role="tabpanel"
aria-labelledby="learn-lesson-tab-notes"
className={`rounded-2xl border p-6 ${t.panel}`}
>
<ul className="space-y-3">
{lesson.notes.map((note, index) => (
<li key={index} className={`flex gap-3 ${t.panelText}`}>
<span className={t.faint}>{index + 1}.</span>
<span>{note}</span>
</li>
))}
</ul>
</div>
)}
{tab === "cards" && lesson.flashcards.length > 0 && (
<div
id="learn-lesson-cards"
role="tabpanel"
aria-labelledby="learn-lesson-tab-cards"
className="flex flex-col items-center gap-4"
>
<button
type="button"
onClick={() => setCardFlipped((value) => !value)}
className={`flex min-h-[220px] w-full items-center justify-center rounded-2xl border p-8 text-center text-lg ${t.card}`}
>
{cardFlipped ? lesson.flashcards[cardIndex].back : lesson.flashcards[cardIndex].front}
</button>
<div className="flex items-center gap-6">
<button
type="button"
onClick={() => { setCardIndex((i) => Math.max(0, i - 1)); setCardFlipped(false); }}
disabled={cardIndex === 0}
aria-label="Previous flashcard"
className={`inline-flex size-11 items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/40 ${t.ctrl} disabled:opacity-30`}
>
<SkipBack size={20} />
</button>
<button type="button" onClick={() => setCardFlipped((value) => !value)} className={`inline-flex min-h-11 items-center gap-2 rounded-lg px-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/40 ${t.ctrl}`}>
<RotateCcw size={16} /> Flip
</button>
<button
type="button"
onClick={() => { setCardIndex((i) => Math.min(lesson.flashcards.length - 1, i + 1)); setCardFlipped(false); }}
disabled={cardIndex === lesson.flashcards.length - 1}
aria-label="Next flashcard"
className={`inline-flex size-11 items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/40 ${t.ctrl} disabled:opacity-30`}
>
<SkipForward size={20} />
</button>
</div>
<p className={`text-xs ${t.faint}`}>Card {cardIndex + 1} of {lesson.flashcards.length} · tap card to flip</p>
</div>
)}
</div>
);
}