"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(null); const [activeIndex, setActiveIndex] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [tab, setTab] = useState("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>( () => [ { 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, 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(`[data-lesson-tab="${nextTab}"]`) ?.focus(); }, [lessonTabs]); return (
{hasAudio ?