learning_ai_common_plat/packages/media-ui/src/VideoPlayer.tsx
saravanakumardb1 99e59597d1 feat(media-ui): @bytelyst/media-ui@0.1.0 — Wave 13.G.1/.2/.4
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.
2026-05-27 17:44:13 -07:00

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')}`;
}