fix: clignotement mini-bar + bouton play noir + badge LU vert + visualiseur horizontal

- IntersectionObserver avec debounce 250ms pour éviter le clignotement
- Bouton play sur cartes: bg-black/85 text-white (plus de blanc sur blanc)
- Badge LU: fond vert solide (var(--accent-emerald))
- AudioVisualizer: forme d'onde horizontale style Stitch (canvas)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.7 committed 2026-04-28 13:52:17 +02:00
1 parent 66d61595bd
commit a49e9f04d8
4 files changed
+102 -61

No files matched your search

+84 -53
View File
@@ -2,87 +2,118 @@ import { useEffect, useRef, useState } from 'react';
import { useAudio } from '../context/AudioContext';
interface AudioVisualizerProps {
barCount?: number;
width?: number;
height?: number;
gap?: number;
}
export default function AudioVisualizer({
barCount = 40,
height = 96,
gap = 2,
width,
height = 64,
}: AudioVisualizerProps) {
const { isPlaying, getFrequencyData } = useAudio();
const [heights, setHeights] = useState<number[]>(() => Array(barCount).fill(3));
const canvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef<number>(0);
const playingRef = useRef(isPlaying);
const getDataRef = useRef(getFrequencyData);
const historyRef = useRef<number[][]>([]);
useEffect(() => { playingRef.current = isPlaying; }, [isPlaying]);
useEffect(() => { getDataRef.current = getFrequencyData; }, [getFrequencyData]);
useEffect(() => {
const half = Math.floor(barCount / 2);
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set canvas size accounting for device pixel ratio
const dpr = window.devicePixelRatio || 1;
const displayW = width || canvas.parentElement?.clientWidth || 300;
const displayH = height;
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
canvas.style.width = `${displayW}px`;
canvas.style.height = `${displayH}px`;
ctx.scale(dpr, dpr);
// Init history with flatline
if (historyRef.current.length === 0) {
historyRef.current = Array.from({ length: 8 }, () =>
Array(Math.floor(displayW / 3)).fill(displayH / 2)
);
}
const animate = () => {
const rawData = getDataRef.current?.();
if (rawData && playingRef.current) {
const newHeights: number[] = [];
for (let i = 0; i < barCount; i++) {
const distFromCenter = Math.abs(i - (barCount - 1) / 2);
const halfIdx = Math.floor(distFromCenter);
const dataIndex = Math.min(halfIdx, rawData.length - 1);
const value = rawData[dataIndex] ?? 0;
if (!ctx || !canvas) return;
const raw = getDataRef.current?.();
const w = displayW;
const h = displayH;
const mid = h / 2;
// Smoother edge fade — keep center bars taller
const edgeFade = 1 - (distFromCenter / half) * 0.2;
// Compute a new row from frequency data
const sampleCount = Math.floor(w / 3);
const newRow: number[] = [];
// Mid boost for vocals/speech
const midBoost = distFromCenter < half * 0.6 ? 1.15 : 1;
// More dynamic range — minimum height of 6%
const h = Math.max(6, Math.min(100, (value / 255) * 100 * edgeFade * midBoost));
newHeights.push(h);
if (raw && playingRef.current) {
for (let i = 0; i < sampleCount; i++) {
// Map sample across the frequency bins
const idx = Math.floor((i / sampleCount) * raw.length);
const val = raw[idx] ?? 0;
// Convert to y offset from center — stronger signal = bigger wave
const amplitude = (val / 255) * (h * 0.42);
newRow.push(mid + (Math.random() - 0.5) * amplitude * 2);
}
setHeights(newHeights);
} else {
// Gentle decay when paused — slower falloff for smoother look
setHeights(prev =>
prev.map(h => {
const next = h * 0.92;
return next < 3.5 ? 3 : next;
})
);
// When paused, flatline decays to center
for (let i = 0; i < sampleCount; i++) {
newRow.push(mid);
}
}
// Push new row, keep last 8 rows
historyRef.current.push(newRow);
if (historyRef.current.length > 12) historyRef.current.shift();
// Draw
ctx.clearRect(0, 0, w, h);
// Draw each row as a thin horizontal line, fading older ones
const rows = historyRef.current;
for (let r = 0; r < rows.length; r++) {
const row = rows[r];
const age = rows.length - 1 - r;
const alpha = 0.08 + (age / rows.length) * 0.75;
const thickness = 1 + (age / rows.length) * 1.5;
ctx.strokeStyle = r === rows.length - 1
? `rgba(108, 111, 247, ${alpha + 0.15})`
: age < 3
? `rgba(13, 197, 224, ${alpha})`
: `rgba(108, 111, 247, ${alpha * 0.6})`;
ctx.lineWidth = thickness;
ctx.beginPath();
for (let i = 0; i < row.length; i++) {
const x = (i / row.length) * w;
const y = row[i];
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafRef.current);
}, [barCount]);
const barWidth = Math.max(2, (100 - gap * (barCount - 1)) / barCount);
}, [width, height]);
return (
<div
className="flex items-end justify-center w-full"
style={{ height, gap: `${gap}%` }}
<canvas
ref={canvasRef}
className="w-full rounded-lg"
aria-hidden="true"
>
{heights.map((h, i) => (
<div
key={i}
className="rounded-full"
style={{
width: `${barWidth}%`,
height: `${h}%`,
background: `linear-gradient(to top, var(--accent-primary), var(--accent-secondary))`,
transition: playingRef.current ? 'height 80ms linear' : 'height 300ms ease-out',
opacity: 0.55 + (h / 200),
boxShadow: h > 40 ? `0 0 ${Math.floor(h / 6)}px rgba(99, 102, 241, ${Math.min(0.5, h / 200)})` : 'none',
}}
/>
))}
</div>
/>
);
}