'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 */}