From a49e9f04d80a17142235abbb09066e76b5ed3933 Mon Sep 17 00:00:00 2001
From: R0m1k3
Date: Tue, 28 Apr 2026 13:52:17 +0200
Subject: [PATCH] fix: clignotement mini-bar + bouton play noir + badge LU vert
+ visualiseur horizontal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
frontend/src/components/AudioPlayer.tsx | 20 ++-
frontend/src/components/AudioVisualizer.tsx | 137 ++++++++++++--------
frontend/src/components/EpisodeCard.tsx | 4 +-
frontend/src/components/PodcastCard.tsx | 2 +-
4 files changed, 102 insertions(+), 61 deletions(-)
diff --git a/frontend/src/components/AudioPlayer.tsx b/frontend/src/components/AudioPlayer.tsx
index 4cc428e..f25f7e6 100644
--- a/frontend/src/components/AudioPlayer.tsx
+++ b/frontend/src/components/AudioPlayer.tsx
@@ -30,14 +30,24 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
useEffect(() => { setUserId(userId ?? null); }, [userId, setUserId]);
+ // IntersectionObserver with debounce to prevent flicker
+ const miniTimer = useRef | null>(null);
useEffect(() => {
if (mode !== 'inline' || !inlineRef.current) return;
const obs = new IntersectionObserver(
- ([entry]) => setShowMiniBar(!entry.isIntersecting),
- { threshold: 0, rootMargin: '-40px 0px 0px 0px' }
+ ([entry]) => {
+ if (miniTimer.current) clearTimeout(miniTimer.current);
+ miniTimer.current = setTimeout(() => {
+ setShowMiniBar(!entry.isIntersecting);
+ }, 250);
+ },
+ { threshold: 0.1, rootMargin: '-80px 0px 0px 0px' }
);
obs.observe(inlineRef.current);
- return () => obs.disconnect();
+ return () => {
+ obs.disconnect();
+ if (miniTimer.current) clearTimeout(miniTimer.current);
+ };
}, [mode, episode?._id]);
const podcast = typeof episode?.podcastId === 'object' ? episode.podcastId : null;
@@ -172,7 +182,7 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
{/* Audio Visualizer */}
{/* Progress */}
@@ -268,7 +278,7 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
-
+
diff --git a/frontend/src/components/AudioVisualizer.tsx b/frontend/src/components/AudioVisualizer.tsx
index c671521..86eb836 100644
--- a/frontend/src/components/AudioVisualizer.tsx
+++ b/frontend/src/components/AudioVisualizer.tsx
@@ -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
(() => Array(barCount).fill(3));
+ const canvasRef = useRef(null);
const rafRef = useRef(0);
const playingRef = useRef(isPlaying);
const getDataRef = useRef(getFrequencyData);
+ const historyRef = useRef([]);
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 (
-
- {heights.map((h, i) => (
-
40 ? `0 0 ${Math.floor(h / 6)}px rgba(99, 102, 241, ${Math.min(0.5, h / 200)})` : 'none',
- }}
- />
- ))}
-
+ />
);
}
diff --git a/frontend/src/components/EpisodeCard.tsx b/frontend/src/components/EpisodeCard.tsx
index adcfbe7..2a86e2d 100644
--- a/frontend/src/components/EpisodeCard.tsx
+++ b/frontend/src/components/EpisodeCard.tsx
@@ -51,7 +51,7 @@ export default function EpisodeCard({ episode, onPlay, onDetails, onToggleRead }
{/* Top left: completed badge */}
{isCompleted && (
-
+
LU
)}
@@ -61,7 +61,7 @@ export default function EpisodeCard({ episode, onPlay, onDetails, onToggleRead }
{isCurrent && isPlaying
?
diff --git a/frontend/src/components/PodcastCard.tsx b/frontend/src/components/PodcastCard.tsx
index a44f0bb..9544961 100644
--- a/frontend/src/components/PodcastCard.tsx
+++ b/frontend/src/components/PodcastCard.tsx
@@ -48,7 +48,7 @@ export default function PodcastCard({
{/* Overlay on hover */}