veza/apps/web/src/features/player/components/WaveformDisplay.tsx
senke 73533bea77 feat(v0.501): Sprint 2 -- HLS production-ready
- S1-01: Add multi-bitrate streaming profiles (128k, 256k, 320k)
- S1-02: Update master.m3u8 endpoint with 3-tier quality system
- S1-03: Integrate hls.js with ABR + useHLSPlayer hook
- S1-04: Add Cache-Control headers on HLS segments and manifests
- S1-05: Create WaveformService with async generation (FFmpeg + audiowaveform)
- S1-06: Add GET /tracks/:id/waveform endpoint with Redis cache
- S1-07: Create WaveformDisplay component with story
- S1-08: Add 4 Prometheus metrics for streaming monitoring
2026-02-22 18:16:37 +01:00

180 lines
4.9 KiB
TypeScript

import { useEffect, useState, useRef, useCallback } from 'react';
import { cn } from '@/lib/utils';
interface WaveformDisplayProps {
trackId: string;
currentTime: number;
duration: number;
onSeek?: (time: number) => void;
className?: string;
height?: number;
barWidth?: number;
barGap?: number;
activeColor?: string;
inactiveColor?: string;
}
interface WaveformDataResponse {
data: number[];
length: number;
sample_rate: number;
samples_per_pixel: number;
}
function normalizeWaveformData(raw: number[], targetBars: number): number[] {
if (raw.length === 0) return Array(targetBars).fill(0);
const step = raw.length / targetBars;
const normalized: number[] = [];
let maxVal = 0;
for (let i = 0; i < targetBars; i++) {
const start = Math.floor(i * step);
const end = Math.floor((i + 1) * step);
let sum = 0;
let count = 0;
for (let j = start; j < end && j < raw.length; j++) {
sum += Math.abs(raw[j]);
count++;
}
const avg = count > 0 ? sum / count : 0;
normalized.push(avg);
if (avg > maxVal) maxVal = avg;
}
if (maxVal > 0) {
return normalized.map((v) => v / maxVal);
}
return normalized;
}
export function WaveformDisplay({
trackId,
currentTime,
duration,
onSeek,
className,
height = 48,
barWidth = 2,
barGap = 1,
}: WaveformDisplayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [bars, setBars] = useState<number[]>([]);
const [barCount, setBarCount] = useState(100);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width ?? 300;
setBarCount(Math.floor(width / (barWidth + barGap)));
});
observer.observe(container);
return () => observer.disconnect();
}, [barWidth, barGap]);
useEffect(() => {
if (!trackId || barCount === 0) return;
let cancelled = false;
setIsLoading(true);
setError(false);
const apiBase = import.meta.env.VITE_API_URL || '/api/v1';
fetch(`${apiBase}/tracks/${trackId}/waveform`, {
credentials: 'include',
})
.then((res) => {
if (!res.ok) throw new Error('Waveform not available');
return res.json();
})
.then((data: WaveformDataResponse) => {
if (cancelled) return;
setBars(normalizeWaveformData(data.data, barCount));
setIsLoading(false);
})
.catch(() => {
if (cancelled) return;
setError(true);
setIsLoading(false);
const placeholder = Array.from({ length: barCount }, (_, i) =>
0.3 + 0.4 * Math.abs(Math.sin(i * 0.15))
);
setBars(placeholder);
});
return () => {
cancelled = true;
};
}, [trackId, barCount]);
const progress = duration > 0 ? currentTime / duration : 0;
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!onSeek || !containerRef.current || duration === 0) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const pct = x / rect.width;
onSeek(pct * duration);
},
[onSeek, duration]
);
return (
<div
ref={containerRef}
className={cn(
'relative flex items-end gap-px w-full',
onSeek && 'cursor-pointer',
className
)}
style={{ height }}
onClick={handleClick}
role={onSeek ? 'slider' : undefined}
aria-valuenow={onSeek ? Math.round(progress * 100) : undefined}
aria-valuemin={onSeek ? 0 : undefined}
aria-valuemax={onSeek ? 100 : undefined}
aria-label={onSeek ? 'Seek position' : 'Waveform'}
>
{isLoading && bars.length === 0 ? (
<div className="flex items-end gap-px w-full h-full">
{Array.from({ length: barCount || 50 }).map((_, i) => (
<div
key={i}
className="flex-1 bg-muted-foreground/20 animate-pulse rounded-t-sm"
style={{
height: `${30 + Math.random() * 40}%`,
animationDelay: `${i * 20}ms`,
}}
/>
))}
</div>
) : (
bars.map((value, i) => {
const barProgress = i / bars.length;
const isActive = barProgress <= progress;
return (
<div
key={i}
className={cn(
'flex-1 rounded-t-sm transition-colors duration-75',
isActive
? 'bg-primary'
: error
? 'bg-muted-foreground/15'
: 'bg-muted-foreground/30'
)}
style={{
height: `${Math.max(value * 100, 4)}%`,
minWidth: barWidth,
}}
/>
);
})
)}
</div>
);
}