Media surface primitives — ImageGenStream, AudioWaveform, VideoPlayer.
Zero runtime deps (canvas + native <video> + native <img>); PdfPreview
deferred to 0.2.x where it'll lazy-load pdf.js.
──────────────────────────────────────────────────────────────────
<ImageGenStream> · Wave 13.G.1
──────────────────────────────────────────────────────────────────
- Driven entirely by props (status + snapshot + finalUrl)
- 4 statuses: idle / streaming / complete / error
- Streaming: blurred partial image + overlay progress bar +
step label (e.g. 'denoising 24/50')
- Complete: drops blur with fade-in
- Error: muted error placeholder (token-tinted danger)
- Host pipes any transport (SSE / WebSocket / polling) into
the snapshot prop — pure presentation
<AudioWaveform> · Wave 13.G.2
- Canvas render with DPR-aware paint
- Two sources: pre-computed peaks (cheapest) OR lazy WebAudio
decode from audioUrl (falls back gracefully w/o AudioContext)
- Click-to-seek (returns 0..1 position)
- Progress overlay tints played bars with progressColor
- Resampling helper handles arbitrary peak-count inputs
<VideoPlayer> · Wave 13.G.4
- Native <video controls> wrapper — accessible by default
- Chapter buttons that seek + auto-play (silently swallows
autoplay rejections)
- Optional in-memory caption track (aria-live polite); hosts
that prefer real WebVTT pass <track> via the slot prop
- Token-tinted; rounded; muted poster background
──────────────────────────────────────────────────────────────────
Quality gates
──────────────────────────────────────────────────────────────────
✓ 10/10 tests passing
- AudioWaveform: 3 cases (canvas size · click-seek math ·
placeholder when no peaks)
- ImageGenStream: 4 cases (idle / streaming / complete /
error)
- VideoPlayer: 3 cases (src · chapter button list · caption
rail)
✓ tsc build clean
✓ Zero new runtime deps; all WebAudio access guarded for SSR
Showcase /futurism/multimodal (MAG.7) lands in the paired showcase
commit.
190 lines
5.0 KiB
TypeScript
190 lines
5.0 KiB
TypeScript
import {
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
type ReactNode,
|
|
} from 'react';
|
|
|
|
export interface VideoChapter {
|
|
/** Stable id — React key. */
|
|
id: string;
|
|
/** Display label. */
|
|
label: string;
|
|
/** Chapter start time in seconds. */
|
|
startSeconds: number;
|
|
}
|
|
|
|
export interface VideoCaption {
|
|
/** Stable id. */
|
|
id: string;
|
|
/** Caption start in seconds. */
|
|
start: number;
|
|
/** Caption end in seconds. */
|
|
end: number;
|
|
/** Display text. */
|
|
text: string;
|
|
}
|
|
|
|
export interface VideoPlayerProps {
|
|
/** Video source URL. */
|
|
src: string;
|
|
/** Optional poster image. */
|
|
poster?: string;
|
|
/** Optional chapter list — rendered as buttons under the player. */
|
|
chapters?: VideoChapter[];
|
|
/**
|
|
* Optional in-memory caption track. When provided, the active line is
|
|
* rendered as an aria-live caption strip below the video.
|
|
* Hosts that prefer real <track> elements can pass `captionsTrackUrl`
|
|
* instead via the `slot` prop and skip this list.
|
|
*/
|
|
captions?: VideoCaption[];
|
|
/** Slot rendered above the chapter list — e.g. native <track>. */
|
|
slot?: ReactNode;
|
|
width?: number;
|
|
height?: number;
|
|
ariaLabel?: string;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* `<VideoPlayer>` — minimal token-themed video wrapper with chapter
|
|
* navigation + optional caption rail.
|
|
*
|
|
* Wave 13.G.4. Uses the native `<video controls>` element; chapters
|
|
* are rendered as discrete "seek-to" buttons. Captions are passed in
|
|
* pure JS for the simple case — hosts wanting WebVTT pass a `<track>`
|
|
* via the `slot` prop.
|
|
*/
|
|
export function VideoPlayer({
|
|
src,
|
|
poster,
|
|
chapters,
|
|
captions,
|
|
slot,
|
|
width = 480,
|
|
height = 270,
|
|
ariaLabel,
|
|
className,
|
|
style,
|
|
}: VideoPlayerProps) {
|
|
const ref = useRef<HTMLVideoElement>(null);
|
|
const [activeCaption, setActiveCaption] = useState<VideoCaption | null>(null);
|
|
|
|
// Tick the active caption as the video plays.
|
|
useEffect(() => {
|
|
if (!captions || captions.length === 0) return;
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const onTime = () => {
|
|
const t = el.currentTime;
|
|
const hit = captions.find((c) => t >= c.start && t < c.end);
|
|
setActiveCaption(hit ?? null);
|
|
};
|
|
el.addEventListener('timeupdate', onTime);
|
|
return () => el.removeEventListener('timeupdate', onTime);
|
|
}, [captions]);
|
|
|
|
const seek = (s: number) => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
el.currentTime = s;
|
|
void el.play().catch(() => {});
|
|
};
|
|
|
|
return (
|
|
<div
|
|
data-testid="bl-video-player"
|
|
className={className}
|
|
style={{
|
|
display: 'inline-flex',
|
|
flexDirection: 'column',
|
|
gap: 8,
|
|
...style,
|
|
}}
|
|
>
|
|
<video
|
|
ref={ref}
|
|
src={src}
|
|
poster={poster}
|
|
controls
|
|
width={width}
|
|
height={height}
|
|
aria-label={ariaLabel}
|
|
data-testid="bl-video-player-video"
|
|
style={{
|
|
width,
|
|
height,
|
|
borderRadius: 12,
|
|
background: 'var(--bl-surface-muted, #000)',
|
|
}}
|
|
>
|
|
{slot}
|
|
Your browser does not support the video element.
|
|
</video>
|
|
{captions && captions.length > 0 && (
|
|
<div
|
|
aria-live="polite"
|
|
data-testid="bl-video-player-caption"
|
|
style={{
|
|
minHeight: 22,
|
|
padding: '4px 8px',
|
|
borderRadius: 8,
|
|
background: 'var(--bl-surface-muted, rgba(0,0,0,0.05))',
|
|
fontSize: 13,
|
|
color: 'var(--bl-text-secondary, #555)',
|
|
textAlign: 'center',
|
|
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
|
}}
|
|
>
|
|
{activeCaption?.text ?? ''}
|
|
</div>
|
|
)}
|
|
{chapters && chapters.length > 0 && (
|
|
<nav
|
|
aria-label="Chapters"
|
|
data-testid="bl-video-player-chapters"
|
|
style={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: 6,
|
|
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
|
}}
|
|
>
|
|
{chapters.map((c) => (
|
|
<button
|
|
key={c.id}
|
|
type="button"
|
|
onClick={() => seek(c.startSeconds)}
|
|
data-testid="bl-video-player-chapter"
|
|
data-chapter-id={c.id}
|
|
style={{
|
|
padding: '4px 10px',
|
|
fontSize: 12,
|
|
fontWeight: 500,
|
|
borderRadius: 999,
|
|
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
|
|
background: 'var(--bl-surface-card, #fff)',
|
|
color: 'var(--bl-text-secondary, #555)',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
<span style={{ opacity: 0.6 }}>{formatTime(c.startSeconds)}</span>{' '}
|
|
{c.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatTime(s: number): string {
|
|
if (!Number.isFinite(s) || s < 0) return '0:00';
|
|
const mm = Math.floor(s / 60);
|
|
const ss = Math.floor(s % 60);
|
|
return `${mm}:${ss.toString().padStart(2, '0')}`;
|
|
}
|