Handy/src/components/ui/AudioPlayer.tsx
CJ Pais 5ba5f77b45
basic working fix of history (#691)
* basic working fix of history

* format

* fix weird jank + cleanup impl
2026-02-01 13:03:13 +08:00

273 lines
8.1 KiB
TypeScript

import React, { useState, useRef, useEffect, useCallback } from "react";
import { Play, Pause } from "lucide-react";
interface AudioPlayerProps {
/** Audio source URL. If not provided, onLoadRequest must be provided. */
src?: string;
/** Called when play is clicked and no src is loaded yet. Should return the audio URL. */
onLoadRequest?: () => Promise<string | null>;
className?: string;
autoPlay?: boolean;
}
export const AudioPlayer: React.FC<AudioPlayerProps> = ({
src: initialSrc,
onLoadRequest,
className = "",
autoPlay = false,
}) => {
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [loadedSrc, setLoadedSrc] = useState<string | null>(initialSrc ?? null);
const [isLoading, setIsLoading] = useState(false);
const audioRef = useRef<HTMLAudioElement>(null);
const src = loadedSrc;
const animationRef = useRef<number>();
const dragTimeRef = useRef<number>(0);
// Use refs to avoid stale closures in animation loop
const isPlayingRef = useRef(false);
const isDraggingRef = useRef(false);
// Keep refs in sync with state
useEffect(() => {
isPlayingRef.current = isPlaying;
}, [isPlaying]);
useEffect(() => {
isDraggingRef.current = isDragging;
}, [isDragging]);
// Stable animation loop with no dependencies
const tick = useCallback(() => {
if (audioRef.current && !isDraggingRef.current) {
const time = audioRef.current.currentTime;
setCurrentTime(time);
}
if (isPlayingRef.current) {
animationRef.current = requestAnimationFrame(tick);
}
}, []); // Empty dependency array is key!
// Manage animation loop lifecycle
useEffect(() => {
if (isPlaying && !isDragging) {
// Only start if not already running
if (!animationRef.current) {
animationRef.current = requestAnimationFrame(tick);
}
} else {
// Stop animation loop
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = undefined;
}
}
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = undefined;
}
};
}, [isPlaying, isDragging, tick]);
// Audio event handlers
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const handleLoadedMetadata = () => {
setDuration(audio.duration || 0);
setCurrentTime(0);
};
const handleEnded = () => {
setIsPlaying(false);
setCurrentTime(audio.duration || 0);
};
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
audio.addEventListener("loadedmetadata", handleLoadedMetadata);
audio.addEventListener("ended", handleEnded);
audio.addEventListener("play", handlePlay);
audio.addEventListener("pause", handlePause);
return () => {
audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
audio.removeEventListener("ended", handleEnded);
audio.removeEventListener("play", handlePlay);
audio.removeEventListener("pause", handlePause);
};
}, []);
// Auto-play when src becomes available (via onLoadRequest or autoPlay prop)
const prevLoadedSrc = useRef<string | null>(null);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
// Play when loadedSrc changes from null to a value (lazy load case)
if (loadedSrc && !prevLoadedSrc.current && onLoadRequest) {
audio.play().catch((error) => {
console.error("Auto-play failed:", error);
});
}
// Or when autoPlay is set with initial src
else if (autoPlay && initialSrc && !prevLoadedSrc.current) {
audio.play().catch((error) => {
console.error("Auto-play failed:", error);
});
}
prevLoadedSrc.current = loadedSrc;
}, [loadedSrc, autoPlay, initialSrc, onLoadRequest]);
// Global drag handlers
const handleMouseUp = useCallback(() => {
if (isDragging) {
setIsDragging(false);
if (audioRef.current) {
audioRef.current.currentTime = dragTimeRef.current;
setCurrentTime(dragTimeRef.current);
}
}
}, [isDragging]);
useEffect(() => {
if (isDragging) {
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("touchend", handleMouseUp);
return () => {
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("touchend", handleMouseUp);
};
}
}, [isDragging, handleMouseUp]);
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
if (loadedSrc?.startsWith("blob:")) {
URL.revokeObjectURL(loadedSrc);
}
};
}, [loadedSrc]);
const togglePlay = async () => {
const audio = audioRef.current;
if (!audio) return;
if (isLoading) return;
try {
if (isPlaying) {
audio.pause();
} else {
// If no src loaded yet, request it
if (!src && onLoadRequest) {
setIsLoading(true);
const newSrc = await onLoadRequest();
setIsLoading(false);
if (newSrc) {
setLoadedSrc(newSrc);
// Playback will be triggered by the useEffect watching loadedSrc
}
} else if (src) {
await audio.play();
}
}
} catch (error) {
console.error("Playback failed:", error);
}
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = parseFloat(e.target.value);
dragTimeRef.current = newTime;
setCurrentTime(newTime);
if (!isDragging && audioRef.current) {
audioRef.current.currentTime = newTime;
}
};
const handleSliderMouseDown = () => {
setIsDragging(true);
};
const handleSliderTouchStart = () => {
setIsDragging(true);
};
const formatTime = (time: number): string => {
if (!isFinite(time)) return "0:00";
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
// Fix playhead positioning with better edge case handling
const getProgressPercent = (): number => {
if (duration <= 0) return 0;
// Handle the end case - if we're within 0.1 seconds of the end, show 100%
if (duration - currentTime < 0.1) return 100;
const percent = (currentTime / duration) * 100;
return Math.min(100, Math.max(0, percent));
};
const progressPercent = getProgressPercent();
return (
<div className={`flex items-center gap-3 ${className}`}>
<audio ref={audioRef} src={src ?? undefined} preload="metadata" />
<button
onClick={togglePlay}
disabled={isLoading}
className="transition-colors cursor-pointer text-text hover:text-logo-primary disabled:opacity-50"
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? (
<Pause width={20} height={20} fill="currentColor" />
) : (
<Play width={20} height={20} fill="currentColor" />
)}
</button>
<div className="flex-1 flex items-center gap-2">
<span className="text-xs text-text/60 min-w-[30px] tabular-nums">
{formatTime(currentTime)}
</span>
<input
type="range"
min="0"
max={duration || 0}
step="0.01"
value={currentTime}
onChange={handleSeek}
onMouseDown={handleSliderMouseDown}
onTouchStart={handleSliderTouchStart}
className={`flex-1 h-1 rounded-lg appearance-none cursor-pointer focus:outline-none focus:ring-1 focus:ring-logo-primary ${progressPercent >= 99.5 ? "[&::-webkit-slider-thumb]:translate-x-0.5 [&::-moz-range-thumb]:translate-x-0.5" : ""}`}
style={{
background: `linear-gradient(to right, #FAA2CA 0%, #FAA2CA ${progressPercent}%, rgba(128, 128, 128, 0.2) ${progressPercent}%, rgba(128, 128, 128, 0.2) 100%)`,
}}
/>
<span className="text-xs text-text/60 min-w-[30px] tabular-nums">
{formatTime(duration)}
</span>
</div>
</div>
);
};