- 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
219 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
} |