Files
eranos/src/components/AudioVisualizer.tsx
T
Chad Curtis c389ab98df Add audio to post detail, volume slider, and theme-aware audio visualizer
- Render audio in PostDetailPage (was only rendering in feed NoteCard)
- Add volume slider to VideoPlayer and AudioVisualizer (hover to reveal)
- Move shared volume state and handlers into usePlayerControls
- Replace hardcoded purple canvas colors with hsl(var(--primary))
- Replace bg-black with bg-background on AudioVisualizer container
2026-03-01 00:36:32 -06:00

361 lines
13 KiB
TypeScript

import { useRef, useState, useEffect, useCallback } from 'react';
import { Play, Pause, Volume1, Volume2, VolumeX } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
import { usePlayerControls } from '@/hooks/usePlayerControls';
import { formatTime } from '@/lib/formatTime';
interface AudioVisualizerProps {
src: string;
mime?: string;
/** Avatar image URL for the circle in the centre */
avatarUrl?: string;
/** Fallback display letter for the avatar */
avatarFallback?: string;
className?: string;
}
/**
* Audio player that renders identically to VideoPlayer — same container,
* same overlay controls, same progress bar — but the "video surface" is
* a canvas showing an animated sinewave with the author's avatar centred.
*/
export function AudioVisualizer({
src,
mime,
avatarUrl,
avatarFallback = '?',
className,
}: AudioVisualizerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const animFrameRef = useRef<number>(0);
const analyserRef = useRef<AnalyserNode | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const idlePhaseRef = useRef(0);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [hasStarted, setHasStarted] = useState(false);
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const { showControls, revealControls, scheduleHide, isMuted, volume, toggleMute, handleVolumeChange } = usePlayerControls({
mediaRef: audioRef,
containerRef,
isPlaying,
});
// ── Canvas: sinewave drawing ───────────────────────────────────────────
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Use CSS pixel dimensions for drawing coordinates
const W = canvas.clientWidth;
const H = canvas.clientHeight;
const dpr = window.devicePixelRatio || 1;
// Ensure backing store matches display size
if (canvas.width !== Math.round(W * dpr) || canvas.height !== Math.round(H * dpr)) {
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
}
ctx.save();
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
// Read the theme's --primary HSL value at draw time so the wave always
// matches the current color scheme (light / dark / custom theme).
const primaryHsl = getComputedStyle(canvas).getPropertyValue('--primary').trim();
const primaryColor = `hsl(${primaryHsl})`;
const primaryFaint = `hsl(${primaryHsl} / 0.15)`;
const primaryMid = `hsl(${primaryHsl} / 0.85)`;
const primaryGlow = `hsl(${primaryHsl} / 0.55)`;
const analyser = analyserRef.current;
let dataArray: Uint8Array | null = null;
if (analyser && isPlaying) {
dataArray = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteTimeDomainData(dataArray);
}
// Avatar clear-zone radius in CSS px (matches the size-20 = 80px avatar)
const avatarR = 52; // half of size-20 (80px) + a small gap
const cx = W / 2;
const midY = H / 2;
const grad = ctx.createLinearGradient(0, 0, W, 0);
grad.addColorStop(0, primaryFaint);
grad.addColorStop(0.35, primaryMid);
grad.addColorStop(0.5, primaryColor);
grad.addColorStop(0.65, primaryMid);
grad.addColorStop(1, primaryFaint);
ctx.beginPath();
ctx.strokeStyle = grad;
ctx.lineWidth = 2.5;
ctx.shadowBlur = 10;
ctx.shadowColor = primaryGlow;
if (dataArray) {
// Real-time waveform
const sliceW = W / dataArray.length;
for (let i = 0; i < dataArray.length; i++) {
const x = i * sliceW;
const v = dataArray[i] / 128.0 - 1; // -1..1
const dist = Math.abs(x - cx);
// Fade smoothly through the avatar zone
const fade = dist < avatarR
? 0
: dist < avatarR * 1.6
? (dist - avatarR) / (avatarR * 0.6)
: 1;
const y = midY + v * (H * 0.35) * fade;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
} else {
// Idle animated sine
idlePhaseRef.current += 0.03;
const phase = idlePhaseRef.current;
const steps = 300;
for (let i = 0; i <= steps; i++) {
const x = (i / steps) * W;
const dist = Math.abs(x - cx);
const fade = dist < avatarR
? 0
: dist < avatarR * 1.6
? (dist - avatarR) / (avatarR * 0.6)
: 1;
const y = midY + Math.sin((i / steps) * Math.PI * 4 + phase) * (H * 0.12) * fade;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
}
ctx.stroke();
ctx.restore();
animFrameRef.current = requestAnimationFrame(draw);
}, [isPlaying]);
useEffect(() => {
cancelAnimationFrame(animFrameRef.current);
animFrameRef.current = requestAnimationFrame(draw);
return () => cancelAnimationFrame(animFrameRef.current);
}, [draw]);
// ── Web Audio API ──────────────────────────────────────────────────────
const ensureAudioContext = useCallback(() => {
const audio = audioRef.current;
if (!audio || audioCtxRef.current) return;
const actx = new AudioContext();
audioCtxRef.current = actx;
const analyser = actx.createAnalyser();
analyser.fftSize = 2048;
analyserRef.current = analyser;
actx.createMediaElementSource(audio).connect(analyser);
analyser.connect(actx.destination);
}, []);
// ── Playback controls ──────────────────────────────────────────────────
const togglePlay = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
const audio = audioRef.current;
if (!audio) return;
ensureAudioContext();
if (audioCtxRef.current?.state === 'suspended') audioCtxRef.current.resume();
if (audio.paused) { audio.play(); } else { audio.pause(); }
}, [ensureAudioContext]);
const handleSeek = (e: React.MouseEvent) => {
e.stopPropagation();
const audio = audioRef.current;
const bar = progressRef.current;
if (!audio || !bar || !duration) return;
const rect = bar.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
audio.currentTime = ratio * duration;
};
const handleCanvasClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (!hasStarted) {
const audio = audioRef.current;
if (audio) { ensureAudioContext(); audio.play(); }
return;
}
togglePlay(e);
revealControls();
};
// ── Audio event listeners ──────────────────────────────────────────────
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onPlay = () => { setIsPlaying(true); setHasStarted(true); };
const onPause = () => setIsPlaying(false);
const onEnded = () => setIsPlaying(false);
const onTime = () => setCurrentTime(audio.currentTime);
const onDur = () => setDuration(audio.duration);
audio.addEventListener('play', onPlay);
audio.addEventListener('pause', onPause);
audio.addEventListener('ended', onEnded);
audio.addEventListener('timeupdate', onTime);
audio.addEventListener('durationchange', onDur);
audio.addEventListener('loadedmetadata', onDur);
return () => {
audio.removeEventListener('play', onPlay);
audio.removeEventListener('pause', onPause);
audio.removeEventListener('ended', onEnded);
audio.removeEventListener('timeupdate', onTime);
audio.removeEventListener('durationchange', onDur);
audio.removeEventListener('loadedmetadata', onDur);
};
}, []);
useEffect(() => () => {
cancelAnimationFrame(animFrameRef.current);
audioCtxRef.current?.close();
}, []);
return (
<div
ref={containerRef}
className={cn(
'relative mt-3 rounded-2xl overflow-hidden border border-border bg-background group',
className,
)}
style={{ aspectRatio: '16 / 9' }}
onMouseMove={revealControls}
onMouseLeave={() => { if (isPlaying) scheduleHide(); }}
onClick={(e) => e.stopPropagation()}
>
{/* Hidden audio element */}
<audio ref={audioRef} preload="metadata" crossOrigin="anonymous" className="hidden">
{mime ? <source src={src} type={mime} /> : <source src={src} />}
</audio>
{/* Sinewave canvas — fills the entire box */}
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full"
onClick={handleCanvasClick}
style={{ cursor: 'pointer' }}
/>
{/* Avatar — centred over the canvas */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div
className={cn(
'rounded-full ring-4 transition-all duration-300',
isPlaying
? 'ring-primary/40 shadow-[0_0_28px_8px_hsl(var(--primary)/0.4)]'
: 'ring-border',
)}
>
<Avatar className="size-20 border-2 border-white/20">
<AvatarImage src={avatarUrl} alt={avatarFallback} />
<AvatarFallback className="bg-primary/20 text-primary text-2xl font-semibold">
{avatarFallback}
</AvatarFallback>
</Avatar>
</div>
</div>
{/* Big centred play button before first play — identical to VideoPlayer */}
{!hasStarted && (
<div
className="absolute inset-0 flex items-center justify-center bg-black/30 cursor-pointer"
onClick={handleCanvasClick}
>
<div className="size-16 rounded-full bg-black/60 flex items-center justify-center backdrop-blur-sm">
<Play className="size-8 text-white ml-1" fill="white" />
</div>
</div>
)}
{/* Bottom control bar — identical markup to VideoPlayer */}
{hasStarted && (
<div
className={cn(
'absolute bottom-0 left-0 right-0 transition-opacity duration-200',
'bg-gradient-to-t from-black/80 via-black/40 to-transparent pt-8 pb-2 px-3',
showControls ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}
>
{/* Progress bar */}
<div
ref={progressRef}
className="w-full h-1 bg-white/30 rounded-full cursor-pointer mb-2 group/progress"
onClick={handleSeek}
>
<div
className="h-full bg-primary rounded-full relative"
style={{ width: `${progress}%` }}
>
<div className="absolute right-0 top-1/2 -translate-y-1/2 size-3 bg-primary rounded-full opacity-0 group-hover/progress:opacity-100 transition-opacity" />
</div>
</div>
{/* Controls row */}
<div className="flex items-center gap-3">
<button
onClick={togglePlay}
className="text-white hover:text-white/80 transition-colors"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying
? <Pause className="size-5" fill="white" />
: <Play className="size-5 ml-0.5" fill="white" />}
</button>
{/* Volume: icon toggles mute, slider sets level */}
<div className="flex items-center gap-1.5 group/vol">
<button
onClick={toggleMute}
className="text-white hover:text-white/80 transition-colors shrink-0"
aria-label={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted || volume === 0
? <VolumeX className="size-5" />
: volume < 0.5
? <Volume1 className="size-5" />
: <Volume2 className="size-5" />}
</button>
<input
type="range"
min={0}
max={1}
step={0.02}
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
onClick={(e) => e.stopPropagation()}
aria-label="Volume"
className={cn(
'w-0 opacity-0 group-hover/vol:w-16 group-hover/vol:opacity-100',
'transition-all duration-200 cursor-pointer accent-white h-1',
)}
/>
</div>
<span className="text-white text-xs tabular-nums min-w-0">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex-1" />
</div>
</div>
)}
</div>
);
}