音乐增加音频动画

This commit is contained in:
mtvpls
2026-05-09 14:47:14 +08:00
parent f445cdf546
commit e3f7d43592
2 changed files with 302 additions and 7 deletions
+251
View File
@@ -8,6 +8,10 @@ import AddToPlaylistModal from '@/components/AddToPlaylistModal';
import Toast, { ToastProps } from '@/components/Toast';
import LyricsPiPWindow from '@/components/LyricsPiPWindow';
const SPECTRUM_BIN_COUNT = 96;
const SPECTRUM_IDLE_LEVEL = 0.02;
const SPECTRUM_EDGE_TRIM = 8;
type MusicSource = 'wy' | 'tx' | 'kw' | 'kg' | 'mg';
interface Song {
@@ -92,6 +96,90 @@ function MusicLoadingIndicator({
);
}
function AudioSpectrumCanvas({
bars,
compact = false,
}: {
bars: number[];
compact?: boolean;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const draw = () => {
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const dpr = window.devicePixelRatio || 1;
const width = Math.round(rect.width * dpr);
const height = Math.round(rect.height * dpr);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, width, height);
const targetPitch = compact ? 4.2 : 4.6;
const gap = 1;
const count = Math.max(1, Math.floor(rect.width / targetPitch));
const barWidth = Math.max(2, Math.floor((width - gap * (count - 1)) / count));
const cubeHeight = compact ? Math.max(2, Math.round(2 * dpr)) : Math.max(2, Math.round(2.5 * dpr));
const cubeGap = 1;
const scaleBase = compact ? height * 1.55 : height * 1.42;
const themeColor = '#10b981';
const sampleBar = (index: number) => {
const usableLength = Math.max(1, bars.length - SPECTRUM_EDGE_TRIM * 2);
const mappedStart = SPECTRUM_EDGE_TRIM + Math.floor((index / count) * usableLength);
const start = Math.min(bars.length - 1, mappedStart);
const mappedEnd = SPECTRUM_EDGE_TRIM + Math.max(mappedStart + 1, Math.floor(((index + 1) / count) * usableLength));
const end = Math.min(bars.length, Math.max(start + 1, mappedEnd));
let total = 0;
for (let i = start; i < end; i++) total += bars[i] ?? 0;
return total / Math.max(1, end - start);
};
ctx.fillStyle = themeColor;
ctx.strokeStyle = themeColor;
for (let i = 0; i < count; i++) {
const q = Math.max(SPECTRUM_IDLE_LEVEL, sampleBar(i)) * scaleBase;
const cubeCount = Math.max(1, Math.ceil(q / Math.max(1, barWidth * 0.9)));
const x = i * (barWidth + gap);
for (let segment = 0; segment < cubeCount; segment++) {
const y = height - segment * (cubeHeight + cubeGap);
ctx.beginPath();
ctx.roundRect(x, y - cubeHeight, barWidth, cubeHeight, Math.min(2 * dpr, cubeHeight / 2));
ctx.fill();
}
}
};
draw();
const observer = new ResizeObserver(draw);
observer.observe(canvas);
return () => observer.disconnect();
}, [bars, compact]);
return (
<div
className={`relative overflow-hidden ${compact ? 'h-6' : 'h-8'}`}
aria-hidden="true"
>
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full opacity-50" />
</div>
);
}
// 扩展 Window 接口以支持 Document PiP API
declare global {
interface Window {
@@ -176,12 +264,26 @@ export default function MusicPage() {
const [showPiPLyrics, setShowPiPLyrics] = useState(false);
const [pipOpacity, setPipOpacity] = useState(0.9);
const [pipMinimized, setPipMinimized] = useState(false);
const [showSpectrum, setShowSpectrum] = useState(() => {
if (typeof window === 'undefined') return true;
return localStorage.getItem('musicShowSpectrum') !== '0';
});
const [spectrumBars, setSpectrumBars] = useState<number[]>(
() => Array.from({ length: SPECTRUM_BIN_COUNT }, () => SPECTRUM_IDLE_LEVEL)
);
const audioRef = useRef<HTMLAudioElement>(null);
const lyricsContainerRef = useRef<HTMLDivElement>(null);
const lastSaveTimeRef = useRef<number>(0);
const restoredTimeRef = useRef<number>(0);
const songStartTimeRef = useRef<number>(0); // 歌曲开始播放的时间戳
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const mediaSourceRef = useRef<MediaElementAudioSourceNode | null>(null);
const spectrumDataRef = useRef<Uint8Array | null>(null);
const spectrumFrameRef = useRef<number | null>(null);
const currentTimeRef = useRef(0);
const spectrumSeedRef = useRef(Math.random() * Math.PI * 2);
const mapSong = (song: any): Song => ({
id: song.songId || song.id,
@@ -1502,6 +1604,15 @@ export default function MusicPage() {
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const toggleSpectrum = () => {
setShowSpectrum(prev => !prev);
};
useEffect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem('musicShowSpectrum', showSpectrum ? '1' : '0');
}, [showSpectrum]);
const getQualityLabel = () => {
switch (quality) {
case '128k': return '标准';
@@ -1528,6 +1639,123 @@ export default function MusicPage() {
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
useEffect(() => {
currentTimeRef.current = currentTime;
}, [currentTime]);
useEffect(() => {
const audio = audioRef.current;
if (!audio || typeof window === 'undefined') return;
let cancelled = false;
const ensureAnalyser = async () => {
try {
const AudioContextClass = window.AudioContext || (window as typeof window & {
webkitAudioContext?: typeof AudioContext;
}).webkitAudioContext;
if (!AudioContextClass) return;
if (!audioContextRef.current) {
audioContextRef.current = new AudioContextClass();
}
if (!mediaSourceRef.current) {
mediaSourceRef.current = audioContextRef.current.createMediaElementSource(audio);
}
if (!analyserRef.current) {
const analyser = audioContextRef.current.createAnalyser();
analyser.fftSize = 512;
analyser.smoothingTimeConstant = 0.8;
mediaSourceRef.current.connect(analyser);
analyser.connect(audioContextRef.current.destination);
analyserRef.current = analyser;
spectrumDataRef.current = new Uint8Array(analyser.frequencyBinCount);
}
if (audioContextRef.current.state === 'suspended') {
await audioContextRef.current.resume();
}
} catch (error) {
console.warn('初始化频谱分析器失败,将使用模拟动画:', error);
}
};
const tick = () => {
if (cancelled) return;
const analyser = analyserRef.current;
const data = spectrumDataRef.current;
const isActive = !audio.paused && !audio.ended;
let nextBars = Array.from({ length: SPECTRUM_BIN_COUNT }, () => SPECTRUM_IDLE_LEVEL);
if (isActive && analyser && data) {
analyser.getByteFrequencyData(data);
const usableBins = Math.max(1, Math.floor(data.length * 0.88));
nextBars = Array.from({ length: SPECTRUM_BIN_COUNT }, (_, index) => {
const start = Math.floor((index / SPECTRUM_BIN_COUNT) * usableBins);
const end = Math.max(start + 1, Math.floor(((index + 1) / SPECTRUM_BIN_COUNT) * usableBins));
let total = 0;
for (let i = start; i < end; i++) {
total += data[i] ?? 0;
}
const average = total / Math.max(1, end - start);
const rightBias = index / Math.max(1, SPECTRUM_BIN_COUNT - 1);
const highFreqCompensation = 1 + rightBias * 0.85;
const floorLift = rightBias * 0.035;
return Math.max(
SPECTRUM_IDLE_LEVEL,
Math.min(1, (average / 255) * highFreqCompensation + floorLift)
);
});
} else if (isActive) {
nextBars = Array.from({ length: SPECTRUM_BIN_COUNT }, (_, index) => {
const wave =
Math.sin(currentTimeRef.current * 5.2 + index * 0.28 + spectrumSeedRef.current) * 0.12 +
Math.sin(currentTimeRef.current * 2.6 + index * 0.16) * 0.08 +
0.22;
return Math.max(SPECTRUM_IDLE_LEVEL, Math.min(0.65, wave));
});
}
setSpectrumBars(prev =>
nextBars.map((value, index) => {
const previous = prev[index] ?? SPECTRUM_IDLE_LEVEL;
return previous + (value - previous) * (isActive ? 0.34 : 0.12);
})
);
spectrumFrameRef.current = window.requestAnimationFrame(tick);
};
void ensureAnalyser();
spectrumFrameRef.current = window.requestAnimationFrame(tick);
return () => {
cancelled = true;
if (spectrumFrameRef.current) {
window.cancelAnimationFrame(spectrumFrameRef.current);
spectrumFrameRef.current = null;
}
};
}, [currentSong]);
useEffect(() => {
return () => {
if (typeof window !== 'undefined' && spectrumFrameRef.current) {
window.cancelAnimationFrame(spectrumFrameRef.current);
}
analyserRef.current?.disconnect();
mediaSourceRef.current?.disconnect();
audioContextRef.current?.close().catch(() => undefined);
};
}, []);
return (
<div className="music-theme min-h-screen bg-zinc-950 text-white">
<>
@@ -2343,6 +2571,24 @@ export default function MusicPage() {
</div>
</div>
{/* PiP 歌词按钮 */}
<button
onClick={toggleSpectrum}
className={`transition-colors ${
showSpectrum ? 'text-green-500 hover:text-green-400' : 'text-zinc-500 hover:text-white'
}`}
title={showSpectrum ? '隐藏音谱图' : '显示音谱图'}
>
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{showSpectrum ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 18V9m4 9V6m4 12v-4m4 4V8m4 10V4" />
) : (
<>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 18V9m4 9V6m4 12v-4m4 4V8m4 10V4" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 3l18 18" />
</>
)}
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
@@ -2380,6 +2626,11 @@ export default function MusicPage() {
{/* 进度条 */}
<div>
{showSpectrum && (
<div className="mb-3">
<AudioSpectrumCanvas bars={spectrumBars} />
</div>
)}
<div className="flex items-center gap-2 text-xs text-zinc-500">
<span>{formatTime(currentTime)}</span>
<div className="flex-1 h-1 bg-white/10 rounded-full overflow-hidden relative">
+51 -7
View File
@@ -31,6 +31,53 @@ interface AddToPlaylistModalProps {
onError?: (message: string) => void;
}
function MusicLoadingIndicator({
text,
size = 'md',
className = '',
}: {
text?: string;
size?: 'sm' | 'md';
className?: string;
}) {
const iconSize = size === 'sm' ? 'w-4 h-4' : 'w-5 h-5';
const textSize = size === 'sm' ? 'text-xs' : 'text-sm';
return (
<>
<style jsx>{`
@keyframes music-note-bounce {
0%,
100% {
transform: translateY(0);
opacity: 0.55;
}
50% {
transform: translateY(-8px);
opacity: 1;
}
}
`}</style>
<div className={`flex items-center justify-center gap-3 text-zinc-400 ${className}`}>
<div className="flex items-end gap-1.5">
{[0, 1, 2].map((index) => (
<svg
key={index}
className={`${iconSize} text-green-400`}
fill="currentColor"
viewBox="0 0 24 24"
style={{ animation: `music-note-bounce 0.9s ease-in-out ${index * 0.14}s infinite` }}
>
<path d="M12 3v11.55A3.98 3.98 0 0010 14c-2.21 0-4 1.34-4 3s1.79 3 4 3 4-1.34 4-3V8h4V3h-6z" />
</svg>
))}
</div>
{text ? <span className={`${textSize} font-medium tracking-wide`}>{text}</span> : null}
</div>
</>
);
}
export default function AddToPlaylistModal({
song,
isOpen,
@@ -205,9 +252,9 @@ export default function AddToPlaylistModal({
<button
onClick={handleCreatePlaylist}
disabled={creating}
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-zinc-700 text-white rounded-lg transition-colors"
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-zinc-700 text-white rounded-lg transition-colors flex items-center justify-center"
>
{creating ? '创建中...' : '确定'}
{creating ? <MusicLoadingIndicator size="sm" className="gap-2 text-white" /> : '确定'}
</button>
<button
onClick={() => {
@@ -225,7 +272,7 @@ export default function AddToPlaylistModal({
{/* Playlists List */}
{loading ? (
<div className="text-center py-8 text-zinc-400">...</div>
<MusicLoadingIndicator className="py-8" />
) : playlists.length === 0 ? (
<div className="text-center py-8 text-zinc-400">
@@ -259,10 +306,7 @@ export default function AddToPlaylistModal({
)}
</div>
{addingToPlaylistId === playlist.id ? (
<svg className="w-5 h-5 text-green-500 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<MusicLoadingIndicator size="sm" className="gap-1" />
) : (
<svg className="w-5 h-5 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />