mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-20 13:38:04 +02:00
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:
1 parent
66d61595bd
commit
a49e9f04d8
4 files changed
+102
-61
No files matched your search
@@ -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<ReturnType<typeof setTimeout> | 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 */}
|
||||
<div className="w-full flex justify-center my-3">
|
||||
<AudioVisualizer barCount={28} height={70} gap={3} />
|
||||
<AudioVisualizer height={56} />
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
@@ -268,7 +278,7 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AudioVisualizer barCount={36} height={80} gap={3} />
|
||||
<AudioVisualizer height={72} />
|
||||
|
||||
<div>
|
||||
<div className="relative h-1.5 group cursor-pointer mb-2">
|
||||
|
||||
@@ -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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export default function EpisodeCard({ episode, onPlay, onDetails, onToggleRead }
|
||||
{/* Top left: completed badge */}
|
||||
<div className="absolute top-3 left-3">
|
||||
{isCompleted && (
|
||||
<span className="px-2 py-1 rounded-md bg-[var(--accent-emerald)]/90 backdrop-blur-md text-white text-[0.6rem] font-extrabold uppercase tracking-wider border border-emerald-300/20">
|
||||
<span className="px-2 py-1 rounded-md bg-[var(--accent-emerald)] backdrop-blur-md text-white text-[0.6rem] font-extrabold uppercase tracking-wider border border-emerald-400/30 shadow-sm">
|
||||
LU
|
||||
</span>
|
||||
)}
|
||||
@@ -61,7 +61,7 @@ export default function EpisodeCard({ episode, onPlay, onDetails, onToggleRead }
|
||||
<div className={`absolute inset-0 bg-black/50 transition-opacity duration-300 flex items-center justify-center ${isCurrent ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
|
||||
<div
|
||||
onClick={handlePlay}
|
||||
className="w-14 h-14 rounded-full bg-white/90 text-[var(--bg-base)] flex items-center justify-center shadow-xl transform translate-y-2 group-hover:translate-y-0 transition-transform duration-300 active:scale-95"
|
||||
className="w-14 h-14 rounded-full bg-black/85 text-white flex items-center justify-center shadow-xl transform translate-y-2 group-hover:translate-y-0 transition-transform duration-300 active:scale-95"
|
||||
>
|
||||
{isCurrent && isPlaying
|
||||
? <Pause className="w-5 h-5 fill-current" />
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function PodcastCard({
|
||||
|
||||
{/* Overlay on hover */}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||
<div className="w-14 h-14 rounded-full bg-white/90 text-[var(--bg-base)] flex items-center justify-center shadow-xl transform translate-y-2 group-hover:translate-y-0 transition-transform duration-300">
|
||||
<div className="w-14 h-14 rounded-full bg-black/85 text-white flex items-center justify-center shadow-xl transform translate-y-2 group-hover:translate-y-0 transition-transform duration-300">
|
||||
<Play className="w-5 h-5 fill-current ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user