Download src/components/learn-anything/LearnLessonPlayer.tsx from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 17.7 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/learn-anything/LearnLessonPlayer.tsx
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/learn-anything/LearnLessonPlayer.tsx
-
curl -L -o LearnLessonPlayer.tsx https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/learn-anything/LearnLessonPlayer.tsx
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'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> | |
| ); | |
| } | |