blog-backup/src/components/AudioPlayer.tsx
OpenClaw Bot 3a81f88bfc Add MP3 audio hosting feature with Supabase Storage integration
- Create /api/audio endpoint for upload/get/delete operations
- Add AudioPlayer component with custom controls
- Add AudioUpload component for admin interface
- Update admin page with audio upload UI and indicators
- Update main blog page to use new AudioPlayer component
- Add database migration for audio_url and audio_duration columns
- Add comprehensive documentation in docs/MP3_AUDIO_FEATURE.md
- Update README with audio feature overview
2026-02-25 17:12:26 -06:00

219 lines
8.1 KiB
TypeScript

"use client";
import { useState, useRef, useEffect } from "react";
interface AudioPlayerProps {
url: string;
duration?: number;
className?: string;
}
export function AudioPlayer({ url, duration, className = "" }: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [audioDuration, setAudioDuration] = useState(duration || 0);
const [volume, setVolume] = useState(1);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
const handleLoadedMetadata = () => {
setAudioDuration(audio.duration);
setIsLoaded(true);
};
const handleEnded = () => setIsPlaying(false);
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
audio.addEventListener("timeupdate", handleTimeUpdate);
audio.addEventListener("loadedmetadata", handleLoadedMetadata);
audio.addEventListener("ended", handleEnded);
audio.addEventListener("play", handlePlay);
audio.addEventListener("pause", handlePause);
return () => {
audio.removeEventListener("timeupdate", handleTimeUpdate);
audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
audio.removeEventListener("ended", handleEnded);
audio.removeEventListener("play", handlePlay);
audio.removeEventListener("pause", handlePause);
};
}, []);
const togglePlay = () => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) {
audio.pause();
} else {
audio.play();
}
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const audio = audioRef.current;
if (!audio) return;
const newTime = parseFloat(e.target.value);
audio.currentTime = newTime;
setCurrentTime(newTime);
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const audio = audioRef.current;
if (!audio) return;
const newVolume = parseFloat(e.target.value);
audio.volume = newVolume;
setVolume(newVolume);
};
const formatTime = (time: number) => {
if (isNaN(time)) return "0:00";
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const progress = audioDuration ? (currentTime / audioDuration) * 100 : 0;
return (
<div className={`bg-gray-50 rounded-xl p-4 border border-gray-200 dark:bg-slate-900 dark:border-slate-800 ${className}`}>
<audio ref={audioRef} src={url} preload="metadata" />
<div className="flex items-center gap-3 mb-3">
<button
onClick={togglePlay}
className="w-10 h-10 flex items-center justify-center bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900"
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-5 h-5 ml-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
</svg>
)}
</button>
<div className="flex-1">
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-slate-400 mb-1">
<span className="font-medium">{formatTime(currentTime)}</span>
<span>/</span>
<span>{formatTime(audioDuration)}</span>
</div>
<div className="relative h-2 bg-gray-200 rounded-full overflow-hidden dark:bg-slate-700">
<input
type="range"
min={0}
max={audioDuration || 100}
value={currentTime}
onChange={handleSeek}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
aria-label="Seek"
/>
<div
className="absolute left-0 top-0 h-full bg-blue-600 rounded-full transition-all duration-100"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
const audio = audioRef.current;
if (audio) {
audio.volume = volume > 0 ? 0 : 1;
setVolume(volume > 0 ? 0 : 1);
}
}}
className="text-gray-500 hover:text-gray-700 dark:text-slate-400 dark:hover:text-slate-200"
aria-label={volume > 0 ? "Mute" : "Unmute"}
>
{volume > 0 ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
</svg>
)}
</button>
<input
type="range"
min={0}
max={1}
step={0.1}
value={volume}
onChange={handleVolumeChange}
className="w-20 h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-slate-700"
aria-label="Volume"
/>
</div>
</div>
</div>
);
}
interface CompactAudioPlayerProps {
url: string;
className?: string;
}
export function CompactAudioPlayer({ url, className = "" }: CompactAudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const togglePlay = () => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) {
audio.pause();
} else {
audio.play();
}
setIsPlaying(!isPlaying);
};
return (
<div className={`inline-flex items-center gap-2 ${className}`}>
<audio
ref={audioRef}
src={url}
onEnded={() => setIsPlaying(false)}
onPause={() => setIsPlaying(false)}
onPlay={() => setIsPlaying(true)}
/>
<button
onClick={togglePlay}
className="w-8 h-8 flex items-center justify-center bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors"
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
</svg>
)}
</button>
<span className="text-sm text-gray-600 dark:text-slate-400">
{isPlaying ? "Playing..." : "Listen"}
</span>
</div>
);
}