'use client' import { useEffect, useRef, useState } from 'react' import { cn, getAssetUrl } from '@/lib/utils' interface VideoPlaceholderProps { /** Large title shown on the hero. */ title?: string /** Small italic eyebrow above the title, e.g. a module name. */ eyebrow?: string /** Pill in the top-right corner. Defaults to "Coming soon" (shown only until a video is set). */ label?: string /** * Self-hosted video source. Accepts an absolute URL, a root-relative path * (`/static/...`), or a bare asset name resolved through the Blob CDN. When * set, the play button loads the video; otherwise the card is "coming soon". */ src?: string className?: string } /** Resolve a video source: pass absolute/root-relative through, send bare names to the Blob CDN. */ function resolveVideoSrc(src: string): string { if (/^https?:\/\//.test(src) || src.startsWith('/')) return src return getAssetUrl(src) } /** The sim logotype, drawn with currentColor so the theme can tint it. */ function SimWordmark({ className }: { className?: string }) { return ( ) } /** * A 16:9 lesson hero used across the Academy. Always shows the design-system * video card (title, blueprint grid, theme-aware dark/light). When a `src` is * provided the play button loads the self-hosted video inline; otherwise the * card reads "Coming soon" and the play button is muted. */ export function VideoPlaceholder({ title, eyebrow, label = 'Coming soon', src, className, }: VideoPlaceholderProps) { const hasVideo = Boolean(src) const [playing, setPlaying] = useState(false) const videoRef = useRef(null) const pendingSeek = useRef(null) // Chapter rows (VideoChapters) dispatch `academy:seek` with a time in seconds. // Start the video if it isn't playing yet, then jump there. We also announce // that a video exists (and answer a chapters-side query) so the chapter rows // only become interactive when there's actually something to seek. useEffect(() => { if (!src) return const onSeek = (e: Event) => { const time = (e as CustomEvent<{ time: number }>).detail?.time if (typeof time !== 'number') return const video = videoRef.current if (video) { video.currentTime = time void video.play() } else { pendingSeek.current = time setPlaying(true) } } const announce = () => window.dispatchEvent(new Event('academy:video-ready')) window.addEventListener('academy:seek', onSeek) window.addEventListener('academy:video-query', announce) announce() return () => { window.removeEventListener('academy:seek', onSeek) window.removeEventListener('academy:video-query', announce) } }, [src]) if (playing && src) { return (
{/* biome-ignore lint/a11y/useMediaCaption: lesson videos have no caption track yet */}
) } return (
{/* Blueprint grid — faint, fading to atmosphere at the edges */}
{/* Corner plus-marks, 20px inset */} {['top-5 left-5', 'top-5 right-5', 'bottom-5 left-5', 'right-5 bottom-5'].map((pos) => ( + ))} {/* Top-right status pill — only until a video is wired up */} {!hasVideo && ( {label} )} {/* Heading: eyebrow + title, bottom-left (design: left:40 bottom:40) */}
{eyebrow && ( {eyebrow} )} {title && ( {title} )}
{/* Wordmark, bottom-right (design: right:40 bottom:40, svg height 22) */} {/* Centered play button — active when a video is wired, muted otherwise */}
{hasVideo ? ( ) : ( )}
) }