音乐界面整改
This commit is contained in:
@@ -0,0 +1,4078 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
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;
|
||||||
|
const SPECTRUM_REFERENCE_VOLUME = 10;
|
||||||
|
const SPECTRUM_MIN_VOLUME = 5;
|
||||||
|
const SPECTRUM_MAX_REFERENCE_VOLUME = 15;
|
||||||
|
|
||||||
|
function getApiErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (typeof error === 'string') return error;
|
||||||
|
if (error && typeof error === 'object' && 'message' in error) {
|
||||||
|
const message = (error as { message?: unknown }).message;
|
||||||
|
if (typeof message === 'string' && message.trim()) return message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MusicSource = 'wy' | 'tx' | 'kw' | 'kg' | 'mg';
|
||||||
|
type MusicQuality = '128k' | '320k' | 'flac' | 'flac24bit';
|
||||||
|
|
||||||
|
interface Song {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
album?: string;
|
||||||
|
pic?: string;
|
||||||
|
platform: MusicSource;
|
||||||
|
duration?: number;
|
||||||
|
durationText?: string;
|
||||||
|
songmid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayRecord {
|
||||||
|
platform: MusicSource;
|
||||||
|
id: string;
|
||||||
|
playTime: number; // 播放时间(秒)
|
||||||
|
duration: number; // 总时长(秒)
|
||||||
|
timestamp: number; // 添加时间戳
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LyricLine {
|
||||||
|
time: number;
|
||||||
|
text: string;
|
||||||
|
translation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Playlist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
pic?: string;
|
||||||
|
source?: MusicSource;
|
||||||
|
updateFrequency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DbRecord {
|
||||||
|
source: MusicSource;
|
||||||
|
songId: string;
|
||||||
|
id: string;
|
||||||
|
playProgressSec: number;
|
||||||
|
durationSec: number;
|
||||||
|
createdAt: number;
|
||||||
|
lastPlayedAt: number;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
album?: string;
|
||||||
|
cover?: string;
|
||||||
|
durationText?: string;
|
||||||
|
songmid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = Math.max(1, Math.round(dpr));
|
||||||
|
const count = Math.max(1, Math.floor(rect.width / targetPitch));
|
||||||
|
const barWidth = Math.max(2 * dpr, (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 === count - 1 ? width - barWidth : 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 w-full overflow-hidden ${compact ? 'h-6' : 'h-8'}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full opacity-50" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const VINYL_NEEDLE_SVG = `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 130"><path d="M21,21 C21,65 86,70 86,100" fill="none" stroke="rgba(0,0,0,0.28)" stroke-width="6" stroke-linecap="round"/><path d="M20,20 C20,65 85,70 85,100" fill="none" stroke="%23e0e0e0" stroke-width="4.5" stroke-linecap="round"/><path d="M19,20 C19,65 84,70 84,100" fill="none" stroke="%23fff" stroke-width="1.5" stroke-linecap="round"/><g transform="translate(85, 100) rotate(25)"><rect x="-6" y="0" width="12" height="18" rx="2" fill="%23ccc"/><rect x="-4" y="5" width="8" height="14" rx="1" fill="%23333"/><rect x="-2" y="16" width="4" height="5" rx="1" fill="%23d43c33"/></g><circle cx="20" cy="20" r="10" fill="%23f0f0f0" stroke="%23ccc" stroke-width="1"/><circle cx="20" cy="20" r="4" fill="%23fff"/><circle cx="20" cy="20" r="1.5" fill="%23999"/></svg>')`;
|
||||||
|
|
||||||
|
function VinylTurntable({
|
||||||
|
cover,
|
||||||
|
title,
|
||||||
|
isPlaying,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
cover?: string;
|
||||||
|
title: string;
|
||||||
|
isPlaying: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative mx-auto mt-12 mb-5 flex h-[280px] w-[280px] items-center justify-center md:mt-16 md:mb-8 md:h-[340px] md:w-[340px] ${className}`}
|
||||||
|
aria-label="黑胶唱片机封面"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute left-1/2 top-[-54px] z-20 h-[140px] w-[108px] drop-shadow-xl transition-transform duration-500"
|
||||||
|
style={{
|
||||||
|
marginLeft: '-20px',
|
||||||
|
backgroundImage: VINYL_NEEDLE_SVG,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
transformOrigin: '20px 20px',
|
||||||
|
transform: isPlaying ? 'rotate(0deg)' : 'rotate(-30deg)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="relative flex h-[246px] w-[246px] items-center justify-center overflow-hidden rounded-full md:h-[300px] md:w-[300px]"
|
||||||
|
style={{
|
||||||
|
background: 'conic-gradient(from 45deg, #070707 0%, #2b2b2b 10%, #101010 20%, #080808 32%, #242424 42%, #0b0b0b 55%, #1e1e1e 68%, #050505 80%, #2c2c2c 90%, #070707 100%)',
|
||||||
|
boxShadow: '0 0 0 8px rgba(255,255,255,0.055), 0 20px 42px rgba(0,0,0,0.65), inset 0 0 28px rgba(255,255,255,0.045)',
|
||||||
|
animation: 'vinyl-spin 20s linear infinite',
|
||||||
|
animationPlayState: isPlaying ? 'running' : 'paused',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: 'repeating-radial-gradient(circle, transparent 0, transparent 3px, rgba(255,255,255,0.055) 3px, rgba(255,255,255,0.055) 4px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute left-[18%] top-[10%] h-[42%] w-[22%] rotate-[-28deg] rounded-full bg-white/10 blur-md" />
|
||||||
|
<div className="relative z-10 flex h-[158px] w-[158px] items-center justify-center overflow-hidden rounded-full border-[5px] border-black bg-zinc-800 md:h-[192px] md:w-[192px]">
|
||||||
|
{cover ? (
|
||||||
|
<img
|
||||||
|
src={cover}
|
||||||
|
alt={title}
|
||||||
|
className="h-full w-full rounded-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<svg className="h-14 w-14 text-zinc-500 md:h-16 md:w-16" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute z-20 h-3 w-3 rounded-full border border-white/30 bg-zinc-950" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扩展 Window 接口以支持 Document PiP API
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
documentPictureInPicture?: {
|
||||||
|
requestWindow: (options: { width: number; height: number }) => Promise<Window>;
|
||||||
|
window: Window | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MusicClient({ children: _children }: { children?: React.ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [currentSource, setCurrentSource] = useState<MusicSource>('wy');
|
||||||
|
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||||
|
const [songs, setSongs] = useState<Song[]>([]);
|
||||||
|
const [currentView, setCurrentView] = useState<'playlists' | 'songs' | 'myPlaylists' | 'search'>('playlists');
|
||||||
|
const [currentPlaylistTitle, setCurrentPlaylistTitle] = useState('');
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [activeSearchKeyword, setActiveSearchKeyword] = useState('');
|
||||||
|
const [searchPage, setSearchPage] = useState(1);
|
||||||
|
const [searchHasMore, setSearchHasMore] = useState(false);
|
||||||
|
const [loadingMoreSearch, setLoadingMoreSearch] = useState(false);
|
||||||
|
const [currentSong, setCurrentSong] = useState<Song | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isBuffering, setIsBuffering] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [volume, setVolume] = useState(100);
|
||||||
|
const [quality, setQuality] = useState<MusicQuality>('320k');
|
||||||
|
const [playMode, setPlayMode] = useState<'loop' | 'single' | 'random'>('loop');
|
||||||
|
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
|
||||||
|
const [showPlayer, setShowPlayer] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showLyrics, setShowLyrics] = useState(false);
|
||||||
|
const [mobileLyricsView, setMobileLyricsView] = useState<'lyrics' | 'vinyl'>('lyrics');
|
||||||
|
const [musicProxyEnabled, setMusicProxyEnabled] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return true;
|
||||||
|
return (window as any).RUNTIME_CONFIG?.MUSIC_PROXY_ENABLED !== false;
|
||||||
|
});
|
||||||
|
const [lyrics, setLyrics] = useState<LyricLine[]>([]);
|
||||||
|
const [currentLyricIndex, setCurrentLyricIndex] = useState(-1);
|
||||||
|
const [currentSongUrl, setCurrentSongUrl] = useState('');
|
||||||
|
const [playRecords, setPlayRecords] = useState<PlayRecord[]>([]); // 播放记录(只存平台和ID)
|
||||||
|
const [playlist, setPlaylist] = useState<Song[]>([]); // 完整歌曲信息(用于显示)
|
||||||
|
const [showPlaylist, setShowPlaylist] = useState(false);
|
||||||
|
const [playlistIndex, setPlaylistIndex] = useState(-1); // 当前在播放列表中的索引
|
||||||
|
const [showQualityMenu, setShowQualityMenu] = useState(false); // 音质选择菜单
|
||||||
|
const [showSourceMenu, setShowSourceMenu] = useState(false); // 移动端音源菜单
|
||||||
|
const [showSidebarDrawer, setShowSidebarDrawer] = useState(false); // 左侧抽屉菜单
|
||||||
|
const [showVolumeSlider, setShowVolumeSlider] = useState(false); // 音量滑块显示状态
|
||||||
|
const [pendingSongToPlay, setPendingSongToPlay] = useState<{ platform: string; id: string } | null>(null); // 待播放的歌曲信息
|
||||||
|
const [resolvingCount, setResolvingCount] = useState(0); // 当前解析中的歌曲数量
|
||||||
|
const [showAddToPlaylistModal, setShowAddToPlaylistModal] = useState(false); // 添加到歌单弹窗
|
||||||
|
const [songToAddToPlaylist, setSongToAddToPlaylist] = useState<Song | null>(null); // 要添加到歌单的歌曲
|
||||||
|
|
||||||
|
// 我的歌单相关状态
|
||||||
|
const [userPlaylists, setUserPlaylists] = useState<any[]>([]);
|
||||||
|
const [selectedUserPlaylist, setSelectedUserPlaylist] = useState<any | null>(null);
|
||||||
|
const [userPlaylistSongs, setUserPlaylistSongs] = useState<any[]>([]);
|
||||||
|
const [loadingUserPlaylists, setLoadingUserPlaylists] = useState(false);
|
||||||
|
const [loadingUserPlaylistSongs, setLoadingUserPlaylistSongs] = useState(false);
|
||||||
|
const [loadingPlayAll, setLoadingPlayAll] = useState(false); // 播放全部加载状态
|
||||||
|
const [loadingCurrentPlayAll, setLoadingCurrentPlayAll] = useState(false); // 当前排行榜/详情页播放全部加载状态
|
||||||
|
const [deletingPlaylistId, setDeletingPlaylistId] = useState<string | null>(null); // 正在删除的歌单ID
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined' && !(window as any).RUNTIME_CONFIG?.MUSIC_ENABLED) {
|
||||||
|
router.replace('/');
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// Toast 和 Confirm Modal 状态
|
||||||
|
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||||
|
const [confirmModal, setConfirmModal] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
onConfirm: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// PiP 相关状态
|
||||||
|
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 searchLoadMoreRef = 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 volumeRef = useRef(volume);
|
||||||
|
const spectrumSeedRef = useRef(Math.random() * Math.PI * 2);
|
||||||
|
const qualitySwitchRequestRef = useRef(0);
|
||||||
|
const currentSongRef = useRef<Song | null>(null);
|
||||||
|
const currentSourceRef = useRef(currentSource);
|
||||||
|
|
||||||
|
const mapSong = (song: any): Song => ({
|
||||||
|
id: song.songId || song.id,
|
||||||
|
name: song.name,
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
pic: song.cover || song.pic,
|
||||||
|
platform: normalizeSource(song.source || song.platform),
|
||||||
|
duration: song.durationSec || song.duration,
|
||||||
|
durationText: song.durationText || song.interval,
|
||||||
|
songmid: song.songmid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeSource = (source: string | undefined): MusicSource => {
|
||||||
|
switch (source) {
|
||||||
|
case 'netease': return 'wy';
|
||||||
|
case 'qq': return 'tx';
|
||||||
|
case 'kuwo': return 'kw';
|
||||||
|
case 'wy':
|
||||||
|
case 'tx':
|
||||||
|
case 'kw':
|
||||||
|
case 'kg':
|
||||||
|
case 'mg':
|
||||||
|
return source;
|
||||||
|
default:
|
||||||
|
return 'wy';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const musicSources: Array<{ key: MusicSource; label: string }> = [
|
||||||
|
{ key: 'wy', label: '网易云' },
|
||||||
|
{ key: 'tx', label: 'QQ' },
|
||||||
|
{ key: 'kw', label: '酷我' },
|
||||||
|
{ key: 'kg', label: '酷狗' },
|
||||||
|
{ key: 'mg', label: '咪咕' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getSourceDisplayLabel = (source?: MusicSource | string, compact = true) => {
|
||||||
|
switch (normalizeSource(source)) {
|
||||||
|
case 'wy': return compact ? '网易' : '网易云';
|
||||||
|
case 'tx': return compact ? 'QQ' : 'QQ音乐';
|
||||||
|
case 'kw': return '酷我';
|
||||||
|
case 'kg': return '酷狗';
|
||||||
|
case 'mg': return '咪咕';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SourcePill = ({
|
||||||
|
source,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
source?: MusicSource | string;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<span
|
||||||
|
className={`inline-flex shrink-0 items-center rounded-lg border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-[10px] font-medium leading-none text-red-400 ${className}`}
|
||||||
|
>
|
||||||
|
{getSourceDisplayLabel(source)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildStreamUrl = (song: Song, source: MusicSource, songQuality: MusicQuality) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
songId: song.id,
|
||||||
|
source,
|
||||||
|
quality: songQuality,
|
||||||
|
songmid: song.songmid || song.id.split('_').slice(1).join('_'),
|
||||||
|
name: song.name,
|
||||||
|
artist: song.artist,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (song.durationText) params.set('durationText', song.durationText);
|
||||||
|
|
||||||
|
return `/api/music/v2/stream?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMusicProxyEnabled = () => {
|
||||||
|
if (typeof window === 'undefined') return true;
|
||||||
|
return (window as any).RUNTIME_CONFIG?.MUSIC_PROXY_ENABLED !== false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPlayData = async (
|
||||||
|
song: Song,
|
||||||
|
source: MusicSource,
|
||||||
|
songQuality: MusicQuality,
|
||||||
|
includeUrl = false
|
||||||
|
) => {
|
||||||
|
const response = await fetch('/api/music/v2/play', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
includeUrl,
|
||||||
|
song: {
|
||||||
|
songId: song.id,
|
||||||
|
source,
|
||||||
|
songmid: song.songmid,
|
||||||
|
name: song.name,
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
cover: song.pic,
|
||||||
|
durationSec: song.duration,
|
||||||
|
durationText: song.durationText,
|
||||||
|
},
|
||||||
|
quality: songQuality,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginResolving = () => {
|
||||||
|
setResolvingCount((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const endResolving = () => {
|
||||||
|
setResolvingCount((prev) => Math.max(0, prev - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveHistoryRecord = async (
|
||||||
|
record: PlayRecord,
|
||||||
|
song: Song,
|
||||||
|
playTime: number,
|
||||||
|
totalDuration: number,
|
||||||
|
lastPlayedAt = Date.now(),
|
||||||
|
recordQuality: MusicQuality = quality
|
||||||
|
) => {
|
||||||
|
await fetch('/api/music/v2/history', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
song: {
|
||||||
|
songId: record.id,
|
||||||
|
source: record.platform,
|
||||||
|
songmid: song.songmid,
|
||||||
|
name: song.name,
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
cover: song.pic,
|
||||||
|
durationSec: totalDuration || song.duration || 0,
|
||||||
|
durationText: song.durationText,
|
||||||
|
},
|
||||||
|
playProgressSec: playTime,
|
||||||
|
lastPlayedAt,
|
||||||
|
lastQuality: recordQuality,
|
||||||
|
createdAt: record.timestamp,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveHistoryRecordSafely = (
|
||||||
|
record: PlayRecord,
|
||||||
|
song: Song,
|
||||||
|
playTime = 0,
|
||||||
|
totalDuration = 0,
|
||||||
|
lastPlayedAt?: number,
|
||||||
|
recordQuality?: MusicQuality
|
||||||
|
) => {
|
||||||
|
saveHistoryRecord(record, song, playTime, totalDuration, lastPlayedAt, recordQuality).catch(err => {
|
||||||
|
console.error('保存播放记录到数据库失败:', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存播放状态到 localStorage
|
||||||
|
const savePlayState = () => {
|
||||||
|
if (!currentSong) return;
|
||||||
|
|
||||||
|
const playState = {
|
||||||
|
currentSong,
|
||||||
|
currentSongIndex,
|
||||||
|
songs,
|
||||||
|
currentPlaylistTitle,
|
||||||
|
currentSource,
|
||||||
|
currentView,
|
||||||
|
quality,
|
||||||
|
playMode,
|
||||||
|
volume,
|
||||||
|
currentTime: audioRef.current?.currentTime || 0,
|
||||||
|
currentSongUrl,
|
||||||
|
lyrics,
|
||||||
|
playRecords, // 只保存播放记录(平台+ID+播放信息)
|
||||||
|
playlist, // 保存完整歌曲信息(用于显示)
|
||||||
|
playlistIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('musicPlayState', JSON.stringify(playState));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清空当前播放状态,并在需要时停止正在播放的音频
|
||||||
|
const clearCurrentPlaybackState = () => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
|
||||||
|
if (audio) {
|
||||||
|
audio.pause();
|
||||||
|
audio.removeAttribute('src');
|
||||||
|
audio.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentSong(null);
|
||||||
|
setCurrentSongIndex(-1);
|
||||||
|
setCurrentSongUrl('');
|
||||||
|
setCurrentTime(0);
|
||||||
|
setDuration(0);
|
||||||
|
setLyrics([]);
|
||||||
|
setCurrentLyricIndex(-1);
|
||||||
|
setShowPlayer(false);
|
||||||
|
setShowLyrics(false);
|
||||||
|
setShowPiPLyrics(false);
|
||||||
|
setPendingSongToPlay(null);
|
||||||
|
restoredTimeRef.current = 0;
|
||||||
|
lastSaveTimeRef.current = 0;
|
||||||
|
currentTimeRef.current = 0;
|
||||||
|
|
||||||
|
localStorage.removeItem('musicPlayState');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从 localStorage 恢复播放状态(已废弃,现在统一使用数据库)
|
||||||
|
const restorePlayState = async () => {
|
||||||
|
// 此函数已不再使用,所有状态恢复都在 initializePlayState 中完成
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMusicProxyEnabled(getMusicProxyEnabled());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 页面加载时恢复播放状态和数据库记录
|
||||||
|
useEffect(() => {
|
||||||
|
const initializePlayState = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/music/v2/history');
|
||||||
|
const history = await response.json();
|
||||||
|
const dbRecords = (history.data?.records || []) as DbRecord[];
|
||||||
|
|
||||||
|
const queueRecords = dbRecords;
|
||||||
|
|
||||||
|
const sortedRecords: PlayRecord[] = queueRecords.map((record) => ({
|
||||||
|
platform: record.source,
|
||||||
|
id: record.songId,
|
||||||
|
playTime: record.playProgressSec,
|
||||||
|
duration: record.durationSec || 0,
|
||||||
|
timestamp: record.createdAt || record.lastPlayedAt || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sortedSongs: Song[] = queueRecords.map((record) => ({
|
||||||
|
id: record.songId,
|
||||||
|
name: record.name,
|
||||||
|
artist: record.artist,
|
||||||
|
album: record.album,
|
||||||
|
pic: record.cover,
|
||||||
|
platform: record.source,
|
||||||
|
duration: record.durationSec,
|
||||||
|
durationText: record.durationText,
|
||||||
|
songmid: record.songmid,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2. 更新播放列表
|
||||||
|
if (sortedRecords.length > 0) {
|
||||||
|
setPlayRecords(sortedRecords);
|
||||||
|
setPlaylist(sortedSongs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取 localStorage 配置(只获取配置,不获取歌曲信息)
|
||||||
|
const savedPlayState = localStorage.getItem('musicPlayState');
|
||||||
|
const playState = savedPlayState ? JSON.parse(savedPlayState) : {};
|
||||||
|
|
||||||
|
// 恢复配置状态(不包括歌曲)
|
||||||
|
setSongs(playState.songs || []);
|
||||||
|
setCurrentPlaylistTitle(playState.currentPlaylistTitle || '');
|
||||||
|
setCurrentSource(normalizeSource(playState.currentSource));
|
||||||
|
setCurrentView(playState.currentView || 'playlists');
|
||||||
|
setQuality(playState.quality || '320k');
|
||||||
|
setPlayMode(playState.playMode || 'loop');
|
||||||
|
setVolume(playState.volume || 100);
|
||||||
|
|
||||||
|
// 4. 使用数据库的最新记录(歌曲和进度都从数据库获取)
|
||||||
|
if (sortedRecords.length > 0) {
|
||||||
|
const proxyEnabled = getMusicProxyEnabled();
|
||||||
|
setMusicProxyEnabled(proxyEnabled);
|
||||||
|
const latestIndex = queueRecords.reduce((bestIndex, record, index) => {
|
||||||
|
if (bestIndex < 0) return index;
|
||||||
|
return (record.lastPlayedAt || 0) > (queueRecords[bestIndex].lastPlayedAt || 0) ? index : bestIndex;
|
||||||
|
}, -1);
|
||||||
|
const activeIndex = latestIndex >= 0 ? latestIndex : 0;
|
||||||
|
const latestDbRecord = sortedRecords[activeIndex];
|
||||||
|
const latestDbSong = sortedSongs[activeIndex];
|
||||||
|
|
||||||
|
// 使用数据库的歌曲信息
|
||||||
|
setCurrentSong(latestDbSong);
|
||||||
|
setPlaylistIndex(activeIndex);
|
||||||
|
setShowPlayer(true);
|
||||||
|
|
||||||
|
// 从数据库恢复播放进度
|
||||||
|
const dbPlayTime = latestDbRecord.playTime || 0;
|
||||||
|
songStartTimeRef.current = Date.now();
|
||||||
|
|
||||||
|
const platform = latestDbSong.platform || 'kw';
|
||||||
|
const selectedQuality = playState.quality || '320k';
|
||||||
|
|
||||||
|
const restoreTime = () => {
|
||||||
|
if (audioRef.current && dbPlayTime > 0) {
|
||||||
|
audioRef.current.currentTime = dbPlayTime;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (proxyEnabled) {
|
||||||
|
const streamUrl = buildStreamUrl(latestDbSong, platform, selectedQuality);
|
||||||
|
setCurrentSongUrl(streamUrl);
|
||||||
|
|
||||||
|
if (audioRef.current) {
|
||||||
|
setIsBuffering(true);
|
||||||
|
audioRef.current.src = streamUrl;
|
||||||
|
audioRef.current.addEventListener('loadedmetadata', restoreTime, { once: true });
|
||||||
|
audioRef.current.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPlayData(latestDbSong, platform, selectedQuality, false)
|
||||||
|
.then((data) => {
|
||||||
|
if (data.success && data.data?.lyric?.lyric) {
|
||||||
|
const parsedLyrics = parseLyric(data.data.lyric.lyric, data.data.lyric.tlyric);
|
||||||
|
setLyrics(parsedLyrics);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('加载歌词失败:', error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const data = await fetchPlayData(latestDbSong, platform, selectedQuality, true);
|
||||||
|
if (data.success && data.data?.play?.directUrl && audioRef.current) {
|
||||||
|
setCurrentSongUrl(data.data.play.directUrl);
|
||||||
|
setIsBuffering(true);
|
||||||
|
audioRef.current.src = data.data.play.directUrl;
|
||||||
|
audioRef.current.addEventListener('loadedmetadata', restoreTime, { once: true });
|
||||||
|
audioRef.current.load();
|
||||||
|
|
||||||
|
if (data.data.lyric?.lyric) {
|
||||||
|
const parsedLyrics = parseLyric(data.data.lyric.lyric, data.data.lyric.tlyric);
|
||||||
|
setLyrics(parsedLyrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载播放记录失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializePlayState();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 恢复 PiP 偏好设置
|
||||||
|
useEffect(() => {
|
||||||
|
const savedOpacity = localStorage.getItem('lyricsPiPOpacity');
|
||||||
|
const savedMinimized = localStorage.getItem('lyricsPiPMinimized');
|
||||||
|
if (savedOpacity) setPipOpacity(parseFloat(savedOpacity));
|
||||||
|
if (savedMinimized) setPipMinimized(savedMinimized === 'true');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 监听来自 PiP 窗口的消息
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
switch (event.data.type) {
|
||||||
|
case 'PIP_OPACITY_CHANGE':
|
||||||
|
setPipOpacity(event.data.opacity);
|
||||||
|
localStorage.setItem('lyricsPiPOpacity', event.data.opacity.toString());
|
||||||
|
break;
|
||||||
|
case 'PIP_MINIMIZED_CHANGE':
|
||||||
|
setPipMinimized(event.data.minimized);
|
||||||
|
localStorage.setItem('lyricsPiPMinimized', event.data.minimized.toString());
|
||||||
|
break;
|
||||||
|
case 'PIP_CLOSE':
|
||||||
|
setShowPiPLyrics(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
return () => window.removeEventListener('message', handleMessage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 监听播放状态变化,自动保存
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentSong) {
|
||||||
|
savePlayState();
|
||||||
|
}
|
||||||
|
}, [currentSong, currentSongIndex, songs, currentPlaylistTitle, currentSource, currentView, quality, playMode, volume, currentSongUrl, lyrics, playRecords, playlistIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
currentSongRef.current = currentSong;
|
||||||
|
}, [currentSong]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
currentSourceRef.current = currentSource;
|
||||||
|
}, [currentSource]);
|
||||||
|
|
||||||
|
// 监听 playRecords 变化,更新 playlistIndex
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingSongToPlay) {
|
||||||
|
const index = playRecords.findIndex(
|
||||||
|
r => r.platform === pendingSongToPlay.platform && r.id === pendingSongToPlay.id
|
||||||
|
);
|
||||||
|
setPlaylistIndex(index);
|
||||||
|
setPendingSongToPlay(null);
|
||||||
|
}
|
||||||
|
}, [playRecords, pendingSongToPlay]);
|
||||||
|
|
||||||
|
// 同步音量状态到 audio 元素
|
||||||
|
useEffect(() => {
|
||||||
|
volumeRef.current = volume;
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.volume = volume / 100;
|
||||||
|
}
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
// 加载排行榜列表
|
||||||
|
const loadPlaylists = async (source: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const boardsResponse = await fetch(`/api/music/v2/discovery/boards?source=${source}`);
|
||||||
|
const boardsData = await boardsResponse.json();
|
||||||
|
|
||||||
|
if (boardsResponse.ok && boardsData.success) {
|
||||||
|
setPlaylists((boardsData.data?.list || []).map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
source: normalizeSource(item.source || boardsData.data?.source || source),
|
||||||
|
updateFrequency: item.updateFrequency || item.description || '',
|
||||||
|
})));
|
||||||
|
} else {
|
||||||
|
console.error('加载排行榜失败:', boardsData);
|
||||||
|
setPlaylists([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载排行榜失败:', error);
|
||||||
|
setPlaylists([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载歌单详情
|
||||||
|
const loadPlaylist = async (playlistId: string, playlistName: string, playlistSource?: MusicSource, navigate = true) => {
|
||||||
|
const source = playlistSource || currentSource;
|
||||||
|
if (navigate) {
|
||||||
|
router.push(`/music/rankings/${source}/${encodeURIComponent(playlistId)}?name=${encodeURIComponent(playlistName)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/music/v2/discovery/board-songs?source=${source}&boardId=${encodeURIComponent(playlistId)}`
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
setSongs((data.data?.list || []).map(mapSong));
|
||||||
|
setCurrentPlaylistTitle(playlistName);
|
||||||
|
setActiveSearchKeyword('');
|
||||||
|
setSearchPage(1);
|
||||||
|
setSearchHasMore(false);
|
||||||
|
setCurrentView('songs');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载歌单失败:', error);
|
||||||
|
setSongs([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当前排行榜歌单:播放全部
|
||||||
|
const handlePlayAllCurrentSongs = async () => {
|
||||||
|
setLoadingCurrentPlayAll(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (songs.length === 0) {
|
||||||
|
setToast({
|
||||||
|
message: '当前歌单为空',
|
||||||
|
type: 'error',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch('/api/music/v2/history', { method: 'DELETE' });
|
||||||
|
|
||||||
|
const baseTime = Date.now();
|
||||||
|
const recordsToAdd = songs.map((song, i) => ({
|
||||||
|
song: {
|
||||||
|
songId: song.id,
|
||||||
|
source: song.platform,
|
||||||
|
songmid: song.songmid,
|
||||||
|
name: song.name,
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
cover: song.pic,
|
||||||
|
durationSec: song.duration || 0,
|
||||||
|
durationText: song.durationText,
|
||||||
|
},
|
||||||
|
playProgressSec: 0,
|
||||||
|
lastPlayedAt: baseTime + i,
|
||||||
|
playCount: 1,
|
||||||
|
lastQuality: quality,
|
||||||
|
createdAt: baseTime + i,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await fetch('/api/music/v2/history', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ records: recordsToAdd }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const newRecords: PlayRecord[] = songs.map((song, i) => ({
|
||||||
|
platform: song.platform,
|
||||||
|
id: song.id,
|
||||||
|
playTime: 0,
|
||||||
|
duration: song.duration || 0,
|
||||||
|
timestamp: baseTime + i,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setPlayRecords(newRecords);
|
||||||
|
setPlaylist(songs);
|
||||||
|
setPlaylistIndex(0);
|
||||||
|
await playSong(songs[0], 0);
|
||||||
|
|
||||||
|
setToast({
|
||||||
|
message: `已开始播放 ${currentPlaylistTitle || '当前歌单'}`,
|
||||||
|
type: 'success',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('排行榜播放全部失败:', error);
|
||||||
|
setToast({
|
||||||
|
message: '播放全部失败',
|
||||||
|
type: 'error',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoadingCurrentPlayAll(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索歌曲
|
||||||
|
const searchSongs = async (keywordArg?: string, navigate = true) => {
|
||||||
|
const keyword = (keywordArg ?? searchKeyword).trim();
|
||||||
|
if (!keyword) return;
|
||||||
|
|
||||||
|
if (navigate) {
|
||||||
|
router.push(`/music/search?source=${currentSource}&q=${encodeURIComponent(keyword)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/music/v2/search?source=${currentSource}&q=${encodeURIComponent(keyword)}&page=1&limit=20`
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
setSongs((data.data?.list || []).map(mapSong));
|
||||||
|
setActiveSearchKeyword(keyword);
|
||||||
|
setSearchKeyword(keyword);
|
||||||
|
setSearchPage(1);
|
||||||
|
setSearchHasMore(Boolean(data.data?.hasMore));
|
||||||
|
setCurrentPlaylistTitle(`搜索: ${keyword}`);
|
||||||
|
setCurrentView('search');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索失败:', error);
|
||||||
|
setSongs([]);
|
||||||
|
setActiveSearchKeyword('');
|
||||||
|
setSearchPage(1);
|
||||||
|
setSearchHasMore(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreSearchSongs = async () => {
|
||||||
|
const keyword = activeSearchKeyword.trim();
|
||||||
|
if (!keyword || loadingMoreSearch || !searchHasMore) return;
|
||||||
|
|
||||||
|
const nextPage = searchPage + 1;
|
||||||
|
setLoadingMoreSearch(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/music/v2/search?source=${currentSource}&q=${encodeURIComponent(keyword)}&page=${nextPage}&limit=20`
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
const nextSongs = (data.data?.list || []).map(mapSong);
|
||||||
|
setSongs((prev) => [...prev, ...nextSongs]);
|
||||||
|
setSearchPage(nextPage);
|
||||||
|
setSearchHasMore(Boolean(data.data?.hasMore));
|
||||||
|
} else {
|
||||||
|
setToast({
|
||||||
|
message: data.error?.message || '加载更多失败',
|
||||||
|
type: 'error',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载更多搜索结果失败:', error);
|
||||||
|
setToast({
|
||||||
|
message: '加载更多失败',
|
||||||
|
type: 'error',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoadingMoreSearch(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开添加到歌单弹窗
|
||||||
|
const handleAddToPlaylist = (song: Song, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation(); // 阻止事件冒泡,避免触发播放
|
||||||
|
setSongToAddToPlaylist(song);
|
||||||
|
setShowAddToPlaylistModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 稍后播放:追加到当前播放列表末尾,不立即播放
|
||||||
|
const handlePlayLater = (song: Song, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!currentSong && playlist.length === 0 && playRecords.length === 0) {
|
||||||
|
playSong(song, -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = song.platform || currentSource;
|
||||||
|
const exists = playlist.some((item) => item.id === song.id && item.platform === platform);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
setToast({
|
||||||
|
message: '歌曲已在播放列表中',
|
||||||
|
type: 'info',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record: PlayRecord = {
|
||||||
|
platform,
|
||||||
|
id: song.id,
|
||||||
|
playTime: 0,
|
||||||
|
duration: song.duration || 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setPlayRecords((prev) => [...prev, record]);
|
||||||
|
setPlaylist((prev) => [...prev, { ...song, platform }]);
|
||||||
|
saveHistoryRecordSafely(record, { ...song, platform }, 0, song.duration || 0, 0);
|
||||||
|
setToast({
|
||||||
|
message: '已加入稍后播放',
|
||||||
|
type: 'success',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载用户歌单列表
|
||||||
|
const loadUserPlaylists = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingUserPlaylists(true);
|
||||||
|
const response = await fetch('/api/music/v2/playlists');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setUserPlaylists(data.data?.playlists || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载歌单失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingUserPlaylists(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载歌单中的歌曲
|
||||||
|
const loadUserPlaylistSongs = async (playlistId: string) => {
|
||||||
|
try {
|
||||||
|
setLoadingUserPlaylistSongs(true);
|
||||||
|
const response = await fetch(`/api/music/v2/playlists/${playlistId}/songs`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setUserPlaylistSongs((data.data?.songs || []).map((song: any) => ({
|
||||||
|
...song,
|
||||||
|
id: song.songId,
|
||||||
|
platform: song.source,
|
||||||
|
pic: song.cover,
|
||||||
|
duration: song.durationSec,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载歌单歌曲失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingUserPlaylistSongs(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选择歌单
|
||||||
|
const handleSelectUserPlaylist = (playlist: any) => {
|
||||||
|
setSelectedUserPlaylist(playlist);
|
||||||
|
loadUserPlaylistSongs(playlist.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 播放全部歌单歌曲
|
||||||
|
const handlePlayAllPlaylist = async () => {
|
||||||
|
if (!selectedUserPlaylist || userPlaylistSongs.length === 0) {
|
||||||
|
setToast({
|
||||||
|
message: '歌单为空',
|
||||||
|
type: 'error',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingPlayAll(true);
|
||||||
|
try {
|
||||||
|
// 1. 清空所有播放历史
|
||||||
|
await fetch('/api/music/v2/history', { method: 'DELETE' });
|
||||||
|
|
||||||
|
// 2. 清空本地状态
|
||||||
|
setPlayRecords([]);
|
||||||
|
setPlaylist([]);
|
||||||
|
|
||||||
|
// 3. 批量添加歌单中的所有歌曲到播放历史
|
||||||
|
const baseTime = Date.now();
|
||||||
|
const recordsToAdd = userPlaylistSongs.map((song, i) => ({
|
||||||
|
song: {
|
||||||
|
songId: song.id,
|
||||||
|
source: song.platform,
|
||||||
|
songmid: song.songmid,
|
||||||
|
name: song.name,
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
cover: song.pic,
|
||||||
|
durationSec: song.duration || 0,
|
||||||
|
durationText: song.durationText,
|
||||||
|
},
|
||||||
|
playProgressSec: 0,
|
||||||
|
lastPlayedAt: baseTime + i,
|
||||||
|
playCount: 1,
|
||||||
|
lastQuality: quality,
|
||||||
|
createdAt: baseTime + i,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 一次性批量添加所有歌曲
|
||||||
|
const response = await fetch('/api/music/v2/history', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
records: recordsToAdd,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('批量添加歌曲失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 立即更新本地状态
|
||||||
|
const newRecords: PlayRecord[] = userPlaylistSongs.map((song, i) => ({
|
||||||
|
platform: song.platform,
|
||||||
|
id: song.id,
|
||||||
|
playTime: 0,
|
||||||
|
duration: song.duration || 0,
|
||||||
|
timestamp: baseTime + i,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const newPlaylist: Song[] = userPlaylistSongs.map((song) => ({
|
||||||
|
id: song.id,
|
||||||
|
name: song.name,
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
pic: song.pic,
|
||||||
|
platform: song.platform,
|
||||||
|
duration: song.duration,
|
||||||
|
durationText: song.durationText,
|
||||||
|
songmid: song.songmid,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setPlayRecords(newRecords);
|
||||||
|
setPlaylist(newPlaylist);
|
||||||
|
|
||||||
|
// 5. 直接播放第一首歌
|
||||||
|
if (userPlaylistSongs.length > 0) {
|
||||||
|
setPlaylistIndex(0);
|
||||||
|
await playSong(userPlaylistSongs[0], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
setToast({
|
||||||
|
message: `已将 ${userPlaylistSongs.length} 首歌曲添加到播放列表`,
|
||||||
|
type: 'success',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('播放全部失败:', error);
|
||||||
|
setToast({
|
||||||
|
message: '播放全部失败',
|
||||||
|
type: 'error',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoadingPlayAll(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除歌单
|
||||||
|
const handleDeleteUserPlaylist = async (playlistId: string) => {
|
||||||
|
setConfirmModal({
|
||||||
|
isOpen: true,
|
||||||
|
title: '确认删除',
|
||||||
|
message: '确定要删除这个歌单吗?',
|
||||||
|
onConfirm: async () => {
|
||||||
|
// 先关闭确认框
|
||||||
|
setConfirmModal({
|
||||||
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
onConfirm: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 然后执行删除
|
||||||
|
setDeletingPlaylistId(playlistId);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/music/v2/playlists/${playlistId}`, { method: 'DELETE' });
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setToast({
|
||||||
|
message: '删除成功',
|
||||||
|
type: 'success',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
if (selectedUserPlaylist?.id === playlistId) {
|
||||||
|
setSelectedUserPlaylist(null);
|
||||||
|
setUserPlaylistSongs([]);
|
||||||
|
}
|
||||||
|
loadUserPlaylists();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
setToast({
|
||||||
|
message: getApiErrorMessage(data.error, '删除失败'),
|
||||||
|
type: 'error',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除歌单失败:', error);
|
||||||
|
setToast({
|
||||||
|
message: '删除歌单失败',
|
||||||
|
type: 'error',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeletingPlaylistId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
setConfirmModal({
|
||||||
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
onConfirm: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从歌单中移除歌曲
|
||||||
|
const handleRemoveSongFromUserPlaylist = async (song: any) => {
|
||||||
|
if (!selectedUserPlaylist) return;
|
||||||
|
|
||||||
|
setConfirmModal({
|
||||||
|
isOpen: true,
|
||||||
|
title: '确认移除',
|
||||||
|
message: `确定要从歌单中移除 "${song.name}" 吗?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/music/v2/playlists/${selectedUserPlaylist.id}/songs?songId=${encodeURIComponent(song.id)}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setToast({
|
||||||
|
message: '移除成功',
|
||||||
|
type: 'success',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
loadUserPlaylistSongs(selectedUserPlaylist.id);
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
setToast({
|
||||||
|
message: getApiErrorMessage(data.error, '移除失败'),
|
||||||
|
type: 'error',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('移除歌曲失败:', error);
|
||||||
|
setToast({
|
||||||
|
message: '移除歌曲失败',
|
||||||
|
type: 'error',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setConfirmModal({
|
||||||
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
onConfirm: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
setConfirmModal({
|
||||||
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
onConfirm: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 播放歌曲
|
||||||
|
const playSong = async (song: Song, index: number) => {
|
||||||
|
beginResolving();
|
||||||
|
try {
|
||||||
|
// 使用歌曲自己的平台信息,如果没有则使用当前选择的平台
|
||||||
|
const platform = song.platform || currentSource;
|
||||||
|
const proxyEnabled = getMusicProxyEnabled();
|
||||||
|
setMusicProxyEnabled(proxyEnabled);
|
||||||
|
|
||||||
|
// 记录歌曲开始播放的时间
|
||||||
|
songStartTimeRef.current = Date.now();
|
||||||
|
|
||||||
|
// 先设置当前歌曲和显示播放器
|
||||||
|
setCurrentSong(song);
|
||||||
|
setCurrentSongIndex(index);
|
||||||
|
setShowPlayer(true);
|
||||||
|
setLyrics([]); // 清空旧歌词
|
||||||
|
|
||||||
|
// 添加到播放记录和播放列表。timestamp 表示入队时间,不能在再次播放时刷新,
|
||||||
|
// 否则会破坏按 createdAt/timestamp 维护的播放队列顺序。
|
||||||
|
const existingRecord = playRecords.find(r => r.platform === platform && r.id === song.id);
|
||||||
|
const record: PlayRecord = existingRecord || {
|
||||||
|
platform: platform,
|
||||||
|
id: song.id,
|
||||||
|
playTime: 0, // 初始播放时间
|
||||||
|
duration: song.duration || 0, // 将在音频加载后更新
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置待播放歌曲信息,用于在 playRecords 更新后找到索引
|
||||||
|
setPendingSongToPlay({ platform, id: song.id });
|
||||||
|
|
||||||
|
setPlayRecords(prev => {
|
||||||
|
const existingIndex = prev.findIndex(r => r.platform === record.platform && r.id === record.id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// 记录已存在:保持原位置和原 timestamp,只补齐可能变化的时长信息。
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[existingIndex] = {
|
||||||
|
...updated[existingIndex],
|
||||||
|
duration: updated[existingIndex].duration || song.duration || 0,
|
||||||
|
};
|
||||||
|
return updated;
|
||||||
|
} else {
|
||||||
|
// 新记录,添加到列表末尾
|
||||||
|
return [...prev, record];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setPlaylist(prev => {
|
||||||
|
const existingIndex = prev.findIndex(s => s.id === song.id && s.platform === platform);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
return prev;
|
||||||
|
} else {
|
||||||
|
return [...prev, { ...song, platform }];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
saveHistoryRecordSafely(record, { ...song, platform }, 0, song.duration || 0);
|
||||||
|
|
||||||
|
if (proxyEnabled) {
|
||||||
|
const streamUrl = buildStreamUrl(song, platform, quality);
|
||||||
|
setCurrentSongUrl(streamUrl);
|
||||||
|
|
||||||
|
if (audioRef.current) {
|
||||||
|
setIsBuffering(true);
|
||||||
|
audioRef.current.src = streamUrl;
|
||||||
|
audioRef.current.load();
|
||||||
|
audioRef.current.play().catch(err => {
|
||||||
|
console.error('播放失败:', err);
|
||||||
|
setIsBuffering(false);
|
||||||
|
});
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPlayData(song, platform, quality, false)
|
||||||
|
.then((data) => {
|
||||||
|
if (data.success) {
|
||||||
|
if (data.data.song?.cover) {
|
||||||
|
setCurrentSong({
|
||||||
|
...song,
|
||||||
|
pic: data.data.song.cover,
|
||||||
|
platform,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.data.lyric?.lyric) {
|
||||||
|
const parsedLyrics = parseLyric(data.data.lyric.lyric, data.data.lyric.tlyric);
|
||||||
|
setLyrics(parsedLyrics);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('播放信息获取失败:', data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('加载歌词失败:', error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const data = await fetchPlayData(song, platform, quality, true);
|
||||||
|
if (data.success && data.data?.play?.directUrl) {
|
||||||
|
if (data.data.song?.cover) {
|
||||||
|
setCurrentSong({
|
||||||
|
...song,
|
||||||
|
pic: data.data.song.cover,
|
||||||
|
platform,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.data.lyric?.lyric) {
|
||||||
|
const parsedLyrics = parseLyric(data.data.lyric.lyric, data.data.lyric.tlyric);
|
||||||
|
setLyrics(parsedLyrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentSongUrl(data.data.play.directUrl);
|
||||||
|
|
||||||
|
if (audioRef.current) {
|
||||||
|
setIsBuffering(true);
|
||||||
|
audioRef.current.src = data.data.play.directUrl;
|
||||||
|
audioRef.current.load();
|
||||||
|
audioRef.current.play().catch(err => {
|
||||||
|
console.error('播放失败:', err);
|
||||||
|
setIsBuffering(false);
|
||||||
|
});
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('播放信息获取失败:', data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('播放失败:', error);
|
||||||
|
setIsBuffering(false);
|
||||||
|
} finally {
|
||||||
|
endResolving();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解析歌词文本
|
||||||
|
const parseLyric = (lyricText: string, tlyricText?: string): LyricLine[] => {
|
||||||
|
if (!lyricText && !tlyricText) return [];
|
||||||
|
|
||||||
|
// 匹配 [mm:ss.xx] 或 [mm:ss] 格式
|
||||||
|
const timeRegex = /\[(\d{2}):(\d{2})(?:\.(\d{2,3}))?\]/g;
|
||||||
|
const parseLyricText = (text: string) => {
|
||||||
|
const parsed = new Map<number, string>();
|
||||||
|
const lines = text.split('\n');
|
||||||
|
|
||||||
|
lines.forEach(line => {
|
||||||
|
const matches = Array.from(line.matchAll(timeRegex));
|
||||||
|
if (matches.length > 0) {
|
||||||
|
const content = line.replace(timeRegex, '').trim();
|
||||||
|
if (content) {
|
||||||
|
matches.forEach(match => {
|
||||||
|
const minutes = parseInt(match[1]);
|
||||||
|
const seconds = parseInt(match[2]);
|
||||||
|
const milliseconds = match[3] ? parseInt(match[3].padEnd(3, '0')) : 0;
|
||||||
|
const time = minutes * 60 + seconds + milliseconds / 1000;
|
||||||
|
parsed.set(time, content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainMap = parseLyricText(lyricText || '');
|
||||||
|
const transMap = parseLyricText(tlyricText || '');
|
||||||
|
const times = Array.from(new Set([
|
||||||
|
...Array.from(mainMap.keys()),
|
||||||
|
...Array.from(transMap.keys()),
|
||||||
|
])).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
return times
|
||||||
|
.map(time => ({
|
||||||
|
time,
|
||||||
|
text: mainMap.get(time) || '',
|
||||||
|
translation: transMap.get(time) || undefined,
|
||||||
|
}))
|
||||||
|
.filter(line => line.text || line.translation);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换播放/暂停
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
// 暂停时保存状态到 localStorage 和数据库
|
||||||
|
savePlayState();
|
||||||
|
|
||||||
|
// 前5秒不保存(避免加载时的跳转触发保存)
|
||||||
|
if (Date.now() - songStartTimeRef.current < 5000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
if (currentSong && playlistIndex >= 0 && playRecords[playlistIndex]) {
|
||||||
|
const record = playRecords[playlistIndex];
|
||||||
|
saveHistoryRecord(record, currentSong, audioRef.current.currentTime, audioRef.current.duration || 0).catch(err => {
|
||||||
|
console.error('暂停时保存播放记录失败:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsBuffering(true);
|
||||||
|
audioRef.current.play().catch(err => {
|
||||||
|
console.error('播放失败:', err);
|
||||||
|
setIsBuffering(false);
|
||||||
|
});
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 上一曲
|
||||||
|
const playPrev = () => {
|
||||||
|
// 优先从播放列表切换
|
||||||
|
if (playlist.length > 0) {
|
||||||
|
// 如果已经是第一首,循环到最后一首
|
||||||
|
const prevIndex = playlistIndex > 0 ? playlistIndex - 1 : playlist.length - 1;
|
||||||
|
setPlaylistIndex(prevIndex);
|
||||||
|
playSong(playlist[prevIndex], -1);
|
||||||
|
} else if (currentSongIndex > 0) {
|
||||||
|
playSong(songs[currentSongIndex - 1], currentSongIndex - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 下一曲
|
||||||
|
const playNext = () => {
|
||||||
|
// 优先从播放列表切换
|
||||||
|
if (playlist.length > 0) {
|
||||||
|
// 如果已经是最后一首,循环到第一首
|
||||||
|
const nextIndex = playlistIndex < playlist.length - 1 ? playlistIndex + 1 : 0;
|
||||||
|
setPlaylistIndex(nextIndex);
|
||||||
|
playSong(playlist[nextIndex], -1);
|
||||||
|
} else if (currentSongIndex < songs.length - 1) {
|
||||||
|
playSong(songs[currentSongIndex + 1], currentSongIndex + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换音质
|
||||||
|
const handleQualityChange = async (nextQuality: MusicQuality) => {
|
||||||
|
setShowQualityMenu(false);
|
||||||
|
|
||||||
|
if (nextQuality === quality) return;
|
||||||
|
|
||||||
|
const targetSong = currentSong;
|
||||||
|
const audio = audioRef.current;
|
||||||
|
const targetPlatform = targetSong?.platform || currentSource;
|
||||||
|
|
||||||
|
currentSongRef.current = targetSong;
|
||||||
|
currentSourceRef.current = currentSource;
|
||||||
|
|
||||||
|
setQuality(nextQuality);
|
||||||
|
|
||||||
|
// 没有正在播放的歌曲时,仅保存偏好;下次播放会使用新音质。
|
||||||
|
if (!targetSong || !audio) return;
|
||||||
|
|
||||||
|
const requestId = ++qualitySwitchRequestRef.current;
|
||||||
|
const targetSongKey = `${targetPlatform}:${targetSong.id}`;
|
||||||
|
const resumeTime = Number.isFinite(audio.currentTime) ? audio.currentTime : currentTimeRef.current;
|
||||||
|
const shouldResume = isPlaying || (!audio.paused && !audio.ended);
|
||||||
|
|
||||||
|
const isStillTargetSong = () => {
|
||||||
|
const activeSong = currentSongRef.current;
|
||||||
|
if (!activeSong) return false;
|
||||||
|
|
||||||
|
const activePlatform = activeSong.platform || currentSourceRef.current;
|
||||||
|
return (
|
||||||
|
requestId === qualitySwitchRequestRef.current &&
|
||||||
|
`${activePlatform}:${activeSong.id}` === targetSongKey
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
beginResolving();
|
||||||
|
try {
|
||||||
|
const proxyEnabled = getMusicProxyEnabled();
|
||||||
|
setMusicProxyEnabled(proxyEnabled);
|
||||||
|
|
||||||
|
let nextSongUrl = '';
|
||||||
|
|
||||||
|
if (proxyEnabled) {
|
||||||
|
nextSongUrl = buildStreamUrl(targetSong, targetPlatform, nextQuality);
|
||||||
|
} else {
|
||||||
|
const data = await fetchPlayData(targetSong, targetPlatform, nextQuality, true);
|
||||||
|
if (!isStillTargetSong()) return;
|
||||||
|
|
||||||
|
if (!data.success || !data.data?.play?.directUrl) {
|
||||||
|
throw new Error(data.error?.message || '获取播放地址失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSongUrl = data.data.play.directUrl;
|
||||||
|
|
||||||
|
if (data.data.song?.cover) {
|
||||||
|
setCurrentSong({
|
||||||
|
...targetSong,
|
||||||
|
pic: data.data.song.cover,
|
||||||
|
platform: targetPlatform,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.data.lyric?.lyric) {
|
||||||
|
const parsedLyrics = parseLyric(data.data.lyric.lyric, data.data.lyric.tlyric);
|
||||||
|
setLyrics(parsedLyrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStillTargetSong()) return;
|
||||||
|
|
||||||
|
const activeRecord =
|
||||||
|
playRecords[playlistIndex]?.platform === targetPlatform && playRecords[playlistIndex]?.id === targetSong.id
|
||||||
|
? playRecords[playlistIndex]
|
||||||
|
: playRecords.find((record) => record.platform === targetPlatform && record.id === targetSong.id);
|
||||||
|
const totalDuration =
|
||||||
|
Number.isFinite(audio.duration) && audio.duration > 0
|
||||||
|
? audio.duration
|
||||||
|
: duration || targetSong.duration || 0;
|
||||||
|
|
||||||
|
if (activeRecord) {
|
||||||
|
saveHistoryRecordSafely(
|
||||||
|
activeRecord,
|
||||||
|
{ ...targetSong, platform: targetPlatform },
|
||||||
|
resumeTime,
|
||||||
|
totalDuration,
|
||||||
|
Date.now(),
|
||||||
|
nextQuality
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentSongUrl(nextSongUrl);
|
||||||
|
setCurrentTime(resumeTime);
|
||||||
|
songStartTimeRef.current = Date.now();
|
||||||
|
restoredTimeRef.current = resumeTime;
|
||||||
|
|
||||||
|
const resumeAfterMetadata = () => {
|
||||||
|
if (!isStillTargetSong()) return;
|
||||||
|
|
||||||
|
if (resumeTime > 0) {
|
||||||
|
try {
|
||||||
|
const maxSeekTime =
|
||||||
|
Number.isFinite(audio.duration) && audio.duration > 0
|
||||||
|
? Math.max(0, audio.duration - 0.25)
|
||||||
|
: resumeTime;
|
||||||
|
const seekTime = Math.min(resumeTime, maxSeekTime);
|
||||||
|
|
||||||
|
if (Math.abs(audio.currentTime - seekTime) > 1) {
|
||||||
|
audio.currentTime = seekTime;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('切换音质后恢复播放进度失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentTime(audio.currentTime || resumeTime);
|
||||||
|
|
||||||
|
if (shouldResume) {
|
||||||
|
audio.play()
|
||||||
|
.then(() => setIsPlaying(true))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('切换音质后播放失败:', error);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setIsBuffering(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.pause();
|
||||||
|
setIsBuffering(true);
|
||||||
|
audio.src = nextSongUrl;
|
||||||
|
audio.addEventListener('loadedmetadata', resumeAfterMetadata, { once: true });
|
||||||
|
audio.load();
|
||||||
|
setIsPlaying(shouldResume);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('切换音质失败:', error);
|
||||||
|
setIsBuffering(false);
|
||||||
|
setToast({
|
||||||
|
message: (error as Error).message || '切换音质失败',
|
||||||
|
type: 'error',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
endResolving();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleQuality = () => {
|
||||||
|
const qualities: MusicQuality[] = ['128k', '320k', 'flac', 'flac24bit'];
|
||||||
|
const currentIndex = qualities.indexOf(quality);
|
||||||
|
const nextIndex = (currentIndex + 1) % qualities.length;
|
||||||
|
void handleQualityChange(qualities[nextIndex]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清空播放记录
|
||||||
|
const handleClearPlayRecords = () => {
|
||||||
|
setConfirmModal({
|
||||||
|
isOpen: true,
|
||||||
|
title: '确认清空',
|
||||||
|
message: '确定要清空全部播放记录吗?',
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/music/v2/history', { method: 'DELETE' });
|
||||||
|
clearCurrentPlaybackState();
|
||||||
|
setPlaylist([]);
|
||||||
|
setPlayRecords([]);
|
||||||
|
setPlaylistIndex(-1);
|
||||||
|
setToast({
|
||||||
|
message: '播放记录已清空',
|
||||||
|
type: 'success',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清空播放记录失败:', error);
|
||||||
|
setToast({
|
||||||
|
message: '清空播放记录失败',
|
||||||
|
type: 'error',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setConfirmModal({
|
||||||
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
onConfirm: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
setConfirmModal({
|
||||||
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
onConfirm: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换播放模式
|
||||||
|
const toggleMode = () => {
|
||||||
|
const modes: Array<'loop' | 'single' | 'random'> = ['loop', 'single', 'random'];
|
||||||
|
const currentIndex = modes.indexOf(playMode);
|
||||||
|
const nextIndex = (currentIndex + 1) % modes.length;
|
||||||
|
setPlayMode(modes[nextIndex]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 返回
|
||||||
|
const goBack = () => {
|
||||||
|
if (currentView === 'songs' || currentView === 'search' || currentView === 'myPlaylists') {
|
||||||
|
router.push('/music/rankings');
|
||||||
|
} else {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 下载歌曲
|
||||||
|
const downloadSong = () => {
|
||||||
|
if (!currentSongUrl || !currentSong) return;
|
||||||
|
|
||||||
|
// 创建一个临时的 a 标签来触发下载
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = currentSongUrl;
|
||||||
|
link.download = `${currentSong.name} - ${currentSong.artist}.mp3`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换平台
|
||||||
|
const switchSource = (source: MusicSource) => {
|
||||||
|
setCurrentSource(source);
|
||||||
|
setSongs([]);
|
||||||
|
setSearchKeyword('');
|
||||||
|
setActiveSearchKeyword('');
|
||||||
|
setSearchPage(1);
|
||||||
|
setSearchHasMore(false);
|
||||||
|
if (pathname?.startsWith('/music/search')) {
|
||||||
|
router.push(`/music/search?source=${source}`);
|
||||||
|
} else {
|
||||||
|
router.push('/music/rankings');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 路由同步:/music 下多页面切换时保留本组件和播放器状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pathname) return;
|
||||||
|
|
||||||
|
if (pathname === '/music' || pathname === '/music/rankings') {
|
||||||
|
setCurrentView('playlists');
|
||||||
|
setSongs([]);
|
||||||
|
setCurrentPlaylistTitle('');
|
||||||
|
setActiveSearchKeyword('');
|
||||||
|
setSearchPage(1);
|
||||||
|
setSearchHasMore(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankingMatch = pathname.match(/^\/music\/rankings\/([^/]+)\/([^/]+)$/);
|
||||||
|
if (rankingMatch) {
|
||||||
|
const source = normalizeSource(decodeURIComponent(rankingMatch[1]));
|
||||||
|
const playlistId = decodeURIComponent(rankingMatch[2]);
|
||||||
|
const playlistName = searchParams.get('name') || currentPlaylistTitle || '排行榜';
|
||||||
|
setCurrentSource(source);
|
||||||
|
void loadPlaylist(playlistId, playlistName, source, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/music/search') {
|
||||||
|
const source = normalizeSource(searchParams.get('source') || currentSource);
|
||||||
|
const keyword = searchParams.get('q') || '';
|
||||||
|
setCurrentSource(source);
|
||||||
|
setCurrentView('search');
|
||||||
|
setCurrentPlaylistTitle(keyword ? `搜索: ${keyword}` : '搜索');
|
||||||
|
setSearchKeyword(keyword);
|
||||||
|
if (keyword) {
|
||||||
|
void searchSongs(keyword, false);
|
||||||
|
} else {
|
||||||
|
setSongs([]);
|
||||||
|
setActiveSearchKeyword('');
|
||||||
|
setSearchPage(1);
|
||||||
|
setSearchHasMore(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/music/my-playlists') {
|
||||||
|
setCurrentView('myPlaylists');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [pathname, searchParams]);
|
||||||
|
|
||||||
|
// 音频事件监听
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
setCurrentTime(audio.currentTime);
|
||||||
|
|
||||||
|
// 更新当前歌词索引
|
||||||
|
if (lyrics.length > 0) {
|
||||||
|
let index = -1;
|
||||||
|
for (let i = 0; i < lyrics.length; i++) {
|
||||||
|
if (lyrics[i].time <= audio.currentTime) {
|
||||||
|
index = i;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCurrentLyricIndex(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每20秒保存一次播放进度和播放时间
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastSaveTimeRef.current > 20000) {
|
||||||
|
lastSaveTimeRef.current = now;
|
||||||
|
|
||||||
|
// 前5秒不保存(避免加载时的跳转触发保存)
|
||||||
|
if (Date.now() - songStartTimeRef.current < 5000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新当前播放记录的播放时间
|
||||||
|
if (currentSong && playlistIndex >= 0) {
|
||||||
|
setPlayRecords(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
if (updated[playlistIndex]) {
|
||||||
|
updated[playlistIndex] = {
|
||||||
|
...updated[playlistIndex],
|
||||||
|
playTime: audio.currentTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
const record = updated[playlistIndex];
|
||||||
|
saveHistoryRecord(record, currentSong, audio.currentTime, audio.duration || 0).catch(err => {
|
||||||
|
console.error('保存播放记录到数据库失败:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
savePlayState();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
// 恢复播放进度
|
||||||
|
if (restoredTimeRef.current > 0) {
|
||||||
|
audio.currentTime = restoredTimeRef.current;
|
||||||
|
restoredTimeRef.current = 0; // 清除标记
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBufferingStart = () => {
|
||||||
|
if (audio.src && !audio.ended) {
|
||||||
|
setIsBuffering(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBufferingEnd = () => {
|
||||||
|
setIsBuffering(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDurationChange = () => {
|
||||||
|
setDuration(audio.duration);
|
||||||
|
|
||||||
|
// 前5秒不保存(避免加载时的跳转触发保存)
|
||||||
|
if (Date.now() - songStartTimeRef.current < 5000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新当前播放记录的总时长
|
||||||
|
if (currentSong && playlistIndex >= 0) {
|
||||||
|
setPlayRecords(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
if (updated[playlistIndex]) {
|
||||||
|
updated[playlistIndex] = {
|
||||||
|
...updated[playlistIndex],
|
||||||
|
duration: audio.duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存到数据库(包含时长信息)
|
||||||
|
const record = updated[playlistIndex];
|
||||||
|
saveHistoryRecord(record, currentSong, record.playTime, audio.duration).catch(err => {
|
||||||
|
console.error('保存播放记录到数据库失败:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleEnded = () => {
|
||||||
|
setIsBuffering(false);
|
||||||
|
if (playMode === 'single') {
|
||||||
|
audio.currentTime = 0;
|
||||||
|
audio.play();
|
||||||
|
} else if (playMode === 'random') {
|
||||||
|
// 优先从播放列表中随机选择
|
||||||
|
if (playlist.length > 0) {
|
||||||
|
const randomIndex = Math.floor(Math.random() * playlist.length);
|
||||||
|
setPlaylistIndex(randomIndex);
|
||||||
|
playSong(playlist[randomIndex], -1);
|
||||||
|
} else if (songs.length > 0) {
|
||||||
|
const randomIndex = Math.floor(Math.random() * songs.length);
|
||||||
|
playSong(songs[randomIndex], randomIndex);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
playNext();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.addEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
audio.addEventListener('loadstart', handleBufferingStart);
|
||||||
|
audio.addEventListener('waiting', handleBufferingStart);
|
||||||
|
audio.addEventListener('stalled', handleBufferingStart);
|
||||||
|
audio.addEventListener('canplay', handleBufferingEnd);
|
||||||
|
audio.addEventListener('canplaythrough', handleBufferingEnd);
|
||||||
|
audio.addEventListener('playing', handleBufferingEnd);
|
||||||
|
audio.addEventListener('pause', handleBufferingEnd);
|
||||||
|
audio.addEventListener('error', handleBufferingEnd);
|
||||||
|
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
audio.addEventListener('durationchange', handleDurationChange);
|
||||||
|
audio.addEventListener('ended', handleEnded);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
audio.removeEventListener('loadstart', handleBufferingStart);
|
||||||
|
audio.removeEventListener('waiting', handleBufferingStart);
|
||||||
|
audio.removeEventListener('stalled', handleBufferingStart);
|
||||||
|
audio.removeEventListener('canplay', handleBufferingEnd);
|
||||||
|
audio.removeEventListener('canplaythrough', handleBufferingEnd);
|
||||||
|
audio.removeEventListener('playing', handleBufferingEnd);
|
||||||
|
audio.removeEventListener('pause', handleBufferingEnd);
|
||||||
|
audio.removeEventListener('error', handleBufferingEnd);
|
||||||
|
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
audio.removeEventListener('durationchange', handleDurationChange);
|
||||||
|
audio.removeEventListener('ended', handleEnded);
|
||||||
|
};
|
||||||
|
}, [playMode, songs, currentSongIndex, lyrics, currentSong, playlistIndex, playRecords, quality]);
|
||||||
|
|
||||||
|
// 初始加载
|
||||||
|
useEffect(() => {
|
||||||
|
loadPlaylists(currentSource);
|
||||||
|
}, [currentSource]);
|
||||||
|
|
||||||
|
// 当切换到我的歌单视图时加载歌单列表
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentView === 'myPlaylists') {
|
||||||
|
loadUserPlaylists();
|
||||||
|
}
|
||||||
|
}, [currentView]);
|
||||||
|
|
||||||
|
// 歌词自动滚动
|
||||||
|
useEffect(() => {
|
||||||
|
if (lyricsContainerRef.current && currentLyricIndex >= 0) {
|
||||||
|
const container = lyricsContainerRef.current;
|
||||||
|
const activeLine = container.querySelector(`[data-index="${currentLyricIndex}"]`);
|
||||||
|
if (activeLine) {
|
||||||
|
activeLine.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentLyricIndex]);
|
||||||
|
|
||||||
|
// 搜索框回车
|
||||||
|
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
searchSongs();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = searchLoadMoreRef.current;
|
||||||
|
if (!sentinel || !activeSearchKeyword || !searchHasMore) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) {
|
||||||
|
loadMoreSearchSongs();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: null,
|
||||||
|
rootMargin: '240px 0px 240px 0px',
|
||||||
|
threshold: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(sentinel);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [activeSearchKeyword, searchHasMore, loadingMoreSearch, searchPage]);
|
||||||
|
|
||||||
|
// 进度条拖动
|
||||||
|
const handleProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newTime = (parseFloat(e.target.value) / 100) * duration;
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = newTime;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 音量调节
|
||||||
|
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newVolume = parseInt(e.target.value);
|
||||||
|
setVolume(newVolume);
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.volume = newVolume / 100;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 触摸/鼠标滑动音量调节(移动端兼容)
|
||||||
|
const handleVolumeSliderInteraction = (e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const slider = e.currentTarget;
|
||||||
|
const rect = slider.getBoundingClientRect();
|
||||||
|
|
||||||
|
const updateVolume = (clientY: number) => {
|
||||||
|
// 计算相对于滑块顶部的位置
|
||||||
|
const y = clientY - rect.top;
|
||||||
|
// 限制在滑块范围内
|
||||||
|
const clampedY = Math.max(0, Math.min(rect.height, y));
|
||||||
|
// 从上到下:0% -> 100%,从下到上:100% -> 0%
|
||||||
|
const percentage = 100 - (clampedY / rect.height) * 100;
|
||||||
|
const newVolume = Math.round(percentage);
|
||||||
|
|
||||||
|
setVolume(newVolume);
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.volume = newVolume / 100;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取初始触摸/点击位置
|
||||||
|
const clientY = 'touches' in e ? e.touches[0]?.clientY || 0 : e.clientY;
|
||||||
|
updateVolume(clientY);
|
||||||
|
|
||||||
|
const handleMove = (moveEvent: MouseEvent | TouchEvent) => {
|
||||||
|
moveEvent.preventDefault();
|
||||||
|
const moveClientY = 'touches' in moveEvent ? moveEvent.touches[0]?.clientY || 0 : moveEvent.clientY;
|
||||||
|
updateVolume(moveClientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
document.removeEventListener('mousemove', handleMove);
|
||||||
|
document.removeEventListener('mouseup', handleEnd);
|
||||||
|
document.removeEventListener('touchmove', handleMove);
|
||||||
|
document.removeEventListener('touchend', handleEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMove);
|
||||||
|
document.addEventListener('mouseup', handleEnd);
|
||||||
|
document.addEventListener('touchmove', handleMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', handleEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
// PiP 窗口管理
|
||||||
|
const togglePiPLyrics = () => {
|
||||||
|
if (!('documentPictureInPicture' in window)) {
|
||||||
|
setToast({
|
||||||
|
message: '您的浏览器不支持画中画功能,请使用 Chrome 116+ 版本',
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
// 降级方案:打开全屏歌词
|
||||||
|
setShowLyrics(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentSong) {
|
||||||
|
setToast({
|
||||||
|
message: '请先播放歌曲',
|
||||||
|
type: 'info',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowPiPLyrics(!showPiPLyrics);
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||||
|
const showStreamBuffering = Boolean(currentSong && isBuffering);
|
||||||
|
|
||||||
|
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 '标准';
|
||||||
|
case '320k': return 'HQ';
|
||||||
|
case 'flac': return 'SQ';
|
||||||
|
case 'flac24bit': return 'HR';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSourceLabel = () => {
|
||||||
|
switch (currentSource) {
|
||||||
|
case 'wy': return '网易云';
|
||||||
|
case 'tx': return 'QQ音乐';
|
||||||
|
case 'kw': return '酷我';
|
||||||
|
case 'kg': return '酷狗';
|
||||||
|
case 'mg': return '咪咕';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
if (isNaN(seconds) || seconds === 0) return '0:00';
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
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));
|
||||||
|
const visualVolume = Math.max(SPECTRUM_MIN_VOLUME, volumeRef.current || SPECTRUM_REFERENCE_VOLUME);
|
||||||
|
const visualVolumeScale =
|
||||||
|
visualVolume > SPECTRUM_MAX_REFERENCE_VOLUME
|
||||||
|
? Math.sqrt(SPECTRUM_MAX_REFERENCE_VOLUME / visualVolume)
|
||||||
|
: SPECTRUM_REFERENCE_VOLUME / visualVolume;
|
||||||
|
|
||||||
|
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)) * visualVolumeScale;
|
||||||
|
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">
|
||||||
|
<>
|
||||||
|
<style jsx global>{`
|
||||||
|
@keyframes vinyl-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.vinyl-player {
|
||||||
|
position: relative;
|
||||||
|
width: min(72vw, 300px);
|
||||||
|
height: min(72vw, 300px);
|
||||||
|
margin: 42px auto 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.vinyl-player {
|
||||||
|
width: clamp(260px, 30vw, 360px);
|
||||||
|
height: clamp(260px, 30vw, 360px);
|
||||||
|
margin: 54px auto 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.vinyl-needle {
|
||||||
|
position: absolute;
|
||||||
|
top: -50px;
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -20px;
|
||||||
|
width: 108px;
|
||||||
|
height: 140px;
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 130"><path d="M21,21 C21,65 86,70 86,100" fill="none" stroke="rgba(0,0,0,0.28)" stroke-width="6" stroke-linecap="round"/><path d="M20,20 C20,65 85,70 85,100" fill="none" stroke="%23e0e0e0" stroke-width="4.5" stroke-linecap="round"/><path d="M19,20 C19,65 84,70 84,100" fill="none" stroke="%23fff" stroke-width="1.5" stroke-linecap="round"/><g transform="translate(85, 100) rotate(25)"><rect x="-6" y="0" width="12" height="18" rx="2" fill="%23ccc"/><rect x="-4" y="5" width="8" height="14" rx="1" fill="%23333"/><rect x="-2" y="16" width="4" height="5" rx="1" fill="%23d43c33"/></g><circle cx="20" cy="20" r="10" fill="%23f0f0f0" stroke="%23ccc" stroke-width="1"/><circle cx="20" cy="20" r="4" fill="%23fff"/><circle cx="20" cy="20" r="1.5" fill="%23999"/></svg>');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
transform-origin: 20px 20px;
|
||||||
|
transform: rotate(-30deg);
|
||||||
|
transition: transform 0.5s cubic-bezier(0.3, 0, 0.1, 1);
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: drop-shadow(0 8px 8px rgba(0,0,0,0.35));
|
||||||
|
}
|
||||||
|
.vinyl-player.playing .vinyl-needle {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
.vinyl-disc {
|
||||||
|
position: relative;
|
||||||
|
width: 88%;
|
||||||
|
height: 88%;
|
||||||
|
border-radius: 9999px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: conic-gradient(from 45deg, #080808 0%, #2a2a2a 10%, #0f0f0f 20%, #111 40%, #303030 50%, #111 60%, #090909 80%, #2a2a2a 90%, #080808 100%);
|
||||||
|
box-shadow: 0 0 0 8px rgba(255,255,255,0.045), 0 18px 36px rgba(0,0,0,0.55), inset 0 0 24px rgba(255,255,255,0.04);
|
||||||
|
animation: vinyl-spin 20s linear infinite;
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
.vinyl-player.playing .vinyl-disc {
|
||||||
|
animation-play-state: running;
|
||||||
|
}
|
||||||
|
.vinyl-disc::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: repeating-radial-gradient(transparent 0, transparent 3px, rgba(255,255,255,0.045) 3px, rgba(255,255,255,0.045) 4px), radial-gradient(circle at 35% 25%, rgba(255,255,255,0.14), transparent 28%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.vinyl-disc::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #111;
|
||||||
|
border: 2px solid rgba(255,255,255,0.25);
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.vinyl-cover {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
width: 64%;
|
||||||
|
height: 64%;
|
||||||
|
border-radius: 9999px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 5px solid #0a0a0a;
|
||||||
|
user-select: none;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.vinyl-cover-fallback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #27272a;
|
||||||
|
}
|
||||||
|
@keyframes music-note-bounce {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes music-buffer-scan {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(200%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes music-buffer-stripes {
|
||||||
|
0% {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 24px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.music-buffer-track {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
115deg,
|
||||||
|
rgba(16, 185, 129, 0.08) 0,
|
||||||
|
rgba(16, 185, 129, 0.08) 6px,
|
||||||
|
rgba(110, 231, 183, 0.28) 6px,
|
||||||
|
rgba(110, 231, 183, 0.28) 12px
|
||||||
|
);
|
||||||
|
background-size: 24px 100%;
|
||||||
|
animation: music-buffer-stripes 0.65s linear infinite;
|
||||||
|
}
|
||||||
|
.music-buffer-track::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 42%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(167, 243, 208, 0.95),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
filter: drop-shadow(0 0 8px rgba(52, 211, 153, 0.7));
|
||||||
|
animation: music-buffer-scan 0.95s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<style jsx global>{`
|
||||||
|
@keyframes music-buffer-scan-global {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-120%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(260%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes music-buffer-stripes-global {
|
||||||
|
0% {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 28px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.music-buffer-track-global {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
115deg,
|
||||||
|
rgba(16, 185, 129, 0.1) 0,
|
||||||
|
rgba(16, 185, 129, 0.1) 7px,
|
||||||
|
rgba(110, 231, 183, 0.34) 7px,
|
||||||
|
rgba(110, 231, 183, 0.34) 14px
|
||||||
|
);
|
||||||
|
background-size: 28px 100%;
|
||||||
|
animation: music-buffer-stripes-global 0.55s linear infinite;
|
||||||
|
}
|
||||||
|
.music-buffer-track-global::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 45%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(236, 253, 245, 0.95),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
box-shadow: 0 0 12px rgba(52, 211, 153, 0.9);
|
||||||
|
animation: music-buffer-scan-global 0.85s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
{resolvingCount > 0 && (
|
||||||
|
<div className="fixed top-4 right-4 z-[80] pointer-events-none">
|
||||||
|
<div className="relative w-16 h-16 md:w-20 md:h-20">
|
||||||
|
<div className="absolute inset-0 rounded-full border-4 border-white/10" />
|
||||||
|
<div className="absolute inset-0 rounded-full border-4 border-transparent border-t-green-500 border-r-emerald-400 animate-spin shadow-[0_0_20px_rgba(34,197,94,0.35)]" />
|
||||||
|
<div className="absolute inset-1 rounded-full bg-zinc-950/90 backdrop-blur-md border border-white/10 flex flex-col items-center justify-center">
|
||||||
|
<div className="text-[10px] md:text-xs text-zinc-400 leading-none mb-1">解析中</div>
|
||||||
|
<div className="text-lg md:text-xl font-bold text-white leading-none">{resolvingCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Header */}
|
||||||
|
<header className="fixed top-0 left-0 right-0 z-40 bg-zinc-950/95 backdrop-blur-md border-b border-white/10 px-4 md:px-6">
|
||||||
|
<div className="w-full mx-auto flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-4 py-3">
|
||||||
|
<div className="flex items-center justify-between md:justify-start md:gap-6 w-full md:w-auto">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSidebarDrawer(true)}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||||
|
title="打开菜单"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="w-8 h-8 rounded-full flex items-center justify-center bg-white/10 text-green-500">
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-lg text-white">音乐</span>
|
||||||
|
</div>
|
||||||
|
<div className="md:hidden relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSourceMenu(true)}
|
||||||
|
className="relative h-10 min-w-[132px] rounded-full border border-white/10 bg-gradient-to-r from-white/8 to-white/4 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] px-3"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-between px-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-green-500/15 text-green-400 flex items-center justify-center shrink-0">
|
||||||
|
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[9px] uppercase tracking-[0.18em] text-zinc-500 leading-none">音源</div>
|
||||||
|
<div className="text-sm font-medium text-white leading-tight truncate">
|
||||||
|
{musicSources.find((source) => source.key === currentSource)?.label || '酷我'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-7 h-7 rounded-full bg-white/6 border border-white/8 flex items-center justify-center text-zinc-300 shrink-0">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:flex flex-wrap bg-white/5 rounded-lg p-1 gap-1 border border-white/5">
|
||||||
|
{musicSources.map((source) => (
|
||||||
|
<button
|
||||||
|
key={source.key}
|
||||||
|
onClick={() => switchSource(source.key)}
|
||||||
|
className={`px-3 py-1 md:px-4 rounded text-[10px] font-bold tracking-wider transition-all ${
|
||||||
|
currentSource === source.key
|
||||||
|
? 'bg-green-500 text-white border border-white/30 shadow-lg shadow-green-500/50'
|
||||||
|
: 'text-zinc-400 border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{source.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center w-full md:flex-1 md:max-w-md md:ml-auto h-10 md:h-9 gap-2">
|
||||||
|
{(currentView === 'songs' || currentView === 'search' || currentView === 'myPlaylists') && (
|
||||||
|
<button
|
||||||
|
onClick={goBack}
|
||||||
|
className="w-10 h-full rounded-lg bg-white/10 hover:bg-white/20 flex items-center justify-center text-white border border-white/10"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="relative group w-full h-full rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors">
|
||||||
|
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
|
||||||
|
<svg className="w-4 h-4 text-zinc-500 transition-colors group-focus-within:text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
|
className="w-full h-full appearance-none border-0 bg-transparent pl-9 pr-4 text-sm text-white outline-none focus:outline-none focus:ring-0 font-mono placeholder:text-zinc-500"
|
||||||
|
placeholder="搜索歌曲或艺术家..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/music/my-playlists')}
|
||||||
|
className="w-10 h-full rounded-lg bg-white/10 hover:bg-white/20 flex items-center justify-center text-white border border-white/10 shrink-0"
|
||||||
|
title="我的歌单"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="pt-[136px] md:pt-[108px] pb-32 px-4 md:px-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{loading && (
|
||||||
|
<MusicLoadingIndicator className="py-8" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Playlists View */}
|
||||||
|
{currentView === 'playlists' && !loading && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-2">
|
||||||
|
<h2 className="text-xs font-mono text-white/50 tracking-widest">排行榜</h2>
|
||||||
|
<SourcePill source={currentSource} />
|
||||||
|
</div>
|
||||||
|
{playlists.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{playlists.map((playlist, index) => (
|
||||||
|
<button
|
||||||
|
key={playlist.id}
|
||||||
|
onClick={() => loadPlaylist(playlist.id, playlist.name, playlist.source)}
|
||||||
|
className="w-full text-left rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 transition-colors px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-8 text-sm text-zinc-500 dark:text-zinc-300 font-mono shrink-0">
|
||||||
|
{String(index + 1).padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-white/90 truncate">{playlist.name}</div>
|
||||||
|
{playlist.updateFrequency ? (
|
||||||
|
<div className="text-xs text-zinc-500 mt-1 truncate">{playlist.updateFrequency}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<SourcePill source={playlist.source || currentSource} />
|
||||||
|
<div className="text-zinc-500 shrink-0">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-6 text-center text-zinc-400">
|
||||||
|
<div className="text-base font-medium text-white/80 mb-2">当前音源暂无排行榜</div>
|
||||||
|
<div className="text-sm text-zinc-500">
|
||||||
|
你可以切换其它音源,或使用上方搜索继续找歌。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Songs View */}
|
||||||
|
{(currentView === 'songs' || currentView === 'search') && !loading && (
|
||||||
|
<div>
|
||||||
|
{currentView === 'search' && (
|
||||||
|
<div className="mb-6 flex items-center gap-2">
|
||||||
|
<div className="relative h-11 flex-1 rounded-xl bg-white/10">
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
|
||||||
|
<svg className="h-4 w-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
|
className="h-full w-full border-0 bg-transparent pl-10 pr-4 text-sm text-white outline-none placeholder:text-zinc-500"
|
||||||
|
placeholder="搜索歌曲或艺术家..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => searchSongs()} className="h-11 rounded-xl bg-green-600 px-5 text-sm font-medium text-white hover:bg-green-700">搜索</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-2">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<h2 className="text-xl font-bold text-white/80 tracking-tight truncate max-w-md">
|
||||||
|
{currentPlaylistTitle}
|
||||||
|
</h2>
|
||||||
|
{songs[0]?.platform ? <SourcePill source={songs[0].platform} /> : null}
|
||||||
|
<span className="text-[10px] font-bold bg-white/10 px-2 py-0.5 rounded text-white shrink-0">
|
||||||
|
{songs.length} 首歌曲
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handlePlayAllCurrentSongs}
|
||||||
|
disabled={songs.length === 0 || loadingCurrentPlayAll}
|
||||||
|
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-zinc-700 disabled:cursor-not-allowed rounded-lg transition-colors flex items-center gap-2 text-sm text-white shrink-0"
|
||||||
|
>
|
||||||
|
{loadingCurrentPlayAll ? (
|
||||||
|
<MusicLoadingIndicator size="sm" className="gap-2 text-white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
播放全部
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{songs.map((song, index) => (
|
||||||
|
<div
|
||||||
|
key={`${song.id}-${index}`}
|
||||||
|
className={`grid grid-cols-[40px_1fr_auto_auto] md:grid-cols-[50px_2fr_1fr_auto_auto] gap-2 px-3 py-3 rounded-lg cursor-pointer transition-all ${
|
||||||
|
currentSongIndex === index
|
||||||
|
? 'bg-white/12 border-l-2 border-green-500'
|
||||||
|
: 'hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="text-center text-zinc-500 dark:text-zinc-300 text-sm col-span-1"
|
||||||
|
onClick={() => playSong(song, index)}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="min-w-0 col-span-1"
|
||||||
|
onClick={() => playSong(song, index)}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-white truncate">{song.name}</div>
|
||||||
|
<div className="text-xs text-zinc-500 truncate md:hidden">{song.artist}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="hidden md:block text-sm text-zinc-400 truncate col-span-1"
|
||||||
|
onClick={() => playSong(song, index)}
|
||||||
|
>
|
||||||
|
{song.artist}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="col-span-1 flex items-center"
|
||||||
|
onClick={() => playSong(song, index)}
|
||||||
|
>
|
||||||
|
<SourcePill source={song.platform} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 flex flex-col items-center justify-center gap-0.5 leading-none">
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleAddToPlaylist(song, e)}
|
||||||
|
className="text-zinc-500 hover:text-red-500 transition-colors p-0.5"
|
||||||
|
title="添加到歌单"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handlePlayLater(song, e)}
|
||||||
|
className="text-zinc-500 hover:text-green-500 transition-colors p-0.5"
|
||||||
|
title="稍后播放"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-9-9 9 9 0 019 9z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{currentView === 'search' && activeSearchKeyword && (
|
||||||
|
<div ref={searchLoadMoreRef} className="mt-6 flex min-h-10 justify-center">
|
||||||
|
{searchHasMore ? (
|
||||||
|
loadingMoreSearch ? (
|
||||||
|
<div className="rounded-lg border border-white/10 bg-white/5 px-5 py-2.5">
|
||||||
|
<MusicLoadingIndicator size="sm" className="gap-2 text-white" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
继续向下滚动加载更多
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : songs.length > 0 ? (
|
||||||
|
<div className="text-xs text-zinc-500">没有更多搜索结果了</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* My Playlists View */}
|
||||||
|
{currentView === 'myPlaylists' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Playlists List */}
|
||||||
|
<div className="md:col-span-1">
|
||||||
|
<div className="bg-zinc-800/50 rounded-xl p-4 border border-white/10">
|
||||||
|
<h2 className="text-lg font-bold mb-4">歌单列表</h2>
|
||||||
|
{loadingUserPlaylists ? (
|
||||||
|
<MusicLoadingIndicator className="py-8" />
|
||||||
|
) : userPlaylists.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-zinc-400">
|
||||||
|
还没有歌单
|
||||||
|
<br />
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentView('playlists')}
|
||||||
|
className="mt-4 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
去添加歌曲
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{userPlaylists.map((playlist) => (
|
||||||
|
<div
|
||||||
|
key={playlist.id}
|
||||||
|
className={`p-3 rounded-lg cursor-pointer transition-colors ${
|
||||||
|
selectedUserPlaylist?.id === playlist.id
|
||||||
|
? 'bg-green-600/20 border border-green-500'
|
||||||
|
: 'bg-white/5 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelectUserPlaylist(playlist)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{playlist.cover ? (
|
||||||
|
<img
|
||||||
|
src={playlist.cover}
|
||||||
|
alt={playlist.name}
|
||||||
|
className="w-12 h-12 rounded object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 rounded bg-zinc-700 flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{playlist.name}</div>
|
||||||
|
{playlist.description && (
|
||||||
|
<div className="text-xs text-zinc-500 truncate">{playlist.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Playlist Songs */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
{selectedUserPlaylist ? (
|
||||||
|
<div className="bg-zinc-800/50 rounded-xl p-4 border border-white/10">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold">{selectedUserPlaylist.name}</h2>
|
||||||
|
{selectedUserPlaylist.description && (
|
||||||
|
<p className="text-sm text-zinc-400 mt-1">{selectedUserPlaylist.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handlePlayAllPlaylist}
|
||||||
|
disabled={userPlaylistSongs.length === 0 || loadingPlayAll}
|
||||||
|
className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-zinc-700 disabled:cursor-not-allowed rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{loadingPlayAll ? (
|
||||||
|
<MusicLoadingIndicator size="sm" className="gap-2 text-white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
播放全部
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUserPlaylist(selectedUserPlaylist.id)}
|
||||||
|
disabled={deletingPlaylistId !== null}
|
||||||
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-zinc-700 disabled:cursor-not-allowed rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{deletingPlaylistId === selectedUserPlaylist.id ? (
|
||||||
|
<>
|
||||||
|
<svg className="w-5 h-5 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>
|
||||||
|
删除中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'删除歌单'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingUserPlaylistSongs ? (
|
||||||
|
<MusicLoadingIndicator className="py-8" />
|
||||||
|
) : userPlaylistSongs.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-zinc-400">歌单为空</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{userPlaylistSongs.map((song, index) => (
|
||||||
|
<div
|
||||||
|
key={`${song.platform}+${song.id}`}
|
||||||
|
className="flex items-center gap-2 p-2.5 md:gap-3 md:p-3 rounded-lg bg-white/5 hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="text-zinc-500 dark:text-zinc-300 text-xs md:text-sm w-6 md:w-8 text-center shrink-0">{index + 1}</div>
|
||||||
|
{song.pic && (
|
||||||
|
<img
|
||||||
|
src={song.pic}
|
||||||
|
alt={song.name}
|
||||||
|
className="w-10 h-10 md:w-12 md:h-12 rounded object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="font-medium truncate">{song.name}</div>
|
||||||
|
<SourcePill source={song.platform || song.source} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-400 truncate">{song.artist}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => playSong(song, index)}
|
||||||
|
className="text-zinc-500 hover:text-green-500 transition-colors p-1 md:p-2 shrink-0"
|
||||||
|
title="播放"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveSongFromUserPlaylist(song)}
|
||||||
|
className="text-zinc-500 hover:text-red-500 transition-colors p-1 md:p-2 shrink-0"
|
||||||
|
title="移除"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-zinc-800/50 rounded-xl p-4 border border-white/10 h-full flex items-center justify-center">
|
||||||
|
<div className="text-center text-zinc-400">
|
||||||
|
<svg className="w-16 h-16 mx-auto mb-4 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||||
|
</svg>
|
||||||
|
<p>选择一个歌单查看详情</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Player */}
|
||||||
|
{showPlayer && currentSong && (
|
||||||
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 w-[95%] max-w-3xl z-50">
|
||||||
|
{showSpectrum && (
|
||||||
|
<div className="pointer-events-none px-4">
|
||||||
|
<AudioSpectrumCanvas bars={spectrumBars} compact />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative bg-zinc-900/95 backdrop-blur-md rounded-xl p-4 pt-5 border border-white/10 shadow-2xl">
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="absolute left-0 right-0 top-0 h-1 bg-white/10 rounded-t-xl overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="relative z-10 h-full bg-green-500 transition-all pointer-events-none"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
{showStreamBuffering && (
|
||||||
|
<div className="music-buffer-track-global absolute inset-0 z-0" />
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={progress}
|
||||||
|
onChange={handleProgressChange}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4 mt-1">
|
||||||
|
{/* Song Info */}
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-lg bg-zinc-800 overflow-hidden shrink-0 flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
onClick={() => setShowLyrics(true)}
|
||||||
|
>
|
||||||
|
{currentSong.pic ? (
|
||||||
|
<img
|
||||||
|
src={currentSong.pic}
|
||||||
|
alt={currentSong.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
// 图片加载失败时显示默认图标
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<svg className="w-6 h-6 text-zinc-600" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<div className="min-w-0 truncate text-sm font-bold text-white">{currentSong.name}</div>
|
||||||
|
<SourcePill source={currentSong.platform} className="hidden sm:inline-flex" />
|
||||||
|
{showStreamBuffering && (
|
||||||
|
<span className="hidden sm:inline-flex items-center gap-1 rounded-full bg-green-500/15 px-2 py-0.5 text-[10px] font-medium text-green-300">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-green-400 animate-pulse" />
|
||||||
|
缓冲中
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 truncate">{currentSong.artist}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button onClick={playPrev} className="text-zinc-500 hover:text-white transition-colors">
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="w-10 h-10 rounded-full bg-green-500 text-white flex items-center justify-center hover:bg-green-600 transition-colors"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button onClick={playNext} className="text-zinc-500 hover:text-white transition-colors">
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Controls */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
value={volume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="w-16 h-1 bg-white/10 rounded-full appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (currentSong) {
|
||||||
|
setSongToAddToPlaylist(currentSong);
|
||||||
|
setShowAddToPlaylistModal(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-zinc-500 hover:text-red-500 transition-colors"
|
||||||
|
title="添加到歌单"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={downloadSong}
|
||||||
|
className="text-zinc-500 hover:text-white transition-colors"
|
||||||
|
title="下载歌曲"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleMode}
|
||||||
|
className="text-zinc-500 hover:text-white transition-colors"
|
||||||
|
title={playMode === 'loop' ? '列表循环' : playMode === 'single' ? '单曲循环' : '随机播放'}
|
||||||
|
>
|
||||||
|
{playMode === 'loop' && (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{playMode === 'single' && (
|
||||||
|
<div className="relative w-4 h-4">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center text-[8px] font-bold">1</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{playMode === 'random' && (
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audio Element */}
|
||||||
|
<audio ref={audioRef} className="hidden" />
|
||||||
|
|
||||||
|
{/* Lyrics Modal */}
|
||||||
|
{showLyrics && currentSong && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/90 backdrop-blur-sm z-[100] flex items-center justify-center p-4"
|
||||||
|
onClick={(e) => {
|
||||||
|
// 点击背景关闭音量条
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
setShowVolumeSlider(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-6xl h-[90vh] bg-zinc-900/95 rounded-2xl overflow-hidden border border-white/10 shadow-2xl flex flex-col"
|
||||||
|
onClick={() => setShowVolumeSlider(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLyrics(false)}
|
||||||
|
className="absolute top-2 right-2 md:top-4 md:right-4 z-10 w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
|
||||||
|
{/* Cover / Meta */}
|
||||||
|
<div className="relative h-32 md:h-auto md:w-[380px] lg:w-[430px] xl:w-[480px] bg-gradient-to-b from-zinc-800 to-zinc-900 shrink-0 overflow-hidden">
|
||||||
|
{currentSong.pic && (
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<img
|
||||||
|
src={currentSong.pic}
|
||||||
|
alt={currentSong.name}
|
||||||
|
className="w-full h-full object-cover opacity-30 blur-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative h-full flex flex-col items-center justify-center p-4 md:p-6 lg:p-8">
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMobileLyricsView((view) => (view === 'lyrics' ? 'vinyl' : 'lyrics'));
|
||||||
|
}}
|
||||||
|
className="w-16 h-16 md:hidden rounded-xl overflow-hidden shadow-2xl mb-2"
|
||||||
|
>
|
||||||
|
{currentSong.pic ? (
|
||||||
|
<img
|
||||||
|
src={currentSong.pic}
|
||||||
|
alt={currentSong.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-zinc-800 flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 text-zinc-600" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block w-full">
|
||||||
|
<VinylTurntable cover={currentSong.pic} title={currentSong.name} isPlaying={isPlaying} />
|
||||||
|
</div>
|
||||||
|
<div className="mb-1 flex min-w-0 items-center justify-center gap-1.5">
|
||||||
|
<h2 className="min-w-0 truncate text-center text-base md:text-xl lg:text-2xl font-bold text-white">{currentSong.name}</h2>
|
||||||
|
<SourcePill source={currentSong.platform} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs md:text-sm lg:text-base text-zinc-400 line-clamp-1 text-center">{currentSong.artist}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mobileLyricsView === 'vinyl' && (
|
||||||
|
<div className="relative md:hidden flex-1 min-h-0 overflow-hidden bg-gradient-to-b from-zinc-800 to-zinc-900">
|
||||||
|
{currentSong.pic && (
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<img
|
||||||
|
src={currentSong.pic}
|
||||||
|
alt={currentSong.name}
|
||||||
|
className="w-full h-full object-cover opacity-30 blur-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative h-full flex items-center justify-center p-4">
|
||||||
|
<VinylTurntable cover={currentSong.pic} title={currentSong.name} isPlaying={isPlaying} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lyrics Content */}
|
||||||
|
<div ref={lyricsContainerRef} className={`${mobileLyricsView === 'vinyl' ? 'hidden md:block' : 'block'} flex-1 overflow-y-auto p-4 md:p-6 lg:p-8`}>
|
||||||
|
{lyrics.length > 0 ? (
|
||||||
|
<div className="space-y-4 md:space-y-5">
|
||||||
|
{lyrics.map((line, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
data-index={index}
|
||||||
|
className={`text-center transition-all duration-300 ${
|
||||||
|
index === currentLyricIndex
|
||||||
|
? 'text-white text-lg md:text-xl lg:text-2xl font-bold scale-110'
|
||||||
|
: index === currentLyricIndex - 1 || index === currentLyricIndex + 1
|
||||||
|
? 'text-zinc-400 text-base md:text-lg'
|
||||||
|
: 'text-zinc-600 text-sm md:text-base'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>{line.text}</div>
|
||||||
|
{line.translation && (
|
||||||
|
<div
|
||||||
|
className={`mt-1 ${
|
||||||
|
index === currentLyricIndex
|
||||||
|
? 'text-zinc-300 text-sm md:text-base lg:text-lg font-normal'
|
||||||
|
: 'text-zinc-500 text-xs md:text-sm lg:text-base font-normal'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{line.translation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center space-y-4 pt-10 md:pt-16 lg:pt-20">
|
||||||
|
<p className="text-zinc-500 text-sm md:text-base">暂无歌词</p>
|
||||||
|
<p className="text-zinc-600 text-xs md:text-sm">纯音乐或歌词获取失败</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini Player Controls */}
|
||||||
|
<div className="border-t border-white/5 p-3 md:p-4 shrink-0">
|
||||||
|
{/* 上排:播放控制按钮 */}
|
||||||
|
<div className="flex items-center justify-center gap-4 md:gap-6 mb-2 md:mb-3">
|
||||||
|
<button onClick={playPrev} className="text-zinc-500 hover:text-white transition-colors">
|
||||||
|
<svg className="w-5 h-5 md:w-6 md:h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="w-10 h-10 md:w-12 md:h-12 rounded-full bg-green-500 text-white flex items-center justify-center hover:bg-green-600 transition-colors"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<svg className="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4 md:w-5 md:h-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button onClick={playNext} className="text-zinc-500 hover:text-white transition-colors">
|
||||||
|
<svg className="w-5 h-5 md:w-6 md:h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 下排:其他按钮(小一号) */}
|
||||||
|
<div className="flex items-center justify-center gap-3 md:gap-4 mb-2 md:mb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPlaylist(true)}
|
||||||
|
className="text-zinc-500 hover:text-white transition-colors relative"
|
||||||
|
title="播放列表"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||||
|
</svg>
|
||||||
|
{playlist.length > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full text-[8px] text-white flex items-center justify-center font-bold">
|
||||||
|
{playlist.length > 9 ? '9+' : playlist.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={downloadSong}
|
||||||
|
className="text-zinc-500 hover:text-white transition-colors"
|
||||||
|
title="下载歌曲"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowQualityMenu(true)}
|
||||||
|
className="px-2 py-0.5 rounded border text-amber-400 border-amber-500/50 bg-amber-900/20 text-[9px] md:text-[10px] font-mono min-w-[32px] text-center hover:bg-amber-900/30 transition-colors"
|
||||||
|
title="音质选择"
|
||||||
|
>
|
||||||
|
{getQualityLabel()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleMode}
|
||||||
|
className="text-zinc-500 hover:text-white transition-colors"
|
||||||
|
title={playMode === 'loop' ? '列表循环' : playMode === 'single' ? '单曲循环' : '随机播放'}
|
||||||
|
>
|
||||||
|
{playMode === 'loop' && (
|
||||||
|
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{playMode === 'single' && (
|
||||||
|
<div className="relative w-4 h-4 md:w-5 md:h-5">
|
||||||
|
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center text-[7px] md:text-[8px] font-bold">1</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{playMode === 'random' && (
|
||||||
|
<svg className="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{/* 音量控制 */}
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
onMouseEnter={() => setShowVolumeSlider(true)}
|
||||||
|
onMouseLeave={() => setShowVolumeSlider(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowVolumeSlider(!showVolumeSlider);
|
||||||
|
}}
|
||||||
|
className="text-zinc-500 hover:text-white transition-colors"
|
||||||
|
title="音量"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* 垂直音量条 - 桌面悬浮/移动端点击 */}
|
||||||
|
<div
|
||||||
|
className={`absolute bottom-full left-1/2 -translate-x-1/2 pb-2 transition-opacity ${showVolumeSlider ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="bg-zinc-800/95 backdrop-blur-sm rounded-lg p-3 shadow-xl border border-white/10">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<span className="text-xs text-zinc-400 font-mono">{volume}</span>
|
||||||
|
<div
|
||||||
|
className="h-24 w-6 bg-white/10 rounded-full relative cursor-pointer select-none"
|
||||||
|
onMouseDown={handleVolumeSliderInteraction}
|
||||||
|
onTouchStart={handleVolumeSliderInteraction}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0 bg-green-500 rounded-full transition-all pointer-events-none"
|
||||||
|
style={{ height: `${volume}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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();
|
||||||
|
togglePiPLyrics();
|
||||||
|
}}
|
||||||
|
className={`transition-colors ${
|
||||||
|
showPiPLyrics
|
||||||
|
? 'text-green-500 hover:text-green-400'
|
||||||
|
: 'text-zinc-500 hover:text-white'
|
||||||
|
}`}
|
||||||
|
title={showPiPLyrics ? '关闭画中画歌词' : '画中画歌词'}
|
||||||
|
disabled={!currentSong}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 1.98 2 1.98h18c1.1 0 2-.88 2-1.98V5c0-1.1-.9-2-2-2zm0 16.01H3V4.98h18v14.03z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* 添加到歌单按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (currentSong) {
|
||||||
|
setSongToAddToPlaylist(currentSong);
|
||||||
|
setShowAddToPlaylistModal(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-zinc-500 hover:text-red-500 transition-colors"
|
||||||
|
title="添加到歌单"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div className="relative">
|
||||||
|
{showStreamBuffering && (
|
||||||
|
<div className="pointer-events-none absolute left-0 top-0 z-10 -translate-y-full">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/15 px-2 py-0.5 text-[10px] font-medium text-green-300">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-green-400 animate-pulse" />
|
||||||
|
缓冲中
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showSpectrum && (
|
||||||
|
<div className="relative mb-3 flex items-center gap-2 text-xs">
|
||||||
|
<span className="invisible">{formatTime(currentTime)}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<AudioSpectrumCanvas bars={spectrumBars} />
|
||||||
|
</div>
|
||||||
|
<span className="invisible">{formatTime(duration)}</span>
|
||||||
|
</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">
|
||||||
|
<div
|
||||||
|
className="relative z-10 h-full bg-green-500 transition-all pointer-events-none"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
{showStreamBuffering && (
|
||||||
|
<div className="music-buffer-track-global absolute inset-0 z-0" />
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={progress}
|
||||||
|
onChange={handleProgressChange}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Playlist Modal */}
|
||||||
|
{showPlaylist && (
|
||||||
|
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-2xl h-[90vh] md:h-auto max-h-[90vh] bg-zinc-900/95 rounded-2xl overflow-hidden border border-white/10 shadow-2xl flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="relative h-16 bg-gradient-to-b from-zinc-800 to-zinc-900 shrink-0 flex items-center justify-between px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-lg font-bold text-white">播放列表</h2>
|
||||||
|
<span className="text-xs text-zinc-500">({playlist.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{playlist.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleClearPlayRecords}
|
||||||
|
className="px-3 py-1 text-xs rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors border border-red-500/50"
|
||||||
|
title="清空全部"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPlaylist(false)}
|
||||||
|
className="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Playlist */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 md:p-6">
|
||||||
|
{playlist.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{playlist.map((song, index) => (
|
||||||
|
<div
|
||||||
|
key={`${song.id}-${index}`}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg transition-colors group ${
|
||||||
|
index === playlistIndex
|
||||||
|
? 'bg-green-500/20 border border-green-500/50'
|
||||||
|
: 'bg-white/5 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setPlaylistIndex(index);
|
||||||
|
playSong(song, -1);
|
||||||
|
setShowPlaylist(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-zinc-800 overflow-hidden shrink-0">
|
||||||
|
{song.pic ? (
|
||||||
|
<img
|
||||||
|
src={song.pic}
|
||||||
|
alt={song.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-zinc-600" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className={`text-sm font-medium truncate transition-colors ${
|
||||||
|
index === playlistIndex ? 'text-green-400' : 'text-white group-hover:text-green-400'
|
||||||
|
}`}>
|
||||||
|
{song.name}
|
||||||
|
</div>
|
||||||
|
<SourcePill source={song.platform} />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 truncate">{song.artist}</div>
|
||||||
|
</div>
|
||||||
|
{index === playlistIndex ? (
|
||||||
|
<svg className="w-5 h-5 text-green-400 shrink-0 animate-pulse" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5 text-zinc-600 group-hover:text-white transition-colors shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await fetch(`/api/music/v2/history?songId=${encodeURIComponent(song.id)}`, { method: 'DELETE' });
|
||||||
|
|
||||||
|
// 更新本地状态
|
||||||
|
const newPlaylist = playlist.filter((_, i) => i !== index);
|
||||||
|
const newRecords = playRecords.filter((_, i) => i !== index);
|
||||||
|
setPlaylist(newPlaylist);
|
||||||
|
setPlayRecords(newRecords);
|
||||||
|
|
||||||
|
// 如果删除的是当前播放的歌曲,调整索引
|
||||||
|
if (index === playlistIndex) {
|
||||||
|
setPlaylistIndex(-1);
|
||||||
|
} else if (index < playlistIndex) {
|
||||||
|
setPlaylistIndex(playlistIndex - 1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除播放记录失败:', error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-8 h-8 rounded-lg bg-red-500/20 hover:bg-red-500/30 flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100 shrink-0"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||||
|
<svg className="w-16 h-16 text-zinc-700 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-zinc-500 text-sm">播放列表为空</p>
|
||||||
|
<p className="text-zinc-600 text-xs mt-2">播放歌曲后会自动添加到列表</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quality Selection Menu */}
|
||||||
|
{showQualityMenu && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100] flex items-end justify-center"
|
||||||
|
onClick={() => setShowQualityMenu(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md bg-zinc-900 rounded-t-2xl border-t border-white/10 shadow-2xl animate-slide-up"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-white/10">
|
||||||
|
<h3 className="text-lg font-bold text-white text-center">选择音质</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quality Options */}
|
||||||
|
<div className="p-4 space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void handleQualityChange('128k');
|
||||||
|
}}
|
||||||
|
className={`w-full p-4 rounded-lg flex items-center justify-between transition-colors ${
|
||||||
|
quality === '128k'
|
||||||
|
? 'bg-amber-500/20 border border-amber-500/50'
|
||||||
|
: 'bg-white/5 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${quality === '128k' ? 'bg-amber-400' : 'bg-zinc-600'}`} />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-white font-medium">标准音质</div>
|
||||||
|
<div className="text-xs text-zinc-500">128kbps</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{quality === '128k' && (
|
||||||
|
<svg className="w-5 h-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void handleQualityChange('320k');
|
||||||
|
}}
|
||||||
|
className={`w-full p-4 rounded-lg flex items-center justify-between transition-colors ${
|
||||||
|
quality === '320k'
|
||||||
|
? 'bg-amber-500/20 border border-amber-500/50'
|
||||||
|
: 'bg-white/5 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${quality === '320k' ? 'bg-amber-400' : 'bg-zinc-600'}`} />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-white font-medium">高品质 HQ</div>
|
||||||
|
<div className="text-xs text-zinc-500">320kbps</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{quality === '320k' && (
|
||||||
|
<svg className="w-5 h-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void handleQualityChange('flac');
|
||||||
|
}}
|
||||||
|
className={`w-full p-4 rounded-lg flex items-center justify-between transition-colors ${
|
||||||
|
quality === 'flac'
|
||||||
|
? 'bg-amber-500/20 border border-amber-500/50'
|
||||||
|
: 'bg-white/5 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${quality === 'flac' ? 'bg-amber-400' : 'bg-zinc-600'}`} />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-white font-medium">无损音质 SQ</div>
|
||||||
|
<div className="text-xs text-zinc-500">FLAC</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{quality === 'flac' && (
|
||||||
|
<svg className="w-5 h-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void handleQualityChange('flac24bit');
|
||||||
|
}}
|
||||||
|
className={`w-full p-4 rounded-lg flex items-center justify-between transition-colors ${
|
||||||
|
quality === 'flac24bit'
|
||||||
|
? 'bg-amber-500/20 border border-amber-500/50'
|
||||||
|
: 'bg-white/5 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${quality === 'flac24bit' ? 'bg-amber-400' : 'bg-zinc-600'}`} />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-white font-medium">Hi-Res音质 HR</div>
|
||||||
|
<div className="text-xs text-zinc-500">FLAC 24bit</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{quality === 'flac24bit' && (
|
||||||
|
<svg className="w-5 h-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cancel Button */}
|
||||||
|
<div className="p-4 pt-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowQualityMenu(false)}
|
||||||
|
className="w-full p-3 rounded-lg bg-white/5 hover:bg-white/10 text-white transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{showSidebarDrawer && (
|
||||||
|
<div className="fixed inset-0 z-[95]">
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={() => setShowSidebarDrawer(false)}
|
||||||
|
aria-label="关闭菜单"
|
||||||
|
/>
|
||||||
|
<aside className="absolute left-0 top-0 flex h-full w-72 max-w-[86vw] flex-col border-r border-white/10 bg-zinc-950/98 px-4 py-5 shadow-2xl">
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-green-500">
|
||||||
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-bold text-white">音乐菜单</div>
|
||||||
|
<div className="text-xs text-zinc-500">Music Navigation</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSidebarDrawer(false)}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-zinc-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ key: 'rankings', label: '排行榜', href: '/music/rankings', icon: 'M3 4h18M8 8h13M3 12h18M8 16h13M3 20h18' },
|
||||||
|
{ key: 'search', label: '搜索', href: `/music/search?source=${currentSource}`, icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' },
|
||||||
|
{ key: 'my-playlists', label: '我的歌单', href: '/music/my-playlists', icon: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z' },
|
||||||
|
].map((item) => {
|
||||||
|
const active = item.key === 'rankings'
|
||||||
|
? pathname?.startsWith('/music/rankings') || pathname === '/music'
|
||||||
|
: pathname?.startsWith(`/music/${item.key}`);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => {
|
||||||
|
setShowSidebarDrawer(false);
|
||||||
|
router.push(item.href);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-all ${
|
||||||
|
active
|
||||||
|
? 'border-green-500/40 bg-green-500/15 text-white'
|
||||||
|
: 'border-white/5 bg-white/5 text-zinc-400 hover:bg-white/10 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d={item.icon} />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowSidebarDrawer(false);
|
||||||
|
router.push('/');
|
||||||
|
}}
|
||||||
|
className="mt-auto flex w-full items-center gap-3 rounded-xl border border-white/10 bg-white/5 px-3 py-3 text-left text-zinc-400 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">返回主页</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSourceMenu && (
|
||||||
|
<div className="md:hidden fixed inset-0 z-[90]">
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={() => setShowSourceMenu(false)}
|
||||||
|
aria-label="关闭音源菜单"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 rounded-t-3xl border-t border-white/10 bg-zinc-950/98 px-4 pb-6 pt-4 shadow-2xl">
|
||||||
|
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-white/15" />
|
||||||
|
<div className="mb-3 px-1 text-sm font-medium text-white">切换音源</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{musicSources.map((source) => {
|
||||||
|
const active = currentSource === source.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={source.key}
|
||||||
|
onClick={() => {
|
||||||
|
setShowSourceMenu(false);
|
||||||
|
if (!active) switchSource(source.key);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition-all ${
|
||||||
|
active
|
||||||
|
? 'border-green-500/50 bg-green-500/12 text-white'
|
||||||
|
: 'border-white/8 bg-white/5 text-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`flex h-9 w-9 items-center justify-center rounded-full ${active ? 'bg-green-500/20 text-green-400' : 'bg-white/8 text-zinc-400'}`}>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-medium">{source.label}</div>
|
||||||
|
</div>
|
||||||
|
{active ? (
|
||||||
|
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-white">
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-zinc-500">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add to Playlist Modal */}
|
||||||
|
<AddToPlaylistModal
|
||||||
|
song={songToAddToPlaylist}
|
||||||
|
isOpen={showAddToPlaylistModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowAddToPlaylistModal(false);
|
||||||
|
setSongToAddToPlaylist(null);
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
setToast({
|
||||||
|
message: '已添加到歌单',
|
||||||
|
type: 'success',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onError={(message) => {
|
||||||
|
setToast({
|
||||||
|
message,
|
||||||
|
type: 'error',
|
||||||
|
onClose: () => setToast(null),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Toast */}
|
||||||
|
{toast && <Toast {...toast} />}
|
||||||
|
|
||||||
|
{/* Confirm Modal */}
|
||||||
|
{confirmModal.isOpen &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className="music-theme-portal fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4"
|
||||||
|
style={{ zIndex: 99999 }}
|
||||||
|
onClick={confirmModal.onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-zinc-900 rounded-xl max-w-md w-full border border-white/10"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-white">
|
||||||
|
{confirmModal.title}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={confirmModal.onCancel}
|
||||||
|
className="text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
{confirmModal.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={confirmModal.onCancel}
|
||||||
|
disabled={deletingPlaylistId !== null}
|
||||||
|
className="px-4 py-2 bg-zinc-700 hover:bg-zinc-600 disabled:bg-zinc-800 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirmModal.onConfirm}
|
||||||
|
disabled={deletingPlaylistId !== null}
|
||||||
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-zinc-700 disabled:cursor-not-allowed text-white rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{deletingPlaylistId !== null ? (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4 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>
|
||||||
|
删除中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'确定'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PiP Lyrics Window */}
|
||||||
|
{showPiPLyrics && (
|
||||||
|
<LyricsPiPWindow
|
||||||
|
currentSong={currentSong}
|
||||||
|
lyrics={lyrics}
|
||||||
|
currentLyricIndex={currentLyricIndex}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
currentTime={currentTime}
|
||||||
|
opacity={pipOpacity}
|
||||||
|
minimized={pipMinimized}
|
||||||
|
onOpacityChange={(opacity) => {
|
||||||
|
setPipOpacity(opacity);
|
||||||
|
localStorage.setItem('lyricsPiPOpacity', opacity.toString());
|
||||||
|
}}
|
||||||
|
onMinimizedChange={(minimized) => {
|
||||||
|
setPipMinimized(minimized);
|
||||||
|
localStorage.setItem('lyricsPiPMinimized', minimized.toString());
|
||||||
|
}}
|
||||||
|
onClose={() => setShowPiPLyrics(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<style jsx global>{`
|
||||||
|
:root {
|
||||||
|
--music-bg: #f4f7fb;
|
||||||
|
--music-bg-strong: rgba(255, 255, 255, 0.96);
|
||||||
|
--music-surface: rgba(255, 255, 255, 0.88);
|
||||||
|
--music-surface-soft: rgba(241, 245, 249, 0.92);
|
||||||
|
--music-overlay: rgba(15, 23, 42, 0.45);
|
||||||
|
--music-glass: rgba(15, 23, 42, 0.06);
|
||||||
|
--music-glass-strong: rgba(15, 23, 42, 0.1);
|
||||||
|
--music-border: rgba(148, 163, 184, 0.28);
|
||||||
|
--music-text: #0f172a;
|
||||||
|
--music-text-soft: #475569;
|
||||||
|
--music-text-muted: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--music-bg: #09090b;
|
||||||
|
--music-bg-strong: rgba(9, 9, 11, 0.95);
|
||||||
|
--music-surface: rgba(24, 24, 27, 0.9);
|
||||||
|
--music-surface-soft: rgba(39, 39, 42, 0.88);
|
||||||
|
--music-overlay: rgba(0, 0, 0, 0.72);
|
||||||
|
--music-glass: rgba(255, 255, 255, 0.05);
|
||||||
|
--music-glass-strong: rgba(255, 255, 255, 0.1);
|
||||||
|
--music-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--music-text: #f8fafc;
|
||||||
|
--music-text-soft: #cbd5e1;
|
||||||
|
--music-text-muted: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-theme {
|
||||||
|
background: linear-gradient(180deg, var(--music-bg) 0%, color-mix(in srgb, var(--music-bg) 82%, #22c55e 18%) 100%);
|
||||||
|
color: var(--music-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-theme :is([class*='bg-zinc-950'], [class*='bg-zinc-900']),
|
||||||
|
.music-theme-portal :is([class*='bg-zinc-950'], [class*='bg-zinc-900']) {
|
||||||
|
background-color: var(--music-bg-strong) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-theme [class*='bg-zinc-800'],
|
||||||
|
.music-theme-portal [class*='bg-zinc-800'] {
|
||||||
|
background-color: var(--music-surface-soft) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-theme :is([class*='bg-white/5'], [class*='bg-white/6'], [class*='bg-white/8'], [class*='bg-white/10'], [class*='bg-white/12']),
|
||||||
|
.music-theme-portal :is([class*='bg-white/5'], [class*='bg-white/6'], [class*='bg-white/8'], [class*='bg-white/10'], [class*='bg-white/12']) {
|
||||||
|
background-color: var(--music-glass) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-theme [class*='bg-white/20'],
|
||||||
|
.music-theme-portal [class*='bg-white/20'] {
|
||||||
|
background-color: var(--music-glass-strong) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-theme :is([class*='bg-black/90'], [class*='bg-black/50'], [class*='bg-black/30']),
|
||||||
|
.music-theme-portal :is([class*='bg-black/90'], [class*='bg-black/50'], [class*='bg-black/30']) {
|
||||||
|
background-color: var(--music-overlay) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-theme :is([class*='border-white/'], [class*='border-zinc-']),
|
||||||
|
.music-theme-portal :is([class*='border-white/'], [class*='border-zinc-']) {
|
||||||
|
border-color: var(--music-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-theme :is([class*='text-white'], [class*='text-zinc-200']),
|
||||||
|
.music-theme-portal :is([class*='text-white'], [class*='text-zinc-200']) {
|
||||||
|
color: var(--music-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-theme :is([class*='text-zinc-300'], [class*='text-zinc-400']),
|
||||||
|
.music-theme-portal :is([class*='text-zinc-300'], [class*='text-zinc-400']) {
|
||||||
|
color: var(--music-text-soft) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-theme :is([class*='text-zinc-500'], [class*='text-zinc-600']),
|
||||||
|
.music-theme-portal :is([class*='text-zinc-500'], [class*='text-zinc-600']) {
|
||||||
|
color: var(--music-text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-theme :is([class*='from-zinc-800'], [class*='from-zinc-900']) {
|
||||||
|
--tw-gradient-from: var(--music-surface-soft) var(--tw-gradient-from-position) !important;
|
||||||
|
--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position) !important;
|
||||||
|
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-theme :is([class*='to-zinc-900'], [class*='to-zinc-800']) {
|
||||||
|
--tw-gradient-to: var(--music-bg-strong) var(--tw-gradient-to-position) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-theme input::placeholder,
|
||||||
|
.music-theme textarea::placeholder {
|
||||||
|
color: var(--music-text-muted) !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import MusicClient from './MusicClient';
|
||||||
|
|
||||||
|
export default function MusicLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <MusicClient>{children}</MusicClient>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function MusicMyPlaylistsPage() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+3
-3921
@@ -1,3923 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
import { redirect } from 'next/navigation';
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
export default function MusicIndexPage() {
|
||||||
import { useEffect, useRef, useState } from 'react';
|
redirect('/music/rankings');
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
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;
|
|
||||||
const SPECTRUM_REFERENCE_VOLUME = 10;
|
|
||||||
const SPECTRUM_MIN_VOLUME = 5;
|
|
||||||
const SPECTRUM_MAX_REFERENCE_VOLUME = 15;
|
|
||||||
|
|
||||||
function getApiErrorMessage(error: unknown, fallback: string): string {
|
|
||||||
if (typeof error === 'string') return error;
|
|
||||||
if (error && typeof error === 'object' && 'message' in error) {
|
|
||||||
const message = (error as { message?: unknown }).message;
|
|
||||||
if (typeof message === 'string' && message.trim()) return message;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MusicSource = 'wy' | 'tx' | 'kw' | 'kg' | 'mg';
|
|
||||||
type MusicQuality = '128k' | '320k' | 'flac' | 'flac24bit';
|
|
||||||
|
|
||||||
interface Song {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
artist: string;
|
|
||||||
album?: string;
|
|
||||||
pic?: string;
|
|
||||||
platform: MusicSource;
|
|
||||||
duration?: number;
|
|
||||||
durationText?: string;
|
|
||||||
songmid?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PlayRecord {
|
|
||||||
platform: MusicSource;
|
|
||||||
id: string;
|
|
||||||
playTime: number; // 播放时间(秒)
|
|
||||||
duration: number; // 总时长(秒)
|
|
||||||
timestamp: number; // 添加时间戳
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LyricLine {
|
|
||||||
time: number;
|
|
||||||
text: string;
|
|
||||||
translation?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Playlist {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
pic?: string;
|
|
||||||
source?: MusicSource;
|
|
||||||
updateFrequency?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DbRecord {
|
|
||||||
source: MusicSource;
|
|
||||||
songId: string;
|
|
||||||
id: string;
|
|
||||||
playProgressSec: number;
|
|
||||||
durationSec: number;
|
|
||||||
createdAt: number;
|
|
||||||
lastPlayedAt: number;
|
|
||||||
name: string;
|
|
||||||
artist: string;
|
|
||||||
album?: string;
|
|
||||||
cover?: string;
|
|
||||||
durationText?: string;
|
|
||||||
songmid?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = Math.max(1, Math.round(dpr));
|
|
||||||
const count = Math.max(1, Math.floor(rect.width / targetPitch));
|
|
||||||
const barWidth = Math.max(2 * dpr, (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 === count - 1 ? width - barWidth : 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 w-full overflow-hidden ${compact ? 'h-6' : 'h-8'}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full opacity-50" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const VINYL_NEEDLE_SVG = `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 130"><path d="M21,21 C21,65 86,70 86,100" fill="none" stroke="rgba(0,0,0,0.28)" stroke-width="6" stroke-linecap="round"/><path d="M20,20 C20,65 85,70 85,100" fill="none" stroke="%23e0e0e0" stroke-width="4.5" stroke-linecap="round"/><path d="M19,20 C19,65 84,70 84,100" fill="none" stroke="%23fff" stroke-width="1.5" stroke-linecap="round"/><g transform="translate(85, 100) rotate(25)"><rect x="-6" y="0" width="12" height="18" rx="2" fill="%23ccc"/><rect x="-4" y="5" width="8" height="14" rx="1" fill="%23333"/><rect x="-2" y="16" width="4" height="5" rx="1" fill="%23d43c33"/></g><circle cx="20" cy="20" r="10" fill="%23f0f0f0" stroke="%23ccc" stroke-width="1"/><circle cx="20" cy="20" r="4" fill="%23fff"/><circle cx="20" cy="20" r="1.5" fill="%23999"/></svg>')`;
|
|
||||||
|
|
||||||
function VinylTurntable({
|
|
||||||
cover,
|
|
||||||
title,
|
|
||||||
isPlaying,
|
|
||||||
className = '',
|
|
||||||
}: {
|
|
||||||
cover?: string;
|
|
||||||
title: string;
|
|
||||||
isPlaying: boolean;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`relative mx-auto mt-12 mb-5 flex h-[280px] w-[280px] items-center justify-center md:mt-16 md:mb-8 md:h-[340px] md:w-[340px] ${className}`}
|
|
||||||
aria-label="黑胶唱片机封面"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute left-1/2 top-[-54px] z-20 h-[140px] w-[108px] drop-shadow-xl transition-transform duration-500"
|
|
||||||
style={{
|
|
||||||
marginLeft: '-20px',
|
|
||||||
backgroundImage: VINYL_NEEDLE_SVG,
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
backgroundSize: 'contain',
|
|
||||||
transformOrigin: '20px 20px',
|
|
||||||
transform: isPlaying ? 'rotate(0deg)' : 'rotate(-30deg)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="relative flex h-[246px] w-[246px] items-center justify-center overflow-hidden rounded-full md:h-[300px] md:w-[300px]"
|
|
||||||
style={{
|
|
||||||
background: 'conic-gradient(from 45deg, #070707 0%, #2b2b2b 10%, #101010 20%, #080808 32%, #242424 42%, #0b0b0b 55%, #1e1e1e 68%, #050505 80%, #2c2c2c 90%, #070707 100%)',
|
|
||||||
boxShadow: '0 0 0 8px rgba(255,255,255,0.055), 0 20px 42px rgba(0,0,0,0.65), inset 0 0 28px rgba(255,255,255,0.045)',
|
|
||||||
animation: 'vinyl-spin 20s linear infinite',
|
|
||||||
animationPlayState: isPlaying ? 'running' : 'paused',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute inset-0 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: 'repeating-radial-gradient(circle, transparent 0, transparent 3px, rgba(255,255,255,0.055) 3px, rgba(255,255,255,0.055) 4px)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute left-[18%] top-[10%] h-[42%] w-[22%] rotate-[-28deg] rounded-full bg-white/10 blur-md" />
|
|
||||||
<div className="relative z-10 flex h-[158px] w-[158px] items-center justify-center overflow-hidden rounded-full border-[5px] border-black bg-zinc-800 md:h-[192px] md:w-[192px]">
|
|
||||||
{cover ? (
|
|
||||||
<img
|
|
||||||
src={cover}
|
|
||||||
alt={title}
|
|
||||||
className="h-full w-full rounded-full object-cover"
|
|
||||||
onError={(e) => {
|
|
||||||
e.currentTarget.style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<svg className="h-14 w-14 text-zinc-500 md:h-16 md:w-16" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="pointer-events-none absolute z-20 h-3 w-3 rounded-full border border-white/30 bg-zinc-950" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 扩展 Window 接口以支持 Document PiP API
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
documentPictureInPicture?: {
|
|
||||||
requestWindow: (options: { width: number; height: number }) => Promise<Window>;
|
|
||||||
window: Window | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MusicPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [currentSource, setCurrentSource] = useState<MusicSource>('wy');
|
|
||||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
|
||||||
const [songs, setSongs] = useState<Song[]>([]);
|
|
||||||
const [currentView, setCurrentView] = useState<'playlists' | 'songs' | 'myPlaylists'>('playlists');
|
|
||||||
const [currentPlaylistTitle, setCurrentPlaylistTitle] = useState('');
|
|
||||||
const [searchKeyword, setSearchKeyword] = useState('');
|
|
||||||
const [activeSearchKeyword, setActiveSearchKeyword] = useState('');
|
|
||||||
const [searchPage, setSearchPage] = useState(1);
|
|
||||||
const [searchHasMore, setSearchHasMore] = useState(false);
|
|
||||||
const [loadingMoreSearch, setLoadingMoreSearch] = useState(false);
|
|
||||||
const [currentSong, setCurrentSong] = useState<Song | null>(null);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(false);
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
const [volume, setVolume] = useState(100);
|
|
||||||
const [quality, setQuality] = useState<MusicQuality>('320k');
|
|
||||||
const [playMode, setPlayMode] = useState<'loop' | 'single' | 'random'>('loop');
|
|
||||||
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
|
|
||||||
const [showPlayer, setShowPlayer] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [showLyrics, setShowLyrics] = useState(false);
|
|
||||||
const [mobileLyricsView, setMobileLyricsView] = useState<'lyrics' | 'vinyl'>('lyrics');
|
|
||||||
const [musicProxyEnabled, setMusicProxyEnabled] = useState(() => {
|
|
||||||
if (typeof window === 'undefined') return true;
|
|
||||||
return (window as any).RUNTIME_CONFIG?.MUSIC_PROXY_ENABLED !== false;
|
|
||||||
});
|
|
||||||
const [lyrics, setLyrics] = useState<LyricLine[]>([]);
|
|
||||||
const [currentLyricIndex, setCurrentLyricIndex] = useState(-1);
|
|
||||||
const [currentSongUrl, setCurrentSongUrl] = useState('');
|
|
||||||
const [playRecords, setPlayRecords] = useState<PlayRecord[]>([]); // 播放记录(只存平台和ID)
|
|
||||||
const [playlist, setPlaylist] = useState<Song[]>([]); // 完整歌曲信息(用于显示)
|
|
||||||
const [showPlaylist, setShowPlaylist] = useState(false);
|
|
||||||
const [playlistIndex, setPlaylistIndex] = useState(-1); // 当前在播放列表中的索引
|
|
||||||
const [showQualityMenu, setShowQualityMenu] = useState(false); // 音质选择菜单
|
|
||||||
const [showSourceMenu, setShowSourceMenu] = useState(false); // 移动端音源菜单
|
|
||||||
const [showVolumeSlider, setShowVolumeSlider] = useState(false); // 音量滑块显示状态
|
|
||||||
const [pendingSongToPlay, setPendingSongToPlay] = useState<{ platform: string; id: string } | null>(null); // 待播放的歌曲信息
|
|
||||||
const [resolvingCount, setResolvingCount] = useState(0); // 当前解析中的歌曲数量
|
|
||||||
const [showAddToPlaylistModal, setShowAddToPlaylistModal] = useState(false); // 添加到歌单弹窗
|
|
||||||
const [songToAddToPlaylist, setSongToAddToPlaylist] = useState<Song | null>(null); // 要添加到歌单的歌曲
|
|
||||||
|
|
||||||
// 我的歌单相关状态
|
|
||||||
const [userPlaylists, setUserPlaylists] = useState<any[]>([]);
|
|
||||||
const [selectedUserPlaylist, setSelectedUserPlaylist] = useState<any | null>(null);
|
|
||||||
const [userPlaylistSongs, setUserPlaylistSongs] = useState<any[]>([]);
|
|
||||||
const [loadingUserPlaylists, setLoadingUserPlaylists] = useState(false);
|
|
||||||
const [loadingUserPlaylistSongs, setLoadingUserPlaylistSongs] = useState(false);
|
|
||||||
const [loadingPlayAll, setLoadingPlayAll] = useState(false); // 播放全部加载状态
|
|
||||||
const [loadingCurrentPlayAll, setLoadingCurrentPlayAll] = useState(false); // 当前排行榜/详情页播放全部加载状态
|
|
||||||
const [deletingPlaylistId, setDeletingPlaylistId] = useState<string | null>(null); // 正在删除的歌单ID
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window !== 'undefined' && !(window as any).RUNTIME_CONFIG?.MUSIC_ENABLED) {
|
|
||||||
router.replace('/');
|
|
||||||
}
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
// Toast 和 Confirm Modal 状态
|
|
||||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
|
||||||
const [confirmModal, setConfirmModal] = useState<{
|
|
||||||
isOpen: boolean;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}>({
|
|
||||||
isOpen: false,
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
onConfirm: () => {},
|
|
||||||
onCancel: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// PiP 相关状态
|
|
||||||
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 searchLoadMoreRef = 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 volumeRef = useRef(volume);
|
|
||||||
const spectrumSeedRef = useRef(Math.random() * Math.PI * 2);
|
|
||||||
const qualitySwitchRequestRef = useRef(0);
|
|
||||||
const currentSongRef = useRef<Song | null>(null);
|
|
||||||
const currentSourceRef = useRef(currentSource);
|
|
||||||
|
|
||||||
const mapSong = (song: any): Song => ({
|
|
||||||
id: song.songId || song.id,
|
|
||||||
name: song.name,
|
|
||||||
artist: song.artist,
|
|
||||||
album: song.album,
|
|
||||||
pic: song.cover || song.pic,
|
|
||||||
platform: normalizeSource(song.source || song.platform),
|
|
||||||
duration: song.durationSec || song.duration,
|
|
||||||
durationText: song.durationText || song.interval,
|
|
||||||
songmid: song.songmid,
|
|
||||||
});
|
|
||||||
|
|
||||||
const normalizeSource = (source: string | undefined): MusicSource => {
|
|
||||||
switch (source) {
|
|
||||||
case 'netease': return 'wy';
|
|
||||||
case 'qq': return 'tx';
|
|
||||||
case 'kuwo': return 'kw';
|
|
||||||
case 'wy':
|
|
||||||
case 'tx':
|
|
||||||
case 'kw':
|
|
||||||
case 'kg':
|
|
||||||
case 'mg':
|
|
||||||
return source;
|
|
||||||
default:
|
|
||||||
return 'wy';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const musicSources: Array<{ key: MusicSource; label: string }> = [
|
|
||||||
{ key: 'wy', label: '网易云' },
|
|
||||||
{ key: 'tx', label: 'QQ' },
|
|
||||||
{ key: 'kw', label: '酷我' },
|
|
||||||
{ key: 'kg', label: '酷狗' },
|
|
||||||
{ key: 'mg', label: '咪咕' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const getSourceDisplayLabel = (source?: MusicSource | string, compact = true) => {
|
|
||||||
switch (normalizeSource(source)) {
|
|
||||||
case 'wy': return compact ? '网易' : '网易云';
|
|
||||||
case 'tx': return compact ? 'QQ' : 'QQ音乐';
|
|
||||||
case 'kw': return '酷我';
|
|
||||||
case 'kg': return '酷狗';
|
|
||||||
case 'mg': return '咪咕';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const SourcePill = ({
|
|
||||||
source,
|
|
||||||
className = '',
|
|
||||||
}: {
|
|
||||||
source?: MusicSource | string;
|
|
||||||
className?: string;
|
|
||||||
}) => (
|
|
||||||
<span
|
|
||||||
className={`inline-flex shrink-0 items-center rounded-lg border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-[10px] font-medium leading-none text-red-400 ${className}`}
|
|
||||||
>
|
|
||||||
{getSourceDisplayLabel(source)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildStreamUrl = (song: Song, source: MusicSource, songQuality: MusicQuality) => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
songId: song.id,
|
|
||||||
source,
|
|
||||||
quality: songQuality,
|
|
||||||
songmid: song.songmid || song.id.split('_').slice(1).join('_'),
|
|
||||||
name: song.name,
|
|
||||||
artist: song.artist,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (song.durationText) params.set('durationText', song.durationText);
|
|
||||||
|
|
||||||
return `/api/music/v2/stream?${params.toString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMusicProxyEnabled = () => {
|
|
||||||
if (typeof window === 'undefined') return true;
|
|
||||||
return (window as any).RUNTIME_CONFIG?.MUSIC_PROXY_ENABLED !== false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchPlayData = async (
|
|
||||||
song: Song,
|
|
||||||
source: MusicSource,
|
|
||||||
songQuality: MusicQuality,
|
|
||||||
includeUrl = false
|
|
||||||
) => {
|
|
||||||
const response = await fetch('/api/music/v2/play', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
includeUrl,
|
|
||||||
song: {
|
|
||||||
songId: song.id,
|
|
||||||
source,
|
|
||||||
songmid: song.songmid,
|
|
||||||
name: song.name,
|
|
||||||
artist: song.artist,
|
|
||||||
album: song.album,
|
|
||||||
cover: song.pic,
|
|
||||||
durationSec: song.duration,
|
|
||||||
durationText: song.durationText,
|
|
||||||
},
|
|
||||||
quality: songQuality,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
const beginResolving = () => {
|
|
||||||
setResolvingCount((prev) => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const endResolving = () => {
|
|
||||||
setResolvingCount((prev) => Math.max(0, prev - 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveHistoryRecord = async (
|
|
||||||
record: PlayRecord,
|
|
||||||
song: Song,
|
|
||||||
playTime: number,
|
|
||||||
totalDuration: number,
|
|
||||||
lastPlayedAt = Date.now(),
|
|
||||||
recordQuality: MusicQuality = quality
|
|
||||||
) => {
|
|
||||||
await fetch('/api/music/v2/history', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
song: {
|
|
||||||
songId: record.id,
|
|
||||||
source: record.platform,
|
|
||||||
songmid: song.songmid,
|
|
||||||
name: song.name,
|
|
||||||
artist: song.artist,
|
|
||||||
album: song.album,
|
|
||||||
cover: song.pic,
|
|
||||||
durationSec: totalDuration || song.duration || 0,
|
|
||||||
durationText: song.durationText,
|
|
||||||
},
|
|
||||||
playProgressSec: playTime,
|
|
||||||
lastPlayedAt,
|
|
||||||
lastQuality: recordQuality,
|
|
||||||
createdAt: record.timestamp,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveHistoryRecordSafely = (
|
|
||||||
record: PlayRecord,
|
|
||||||
song: Song,
|
|
||||||
playTime = 0,
|
|
||||||
totalDuration = 0,
|
|
||||||
lastPlayedAt?: number,
|
|
||||||
recordQuality?: MusicQuality
|
|
||||||
) => {
|
|
||||||
saveHistoryRecord(record, song, playTime, totalDuration, lastPlayedAt, recordQuality).catch(err => {
|
|
||||||
console.error('保存播放记录到数据库失败:', err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 保存播放状态到 localStorage
|
|
||||||
const savePlayState = () => {
|
|
||||||
if (!currentSong) return;
|
|
||||||
|
|
||||||
const playState = {
|
|
||||||
currentSong,
|
|
||||||
currentSongIndex,
|
|
||||||
songs,
|
|
||||||
currentPlaylistTitle,
|
|
||||||
currentSource,
|
|
||||||
currentView,
|
|
||||||
quality,
|
|
||||||
playMode,
|
|
||||||
volume,
|
|
||||||
currentTime: audioRef.current?.currentTime || 0,
|
|
||||||
currentSongUrl,
|
|
||||||
lyrics,
|
|
||||||
playRecords, // 只保存播放记录(平台+ID+播放信息)
|
|
||||||
playlist, // 保存完整歌曲信息(用于显示)
|
|
||||||
playlistIndex,
|
|
||||||
};
|
|
||||||
|
|
||||||
localStorage.setItem('musicPlayState', JSON.stringify(playState));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 清空当前播放状态,并在需要时停止正在播放的音频
|
|
||||||
const clearCurrentPlaybackState = () => {
|
|
||||||
const audio = audioRef.current;
|
|
||||||
|
|
||||||
if (audio) {
|
|
||||||
audio.pause();
|
|
||||||
audio.removeAttribute('src');
|
|
||||||
audio.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsPlaying(false);
|
|
||||||
setCurrentSong(null);
|
|
||||||
setCurrentSongIndex(-1);
|
|
||||||
setCurrentSongUrl('');
|
|
||||||
setCurrentTime(0);
|
|
||||||
setDuration(0);
|
|
||||||
setLyrics([]);
|
|
||||||
setCurrentLyricIndex(-1);
|
|
||||||
setShowPlayer(false);
|
|
||||||
setShowLyrics(false);
|
|
||||||
setShowPiPLyrics(false);
|
|
||||||
setPendingSongToPlay(null);
|
|
||||||
restoredTimeRef.current = 0;
|
|
||||||
lastSaveTimeRef.current = 0;
|
|
||||||
currentTimeRef.current = 0;
|
|
||||||
|
|
||||||
localStorage.removeItem('musicPlayState');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 从 localStorage 恢复播放状态(已废弃,现在统一使用数据库)
|
|
||||||
const restorePlayState = async () => {
|
|
||||||
// 此函数已不再使用,所有状态恢复都在 initializePlayState 中完成
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMusicProxyEnabled(getMusicProxyEnabled());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 页面加载时恢复播放状态和数据库记录
|
|
||||||
useEffect(() => {
|
|
||||||
const initializePlayState = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/music/v2/history');
|
|
||||||
const history = await response.json();
|
|
||||||
const dbRecords = (history.data?.records || []) as DbRecord[];
|
|
||||||
|
|
||||||
const queueRecords = dbRecords;
|
|
||||||
|
|
||||||
const sortedRecords: PlayRecord[] = queueRecords.map((record) => ({
|
|
||||||
platform: record.source,
|
|
||||||
id: record.songId,
|
|
||||||
playTime: record.playProgressSec,
|
|
||||||
duration: record.durationSec || 0,
|
|
||||||
timestamp: record.createdAt || record.lastPlayedAt || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const sortedSongs: Song[] = queueRecords.map((record) => ({
|
|
||||||
id: record.songId,
|
|
||||||
name: record.name,
|
|
||||||
artist: record.artist,
|
|
||||||
album: record.album,
|
|
||||||
pic: record.cover,
|
|
||||||
platform: record.source,
|
|
||||||
duration: record.durationSec,
|
|
||||||
durationText: record.durationText,
|
|
||||||
songmid: record.songmid,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 2. 更新播放列表
|
|
||||||
if (sortedRecords.length > 0) {
|
|
||||||
setPlayRecords(sortedRecords);
|
|
||||||
setPlaylist(sortedSongs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 获取 localStorage 配置(只获取配置,不获取歌曲信息)
|
|
||||||
const savedPlayState = localStorage.getItem('musicPlayState');
|
|
||||||
const playState = savedPlayState ? JSON.parse(savedPlayState) : {};
|
|
||||||
|
|
||||||
// 恢复配置状态(不包括歌曲)
|
|
||||||
setSongs(playState.songs || []);
|
|
||||||
setCurrentPlaylistTitle(playState.currentPlaylistTitle || '');
|
|
||||||
setCurrentSource(normalizeSource(playState.currentSource));
|
|
||||||
setCurrentView(playState.currentView || 'playlists');
|
|
||||||
setQuality(playState.quality || '320k');
|
|
||||||
setPlayMode(playState.playMode || 'loop');
|
|
||||||
setVolume(playState.volume || 100);
|
|
||||||
|
|
||||||
// 4. 使用数据库的最新记录(歌曲和进度都从数据库获取)
|
|
||||||
if (sortedRecords.length > 0) {
|
|
||||||
const proxyEnabled = getMusicProxyEnabled();
|
|
||||||
setMusicProxyEnabled(proxyEnabled);
|
|
||||||
const latestIndex = queueRecords.reduce((bestIndex, record, index) => {
|
|
||||||
if (bestIndex < 0) return index;
|
|
||||||
return (record.lastPlayedAt || 0) > (queueRecords[bestIndex].lastPlayedAt || 0) ? index : bestIndex;
|
|
||||||
}, -1);
|
|
||||||
const activeIndex = latestIndex >= 0 ? latestIndex : 0;
|
|
||||||
const latestDbRecord = sortedRecords[activeIndex];
|
|
||||||
const latestDbSong = sortedSongs[activeIndex];
|
|
||||||
|
|
||||||
// 使用数据库的歌曲信息
|
|
||||||
setCurrentSong(latestDbSong);
|
|
||||||
setPlaylistIndex(activeIndex);
|
|
||||||
setShowPlayer(true);
|
|
||||||
|
|
||||||
// 从数据库恢复播放进度
|
|
||||||
const dbPlayTime = latestDbRecord.playTime || 0;
|
|
||||||
songStartTimeRef.current = Date.now();
|
|
||||||
|
|
||||||
const platform = latestDbSong.platform || 'kw';
|
|
||||||
const selectedQuality = playState.quality || '320k';
|
|
||||||
|
|
||||||
const restoreTime = () => {
|
|
||||||
if (audioRef.current && dbPlayTime > 0) {
|
|
||||||
audioRef.current.currentTime = dbPlayTime;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (proxyEnabled) {
|
|
||||||
const streamUrl = buildStreamUrl(latestDbSong, platform, selectedQuality);
|
|
||||||
setCurrentSongUrl(streamUrl);
|
|
||||||
|
|
||||||
if (audioRef.current) {
|
|
||||||
setIsBuffering(true);
|
|
||||||
audioRef.current.src = streamUrl;
|
|
||||||
audioRef.current.addEventListener('loadedmetadata', restoreTime, { once: true });
|
|
||||||
audioRef.current.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchPlayData(latestDbSong, platform, selectedQuality, false)
|
|
||||||
.then((data) => {
|
|
||||||
if (data.success && data.data?.lyric?.lyric) {
|
|
||||||
const parsedLyrics = parseLyric(data.data.lyric.lyric, data.data.lyric.tlyric);
|
|
||||||
setLyrics(parsedLyrics);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('加载歌词失败:', error);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const data = await fetchPlayData(latestDbSong, platform, selectedQuality, true);
|
|
||||||
if (data.success && data.data?.play?.directUrl && audioRef.current) {
|
|
||||||
setCurrentSongUrl(data.data.play.directUrl);
|
|
||||||
setIsBuffering(true);
|
|
||||||
audioRef.current.src = data.data.play.directUrl;
|
|
||||||
audioRef.current.addEventListener('loadedmetadata', restoreTime, { once: true });
|
|
||||||
audioRef.current.load();
|
|
||||||
|
|
||||||
if (data.data.lyric?.lyric) {
|
|
||||||
const parsedLyrics = parseLyric(data.data.lyric.lyric, data.data.lyric.tlyric);
|
|
||||||
setLyrics(parsedLyrics);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载播放记录失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initializePlayState();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 恢复 PiP 偏好设置
|
|
||||||
useEffect(() => {
|
|
||||||
const savedOpacity = localStorage.getItem('lyricsPiPOpacity');
|
|
||||||
const savedMinimized = localStorage.getItem('lyricsPiPMinimized');
|
|
||||||
if (savedOpacity) setPipOpacity(parseFloat(savedOpacity));
|
|
||||||
if (savedMinimized) setPipMinimized(savedMinimized === 'true');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 监听来自 PiP 窗口的消息
|
|
||||||
useEffect(() => {
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
|
||||||
switch (event.data.type) {
|
|
||||||
case 'PIP_OPACITY_CHANGE':
|
|
||||||
setPipOpacity(event.data.opacity);
|
|
||||||
localStorage.setItem('lyricsPiPOpacity', event.data.opacity.toString());
|
|
||||||
break;
|
|
||||||
case 'PIP_MINIMIZED_CHANGE':
|
|
||||||
setPipMinimized(event.data.minimized);
|
|
||||||
localStorage.setItem('lyricsPiPMinimized', event.data.minimized.toString());
|
|
||||||
break;
|
|
||||||
case 'PIP_CLOSE':
|
|
||||||
setShowPiPLyrics(false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('message', handleMessage);
|
|
||||||
return () => window.removeEventListener('message', handleMessage);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 监听播放状态变化,自动保存
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentSong) {
|
|
||||||
savePlayState();
|
|
||||||
}
|
|
||||||
}, [currentSong, currentSongIndex, songs, currentPlaylistTitle, currentSource, currentView, quality, playMode, volume, currentSongUrl, lyrics, playRecords, playlistIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
currentSongRef.current = currentSong;
|
|
||||||
}, [currentSong]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
currentSourceRef.current = currentSource;
|
|
||||||
}, [currentSource]);
|
|
||||||
|
|
||||||
// 监听 playRecords 变化,更新 playlistIndex
|
|
||||||
useEffect(() => {
|
|
||||||
if (pendingSongToPlay) {
|
|
||||||
const index = playRecords.findIndex(
|
|
||||||
r => r.platform === pendingSongToPlay.platform && r.id === pendingSongToPlay.id
|
|
||||||
);
|
|
||||||
setPlaylistIndex(index);
|
|
||||||
setPendingSongToPlay(null);
|
|
||||||
}
|
|
||||||
}, [playRecords, pendingSongToPlay]);
|
|
||||||
|
|
||||||
// 同步音量状态到 audio 元素
|
|
||||||
useEffect(() => {
|
|
||||||
volumeRef.current = volume;
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.volume = volume / 100;
|
|
||||||
}
|
|
||||||
}, [volume]);
|
|
||||||
|
|
||||||
// 加载排行榜列表
|
|
||||||
const loadPlaylists = async (source: string) => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const boardsResponse = await fetch(`/api/music/v2/discovery/boards?source=${source}`);
|
|
||||||
const boardsData = await boardsResponse.json();
|
|
||||||
|
|
||||||
if (boardsResponse.ok && boardsData.success) {
|
|
||||||
setPlaylists((boardsData.data?.list || []).map((item: any) => ({
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
source: normalizeSource(item.source || boardsData.data?.source || source),
|
|
||||||
updateFrequency: item.updateFrequency || item.description || '',
|
|
||||||
})));
|
|
||||||
} else {
|
|
||||||
console.error('加载排行榜失败:', boardsData);
|
|
||||||
setPlaylists([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载排行榜失败:', error);
|
|
||||||
setPlaylists([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载歌单详情
|
|
||||||
const loadPlaylist = async (playlistId: string, playlistName: string, playlistSource?: MusicSource) => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const source = playlistSource || currentSource;
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/music/v2/discovery/board-songs?source=${source}&boardId=${playlistId}`
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
|
||||||
setSongs((data.data?.list || []).map(mapSong));
|
|
||||||
setCurrentPlaylistTitle(playlistName);
|
|
||||||
setActiveSearchKeyword('');
|
|
||||||
setSearchPage(1);
|
|
||||||
setSearchHasMore(false);
|
|
||||||
setCurrentView('songs');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载歌单失败:', error);
|
|
||||||
setSongs([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 当前排行榜歌单:播放全部
|
|
||||||
const handlePlayAllCurrentSongs = async () => {
|
|
||||||
setLoadingCurrentPlayAll(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (songs.length === 0) {
|
|
||||||
setToast({
|
|
||||||
message: '当前歌单为空',
|
|
||||||
type: 'error',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetch('/api/music/v2/history', { method: 'DELETE' });
|
|
||||||
|
|
||||||
const baseTime = Date.now();
|
|
||||||
const recordsToAdd = songs.map((song, i) => ({
|
|
||||||
song: {
|
|
||||||
songId: song.id,
|
|
||||||
source: song.platform,
|
|
||||||
songmid: song.songmid,
|
|
||||||
name: song.name,
|
|
||||||
artist: song.artist,
|
|
||||||
album: song.album,
|
|
||||||
cover: song.pic,
|
|
||||||
durationSec: song.duration || 0,
|
|
||||||
durationText: song.durationText,
|
|
||||||
},
|
|
||||||
playProgressSec: 0,
|
|
||||||
lastPlayedAt: baseTime + i,
|
|
||||||
playCount: 1,
|
|
||||||
lastQuality: quality,
|
|
||||||
createdAt: baseTime + i,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await fetch('/api/music/v2/history', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ records: recordsToAdd }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const newRecords: PlayRecord[] = songs.map((song, i) => ({
|
|
||||||
platform: song.platform,
|
|
||||||
id: song.id,
|
|
||||||
playTime: 0,
|
|
||||||
duration: song.duration || 0,
|
|
||||||
timestamp: baseTime + i,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setPlayRecords(newRecords);
|
|
||||||
setPlaylist(songs);
|
|
||||||
setPlaylistIndex(0);
|
|
||||||
await playSong(songs[0], 0);
|
|
||||||
|
|
||||||
setToast({
|
|
||||||
message: `已开始播放 ${currentPlaylistTitle || '当前歌单'}`,
|
|
||||||
type: 'success',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('排行榜播放全部失败:', error);
|
|
||||||
setToast({
|
|
||||||
message: '播放全部失败',
|
|
||||||
type: 'error',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoadingCurrentPlayAll(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 搜索歌曲
|
|
||||||
const searchSongs = async () => {
|
|
||||||
const keyword = searchKeyword.trim();
|
|
||||||
if (!keyword) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/music/v2/search?source=${currentSource}&q=${encodeURIComponent(keyword)}&page=1&limit=20`
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
|
||||||
setSongs((data.data?.list || []).map(mapSong));
|
|
||||||
setActiveSearchKeyword(keyword);
|
|
||||||
setSearchPage(1);
|
|
||||||
setSearchHasMore(Boolean(data.data?.hasMore));
|
|
||||||
setCurrentPlaylistTitle(`搜索: ${keyword}`);
|
|
||||||
setCurrentView('songs');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('搜索失败:', error);
|
|
||||||
setSongs([]);
|
|
||||||
setActiveSearchKeyword('');
|
|
||||||
setSearchPage(1);
|
|
||||||
setSearchHasMore(false);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadMoreSearchSongs = async () => {
|
|
||||||
const keyword = activeSearchKeyword.trim();
|
|
||||||
if (!keyword || loadingMoreSearch || !searchHasMore) return;
|
|
||||||
|
|
||||||
const nextPage = searchPage + 1;
|
|
||||||
setLoadingMoreSearch(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/music/v2/search?source=${currentSource}&q=${encodeURIComponent(keyword)}&page=${nextPage}&limit=20`
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && data.success) {
|
|
||||||
const nextSongs = (data.data?.list || []).map(mapSong);
|
|
||||||
setSongs((prev) => [...prev, ...nextSongs]);
|
|
||||||
setSearchPage(nextPage);
|
|
||||||
setSearchHasMore(Boolean(data.data?.hasMore));
|
|
||||||
} else {
|
|
||||||
setToast({
|
|
||||||
message: data.error?.message || '加载更多失败',
|
|
||||||
type: 'error',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载更多搜索结果失败:', error);
|
|
||||||
setToast({
|
|
||||||
message: '加载更多失败',
|
|
||||||
type: 'error',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoadingMoreSearch(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 打开添加到歌单弹窗
|
|
||||||
const handleAddToPlaylist = (song: Song, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation(); // 阻止事件冒泡,避免触发播放
|
|
||||||
setSongToAddToPlaylist(song);
|
|
||||||
setShowAddToPlaylistModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 稍后播放:追加到当前播放列表末尾,不立即播放
|
|
||||||
const handlePlayLater = (song: Song, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (!currentSong && playlist.length === 0 && playRecords.length === 0) {
|
|
||||||
playSong(song, -1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const platform = song.platform || currentSource;
|
|
||||||
const exists = playlist.some((item) => item.id === song.id && item.platform === platform);
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
setToast({
|
|
||||||
message: '歌曲已在播放列表中',
|
|
||||||
type: 'info',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record: PlayRecord = {
|
|
||||||
platform,
|
|
||||||
id: song.id,
|
|
||||||
playTime: 0,
|
|
||||||
duration: song.duration || 0,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setPlayRecords((prev) => [...prev, record]);
|
|
||||||
setPlaylist((prev) => [...prev, { ...song, platform }]);
|
|
||||||
saveHistoryRecordSafely(record, { ...song, platform }, 0, song.duration || 0, 0);
|
|
||||||
setToast({
|
|
||||||
message: '已加入稍后播放',
|
|
||||||
type: 'success',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载用户歌单列表
|
|
||||||
const loadUserPlaylists = async () => {
|
|
||||||
try {
|
|
||||||
setLoadingUserPlaylists(true);
|
|
||||||
const response = await fetch('/api/music/v2/playlists');
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setUserPlaylists(data.data?.playlists || []);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载歌单失败:', error);
|
|
||||||
} finally {
|
|
||||||
setLoadingUserPlaylists(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载歌单中的歌曲
|
|
||||||
const loadUserPlaylistSongs = async (playlistId: string) => {
|
|
||||||
try {
|
|
||||||
setLoadingUserPlaylistSongs(true);
|
|
||||||
const response = await fetch(`/api/music/v2/playlists/${playlistId}/songs`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setUserPlaylistSongs((data.data?.songs || []).map((song: any) => ({
|
|
||||||
...song,
|
|
||||||
id: song.songId,
|
|
||||||
platform: song.source,
|
|
||||||
pic: song.cover,
|
|
||||||
duration: song.durationSec,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载歌单歌曲失败:', error);
|
|
||||||
} finally {
|
|
||||||
setLoadingUserPlaylistSongs(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 选择歌单
|
|
||||||
const handleSelectUserPlaylist = (playlist: any) => {
|
|
||||||
setSelectedUserPlaylist(playlist);
|
|
||||||
loadUserPlaylistSongs(playlist.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 播放全部歌单歌曲
|
|
||||||
const handlePlayAllPlaylist = async () => {
|
|
||||||
if (!selectedUserPlaylist || userPlaylistSongs.length === 0) {
|
|
||||||
setToast({
|
|
||||||
message: '歌单为空',
|
|
||||||
type: 'error',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingPlayAll(true);
|
|
||||||
try {
|
|
||||||
// 1. 清空所有播放历史
|
|
||||||
await fetch('/api/music/v2/history', { method: 'DELETE' });
|
|
||||||
|
|
||||||
// 2. 清空本地状态
|
|
||||||
setPlayRecords([]);
|
|
||||||
setPlaylist([]);
|
|
||||||
|
|
||||||
// 3. 批量添加歌单中的所有歌曲到播放历史
|
|
||||||
const baseTime = Date.now();
|
|
||||||
const recordsToAdd = userPlaylistSongs.map((song, i) => ({
|
|
||||||
song: {
|
|
||||||
songId: song.id,
|
|
||||||
source: song.platform,
|
|
||||||
songmid: song.songmid,
|
|
||||||
name: song.name,
|
|
||||||
artist: song.artist,
|
|
||||||
album: song.album,
|
|
||||||
cover: song.pic,
|
|
||||||
durationSec: song.duration || 0,
|
|
||||||
durationText: song.durationText,
|
|
||||||
},
|
|
||||||
playProgressSec: 0,
|
|
||||||
lastPlayedAt: baseTime + i,
|
|
||||||
playCount: 1,
|
|
||||||
lastQuality: quality,
|
|
||||||
createdAt: baseTime + i,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 一次性批量添加所有歌曲
|
|
||||||
const response = await fetch('/api/music/v2/history', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
records: recordsToAdd,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('批量添加歌曲失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 立即更新本地状态
|
|
||||||
const newRecords: PlayRecord[] = userPlaylistSongs.map((song, i) => ({
|
|
||||||
platform: song.platform,
|
|
||||||
id: song.id,
|
|
||||||
playTime: 0,
|
|
||||||
duration: song.duration || 0,
|
|
||||||
timestamp: baseTime + i,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const newPlaylist: Song[] = userPlaylistSongs.map((song) => ({
|
|
||||||
id: song.id,
|
|
||||||
name: song.name,
|
|
||||||
artist: song.artist,
|
|
||||||
album: song.album,
|
|
||||||
pic: song.pic,
|
|
||||||
platform: song.platform,
|
|
||||||
duration: song.duration,
|
|
||||||
durationText: song.durationText,
|
|
||||||
songmid: song.songmid,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setPlayRecords(newRecords);
|
|
||||||
setPlaylist(newPlaylist);
|
|
||||||
|
|
||||||
// 5. 直接播放第一首歌
|
|
||||||
if (userPlaylistSongs.length > 0) {
|
|
||||||
setPlaylistIndex(0);
|
|
||||||
await playSong(userPlaylistSongs[0], 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
setToast({
|
|
||||||
message: `已将 ${userPlaylistSongs.length} 首歌曲添加到播放列表`,
|
|
||||||
type: 'success',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('播放全部失败:', error);
|
|
||||||
setToast({
|
|
||||||
message: '播放全部失败',
|
|
||||||
type: 'error',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoadingPlayAll(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 删除歌单
|
|
||||||
const handleDeleteUserPlaylist = async (playlistId: string) => {
|
|
||||||
setConfirmModal({
|
|
||||||
isOpen: true,
|
|
||||||
title: '确认删除',
|
|
||||||
message: '确定要删除这个歌单吗?',
|
|
||||||
onConfirm: async () => {
|
|
||||||
// 先关闭确认框
|
|
||||||
setConfirmModal({
|
|
||||||
isOpen: false,
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
onConfirm: () => {},
|
|
||||||
onCancel: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 然后执行删除
|
|
||||||
setDeletingPlaylistId(playlistId);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/music/v2/playlists/${playlistId}`, { method: 'DELETE' });
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setToast({
|
|
||||||
message: '删除成功',
|
|
||||||
type: 'success',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
if (selectedUserPlaylist?.id === playlistId) {
|
|
||||||
setSelectedUserPlaylist(null);
|
|
||||||
setUserPlaylistSongs([]);
|
|
||||||
}
|
|
||||||
loadUserPlaylists();
|
|
||||||
} else {
|
|
||||||
const data = await response.json();
|
|
||||||
setToast({
|
|
||||||
message: getApiErrorMessage(data.error, '删除失败'),
|
|
||||||
type: 'error',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除歌单失败:', error);
|
|
||||||
setToast({
|
|
||||||
message: '删除歌单失败',
|
|
||||||
type: 'error',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setDeletingPlaylistId(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCancel: () => {
|
|
||||||
setConfirmModal({
|
|
||||||
isOpen: false,
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
onConfirm: () => {},
|
|
||||||
onCancel: () => {},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 从歌单中移除歌曲
|
|
||||||
const handleRemoveSongFromUserPlaylist = async (song: any) => {
|
|
||||||
if (!selectedUserPlaylist) return;
|
|
||||||
|
|
||||||
setConfirmModal({
|
|
||||||
isOpen: true,
|
|
||||||
title: '确认移除',
|
|
||||||
message: `确定要从歌单中移除 "${song.name}" 吗?`,
|
|
||||||
onConfirm: async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/music/v2/playlists/${selectedUserPlaylist.id}/songs?songId=${encodeURIComponent(song.id)}`,
|
|
||||||
{ method: 'DELETE' }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setToast({
|
|
||||||
message: '移除成功',
|
|
||||||
type: 'success',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
loadUserPlaylistSongs(selectedUserPlaylist.id);
|
|
||||||
} else {
|
|
||||||
const data = await response.json();
|
|
||||||
setToast({
|
|
||||||
message: getApiErrorMessage(data.error, '移除失败'),
|
|
||||||
type: 'error',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('移除歌曲失败:', error);
|
|
||||||
setToast({
|
|
||||||
message: '移除歌曲失败',
|
|
||||||
type: 'error',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setConfirmModal({
|
|
||||||
isOpen: false,
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
onConfirm: () => {},
|
|
||||||
onCancel: () => {},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onCancel: () => {
|
|
||||||
setConfirmModal({
|
|
||||||
isOpen: false,
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
onConfirm: () => {},
|
|
||||||
onCancel: () => {},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 播放歌曲
|
|
||||||
const playSong = async (song: Song, index: number) => {
|
|
||||||
beginResolving();
|
|
||||||
try {
|
|
||||||
// 使用歌曲自己的平台信息,如果没有则使用当前选择的平台
|
|
||||||
const platform = song.platform || currentSource;
|
|
||||||
const proxyEnabled = getMusicProxyEnabled();
|
|
||||||
setMusicProxyEnabled(proxyEnabled);
|
|
||||||
|
|
||||||
// 记录歌曲开始播放的时间
|
|
||||||
songStartTimeRef.current = Date.now();
|
|
||||||
|
|
||||||
// 先设置当前歌曲和显示播放器
|
|
||||||
setCurrentSong(song);
|
|
||||||
setCurrentSongIndex(index);
|
|
||||||
setShowPlayer(true);
|
|
||||||
setLyrics([]); // 清空旧歌词
|
|
||||||
|
|
||||||
// 添加到播放记录和播放列表。timestamp 表示入队时间,不能在再次播放时刷新,
|
|
||||||
// 否则会破坏按 createdAt/timestamp 维护的播放队列顺序。
|
|
||||||
const existingRecord = playRecords.find(r => r.platform === platform && r.id === song.id);
|
|
||||||
const record: PlayRecord = existingRecord || {
|
|
||||||
platform: platform,
|
|
||||||
id: song.id,
|
|
||||||
playTime: 0, // 初始播放时间
|
|
||||||
duration: song.duration || 0, // 将在音频加载后更新
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 设置待播放歌曲信息,用于在 playRecords 更新后找到索引
|
|
||||||
setPendingSongToPlay({ platform, id: song.id });
|
|
||||||
|
|
||||||
setPlayRecords(prev => {
|
|
||||||
const existingIndex = prev.findIndex(r => r.platform === record.platform && r.id === record.id);
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
// 记录已存在:保持原位置和原 timestamp,只补齐可能变化的时长信息。
|
|
||||||
const updated = [...prev];
|
|
||||||
updated[existingIndex] = {
|
|
||||||
...updated[existingIndex],
|
|
||||||
duration: updated[existingIndex].duration || song.duration || 0,
|
|
||||||
};
|
|
||||||
return updated;
|
|
||||||
} else {
|
|
||||||
// 新记录,添加到列表末尾
|
|
||||||
return [...prev, record];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setPlaylist(prev => {
|
|
||||||
const existingIndex = prev.findIndex(s => s.id === song.id && s.platform === platform);
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
return prev;
|
|
||||||
} else {
|
|
||||||
return [...prev, { ...song, platform }];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
saveHistoryRecordSafely(record, { ...song, platform }, 0, song.duration || 0);
|
|
||||||
|
|
||||||
if (proxyEnabled) {
|
|
||||||
const streamUrl = buildStreamUrl(song, platform, quality);
|
|
||||||
setCurrentSongUrl(streamUrl);
|
|
||||||
|
|
||||||
if (audioRef.current) {
|
|
||||||
setIsBuffering(true);
|
|
||||||
audioRef.current.src = streamUrl;
|
|
||||||
audioRef.current.load();
|
|
||||||
audioRef.current.play().catch(err => {
|
|
||||||
console.error('播放失败:', err);
|
|
||||||
setIsBuffering(false);
|
|
||||||
});
|
|
||||||
setIsPlaying(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchPlayData(song, platform, quality, false)
|
|
||||||
.then((data) => {
|
|
||||||
if (data.success) {
|
|
||||||
if (data.data.song?.cover) {
|
|
||||||
setCurrentSong({
|
|
||||||
...song,
|
|
||||||
pic: data.data.song.cover,
|
|
||||||
platform,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.data.lyric?.lyric) {
|
|
||||||
const parsedLyrics = parseLyric(data.data.lyric.lyric, data.data.lyric.tlyric);
|
|
||||||
setLyrics(parsedLyrics);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('播放信息获取失败:', data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('加载歌词失败:', error);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const data = await fetchPlayData(song, platform, quality, true);
|
|
||||||
if (data.success && data.data?.play?.directUrl) {
|
|
||||||
if (data.data.song?.cover) {
|
|
||||||
setCurrentSong({
|
|
||||||
...song,
|
|
||||||
pic: data.data.song.cover,
|
|
||||||
platform,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.data.lyric?.lyric) {
|
|
||||||
const parsedLyrics = parseLyric(data.data.lyric.lyric, data.data.lyric.tlyric);
|
|
||||||
setLyrics(parsedLyrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentSongUrl(data.data.play.directUrl);
|
|
||||||
|
|
||||||
if (audioRef.current) {
|
|
||||||
setIsBuffering(true);
|
|
||||||
audioRef.current.src = data.data.play.directUrl;
|
|
||||||
audioRef.current.load();
|
|
||||||
audioRef.current.play().catch(err => {
|
|
||||||
console.error('播放失败:', err);
|
|
||||||
setIsBuffering(false);
|
|
||||||
});
|
|
||||||
setIsPlaying(true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('播放信息获取失败:', data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('播放失败:', error);
|
|
||||||
setIsBuffering(false);
|
|
||||||
} finally {
|
|
||||||
endResolving();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 解析歌词文本
|
|
||||||
const parseLyric = (lyricText: string, tlyricText?: string): LyricLine[] => {
|
|
||||||
if (!lyricText && !tlyricText) return [];
|
|
||||||
|
|
||||||
// 匹配 [mm:ss.xx] 或 [mm:ss] 格式
|
|
||||||
const timeRegex = /\[(\d{2}):(\d{2})(?:\.(\d{2,3}))?\]/g;
|
|
||||||
const parseLyricText = (text: string) => {
|
|
||||||
const parsed = new Map<number, string>();
|
|
||||||
const lines = text.split('\n');
|
|
||||||
|
|
||||||
lines.forEach(line => {
|
|
||||||
const matches = Array.from(line.matchAll(timeRegex));
|
|
||||||
if (matches.length > 0) {
|
|
||||||
const content = line.replace(timeRegex, '').trim();
|
|
||||||
if (content) {
|
|
||||||
matches.forEach(match => {
|
|
||||||
const minutes = parseInt(match[1]);
|
|
||||||
const seconds = parseInt(match[2]);
|
|
||||||
const milliseconds = match[3] ? parseInt(match[3].padEnd(3, '0')) : 0;
|
|
||||||
const time = minutes * 60 + seconds + milliseconds / 1000;
|
|
||||||
parsed.set(time, content);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mainMap = parseLyricText(lyricText || '');
|
|
||||||
const transMap = parseLyricText(tlyricText || '');
|
|
||||||
const times = Array.from(new Set([
|
|
||||||
...Array.from(mainMap.keys()),
|
|
||||||
...Array.from(transMap.keys()),
|
|
||||||
])).sort((a, b) => a - b);
|
|
||||||
|
|
||||||
return times
|
|
||||||
.map(time => ({
|
|
||||||
time,
|
|
||||||
text: mainMap.get(time) || '',
|
|
||||||
translation: transMap.get(time) || undefined,
|
|
||||||
}))
|
|
||||||
.filter(line => line.text || line.translation);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换播放/暂停
|
|
||||||
const togglePlay = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
if (isPlaying) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
setIsPlaying(false);
|
|
||||||
// 暂停时保存状态到 localStorage 和数据库
|
|
||||||
savePlayState();
|
|
||||||
|
|
||||||
// 前5秒不保存(避免加载时的跳转触发保存)
|
|
||||||
if (Date.now() - songStartTimeRef.current < 5000) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存到数据库
|
|
||||||
if (currentSong && playlistIndex >= 0 && playRecords[playlistIndex]) {
|
|
||||||
const record = playRecords[playlistIndex];
|
|
||||||
saveHistoryRecord(record, currentSong, audioRef.current.currentTime, audioRef.current.duration || 0).catch(err => {
|
|
||||||
console.error('暂停时保存播放记录失败:', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setIsBuffering(true);
|
|
||||||
audioRef.current.play().catch(err => {
|
|
||||||
console.error('播放失败:', err);
|
|
||||||
setIsBuffering(false);
|
|
||||||
});
|
|
||||||
setIsPlaying(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 上一曲
|
|
||||||
const playPrev = () => {
|
|
||||||
// 优先从播放列表切换
|
|
||||||
if (playlist.length > 0) {
|
|
||||||
// 如果已经是第一首,循环到最后一首
|
|
||||||
const prevIndex = playlistIndex > 0 ? playlistIndex - 1 : playlist.length - 1;
|
|
||||||
setPlaylistIndex(prevIndex);
|
|
||||||
playSong(playlist[prevIndex], -1);
|
|
||||||
} else if (currentSongIndex > 0) {
|
|
||||||
playSong(songs[currentSongIndex - 1], currentSongIndex - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 下一曲
|
|
||||||
const playNext = () => {
|
|
||||||
// 优先从播放列表切换
|
|
||||||
if (playlist.length > 0) {
|
|
||||||
// 如果已经是最后一首,循环到第一首
|
|
||||||
const nextIndex = playlistIndex < playlist.length - 1 ? playlistIndex + 1 : 0;
|
|
||||||
setPlaylistIndex(nextIndex);
|
|
||||||
playSong(playlist[nextIndex], -1);
|
|
||||||
} else if (currentSongIndex < songs.length - 1) {
|
|
||||||
playSong(songs[currentSongIndex + 1], currentSongIndex + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换音质
|
|
||||||
const handleQualityChange = async (nextQuality: MusicQuality) => {
|
|
||||||
setShowQualityMenu(false);
|
|
||||||
|
|
||||||
if (nextQuality === quality) return;
|
|
||||||
|
|
||||||
const targetSong = currentSong;
|
|
||||||
const audio = audioRef.current;
|
|
||||||
const targetPlatform = targetSong?.platform || currentSource;
|
|
||||||
|
|
||||||
currentSongRef.current = targetSong;
|
|
||||||
currentSourceRef.current = currentSource;
|
|
||||||
|
|
||||||
setQuality(nextQuality);
|
|
||||||
|
|
||||||
// 没有正在播放的歌曲时,仅保存偏好;下次播放会使用新音质。
|
|
||||||
if (!targetSong || !audio) return;
|
|
||||||
|
|
||||||
const requestId = ++qualitySwitchRequestRef.current;
|
|
||||||
const targetSongKey = `${targetPlatform}:${targetSong.id}`;
|
|
||||||
const resumeTime = Number.isFinite(audio.currentTime) ? audio.currentTime : currentTimeRef.current;
|
|
||||||
const shouldResume = isPlaying || (!audio.paused && !audio.ended);
|
|
||||||
|
|
||||||
const isStillTargetSong = () => {
|
|
||||||
const activeSong = currentSongRef.current;
|
|
||||||
if (!activeSong) return false;
|
|
||||||
|
|
||||||
const activePlatform = activeSong.platform || currentSourceRef.current;
|
|
||||||
return (
|
|
||||||
requestId === qualitySwitchRequestRef.current &&
|
|
||||||
`${activePlatform}:${activeSong.id}` === targetSongKey
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
beginResolving();
|
|
||||||
try {
|
|
||||||
const proxyEnabled = getMusicProxyEnabled();
|
|
||||||
setMusicProxyEnabled(proxyEnabled);
|
|
||||||
|
|
||||||
let nextSongUrl = '';
|
|
||||||
|
|
||||||
if (proxyEnabled) {
|
|
||||||
nextSongUrl = buildStreamUrl(targetSong, targetPlatform, nextQuality);
|
|
||||||
} else {
|
|
||||||
const data = await fetchPlayData(targetSong, targetPlatform, nextQuality, true);
|
|
||||||
if (!isStillTargetSong()) return;
|
|
||||||
|
|
||||||
if (!data.success || !data.data?.play?.directUrl) {
|
|
||||||
throw new Error(data.error?.message || '获取播放地址失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
nextSongUrl = data.data.play.directUrl;
|
|
||||||
|
|
||||||
if (data.data.song?.cover) {
|
|
||||||
setCurrentSong({
|
|
||||||
...targetSong,
|
|
||||||
pic: data.data.song.cover,
|
|
||||||
platform: targetPlatform,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.data.lyric?.lyric) {
|
|
||||||
const parsedLyrics = parseLyric(data.data.lyric.lyric, data.data.lyric.tlyric);
|
|
||||||
setLyrics(parsedLyrics);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isStillTargetSong()) return;
|
|
||||||
|
|
||||||
const activeRecord =
|
|
||||||
playRecords[playlistIndex]?.platform === targetPlatform && playRecords[playlistIndex]?.id === targetSong.id
|
|
||||||
? playRecords[playlistIndex]
|
|
||||||
: playRecords.find((record) => record.platform === targetPlatform && record.id === targetSong.id);
|
|
||||||
const totalDuration =
|
|
||||||
Number.isFinite(audio.duration) && audio.duration > 0
|
|
||||||
? audio.duration
|
|
||||||
: duration || targetSong.duration || 0;
|
|
||||||
|
|
||||||
if (activeRecord) {
|
|
||||||
saveHistoryRecordSafely(
|
|
||||||
activeRecord,
|
|
||||||
{ ...targetSong, platform: targetPlatform },
|
|
||||||
resumeTime,
|
|
||||||
totalDuration,
|
|
||||||
Date.now(),
|
|
||||||
nextQuality
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentSongUrl(nextSongUrl);
|
|
||||||
setCurrentTime(resumeTime);
|
|
||||||
songStartTimeRef.current = Date.now();
|
|
||||||
restoredTimeRef.current = resumeTime;
|
|
||||||
|
|
||||||
const resumeAfterMetadata = () => {
|
|
||||||
if (!isStillTargetSong()) return;
|
|
||||||
|
|
||||||
if (resumeTime > 0) {
|
|
||||||
try {
|
|
||||||
const maxSeekTime =
|
|
||||||
Number.isFinite(audio.duration) && audio.duration > 0
|
|
||||||
? Math.max(0, audio.duration - 0.25)
|
|
||||||
: resumeTime;
|
|
||||||
const seekTime = Math.min(resumeTime, maxSeekTime);
|
|
||||||
|
|
||||||
if (Math.abs(audio.currentTime - seekTime) > 1) {
|
|
||||||
audio.currentTime = seekTime;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('切换音质后恢复播放进度失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentTime(audio.currentTime || resumeTime);
|
|
||||||
|
|
||||||
if (shouldResume) {
|
|
||||||
audio.play()
|
|
||||||
.then(() => setIsPlaying(true))
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('切换音质后播放失败:', error);
|
|
||||||
setIsPlaying(false);
|
|
||||||
setIsBuffering(false);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setIsPlaying(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
audio.pause();
|
|
||||||
setIsBuffering(true);
|
|
||||||
audio.src = nextSongUrl;
|
|
||||||
audio.addEventListener('loadedmetadata', resumeAfterMetadata, { once: true });
|
|
||||||
audio.load();
|
|
||||||
setIsPlaying(shouldResume);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('切换音质失败:', error);
|
|
||||||
setIsBuffering(false);
|
|
||||||
setToast({
|
|
||||||
message: (error as Error).message || '切换音质失败',
|
|
||||||
type: 'error',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
endResolving();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cycleQuality = () => {
|
|
||||||
const qualities: MusicQuality[] = ['128k', '320k', 'flac', 'flac24bit'];
|
|
||||||
const currentIndex = qualities.indexOf(quality);
|
|
||||||
const nextIndex = (currentIndex + 1) % qualities.length;
|
|
||||||
void handleQualityChange(qualities[nextIndex]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 清空播放记录
|
|
||||||
const handleClearPlayRecords = () => {
|
|
||||||
setConfirmModal({
|
|
||||||
isOpen: true,
|
|
||||||
title: '确认清空',
|
|
||||||
message: '确定要清空全部播放记录吗?',
|
|
||||||
onConfirm: async () => {
|
|
||||||
try {
|
|
||||||
await fetch('/api/music/v2/history', { method: 'DELETE' });
|
|
||||||
clearCurrentPlaybackState();
|
|
||||||
setPlaylist([]);
|
|
||||||
setPlayRecords([]);
|
|
||||||
setPlaylistIndex(-1);
|
|
||||||
setToast({
|
|
||||||
message: '播放记录已清空',
|
|
||||||
type: 'success',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('清空播放记录失败:', error);
|
|
||||||
setToast({
|
|
||||||
message: '清空播放记录失败',
|
|
||||||
type: 'error',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setConfirmModal({
|
|
||||||
isOpen: false,
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
onConfirm: () => {},
|
|
||||||
onCancel: () => {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCancel: () => {
|
|
||||||
setConfirmModal({
|
|
||||||
isOpen: false,
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
onConfirm: () => {},
|
|
||||||
onCancel: () => {},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换播放模式
|
|
||||||
const toggleMode = () => {
|
|
||||||
const modes: Array<'loop' | 'single' | 'random'> = ['loop', 'single', 'random'];
|
|
||||||
const currentIndex = modes.indexOf(playMode);
|
|
||||||
const nextIndex = (currentIndex + 1) % modes.length;
|
|
||||||
setPlayMode(modes[nextIndex]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 返回
|
|
||||||
const goBack = () => {
|
|
||||||
if (currentView === 'songs') {
|
|
||||||
setCurrentView('playlists');
|
|
||||||
setSongs([]);
|
|
||||||
setActiveSearchKeyword('');
|
|
||||||
setSearchPage(1);
|
|
||||||
setSearchHasMore(false);
|
|
||||||
} else if (currentView === 'myPlaylists') {
|
|
||||||
setCurrentView('playlists');
|
|
||||||
setSelectedUserPlaylist(null);
|
|
||||||
setUserPlaylistSongs([]);
|
|
||||||
} else {
|
|
||||||
router.back();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 下载歌曲
|
|
||||||
const downloadSong = () => {
|
|
||||||
if (!currentSongUrl || !currentSong) return;
|
|
||||||
|
|
||||||
// 创建一个临时的 a 标签来触发下载
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = currentSongUrl;
|
|
||||||
link.download = `${currentSong.name} - ${currentSong.artist}.mp3`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换平台
|
|
||||||
const switchSource = (source: MusicSource) => {
|
|
||||||
setCurrentSource(source);
|
|
||||||
setCurrentView('playlists');
|
|
||||||
setSongs([]);
|
|
||||||
setSearchKeyword('');
|
|
||||||
setActiveSearchKeyword('');
|
|
||||||
setSearchPage(1);
|
|
||||||
setSearchHasMore(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 音频事件监听
|
|
||||||
useEffect(() => {
|
|
||||||
const audio = audioRef.current;
|
|
||||||
if (!audio) return;
|
|
||||||
|
|
||||||
const handleTimeUpdate = () => {
|
|
||||||
setCurrentTime(audio.currentTime);
|
|
||||||
|
|
||||||
// 更新当前歌词索引
|
|
||||||
if (lyrics.length > 0) {
|
|
||||||
let index = -1;
|
|
||||||
for (let i = 0; i < lyrics.length; i++) {
|
|
||||||
if (lyrics[i].time <= audio.currentTime) {
|
|
||||||
index = i;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setCurrentLyricIndex(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每20秒保存一次播放进度和播放时间
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastSaveTimeRef.current > 20000) {
|
|
||||||
lastSaveTimeRef.current = now;
|
|
||||||
|
|
||||||
// 前5秒不保存(避免加载时的跳转触发保存)
|
|
||||||
if (Date.now() - songStartTimeRef.current < 5000) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新当前播放记录的播放时间
|
|
||||||
if (currentSong && playlistIndex >= 0) {
|
|
||||||
setPlayRecords(prev => {
|
|
||||||
const updated = [...prev];
|
|
||||||
if (updated[playlistIndex]) {
|
|
||||||
updated[playlistIndex] = {
|
|
||||||
...updated[playlistIndex],
|
|
||||||
playTime: audio.currentTime,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 保存到数据库
|
|
||||||
const record = updated[playlistIndex];
|
|
||||||
saveHistoryRecord(record, currentSong, audio.currentTime, audio.duration || 0).catch(err => {
|
|
||||||
console.error('保存播放记录到数据库失败:', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
savePlayState();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLoadedMetadata = () => {
|
|
||||||
// 恢复播放进度
|
|
||||||
if (restoredTimeRef.current > 0) {
|
|
||||||
audio.currentTime = restoredTimeRef.current;
|
|
||||||
restoredTimeRef.current = 0; // 清除标记
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBufferingStart = () => {
|
|
||||||
if (audio.src && !audio.ended) {
|
|
||||||
setIsBuffering(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBufferingEnd = () => {
|
|
||||||
setIsBuffering(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDurationChange = () => {
|
|
||||||
setDuration(audio.duration);
|
|
||||||
|
|
||||||
// 前5秒不保存(避免加载时的跳转触发保存)
|
|
||||||
if (Date.now() - songStartTimeRef.current < 5000) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新当前播放记录的总时长
|
|
||||||
if (currentSong && playlistIndex >= 0) {
|
|
||||||
setPlayRecords(prev => {
|
|
||||||
const updated = [...prev];
|
|
||||||
if (updated[playlistIndex]) {
|
|
||||||
updated[playlistIndex] = {
|
|
||||||
...updated[playlistIndex],
|
|
||||||
duration: audio.duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 保存到数据库(包含时长信息)
|
|
||||||
const record = updated[playlistIndex];
|
|
||||||
saveHistoryRecord(record, currentSong, record.playTime, audio.duration).catch(err => {
|
|
||||||
console.error('保存播放记录到数据库失败:', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleEnded = () => {
|
|
||||||
setIsBuffering(false);
|
|
||||||
if (playMode === 'single') {
|
|
||||||
audio.currentTime = 0;
|
|
||||||
audio.play();
|
|
||||||
} else if (playMode === 'random') {
|
|
||||||
// 优先从播放列表中随机选择
|
|
||||||
if (playlist.length > 0) {
|
|
||||||
const randomIndex = Math.floor(Math.random() * playlist.length);
|
|
||||||
setPlaylistIndex(randomIndex);
|
|
||||||
playSong(playlist[randomIndex], -1);
|
|
||||||
} else if (songs.length > 0) {
|
|
||||||
const randomIndex = Math.floor(Math.random() * songs.length);
|
|
||||||
playSong(songs[randomIndex], randomIndex);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
playNext();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
audio.addEventListener('timeupdate', handleTimeUpdate);
|
|
||||||
audio.addEventListener('loadstart', handleBufferingStart);
|
|
||||||
audio.addEventListener('waiting', handleBufferingStart);
|
|
||||||
audio.addEventListener('stalled', handleBufferingStart);
|
|
||||||
audio.addEventListener('canplay', handleBufferingEnd);
|
|
||||||
audio.addEventListener('canplaythrough', handleBufferingEnd);
|
|
||||||
audio.addEventListener('playing', handleBufferingEnd);
|
|
||||||
audio.addEventListener('pause', handleBufferingEnd);
|
|
||||||
audio.addEventListener('error', handleBufferingEnd);
|
|
||||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
|
||||||
audio.addEventListener('durationchange', handleDurationChange);
|
|
||||||
audio.addEventListener('ended', handleEnded);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
|
||||||
audio.removeEventListener('loadstart', handleBufferingStart);
|
|
||||||
audio.removeEventListener('waiting', handleBufferingStart);
|
|
||||||
audio.removeEventListener('stalled', handleBufferingStart);
|
|
||||||
audio.removeEventListener('canplay', handleBufferingEnd);
|
|
||||||
audio.removeEventListener('canplaythrough', handleBufferingEnd);
|
|
||||||
audio.removeEventListener('playing', handleBufferingEnd);
|
|
||||||
audio.removeEventListener('pause', handleBufferingEnd);
|
|
||||||
audio.removeEventListener('error', handleBufferingEnd);
|
|
||||||
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
|
||||||
audio.removeEventListener('durationchange', handleDurationChange);
|
|
||||||
audio.removeEventListener('ended', handleEnded);
|
|
||||||
};
|
|
||||||
}, [playMode, songs, currentSongIndex, lyrics, currentSong, playlistIndex, playRecords, quality]);
|
|
||||||
|
|
||||||
// 初始加载
|
|
||||||
useEffect(() => {
|
|
||||||
loadPlaylists(currentSource);
|
|
||||||
}, [currentSource]);
|
|
||||||
|
|
||||||
// 当切换到我的歌单视图时加载歌单列表
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentView === 'myPlaylists') {
|
|
||||||
loadUserPlaylists();
|
|
||||||
}
|
|
||||||
}, [currentView]);
|
|
||||||
|
|
||||||
// 歌词自动滚动
|
|
||||||
useEffect(() => {
|
|
||||||
if (lyricsContainerRef.current && currentLyricIndex >= 0) {
|
|
||||||
const container = lyricsContainerRef.current;
|
|
||||||
const activeLine = container.querySelector(`[data-index="${currentLyricIndex}"]`);
|
|
||||||
if (activeLine) {
|
|
||||||
activeLine.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'center',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [currentLyricIndex]);
|
|
||||||
|
|
||||||
// 搜索框回车
|
|
||||||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
searchSongs();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const sentinel = searchLoadMoreRef.current;
|
|
||||||
if (!sentinel || !activeSearchKeyword || !searchHasMore) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (entries[0]?.isIntersecting) {
|
|
||||||
loadMoreSearchSongs();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
root: null,
|
|
||||||
rootMargin: '240px 0px 240px 0px',
|
|
||||||
threshold: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(sentinel);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [activeSearchKeyword, searchHasMore, loadingMoreSearch, searchPage]);
|
|
||||||
|
|
||||||
// 进度条拖动
|
|
||||||
const handleProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const newTime = (parseFloat(e.target.value) / 100) * duration;
|
|
||||||
setCurrentTime(newTime);
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = newTime;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 音量调节
|
|
||||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const newVolume = parseInt(e.target.value);
|
|
||||||
setVolume(newVolume);
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.volume = newVolume / 100;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 触摸/鼠标滑动音量调节(移动端兼容)
|
|
||||||
const handleVolumeSliderInteraction = (e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const slider = e.currentTarget;
|
|
||||||
const rect = slider.getBoundingClientRect();
|
|
||||||
|
|
||||||
const updateVolume = (clientY: number) => {
|
|
||||||
// 计算相对于滑块顶部的位置
|
|
||||||
const y = clientY - rect.top;
|
|
||||||
// 限制在滑块范围内
|
|
||||||
const clampedY = Math.max(0, Math.min(rect.height, y));
|
|
||||||
// 从上到下:0% -> 100%,从下到上:100% -> 0%
|
|
||||||
const percentage = 100 - (clampedY / rect.height) * 100;
|
|
||||||
const newVolume = Math.round(percentage);
|
|
||||||
|
|
||||||
setVolume(newVolume);
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.volume = newVolume / 100;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取初始触摸/点击位置
|
|
||||||
const clientY = 'touches' in e ? e.touches[0]?.clientY || 0 : e.clientY;
|
|
||||||
updateVolume(clientY);
|
|
||||||
|
|
||||||
const handleMove = (moveEvent: MouseEvent | TouchEvent) => {
|
|
||||||
moveEvent.preventDefault();
|
|
||||||
const moveClientY = 'touches' in moveEvent ? moveEvent.touches[0]?.clientY || 0 : moveEvent.clientY;
|
|
||||||
updateVolume(moveClientY);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnd = () => {
|
|
||||||
document.removeEventListener('mousemove', handleMove);
|
|
||||||
document.removeEventListener('mouseup', handleEnd);
|
|
||||||
document.removeEventListener('touchmove', handleMove);
|
|
||||||
document.removeEventListener('touchend', handleEnd);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMove);
|
|
||||||
document.addEventListener('mouseup', handleEnd);
|
|
||||||
document.addEventListener('touchmove', handleMove, { passive: false });
|
|
||||||
document.addEventListener('touchend', handleEnd);
|
|
||||||
};
|
|
||||||
|
|
||||||
// PiP 窗口管理
|
|
||||||
const togglePiPLyrics = () => {
|
|
||||||
if (!('documentPictureInPicture' in window)) {
|
|
||||||
setToast({
|
|
||||||
message: '您的浏览器不支持画中画功能,请使用 Chrome 116+ 版本',
|
|
||||||
type: 'error',
|
|
||||||
});
|
|
||||||
// 降级方案:打开全屏歌词
|
|
||||||
setShowLyrics(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentSong) {
|
|
||||||
setToast({
|
|
||||||
message: '请先播放歌曲',
|
|
||||||
type: 'info',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowPiPLyrics(!showPiPLyrics);
|
|
||||||
};
|
|
||||||
|
|
||||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
||||||
const showStreamBuffering = Boolean(currentSong && isBuffering);
|
|
||||||
|
|
||||||
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 '标准';
|
|
||||||
case '320k': return 'HQ';
|
|
||||||
case 'flac': return 'SQ';
|
|
||||||
case 'flac24bit': return 'HR';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSourceLabel = () => {
|
|
||||||
switch (currentSource) {
|
|
||||||
case 'wy': return '网易云';
|
|
||||||
case 'tx': return 'QQ音乐';
|
|
||||||
case 'kw': return '酷我';
|
|
||||||
case 'kg': return '酷狗';
|
|
||||||
case 'mg': return '咪咕';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
|
||||||
if (isNaN(seconds) || seconds === 0) return '0:00';
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
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));
|
|
||||||
const visualVolume = Math.max(SPECTRUM_MIN_VOLUME, volumeRef.current || SPECTRUM_REFERENCE_VOLUME);
|
|
||||||
const visualVolumeScale =
|
|
||||||
visualVolume > SPECTRUM_MAX_REFERENCE_VOLUME
|
|
||||||
? Math.sqrt(SPECTRUM_MAX_REFERENCE_VOLUME / visualVolume)
|
|
||||||
: SPECTRUM_REFERENCE_VOLUME / visualVolume;
|
|
||||||
|
|
||||||
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)) * visualVolumeScale;
|
|
||||||
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">
|
|
||||||
<>
|
|
||||||
<style jsx global>{`
|
|
||||||
@keyframes vinyl-spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
.vinyl-player {
|
|
||||||
position: relative;
|
|
||||||
width: min(72vw, 300px);
|
|
||||||
height: min(72vw, 300px);
|
|
||||||
margin: 42px auto 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.vinyl-player {
|
|
||||||
width: clamp(260px, 30vw, 360px);
|
|
||||||
height: clamp(260px, 30vw, 360px);
|
|
||||||
margin: 54px auto 28px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.vinyl-needle {
|
|
||||||
position: absolute;
|
|
||||||
top: -50px;
|
|
||||||
left: 50%;
|
|
||||||
margin-left: -20px;
|
|
||||||
width: 108px;
|
|
||||||
height: 140px;
|
|
||||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 130"><path d="M21,21 C21,65 86,70 86,100" fill="none" stroke="rgba(0,0,0,0.28)" stroke-width="6" stroke-linecap="round"/><path d="M20,20 C20,65 85,70 85,100" fill="none" stroke="%23e0e0e0" stroke-width="4.5" stroke-linecap="round"/><path d="M19,20 C19,65 84,70 84,100" fill="none" stroke="%23fff" stroke-width="1.5" stroke-linecap="round"/><g transform="translate(85, 100) rotate(25)"><rect x="-6" y="0" width="12" height="18" rx="2" fill="%23ccc"/><rect x="-4" y="5" width="8" height="14" rx="1" fill="%23333"/><rect x="-2" y="16" width="4" height="5" rx="1" fill="%23d43c33"/></g><circle cx="20" cy="20" r="10" fill="%23f0f0f0" stroke="%23ccc" stroke-width="1"/><circle cx="20" cy="20" r="4" fill="%23fff"/><circle cx="20" cy="20" r="1.5" fill="%23999"/></svg>');
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: contain;
|
|
||||||
transform-origin: 20px 20px;
|
|
||||||
transform: rotate(-30deg);
|
|
||||||
transition: transform 0.5s cubic-bezier(0.3, 0, 0.1, 1);
|
|
||||||
z-index: 5;
|
|
||||||
pointer-events: none;
|
|
||||||
filter: drop-shadow(0 8px 8px rgba(0,0,0,0.35));
|
|
||||||
}
|
|
||||||
.vinyl-player.playing .vinyl-needle {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
.vinyl-disc {
|
|
||||||
position: relative;
|
|
||||||
width: 88%;
|
|
||||||
height: 88%;
|
|
||||||
border-radius: 9999px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: conic-gradient(from 45deg, #080808 0%, #2a2a2a 10%, #0f0f0f 20%, #111 40%, #303030 50%, #111 60%, #090909 80%, #2a2a2a 90%, #080808 100%);
|
|
||||||
box-shadow: 0 0 0 8px rgba(255,255,255,0.045), 0 18px 36px rgba(0,0,0,0.55), inset 0 0 24px rgba(255,255,255,0.04);
|
|
||||||
animation: vinyl-spin 20s linear infinite;
|
|
||||||
animation-play-state: paused;
|
|
||||||
}
|
|
||||||
.vinyl-player.playing .vinyl-disc {
|
|
||||||
animation-play-state: running;
|
|
||||||
}
|
|
||||||
.vinyl-disc::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
border-radius: inherit;
|
|
||||||
background: repeating-radial-gradient(transparent 0, transparent 3px, rgba(255,255,255,0.045) 3px, rgba(255,255,255,0.045) 4px), radial-gradient(circle at 35% 25%, rgba(255,255,255,0.14), transparent 28%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.vinyl-disc::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: #111;
|
|
||||||
border: 2px solid rgba(255,255,255,0.25);
|
|
||||||
z-index: 3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.vinyl-cover {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
width: 64%;
|
|
||||||
height: 64%;
|
|
||||||
border-radius: 9999px;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 5px solid #0a0a0a;
|
|
||||||
user-select: none;
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.06);
|
|
||||||
}
|
|
||||||
.vinyl-cover-fallback {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #27272a;
|
|
||||||
}
|
|
||||||
@keyframes music-note-bounce {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-8px);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes music-buffer-scan {
|
|
||||||
0% {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(200%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes music-buffer-stripes {
|
|
||||||
0% {
|
|
||||||
background-position: 0 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 24px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.music-buffer-track {
|
|
||||||
background-image: repeating-linear-gradient(
|
|
||||||
115deg,
|
|
||||||
rgba(16, 185, 129, 0.08) 0,
|
|
||||||
rgba(16, 185, 129, 0.08) 6px,
|
|
||||||
rgba(110, 231, 183, 0.28) 6px,
|
|
||||||
rgba(110, 231, 183, 0.28) 12px
|
|
||||||
);
|
|
||||||
background-size: 24px 100%;
|
|
||||||
animation: music-buffer-stripes 0.65s linear infinite;
|
|
||||||
}
|
|
||||||
.music-buffer-track::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 42%;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent,
|
|
||||||
rgba(167, 243, 208, 0.95),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
filter: drop-shadow(0 0 8px rgba(52, 211, 153, 0.7));
|
|
||||||
animation: music-buffer-scan 0.95s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<style jsx global>{`
|
|
||||||
@keyframes music-buffer-scan-global {
|
|
||||||
0% {
|
|
||||||
transform: translateX(-120%);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(260%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes music-buffer-stripes-global {
|
|
||||||
0% {
|
|
||||||
background-position: 0 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 28px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.music-buffer-track-global {
|
|
||||||
background-image: repeating-linear-gradient(
|
|
||||||
115deg,
|
|
||||||
rgba(16, 185, 129, 0.1) 0,
|
|
||||||
rgba(16, 185, 129, 0.1) 7px,
|
|
||||||
rgba(110, 231, 183, 0.34) 7px,
|
|
||||||
rgba(110, 231, 183, 0.34) 14px
|
|
||||||
);
|
|
||||||
background-size: 28px 100%;
|
|
||||||
animation: music-buffer-stripes-global 0.55s linear infinite;
|
|
||||||
}
|
|
||||||
.music-buffer-track-global::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 45%;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent,
|
|
||||||
rgba(236, 253, 245, 0.95),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
box-shadow: 0 0 12px rgba(52, 211, 153, 0.9);
|
|
||||||
animation: music-buffer-scan-global 0.85s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
{resolvingCount > 0 && (
|
|
||||||
<div className="fixed top-4 right-4 z-[80] pointer-events-none">
|
|
||||||
<div className="relative w-16 h-16 md:w-20 md:h-20">
|
|
||||||
<div className="absolute inset-0 rounded-full border-4 border-white/10" />
|
|
||||||
<div className="absolute inset-0 rounded-full border-4 border-transparent border-t-green-500 border-r-emerald-400 animate-spin shadow-[0_0_20px_rgba(34,197,94,0.35)]" />
|
|
||||||
<div className="absolute inset-1 rounded-full bg-zinc-950/90 backdrop-blur-md border border-white/10 flex flex-col items-center justify-center">
|
|
||||||
<div className="text-[10px] md:text-xs text-zinc-400 leading-none mb-1">解析中</div>
|
|
||||||
<div className="text-lg md:text-xl font-bold text-white leading-none">{resolvingCount}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Header */}
|
|
||||||
<header className="fixed top-0 left-0 right-0 z-40 bg-zinc-950/95 backdrop-blur-md border-b border-white/10 px-4 md:px-6">
|
|
||||||
<div className="w-full mx-auto flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-4 py-3">
|
|
||||||
<div className="flex items-center justify-between md:justify-start md:gap-6 w-full md:w-auto">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => router.push('/')}
|
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center bg-white/10 hover:bg-white/20 text-white transition-colors"
|
|
||||||
title="返回首页"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div className="w-8 h-8 rounded-full flex items-center justify-center bg-white/10 text-green-500">
|
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="font-bold text-lg text-white">音乐</span>
|
|
||||||
</div>
|
|
||||||
<div className="md:hidden relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowSourceMenu(true)}
|
|
||||||
className="relative h-10 min-w-[132px] rounded-full border border-white/10 bg-gradient-to-r from-white/8 to-white/4 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] px-3"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-between px-3">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<div className="w-6 h-6 rounded-full bg-green-500/15 text-green-400 flex items-center justify-center shrink-0">
|
|
||||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-[9px] uppercase tracking-[0.18em] text-zinc-500 leading-none">音源</div>
|
|
||||||
<div className="text-sm font-medium text-white leading-tight truncate">
|
|
||||||
{musicSources.find((source) => source.key === currentSource)?.label || '酷我'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-7 h-7 rounded-full bg-white/6 border border-white/8 flex items-center justify-center text-zinc-300 shrink-0">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="hidden md:flex flex-wrap bg-white/5 rounded-lg p-1 gap-1 border border-white/5">
|
|
||||||
{musicSources.map((source) => (
|
|
||||||
<button
|
|
||||||
key={source.key}
|
|
||||||
onClick={() => switchSource(source.key)}
|
|
||||||
className={`px-3 py-1 md:px-4 rounded text-[10px] font-bold tracking-wider transition-all ${
|
|
||||||
currentSource === source.key
|
|
||||||
? 'bg-green-500 text-white border border-white/30 shadow-lg shadow-green-500/50'
|
|
||||||
: 'text-zinc-400 border border-transparent'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{source.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center w-full md:flex-1 md:max-w-md md:ml-auto h-10 md:h-9 gap-2">
|
|
||||||
{(currentView === 'songs' || currentView === 'myPlaylists') && (
|
|
||||||
<button
|
|
||||||
onClick={goBack}
|
|
||||||
className="w-10 h-full rounded-lg bg-white/10 hover:bg-white/20 flex items-center justify-center text-white border border-white/10"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="relative group w-full h-full rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
|
|
||||||
<svg className="w-4 h-4 text-zinc-500 transition-colors group-focus-within:text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchKeyword}
|
|
||||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
|
||||||
onKeyDown={handleSearchKeyDown}
|
|
||||||
className="w-full h-full appearance-none border-0 bg-transparent pl-9 pr-4 text-sm text-white outline-none focus:outline-none focus:ring-0 font-mono placeholder:text-zinc-500"
|
|
||||||
placeholder="搜索歌曲或艺术家..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentView('myPlaylists')}
|
|
||||||
className="w-10 h-full rounded-lg bg-white/10 hover:bg-white/20 flex items-center justify-center text-white border border-white/10 shrink-0"
|
|
||||||
title="我的歌单"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="pt-[136px] md:pt-[108px] pb-32 px-4 md:px-6">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
{loading && (
|
|
||||||
<MusicLoadingIndicator className="py-8" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Playlists View */}
|
|
||||||
{currentView === 'playlists' && !loading && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-2">
|
|
||||||
<h2 className="text-xs font-mono text-white/50 tracking-widest">排行榜</h2>
|
|
||||||
<SourcePill source={currentSource} />
|
|
||||||
</div>
|
|
||||||
{playlists.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{playlists.map((playlist, index) => (
|
|
||||||
<button
|
|
||||||
key={playlist.id}
|
|
||||||
onClick={() => loadPlaylist(playlist.id, playlist.name, playlist.source)}
|
|
||||||
className="w-full text-left rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 transition-colors px-4 py-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-8 text-sm text-zinc-500 dark:text-zinc-300 font-mono shrink-0">
|
|
||||||
{String(index + 1).padStart(2, '0')}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium text-white/90 truncate">{playlist.name}</div>
|
|
||||||
{playlist.updateFrequency ? (
|
|
||||||
<div className="text-xs text-zinc-500 mt-1 truncate">{playlist.updateFrequency}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<SourcePill source={playlist.source || currentSource} />
|
|
||||||
<div className="text-zinc-500 shrink-0">
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-xl border border-white/10 bg-white/5 p-6 text-center text-zinc-400">
|
|
||||||
<div className="text-base font-medium text-white/80 mb-2">当前音源暂无排行榜</div>
|
|
||||||
<div className="text-sm text-zinc-500">
|
|
||||||
你可以切换其它音源,或使用上方搜索继续找歌。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Songs View */}
|
|
||||||
{currentView === 'songs' && !loading && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-2">
|
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
|
||||||
<h2 className="text-xl font-bold text-white/80 tracking-tight truncate max-w-md">
|
|
||||||
{currentPlaylistTitle}
|
|
||||||
</h2>
|
|
||||||
{songs[0]?.platform ? <SourcePill source={songs[0].platform} /> : null}
|
|
||||||
<span className="text-[10px] font-bold bg-white/10 px-2 py-0.5 rounded text-white shrink-0">
|
|
||||||
{songs.length} 首歌曲
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handlePlayAllCurrentSongs}
|
|
||||||
disabled={songs.length === 0 || loadingCurrentPlayAll}
|
|
||||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-zinc-700 disabled:cursor-not-allowed rounded-lg transition-colors flex items-center gap-2 text-sm text-white shrink-0"
|
|
||||||
>
|
|
||||||
{loadingCurrentPlayAll ? (
|
|
||||||
<MusicLoadingIndicator size="sm" className="gap-2 text-white" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M8 5v14l11-7z" />
|
|
||||||
</svg>
|
|
||||||
播放全部
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{songs.map((song, index) => (
|
|
||||||
<div
|
|
||||||
key={`${song.id}-${index}`}
|
|
||||||
className={`grid grid-cols-[40px_1fr_auto_auto] md:grid-cols-[50px_2fr_1fr_auto_auto] gap-2 px-3 py-3 rounded-lg cursor-pointer transition-all ${
|
|
||||||
currentSongIndex === index
|
|
||||||
? 'bg-white/12 border-l-2 border-green-500'
|
|
||||||
: 'hover:bg-white/5'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-center text-zinc-500 dark:text-zinc-300 text-sm col-span-1"
|
|
||||||
onClick={() => playSong(song, index)}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="min-w-0 col-span-1"
|
|
||||||
onClick={() => playSong(song, index)}
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium text-white truncate">{song.name}</div>
|
|
||||||
<div className="text-xs text-zinc-500 truncate md:hidden">{song.artist}</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="hidden md:block text-sm text-zinc-400 truncate col-span-1"
|
|
||||||
onClick={() => playSong(song, index)}
|
|
||||||
>
|
|
||||||
{song.artist}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="col-span-1 flex items-center"
|
|
||||||
onClick={() => playSong(song, index)}
|
|
||||||
>
|
|
||||||
<SourcePill source={song.platform} />
|
|
||||||
</div>
|
|
||||||
<div className="col-span-1 flex flex-col items-center justify-center gap-0.5 leading-none">
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleAddToPlaylist(song, e)}
|
|
||||||
className="text-zinc-500 hover:text-red-500 transition-colors p-0.5"
|
|
||||||
title="添加到歌单"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => handlePlayLater(song, e)}
|
|
||||||
className="text-zinc-500 hover:text-green-500 transition-colors p-0.5"
|
|
||||||
title="稍后播放"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-9-9 9 9 0 019 9z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{activeSearchKeyword && (
|
|
||||||
<div ref={searchLoadMoreRef} className="mt-6 flex min-h-10 justify-center">
|
|
||||||
{searchHasMore ? (
|
|
||||||
loadingMoreSearch ? (
|
|
||||||
<div className="rounded-lg border border-white/10 bg-white/5 px-5 py-2.5">
|
|
||||||
<MusicLoadingIndicator size="sm" className="gap-2 text-white" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-zinc-500">
|
|
||||||
继续向下滚动加载更多
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : songs.length > 0 ? (
|
|
||||||
<div className="text-xs text-zinc-500">没有更多搜索结果了</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* My Playlists View */}
|
|
||||||
{currentView === 'myPlaylists' && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
{/* Playlists List */}
|
|
||||||
<div className="md:col-span-1">
|
|
||||||
<div className="bg-zinc-800/50 rounded-xl p-4 border border-white/10">
|
|
||||||
<h2 className="text-lg font-bold mb-4">歌单列表</h2>
|
|
||||||
{loadingUserPlaylists ? (
|
|
||||||
<MusicLoadingIndicator className="py-8" />
|
|
||||||
) : userPlaylists.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-zinc-400">
|
|
||||||
还没有歌单
|
|
||||||
<br />
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentView('playlists')}
|
|
||||||
className="mt-4 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
去添加歌曲
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{userPlaylists.map((playlist) => (
|
|
||||||
<div
|
|
||||||
key={playlist.id}
|
|
||||||
className={`p-3 rounded-lg cursor-pointer transition-colors ${
|
|
||||||
selectedUserPlaylist?.id === playlist.id
|
|
||||||
? 'bg-green-600/20 border border-green-500'
|
|
||||||
: 'bg-white/5 hover:bg-white/10'
|
|
||||||
}`}
|
|
||||||
onClick={() => handleSelectUserPlaylist(playlist)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{playlist.cover ? (
|
|
||||||
<img
|
|
||||||
src={playlist.cover}
|
|
||||||
alt={playlist.name}
|
|
||||||
className="w-12 h-12 rounded object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-12 h-12 rounded bg-zinc-700 flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-medium truncate">{playlist.name}</div>
|
|
||||||
{playlist.description && (
|
|
||||||
<div className="text-xs text-zinc-500 truncate">{playlist.description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Playlist Songs */}
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
{selectedUserPlaylist ? (
|
|
||||||
<div className="bg-zinc-800/50 rounded-xl p-4 border border-white/10">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-bold">{selectedUserPlaylist.name}</h2>
|
|
||||||
{selectedUserPlaylist.description && (
|
|
||||||
<p className="text-sm text-zinc-400 mt-1">{selectedUserPlaylist.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handlePlayAllPlaylist}
|
|
||||||
disabled={userPlaylistSongs.length === 0 || loadingPlayAll}
|
|
||||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-zinc-700 disabled:cursor-not-allowed rounded-lg transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{loadingPlayAll ? (
|
|
||||||
<MusicLoadingIndicator size="sm" className="gap-2 text-white" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M8 5v14l11-7z" />
|
|
||||||
</svg>
|
|
||||||
播放全部
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteUserPlaylist(selectedUserPlaylist.id)}
|
|
||||||
disabled={deletingPlaylistId !== null}
|
|
||||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-zinc-700 disabled:cursor-not-allowed rounded-lg transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{deletingPlaylistId === selectedUserPlaylist.id ? (
|
|
||||||
<>
|
|
||||||
<svg className="w-5 h-5 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>
|
|
||||||
删除中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'删除歌单'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadingUserPlaylistSongs ? (
|
|
||||||
<MusicLoadingIndicator className="py-8" />
|
|
||||||
) : userPlaylistSongs.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-zinc-400">歌单为空</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{userPlaylistSongs.map((song, index) => (
|
|
||||||
<div
|
|
||||||
key={`${song.platform}+${song.id}`}
|
|
||||||
className="flex items-center gap-2 p-2.5 md:gap-3 md:p-3 rounded-lg bg-white/5 hover:bg-white/10 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="text-zinc-500 dark:text-zinc-300 text-xs md:text-sm w-6 md:w-8 text-center shrink-0">{index + 1}</div>
|
|
||||||
{song.pic && (
|
|
||||||
<img
|
|
||||||
src={song.pic}
|
|
||||||
alt={song.name}
|
|
||||||
className="w-10 h-10 md:w-12 md:h-12 rounded object-cover shrink-0"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<div className="font-medium truncate">{song.name}</div>
|
|
||||||
<SourcePill source={song.platform || song.source} />
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-zinc-400 truncate">{song.artist}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => playSong(song, index)}
|
|
||||||
className="text-zinc-500 hover:text-green-500 transition-colors p-1 md:p-2 shrink-0"
|
|
||||||
title="播放"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M8 5v14l11-7z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveSongFromUserPlaylist(song)}
|
|
||||||
className="text-zinc-500 hover:text-red-500 transition-colors p-1 md:p-2 shrink-0"
|
|
||||||
title="移除"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-zinc-800/50 rounded-xl p-4 border border-white/10 h-full flex items-center justify-center">
|
|
||||||
<div className="text-center text-zinc-400">
|
|
||||||
<svg className="w-16 h-16 mx-auto mb-4 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
|
||||||
</svg>
|
|
||||||
<p>选择一个歌单查看详情</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Player */}
|
|
||||||
{showPlayer && currentSong && (
|
|
||||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 w-[95%] max-w-3xl z-50">
|
|
||||||
{showSpectrum && (
|
|
||||||
<div className="pointer-events-none px-4">
|
|
||||||
<AudioSpectrumCanvas bars={spectrumBars} compact />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative bg-zinc-900/95 backdrop-blur-md rounded-xl p-4 pt-5 border border-white/10 shadow-2xl">
|
|
||||||
{/* Progress Bar */}
|
|
||||||
<div className="absolute left-0 right-0 top-0 h-1 bg-white/10 rounded-t-xl overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="relative z-10 h-full bg-green-500 transition-all pointer-events-none"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
{showStreamBuffering && (
|
|
||||||
<div className="music-buffer-track-global absolute inset-0 z-0" />
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
value={progress}
|
|
||||||
onChange={handleProgressChange}
|
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4 mt-1">
|
|
||||||
{/* Song Info */}
|
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
||||||
<div
|
|
||||||
className="w-12 h-12 rounded-lg bg-zinc-800 overflow-hidden shrink-0 flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
|
|
||||||
onClick={() => setShowLyrics(true)}
|
|
||||||
>
|
|
||||||
{currentSong.pic ? (
|
|
||||||
<img
|
|
||||||
src={currentSong.pic}
|
|
||||||
alt={currentSong.name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
onError={(e) => {
|
|
||||||
// 图片加载失败时显示默认图标
|
|
||||||
e.currentTarget.style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<svg className="w-6 h-6 text-zinc-600" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
|
||||||
<div className="min-w-0 truncate text-sm font-bold text-white">{currentSong.name}</div>
|
|
||||||
<SourcePill source={currentSong.platform} className="hidden sm:inline-flex" />
|
|
||||||
{showStreamBuffering && (
|
|
||||||
<span className="hidden sm:inline-flex items-center gap-1 rounded-full bg-green-500/15 px-2 py-0.5 text-[10px] font-medium text-green-300">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-green-400 animate-pulse" />
|
|
||||||
缓冲中
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-zinc-500 truncate">{currentSong.artist}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button onClick={playPrev} className="text-zinc-500 hover:text-white transition-colors">
|
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={togglePlay}
|
|
||||||
className="w-10 h-10 rounded-full bg-green-500 text-white flex items-center justify-center hover:bg-green-600 transition-colors"
|
|
||||||
>
|
|
||||||
{isPlaying ? (
|
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M8 5v14l11-7z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button onClick={playNext} className="text-zinc-500 hover:text-white transition-colors">
|
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Controls */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="hidden sm:flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
value={volume}
|
|
||||||
onChange={handleVolumeChange}
|
|
||||||
className="w-16 h-1 bg-white/10 rounded-full appearance-none cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (currentSong) {
|
|
||||||
setSongToAddToPlaylist(currentSong);
|
|
||||||
setShowAddToPlaylistModal(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-zinc-500 hover:text-red-500 transition-colors"
|
|
||||||
title="添加到歌单"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={downloadSong}
|
|
||||||
className="text-zinc-500 hover:text-white transition-colors"
|
|
||||||
title="下载歌曲"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={toggleMode}
|
|
||||||
className="text-zinc-500 hover:text-white transition-colors"
|
|
||||||
title={playMode === 'loop' ? '列表循环' : playMode === 'single' ? '单曲循环' : '随机播放'}
|
|
||||||
>
|
|
||||||
{playMode === 'loop' && (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{playMode === 'single' && (
|
|
||||||
<div className="relative w-4 h-4">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
<span className="absolute inset-0 flex items-center justify-center text-[8px] font-bold">1</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{playMode === 'random' && (
|
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Audio Element */}
|
|
||||||
<audio ref={audioRef} className="hidden" />
|
|
||||||
|
|
||||||
{/* Lyrics Modal */}
|
|
||||||
{showLyrics && currentSong && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black/90 backdrop-blur-sm z-[100] flex items-center justify-center p-4"
|
|
||||||
onClick={(e) => {
|
|
||||||
// 点击背景关闭音量条
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
setShowVolumeSlider(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative w-full max-w-6xl h-[90vh] bg-zinc-900/95 rounded-2xl overflow-hidden border border-white/10 shadow-2xl flex flex-col"
|
|
||||||
onClick={() => setShowVolumeSlider(false)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowLyrics(false)}
|
|
||||||
className="absolute top-2 right-2 md:top-4 md:right-4 z-10 w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
|
|
||||||
{/* Cover / Meta */}
|
|
||||||
<div className="relative h-32 md:h-auto md:w-[380px] lg:w-[430px] xl:w-[480px] bg-gradient-to-b from-zinc-800 to-zinc-900 shrink-0 overflow-hidden">
|
|
||||||
{currentSong.pic && (
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<img
|
|
||||||
src={currentSong.pic}
|
|
||||||
alt={currentSong.name}
|
|
||||||
className="w-full h-full object-cover opacity-30 blur-xl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="relative h-full flex flex-col items-center justify-center p-4 md:p-6 lg:p-8">
|
|
||||||
<div
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setMobileLyricsView((view) => (view === 'lyrics' ? 'vinyl' : 'lyrics'));
|
|
||||||
}}
|
|
||||||
className="w-16 h-16 md:hidden rounded-xl overflow-hidden shadow-2xl mb-2"
|
|
||||||
>
|
|
||||||
{currentSong.pic ? (
|
|
||||||
<img
|
|
||||||
src={currentSong.pic}
|
|
||||||
alt={currentSong.name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full bg-zinc-800 flex items-center justify-center">
|
|
||||||
<svg className="w-8 h-8 text-zinc-600" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="hidden md:block w-full">
|
|
||||||
<VinylTurntable cover={currentSong.pic} title={currentSong.name} isPlaying={isPlaying} />
|
|
||||||
</div>
|
|
||||||
<div className="mb-1 flex min-w-0 items-center justify-center gap-1.5">
|
|
||||||
<h2 className="min-w-0 truncate text-center text-base md:text-xl lg:text-2xl font-bold text-white">{currentSong.name}</h2>
|
|
||||||
<SourcePill source={currentSong.platform} />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs md:text-sm lg:text-base text-zinc-400 line-clamp-1 text-center">{currentSong.artist}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mobileLyricsView === 'vinyl' && (
|
|
||||||
<div className="relative md:hidden flex-1 min-h-0 overflow-hidden bg-gradient-to-b from-zinc-800 to-zinc-900">
|
|
||||||
{currentSong.pic && (
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<img
|
|
||||||
src={currentSong.pic}
|
|
||||||
alt={currentSong.name}
|
|
||||||
className="w-full h-full object-cover opacity-30 blur-xl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="relative h-full flex items-center justify-center p-4">
|
|
||||||
<VinylTurntable cover={currentSong.pic} title={currentSong.name} isPlaying={isPlaying} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Lyrics Content */}
|
|
||||||
<div ref={lyricsContainerRef} className={`${mobileLyricsView === 'vinyl' ? 'hidden md:block' : 'block'} flex-1 overflow-y-auto p-4 md:p-6 lg:p-8`}>
|
|
||||||
{lyrics.length > 0 ? (
|
|
||||||
<div className="space-y-4 md:space-y-5">
|
|
||||||
{lyrics.map((line, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
data-index={index}
|
|
||||||
className={`text-center transition-all duration-300 ${
|
|
||||||
index === currentLyricIndex
|
|
||||||
? 'text-white text-lg md:text-xl lg:text-2xl font-bold scale-110'
|
|
||||||
: index === currentLyricIndex - 1 || index === currentLyricIndex + 1
|
|
||||||
? 'text-zinc-400 text-base md:text-lg'
|
|
||||||
: 'text-zinc-600 text-sm md:text-base'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div>{line.text}</div>
|
|
||||||
{line.translation && (
|
|
||||||
<div
|
|
||||||
className={`mt-1 ${
|
|
||||||
index === currentLyricIndex
|
|
||||||
? 'text-zinc-300 text-sm md:text-base lg:text-lg font-normal'
|
|
||||||
: 'text-zinc-500 text-xs md:text-sm lg:text-base font-normal'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{line.translation}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center space-y-4 pt-10 md:pt-16 lg:pt-20">
|
|
||||||
<p className="text-zinc-500 text-sm md:text-base">暂无歌词</p>
|
|
||||||
<p className="text-zinc-600 text-xs md:text-sm">纯音乐或歌词获取失败</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mini Player Controls */}
|
|
||||||
<div className="border-t border-white/5 p-3 md:p-4 shrink-0">
|
|
||||||
{/* 上排:播放控制按钮 */}
|
|
||||||
<div className="flex items-center justify-center gap-4 md:gap-6 mb-2 md:mb-3">
|
|
||||||
<button onClick={playPrev} className="text-zinc-500 hover:text-white transition-colors">
|
|
||||||
<svg className="w-5 h-5 md:w-6 md:h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={togglePlay}
|
|
||||||
className="w-10 h-10 md:w-12 md:h-12 rounded-full bg-green-500 text-white flex items-center justify-center hover:bg-green-600 transition-colors"
|
|
||||||
>
|
|
||||||
{isPlaying ? (
|
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M8 5v14l11-7z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button onClick={playNext} className="text-zinc-500 hover:text-white transition-colors">
|
|
||||||
<svg className="w-5 h-5 md:w-6 md:h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 下排:其他按钮(小一号) */}
|
|
||||||
<div className="flex items-center justify-center gap-3 md:gap-4 mb-2 md:mb-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowPlaylist(true)}
|
|
||||||
className="text-zinc-500 hover:text-white transition-colors relative"
|
|
||||||
title="播放列表"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
|
||||||
</svg>
|
|
||||||
{playlist.length > 0 && (
|
|
||||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full text-[8px] text-white flex items-center justify-center font-bold">
|
|
||||||
{playlist.length > 9 ? '9+' : playlist.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={downloadSong}
|
|
||||||
className="text-zinc-500 hover:text-white transition-colors"
|
|
||||||
title="下载歌曲"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowQualityMenu(true)}
|
|
||||||
className="px-2 py-0.5 rounded border text-amber-400 border-amber-500/50 bg-amber-900/20 text-[9px] md:text-[10px] font-mono min-w-[32px] text-center hover:bg-amber-900/30 transition-colors"
|
|
||||||
title="音质选择"
|
|
||||||
>
|
|
||||||
{getQualityLabel()}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={toggleMode}
|
|
||||||
className="text-zinc-500 hover:text-white transition-colors"
|
|
||||||
title={playMode === 'loop' ? '列表循环' : playMode === 'single' ? '单曲循环' : '随机播放'}
|
|
||||||
>
|
|
||||||
{playMode === 'loop' && (
|
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{playMode === 'single' && (
|
|
||||||
<div className="relative w-4 h-4 md:w-5 md:h-5">
|
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
<span className="absolute inset-0 flex items-center justify-center text-[7px] md:text-[8px] font-bold">1</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{playMode === 'random' && (
|
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{/* 音量控制 */}
|
|
||||||
<div
|
|
||||||
className="relative"
|
|
||||||
onMouseEnter={() => setShowVolumeSlider(true)}
|
|
||||||
onMouseLeave={() => setShowVolumeSlider(false)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowVolumeSlider(!showVolumeSlider);
|
|
||||||
}}
|
|
||||||
className="text-zinc-500 hover:text-white transition-colors"
|
|
||||||
title="音量"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/* 垂直音量条 - 桌面悬浮/移动端点击 */}
|
|
||||||
<div
|
|
||||||
className={`absolute bottom-full left-1/2 -translate-x-1/2 pb-2 transition-opacity ${showVolumeSlider ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="bg-zinc-800/95 backdrop-blur-sm rounded-lg p-3 shadow-xl border border-white/10">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<span className="text-xs text-zinc-400 font-mono">{volume}</span>
|
|
||||||
<div
|
|
||||||
className="h-24 w-6 bg-white/10 rounded-full relative cursor-pointer select-none"
|
|
||||||
onMouseDown={handleVolumeSliderInteraction}
|
|
||||||
onTouchStart={handleVolumeSliderInteraction}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute bottom-0 left-0 right-0 bg-green-500 rounded-full transition-all pointer-events-none"
|
|
||||||
style={{ height: `${volume}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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();
|
|
||||||
togglePiPLyrics();
|
|
||||||
}}
|
|
||||||
className={`transition-colors ${
|
|
||||||
showPiPLyrics
|
|
||||||
? 'text-green-500 hover:text-green-400'
|
|
||||||
: 'text-zinc-500 hover:text-white'
|
|
||||||
}`}
|
|
||||||
title={showPiPLyrics ? '关闭画中画歌词' : '画中画歌词'}
|
|
||||||
disabled={!currentSong}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 1.98 2 1.98h18c1.1 0 2-.88 2-1.98V5c0-1.1-.9-2-2-2zm0 16.01H3V4.98h18v14.03z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/* 添加到歌单按钮 */}
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (currentSong) {
|
|
||||||
setSongToAddToPlaylist(currentSong);
|
|
||||||
setShowAddToPlaylistModal(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-zinc-500 hover:text-red-500 transition-colors"
|
|
||||||
title="添加到歌单"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 进度条 */}
|
|
||||||
<div className="relative">
|
|
||||||
{showStreamBuffering && (
|
|
||||||
<div className="pointer-events-none absolute left-0 top-0 z-10 -translate-y-full">
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/15 px-2 py-0.5 text-[10px] font-medium text-green-300">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-green-400 animate-pulse" />
|
|
||||||
缓冲中
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showSpectrum && (
|
|
||||||
<div className="relative mb-3 flex items-center gap-2 text-xs">
|
|
||||||
<span className="invisible">{formatTime(currentTime)}</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<AudioSpectrumCanvas bars={spectrumBars} />
|
|
||||||
</div>
|
|
||||||
<span className="invisible">{formatTime(duration)}</span>
|
|
||||||
</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">
|
|
||||||
<div
|
|
||||||
className="relative z-10 h-full bg-green-500 transition-all pointer-events-none"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
{showStreamBuffering && (
|
|
||||||
<div className="music-buffer-track-global absolute inset-0 z-0" />
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
value={progress}
|
|
||||||
onChange={handleProgressChange}
|
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span>{formatTime(duration)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Playlist Modal */}
|
|
||||||
{showPlaylist && (
|
|
||||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
|
|
||||||
<div className="w-full max-w-2xl h-[90vh] md:h-auto max-h-[90vh] bg-zinc-900/95 rounded-2xl overflow-hidden border border-white/10 shadow-2xl flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="relative h-16 bg-gradient-to-b from-zinc-800 to-zinc-900 shrink-0 flex items-center justify-between px-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h2 className="text-lg font-bold text-white">播放列表</h2>
|
|
||||||
<span className="text-xs text-zinc-500">({playlist.length})</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{playlist.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={handleClearPlayRecords}
|
|
||||||
className="px-3 py-1 text-xs rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors border border-red-500/50"
|
|
||||||
title="清空全部"
|
|
||||||
>
|
|
||||||
清空
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowPlaylist(false)}
|
|
||||||
className="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Playlist */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 md:p-6">
|
|
||||||
{playlist.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{playlist.map((song, index) => (
|
|
||||||
<div
|
|
||||||
key={`${song.id}-${index}`}
|
|
||||||
className={`flex items-center gap-3 p-3 rounded-lg transition-colors group ${
|
|
||||||
index === playlistIndex
|
|
||||||
? 'bg-green-500/20 border border-green-500/50'
|
|
||||||
: 'bg-white/5 hover:bg-white/10'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
setPlaylistIndex(index);
|
|
||||||
playSong(song, -1);
|
|
||||||
setShowPlaylist(false);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="w-12 h-12 rounded-lg bg-zinc-800 overflow-hidden shrink-0">
|
|
||||||
{song.pic ? (
|
|
||||||
<img
|
|
||||||
src={song.pic}
|
|
||||||
alt={song.name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-zinc-600" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<div className={`text-sm font-medium truncate transition-colors ${
|
|
||||||
index === playlistIndex ? 'text-green-400' : 'text-white group-hover:text-green-400'
|
|
||||||
}`}>
|
|
||||||
{song.name}
|
|
||||||
</div>
|
|
||||||
<SourcePill source={song.platform} />
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-zinc-500 truncate">{song.artist}</div>
|
|
||||||
</div>
|
|
||||||
{index === playlistIndex ? (
|
|
||||||
<svg className="w-5 h-5 text-green-400 shrink-0 animate-pulse" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-5 h-5 text-zinc-600 group-hover:text-white transition-colors shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
await fetch(`/api/music/v2/history?songId=${encodeURIComponent(song.id)}`, { method: 'DELETE' });
|
|
||||||
|
|
||||||
// 更新本地状态
|
|
||||||
const newPlaylist = playlist.filter((_, i) => i !== index);
|
|
||||||
const newRecords = playRecords.filter((_, i) => i !== index);
|
|
||||||
setPlaylist(newPlaylist);
|
|
||||||
setPlayRecords(newRecords);
|
|
||||||
|
|
||||||
// 如果删除的是当前播放的歌曲,调整索引
|
|
||||||
if (index === playlistIndex) {
|
|
||||||
setPlaylistIndex(-1);
|
|
||||||
} else if (index < playlistIndex) {
|
|
||||||
setPlaylistIndex(playlistIndex - 1);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除播放记录失败:', error);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-8 h-8 rounded-lg bg-red-500/20 hover:bg-red-500/30 flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100 shrink-0"
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
|
||||||
<svg className="w-16 h-16 text-zinc-700 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
|
||||||
</svg>
|
|
||||||
<p className="text-zinc-500 text-sm">播放列表为空</p>
|
|
||||||
<p className="text-zinc-600 text-xs mt-2">播放歌曲后会自动添加到列表</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quality Selection Menu */}
|
|
||||||
{showQualityMenu && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100] flex items-end justify-center"
|
|
||||||
onClick={() => setShowQualityMenu(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full max-w-md bg-zinc-900 rounded-t-2xl border-t border-white/10 shadow-2xl animate-slide-up"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="p-4 border-b border-white/10">
|
|
||||||
<h3 className="text-lg font-bold text-white text-center">选择音质</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quality Options */}
|
|
||||||
<div className="p-4 space-y-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void handleQualityChange('128k');
|
|
||||||
}}
|
|
||||||
className={`w-full p-4 rounded-lg flex items-center justify-between transition-colors ${
|
|
||||||
quality === '128k'
|
|
||||||
? 'bg-amber-500/20 border border-amber-500/50'
|
|
||||||
: 'bg-white/5 hover:bg-white/10'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${quality === '128k' ? 'bg-amber-400' : 'bg-zinc-600'}`} />
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="text-white font-medium">标准音质</div>
|
|
||||||
<div className="text-xs text-zinc-500">128kbps</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{quality === '128k' && (
|
|
||||||
<svg className="w-5 h-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void handleQualityChange('320k');
|
|
||||||
}}
|
|
||||||
className={`w-full p-4 rounded-lg flex items-center justify-between transition-colors ${
|
|
||||||
quality === '320k'
|
|
||||||
? 'bg-amber-500/20 border border-amber-500/50'
|
|
||||||
: 'bg-white/5 hover:bg-white/10'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${quality === '320k' ? 'bg-amber-400' : 'bg-zinc-600'}`} />
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="text-white font-medium">高品质 HQ</div>
|
|
||||||
<div className="text-xs text-zinc-500">320kbps</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{quality === '320k' && (
|
|
||||||
<svg className="w-5 h-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void handleQualityChange('flac');
|
|
||||||
}}
|
|
||||||
className={`w-full p-4 rounded-lg flex items-center justify-between transition-colors ${
|
|
||||||
quality === 'flac'
|
|
||||||
? 'bg-amber-500/20 border border-amber-500/50'
|
|
||||||
: 'bg-white/5 hover:bg-white/10'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${quality === 'flac' ? 'bg-amber-400' : 'bg-zinc-600'}`} />
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="text-white font-medium">无损音质 SQ</div>
|
|
||||||
<div className="text-xs text-zinc-500">FLAC</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{quality === 'flac' && (
|
|
||||||
<svg className="w-5 h-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void handleQualityChange('flac24bit');
|
|
||||||
}}
|
|
||||||
className={`w-full p-4 rounded-lg flex items-center justify-between transition-colors ${
|
|
||||||
quality === 'flac24bit'
|
|
||||||
? 'bg-amber-500/20 border border-amber-500/50'
|
|
||||||
: 'bg-white/5 hover:bg-white/10'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${quality === 'flac24bit' ? 'bg-amber-400' : 'bg-zinc-600'}`} />
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="text-white font-medium">Hi-Res音质 HR</div>
|
|
||||||
<div className="text-xs text-zinc-500">FLAC 24bit</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{quality === 'flac24bit' && (
|
|
||||||
<svg className="w-5 h-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cancel Button */}
|
|
||||||
<div className="p-4 pt-0">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowQualityMenu(false)}
|
|
||||||
className="w-full p-3 rounded-lg bg-white/5 hover:bg-white/10 text-white transition-colors"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSourceMenu && (
|
|
||||||
<div className="md:hidden fixed inset-0 z-[90]">
|
|
||||||
<button
|
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
||||||
onClick={() => setShowSourceMenu(false)}
|
|
||||||
aria-label="关闭音源菜单"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-x-0 bottom-0 rounded-t-3xl border-t border-white/10 bg-zinc-950/98 px-4 pb-6 pt-4 shadow-2xl">
|
|
||||||
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-white/15" />
|
|
||||||
<div className="mb-3 px-1 text-sm font-medium text-white">切换音源</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{musicSources.map((source) => {
|
|
||||||
const active = currentSource === source.key;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={source.key}
|
|
||||||
onClick={() => {
|
|
||||||
setShowSourceMenu(false);
|
|
||||||
if (!active) switchSource(source.key);
|
|
||||||
}}
|
|
||||||
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition-all ${
|
|
||||||
active
|
|
||||||
? 'border-green-500/50 bg-green-500/12 text-white'
|
|
||||||
: 'border-white/8 bg-white/5 text-zinc-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`flex h-9 w-9 items-center justify-center rounded-full ${active ? 'bg-green-500/20 text-green-400' : 'bg-white/8 text-zinc-400'}`}>
|
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="text-base font-medium">{source.label}</div>
|
|
||||||
</div>
|
|
||||||
{active ? (
|
|
||||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-white">
|
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-zinc-500">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add to Playlist Modal */}
|
|
||||||
<AddToPlaylistModal
|
|
||||||
song={songToAddToPlaylist}
|
|
||||||
isOpen={showAddToPlaylistModal}
|
|
||||||
onClose={() => {
|
|
||||||
setShowAddToPlaylistModal(false);
|
|
||||||
setSongToAddToPlaylist(null);
|
|
||||||
}}
|
|
||||||
onSuccess={() => {
|
|
||||||
setToast({
|
|
||||||
message: '已添加到歌单',
|
|
||||||
type: 'success',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onError={(message) => {
|
|
||||||
setToast({
|
|
||||||
message,
|
|
||||||
type: 'error',
|
|
||||||
onClose: () => setToast(null),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Toast */}
|
|
||||||
{toast && <Toast {...toast} />}
|
|
||||||
|
|
||||||
{/* Confirm Modal */}
|
|
||||||
{confirmModal.isOpen &&
|
|
||||||
createPortal(
|
|
||||||
<div
|
|
||||||
className="music-theme-portal fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4"
|
|
||||||
style={{ zIndex: 99999 }}
|
|
||||||
onClick={confirmModal.onCancel}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="bg-zinc-900 rounded-xl max-w-md w-full border border-white/10"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-white">
|
|
||||||
{confirmModal.title}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={confirmModal.onCancel}
|
|
||||||
className="text-zinc-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<p className="text-sm text-zinc-400">
|
|
||||||
{confirmModal.message}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3">
|
|
||||||
<button
|
|
||||||
onClick={confirmModal.onCancel}
|
|
||||||
disabled={deletingPlaylistId !== null}
|
|
||||||
className="px-4 py-2 bg-zinc-700 hover:bg-zinc-600 disabled:bg-zinc-800 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={confirmModal.onConfirm}
|
|
||||||
disabled={deletingPlaylistId !== null}
|
|
||||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-zinc-700 disabled:cursor-not-allowed text-white rounded-lg transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{deletingPlaylistId !== null ? (
|
|
||||||
<>
|
|
||||||
<svg className="w-4 h-4 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>
|
|
||||||
删除中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'确定'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* PiP Lyrics Window */}
|
|
||||||
{showPiPLyrics && (
|
|
||||||
<LyricsPiPWindow
|
|
||||||
currentSong={currentSong}
|
|
||||||
lyrics={lyrics}
|
|
||||||
currentLyricIndex={currentLyricIndex}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
currentTime={currentTime}
|
|
||||||
opacity={pipOpacity}
|
|
||||||
minimized={pipMinimized}
|
|
||||||
onOpacityChange={(opacity) => {
|
|
||||||
setPipOpacity(opacity);
|
|
||||||
localStorage.setItem('lyricsPiPOpacity', opacity.toString());
|
|
||||||
}}
|
|
||||||
onMinimizedChange={(minimized) => {
|
|
||||||
setPipMinimized(minimized);
|
|
||||||
localStorage.setItem('lyricsPiPMinimized', minimized.toString());
|
|
||||||
}}
|
|
||||||
onClose={() => setShowPiPLyrics(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<style jsx global>{`
|
|
||||||
:root {
|
|
||||||
--music-bg: #f4f7fb;
|
|
||||||
--music-bg-strong: rgba(255, 255, 255, 0.96);
|
|
||||||
--music-surface: rgba(255, 255, 255, 0.88);
|
|
||||||
--music-surface-soft: rgba(241, 245, 249, 0.92);
|
|
||||||
--music-overlay: rgba(15, 23, 42, 0.45);
|
|
||||||
--music-glass: rgba(15, 23, 42, 0.06);
|
|
||||||
--music-glass-strong: rgba(15, 23, 42, 0.1);
|
|
||||||
--music-border: rgba(148, 163, 184, 0.28);
|
|
||||||
--music-text: #0f172a;
|
|
||||||
--music-text-soft: #475569;
|
|
||||||
--music-text-muted: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--music-bg: #09090b;
|
|
||||||
--music-bg-strong: rgba(9, 9, 11, 0.95);
|
|
||||||
--music-surface: rgba(24, 24, 27, 0.9);
|
|
||||||
--music-surface-soft: rgba(39, 39, 42, 0.88);
|
|
||||||
--music-overlay: rgba(0, 0, 0, 0.72);
|
|
||||||
--music-glass: rgba(255, 255, 255, 0.05);
|
|
||||||
--music-glass-strong: rgba(255, 255, 255, 0.1);
|
|
||||||
--music-border: rgba(255, 255, 255, 0.1);
|
|
||||||
--music-text: #f8fafc;
|
|
||||||
--music-text-soft: #cbd5e1;
|
|
||||||
--music-text-muted: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-theme {
|
|
||||||
background: linear-gradient(180deg, var(--music-bg) 0%, color-mix(in srgb, var(--music-bg) 82%, #22c55e 18%) 100%);
|
|
||||||
color: var(--music-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-theme :is([class*='bg-zinc-950'], [class*='bg-zinc-900']),
|
|
||||||
.music-theme-portal :is([class*='bg-zinc-950'], [class*='bg-zinc-900']) {
|
|
||||||
background-color: var(--music-bg-strong) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-theme [class*='bg-zinc-800'],
|
|
||||||
.music-theme-portal [class*='bg-zinc-800'] {
|
|
||||||
background-color: var(--music-surface-soft) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-theme :is([class*='bg-white/5'], [class*='bg-white/6'], [class*='bg-white/8'], [class*='bg-white/10'], [class*='bg-white/12']),
|
|
||||||
.music-theme-portal :is([class*='bg-white/5'], [class*='bg-white/6'], [class*='bg-white/8'], [class*='bg-white/10'], [class*='bg-white/12']) {
|
|
||||||
background-color: var(--music-glass) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-theme [class*='bg-white/20'],
|
|
||||||
.music-theme-portal [class*='bg-white/20'] {
|
|
||||||
background-color: var(--music-glass-strong) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-theme :is([class*='bg-black/90'], [class*='bg-black/50'], [class*='bg-black/30']),
|
|
||||||
.music-theme-portal :is([class*='bg-black/90'], [class*='bg-black/50'], [class*='bg-black/30']) {
|
|
||||||
background-color: var(--music-overlay) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-theme :is([class*='border-white/'], [class*='border-zinc-']),
|
|
||||||
.music-theme-portal :is([class*='border-white/'], [class*='border-zinc-']) {
|
|
||||||
border-color: var(--music-border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-theme :is([class*='text-white'], [class*='text-zinc-200']),
|
|
||||||
.music-theme-portal :is([class*='text-white'], [class*='text-zinc-200']) {
|
|
||||||
color: var(--music-text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-theme :is([class*='text-zinc-300'], [class*='text-zinc-400']),
|
|
||||||
.music-theme-portal :is([class*='text-zinc-300'], [class*='text-zinc-400']) {
|
|
||||||
color: var(--music-text-soft) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-theme :is([class*='text-zinc-500'], [class*='text-zinc-600']),
|
|
||||||
.music-theme-portal :is([class*='text-zinc-500'], [class*='text-zinc-600']) {
|
|
||||||
color: var(--music-text-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-theme :is([class*='from-zinc-800'], [class*='from-zinc-900']) {
|
|
||||||
--tw-gradient-from: var(--music-surface-soft) var(--tw-gradient-from-position) !important;
|
|
||||||
--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position) !important;
|
|
||||||
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-theme :is([class*='to-zinc-900'], [class*='to-zinc-800']) {
|
|
||||||
--tw-gradient-to: var(--music-bg-strong) var(--tw-gradient-to-position) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-theme input::placeholder,
|
|
||||||
.music-theme textarea::placeholder {
|
|
||||||
color: var(--music-text-muted) !important;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function LegacyMusicPlaylistsPage() {
|
||||||
|
redirect('/music/rankings');
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function MusicRankingDetailPage() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function MusicRankingsPage() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function MusicSearchPage() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user