2026-01-07 09:31:02 +00:00
|
|
|
import React, { useRef, useEffect, useState } from 'react';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* WaveformVisualizerProps - Propriétés du composant WaveformVisualizer
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2026-01-07 09:31:02 +00:00
|
|
|
* @interface WaveformVisualizerProps
|
|
|
|
|
*/
|
|
|
|
|
interface WaveformVisualizerProps {
|
|
|
|
|
/**
|
|
|
|
|
* URL de l'audio source (optionnel, pour générer la waveform)
|
|
|
|
|
*/
|
|
|
|
|
audioUrl?: string;
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2026-01-07 09:31:02 +00:00
|
|
|
/**
|
|
|
|
|
* Données de waveform pré-calculées (valeurs entre 0.0 et 1.0)
|
|
|
|
|
* Si non fourni, génère des données mock
|
|
|
|
|
*/
|
|
|
|
|
waveformData?: number[];
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2026-01-07 09:31:02 +00:00
|
|
|
/**
|
|
|
|
|
* Progression de la lecture (0 à 100)
|
|
|
|
|
*/
|
|
|
|
|
progress: number;
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2026-01-07 09:31:02 +00:00
|
|
|
/**
|
|
|
|
|
* Fonction appelée lors du clic sur la waveform pour naviguer
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2026-01-07 09:31:02 +00:00
|
|
|
* @param {number} percentage - Pourcentage cliqué (0 à 100)
|
|
|
|
|
*/
|
|
|
|
|
onSeek: (percentage: number) => void;
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2026-01-07 09:31:02 +00:00
|
|
|
/**
|
|
|
|
|
* Hauteur de la waveform en pixels
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2026-01-07 09:31:02 +00:00
|
|
|
* @default 64
|
|
|
|
|
*/
|
|
|
|
|
height?: number;
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2026-01-07 09:31:02 +00:00
|
|
|
/**
|
|
|
|
|
* Couleur des barres non jouées
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2026-02-12 01:09:29 +00:00
|
|
|
* @default '#374054' (sumi-bg-hover)
|
2026-01-07 09:31:02 +00:00
|
|
|
*/
|
|
|
|
|
color?: string;
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2026-01-07 09:31:02 +00:00
|
|
|
/**
|
|
|
|
|
* Couleur des barres jouées
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2026-02-12 01:09:29 +00:00
|
|
|
* @default '#00FFF7' (sumi-accent)
|
2026-01-07 09:31:02 +00:00
|
|
|
*/
|
|
|
|
|
playedColor?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* WaveformVisualizer - Composant de visualisation de waveform audio
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2026-01-07 09:31:02 +00:00
|
|
|
* Composant pour afficher une waveform audio interactive avec :
|
|
|
|
|
* - Visualisation des données audio
|
|
|
|
|
* - Indicateur de progression
|
|
|
|
|
* - Navigation par clic
|
|
|
|
|
* - Support pour données pré-calculées ou génération automatique
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2026-01-07 09:31:02 +00:00
|
|
|
* @example
|
|
|
|
|
* ```tsx
|
|
|
|
|
* // Waveform avec données pré-calculées
|
|
|
|
|
* <WaveformVisualizer
|
|
|
|
|
* waveformData={waveformData}
|
|
|
|
|
* progress={currentProgress}
|
|
|
|
|
* onSeek={(percentage) => seekTo(percentage)}
|
|
|
|
|
* />
|
|
|
|
|
* ```
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2026-01-07 09:31:02 +00:00
|
|
|
* @example
|
|
|
|
|
* ```tsx
|
|
|
|
|
* // Waveform avec génération automatique
|
|
|
|
|
* <WaveformVisualizer
|
|
|
|
|
* audioUrl="/audio.mp3"
|
|
|
|
|
* progress={50}
|
|
|
|
|
* onSeek={handleSeek}
|
|
|
|
|
* height={80}
|
|
|
|
|
* />
|
|
|
|
|
* ```
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2026-01-07 09:31:02 +00:00
|
|
|
* @component
|
|
|
|
|
* @param {WaveformVisualizerProps} props - Propriétés du composant
|
|
|
|
|
* @returns {JSX.Element} Canvas avec waveform interactive
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export const WaveformVisualizer: React.FC<WaveformVisualizerProps> = ({
|
|
|
|
|
waveformData,
|
|
|
|
|
progress,
|
|
|
|
|
onSeek,
|
|
|
|
|
height = 64,
|
2026-04-27 03:07:24 +00:00
|
|
|
color = 'var(--sumi-bg-hover)',
|
|
|
|
|
playedColor = 'var(--sumi-viz-indigo)',
|
2026-01-07 09:31:02 +00:00
|
|
|
}) => {
|
|
|
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
|
const [data, setData] = useState<number[]>([]);
|
|
|
|
|
|
|
|
|
|
// Initialize or generate mock data if none provided
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (waveformData && waveformData.length > 0) {
|
|
|
|
|
setData(waveformData);
|
|
|
|
|
} else {
|
|
|
|
|
// Generate mock waveform
|
2026-01-13 18:47:57 +00:00
|
|
|
const mockData = Array.from(
|
|
|
|
|
{ length: 100 },
|
|
|
|
|
() => Math.random() * 0.8 + 0.2,
|
|
|
|
|
);
|
2026-01-07 09:31:02 +00:00
|
|
|
setData(mockData);
|
|
|
|
|
}
|
|
|
|
|
}, [waveformData]);
|
|
|
|
|
|
|
|
|
|
// Draw loop
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const canvas = canvasRef.current;
|
|
|
|
|
if (!canvas) return;
|
|
|
|
|
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
if (!ctx) return;
|
|
|
|
|
|
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
|
|
const width = canvas.offsetWidth;
|
|
|
|
|
const drawHeight = height;
|
|
|
|
|
|
|
|
|
|
canvas.width = width * dpr;
|
|
|
|
|
canvas.height = drawHeight * dpr;
|
|
|
|
|
ctx.scale(dpr, dpr);
|
|
|
|
|
|
|
|
|
|
ctx.clearRect(0, 0, width, drawHeight);
|
|
|
|
|
|
|
|
|
|
const barWidth = width / data.length;
|
|
|
|
|
const gap = 1;
|
|
|
|
|
const effectiveBarWidth = Math.max(1, barWidth - gap);
|
|
|
|
|
|
2026-04-27 03:07:24 +00:00
|
|
|
// Resolve CSS vars to hex values (canvas can't resolve var() directly)
|
|
|
|
|
const styles = getComputedStyle(canvas);
|
|
|
|
|
const resolve = (c: string) =>
|
|
|
|
|
c.startsWith('var(')
|
|
|
|
|
? styles.getPropertyValue(c.slice(4, -1).trim()).trim() || c
|
|
|
|
|
: c;
|
|
|
|
|
const resolvedColor = resolve(color);
|
|
|
|
|
const resolvedPlayed = resolve(playedColor);
|
|
|
|
|
|
2026-01-07 09:31:02 +00:00
|
|
|
data.forEach((val, i) => {
|
|
|
|
|
const x = i * barWidth;
|
|
|
|
|
const barHeight = val * drawHeight;
|
|
|
|
|
const y = (drawHeight - barHeight) / 2;
|
|
|
|
|
|
|
|
|
|
// Determine color based on progress
|
|
|
|
|
const isPlayed = (i / data.length) * 100 <= progress;
|
2026-04-27 03:07:24 +00:00
|
|
|
ctx.fillStyle = isPlayed ? resolvedPlayed : resolvedColor;
|
2026-01-07 09:31:02 +00:00
|
|
|
|
|
|
|
|
// Draw rounded rect equivalent
|
|
|
|
|
ctx.fillRect(x, y, effectiveBarWidth, barHeight);
|
|
|
|
|
});
|
|
|
|
|
}, [data, progress, height, color, playedColor]);
|
|
|
|
|
|
|
|
|
|
const handleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
|
|
|
const x = e.clientX - rect.left;
|
|
|
|
|
const percentage = (x / rect.width) * 100;
|
|
|
|
|
onSeek(Math.min(100, Math.max(0, percentage)));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-13 18:47:57 +00:00
|
|
|
<canvas
|
2026-01-07 09:31:02 +00:00
|
|
|
ref={canvasRef}
|
|
|
|
|
style={{ width: '100%', height: `${height}px` }}
|
|
|
|
|
onClick={handleClick}
|
|
|
|
|
className="cursor-pointer hover:opacity-90 transition-opacity"
|
|
|
|
|
/>
|
|
|
|
|
);
|
2026-01-13 18:47:57 +00:00
|
|
|
};
|