diff --git a/packages/media-ui/package.json b/packages/media-ui/package.json new file mode 100644 index 00000000..33698d6e --- /dev/null +++ b/packages/media-ui/package.json @@ -0,0 +1,34 @@ +{ + "name": "@bytelyst/media-ui", + "version": "0.1.0", + "type": "module", + "description": "Media surface primitives — AudioWaveform, VideoPlayer, ImageGenStream. Zero runtime deps; PdfPreview deferred to 0.2.x (pdf.js).", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": ["dist"], + "scripts": { + "build": "tsc", + "test": "vitest run --pool forks", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "happy-dom": "^18.0.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "typescript": "^5.7.3", + "vitest": "^4.0.18" + } +} diff --git a/packages/media-ui/src/AudioWaveform.tsx b/packages/media-ui/src/AudioWaveform.tsx new file mode 100644 index 00000000..08ee65f3 --- /dev/null +++ b/packages/media-ui/src/AudioWaveform.tsx @@ -0,0 +1,180 @@ +import { + useEffect, + useId, + useMemo, + useRef, + useState, + type CSSProperties, + type MouseEvent as ReactMouseEvent, +} from 'react'; + +export interface AudioWaveformProps { + /** Pre-computed peak samples (0..1). Mutually exclusive with `audioUrl`. */ + peaks?: number[]; + /** Audio file URL — when set + peaks absent, peaks are derived via WebAudio. */ + audioUrl?: string; + /** Number of bars to render. Default 96. */ + bars?: number; + /** Current playback position (0..1) — drives the progress overlay. */ + progress?: number; + /** Called when the user clicks a bar (returns 0..1 position). */ + onSeek?: (position: number) => void; + width?: number; + height?: number; + color?: string; + progressColor?: string; + ariaLabel?: string; + className?: string; + style?: CSSProperties; +} + +/** + * `` — canvas-rendered waveform with click-to-seek. + * + * Wave 13.G.2. Accepts a pre-computed `peaks` array (cheapest) OR + * lazy-decodes peaks via WebAudio for an `audioUrl`. While the decode + * is in flight (or in environments without `AudioContext`) a flat + * placeholder is rendered. + */ +export function AudioWaveform({ + peaks: peaksProp, + audioUrl, + bars = 96, + progress = 0, + onSeek, + width = 480, + height = 80, + color = 'var(--bl-accent, #6366f1)', + progressColor = 'var(--bl-text-primary, #111)', + ariaLabel, + className, + style, +}: AudioWaveformProps) { + const canvasRef = useRef(null); + const titleId = useId(); + const [decoded, setDecoded] = useState(null); + + // Lazy WebAudio decode when only audioUrl is supplied. + useEffect(() => { + if (peaksProp || !audioUrl) return; + if (typeof window === 'undefined') return; + let cancelled = false; + (async () => { + try { + const res = await fetch(audioUrl); + const buf = await res.arrayBuffer(); + const Ctx = + ( + window as unknown as { + AudioContext?: typeof AudioContext; + webkitAudioContext?: typeof AudioContext; + } + ).AudioContext ?? + (window as unknown as { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext; + if (!Ctx) return; + const ac = new Ctx(); + const audio = await ac.decodeAudioData(buf); + if (cancelled) return; + const channel = audio.getChannelData(0); + const block = Math.max(1, Math.floor(channel.length / bars)); + const out = new Array(bars).fill(0); + for (let i = 0; i < bars; i++) { + const start = i * block; + let max = 0; + for (let j = 0; j < block && start + j < channel.length; j++) { + const v = Math.abs(channel[start + j] ?? 0); + if (v > max) max = v; + } + out[i] = Math.min(1, max); + } + if (!cancelled) setDecoded(out); + } catch { + /* swallow — placeholder remains */ + } + })(); + return () => { + cancelled = true; + }; + }, [peaksProp, audioUrl, bars]); + + const finalPeaks = useMemo(() => { + if (peaksProp && peaksProp.length > 0) return resampleTo(peaksProp, bars); + if (decoded && decoded.length > 0) return decoded; + return new Array(bars).fill(0.04); + }, [peaksProp, decoded, bars]); + + // Paint. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const dpr = + typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; + canvas.width = Math.floor(width * dpr); + canvas.height = Math.floor(height * dpr); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, width, height); + const slot = width / finalPeaks.length; + const barW = Math.max(1, slot * 0.65); + const mid = height / 2; + const progressX = Math.max(0, Math.min(1, progress)) * width; + finalPeaks.forEach((p, i) => { + const x = slot * i + (slot - barW) / 2; + const h = Math.max(2, p * height * 0.9); + ctx.fillStyle = x + barW <= progressX ? progressColor : color; + ctx.fillRect(x, mid - h / 2, barW, h); + }); + }, [finalPeaks, width, height, color, progressColor, progress]); + + const onClick = (e: ReactMouseEvent) => { + if (!onSeek) return; + const rect = e.currentTarget.getBoundingClientRect(); + onSeek(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))); + }; + + return ( +
+ + +
+ ); +} + +/** Resample any peaks array up/down to `bars` length. */ +function resampleTo(src: number[], bars: number): number[] { + if (src.length === bars) return src; + if (src.length === 0) return new Array(bars).fill(0); + const out = new Array(bars); + const ratio = src.length / bars; + for (let i = 0; i < bars; i++) { + const start = Math.floor(i * ratio); + const end = Math.max(start + 1, Math.floor((i + 1) * ratio)); + let max = 0; + for (let j = start; j < end && j < src.length; j++) { + const v = Math.abs(src[j] ?? 0); + if (v > max) max = v; + } + out[i] = Math.min(1, max); + } + return out; +} diff --git a/packages/media-ui/src/ImageGenStream.tsx b/packages/media-ui/src/ImageGenStream.tsx new file mode 100644 index 00000000..4e6131a9 --- /dev/null +++ b/packages/media-ui/src/ImageGenStream.tsx @@ -0,0 +1,191 @@ +import { + useEffect, + useState, + type CSSProperties, + type ReactNode, +} from 'react'; + +export type ImageGenStatus = 'idle' | 'streaming' | 'complete' | 'error'; + +export interface ImageGenSnapshot { + /** Progress 0..1. */ + progress: number; + /** Optional partial image URL (e.g. data: or blob:). */ + partialUrl?: string; + /** Step label (e.g. "denoising 24/50"). */ + label?: string; +} + +export interface ImageGenStreamProps { + /** Live status. */ + status: ImageGenStatus; + /** Latest snapshot from the upstream stream. */ + snapshot?: ImageGenSnapshot; + /** Final image URL (rendered when status === 'complete'). */ + finalUrl?: string; + /** Optional error message (rendered when status === 'error'). */ + errorMessage?: string; + /** Aspect ratio (w/h). Default 1 (square). */ + aspect?: number; + /** Width in px. Default 320. */ + width?: number; + /** Optional caption slot. */ + caption?: ReactNode; + ariaLabel?: string; + className?: string; + style?: CSSProperties; +} + +/** + * `` — surface that animates a streaming image-generation + * job. Driven entirely by props; the host pipes whatever transport + * (SSE / WebSocket / polling) into the `status` + `snapshot` props. + * + * Wave 13.G.1. Pure presentation. Status drives visual: + * - idle: empty placeholder with helpful copy + * - streaming: partial image + progress bar + label + * - complete: final image with subtle fade-in + * - error: muted error card + */ +export function ImageGenStream({ + status, + snapshot, + finalUrl, + errorMessage, + aspect = 1, + width = 320, + caption, + ariaLabel, + className, + style, +}: ImageGenStreamProps) { + const height = Math.round(width / Math.max(0.1, aspect)); + const [revealedAt, setRevealedAt] = useState(null); + useEffect(() => { + if (status === 'complete') setRevealedAt(Date.now()); + else setRevealedAt(null); + }, [status, finalUrl]); + + const progress = Math.max(0, Math.min(1, snapshot?.progress ?? 0)); + + return ( +
+
+ {status === 'idle' && ( + + )} + {(status === 'streaming' || status === 'complete') && ( + // eslint-disable-next-line @next/next/no-img-element + {ariaLabel + )} + {status === 'streaming' && ( +
+
+
+
+
{snapshot?.label ?? `${Math.round(progress * 100)}%`}
+
+ )} + {status === 'error' && } +
+ {caption && ( +
+ {caption} +
+ )} +
+ ); +} + +function Placeholder({ text, kind }: { text: string; kind?: 'error' }) { + return ( +
+ {text} +
+ ); +} diff --git a/packages/media-ui/src/VideoPlayer.tsx b/packages/media-ui/src/VideoPlayer.tsx new file mode 100644 index 00000000..30a89dbe --- /dev/null +++ b/packages/media-ui/src/VideoPlayer.tsx @@ -0,0 +1,189 @@ +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 elements can pass `captionsTrackUrl` + * instead via the `slot` prop and skip this list. + */ + captions?: VideoCaption[]; + /** Slot rendered above the chapter list — e.g. native . */ + slot?: ReactNode; + width?: number; + height?: number; + ariaLabel?: string; + className?: string; + style?: CSSProperties; +} + +/** + * `` — minimal token-themed video wrapper with chapter + * navigation + optional caption rail. + * + * Wave 13.G.4. Uses the native `