- 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
180 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|