diff --git a/src/app/music/MusicClient.tsx b/src/app/music/MusicClient.tsx new file mode 100644 index 0000000..a202f18 --- /dev/null +++ b/src/app/music/MusicClient.tsx @@ -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 ( +
+
+ {[0, 1, 2].map((index) => ( + + + + ))} +
+ {text ? {text} : null} +
+ ); +} + +function AudioSpectrumCanvas({ + bars, + compact = false, +}: { + bars: number[]; + compact?: boolean; +}) { + const canvasRef = useRef(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 ( + + ); +} + + +const VINYL_NEEDLE_SVG = `url('data:image/svg+xml;utf8,')`; + +function VinylTurntable({ + cover, + title, + isPlaying, + className = '', +}: { + cover?: string; + title: string; + isPlaying: boolean; + className?: string; +}) { + return ( +
+
+
+
+
+
+ {cover ? ( + {title} { + e.currentTarget.style.display = 'none'; + }} + /> + ) : ( + + + + )} +
+
+
+
+ ); +} + +// 扩展 Window 接口以支持 Document PiP API +declare global { + interface Window { + documentPictureInPicture?: { + requestWindow: (options: { width: number; height: number }) => Promise; + 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('wy'); + const [playlists, setPlaylists] = useState([]); + const [songs, setSongs] = useState([]); + 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(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('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([]); + const [currentLyricIndex, setCurrentLyricIndex] = useState(-1); + const [currentSongUrl, setCurrentSongUrl] = useState(''); + const [playRecords, setPlayRecords] = useState([]); // 播放记录(只存平台和ID) + const [playlist, setPlaylist] = useState([]); // 完整歌曲信息(用于显示) + 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(null); // 要添加到歌单的歌曲 + + // 我的歌单相关状态 + const [userPlaylists, setUserPlaylists] = useState([]); + const [selectedUserPlaylist, setSelectedUserPlaylist] = useState(null); + const [userPlaylistSongs, setUserPlaylistSongs] = useState([]); + const [loadingUserPlaylists, setLoadingUserPlaylists] = useState(false); + const [loadingUserPlaylistSongs, setLoadingUserPlaylistSongs] = useState(false); + const [loadingPlayAll, setLoadingPlayAll] = useState(false); // 播放全部加载状态 + const [loadingCurrentPlayAll, setLoadingCurrentPlayAll] = useState(false); // 当前排行榜/详情页播放全部加载状态 + const [deletingPlaylistId, setDeletingPlaylistId] = useState(null); // 正在删除的歌单ID + + useEffect(() => { + if (typeof window !== 'undefined' && !(window as any).RUNTIME_CONFIG?.MUSIC_ENABLED) { + router.replace('/'); + } + }, [router]); + + // Toast 和 Confirm Modal 状态 + const [toast, setToast] = useState(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( + () => Array.from({ length: SPECTRUM_BIN_COUNT }, () => SPECTRUM_IDLE_LEVEL) + ); + + const audioRef = useRef(null); + const lyricsContainerRef = useRef(null); + const searchLoadMoreRef = useRef(null); + const lastSaveTimeRef = useRef(0); + const restoredTimeRef = useRef(0); + const songStartTimeRef = useRef(0); // 歌曲开始播放的时间戳 + const audioContextRef = useRef(null); + const analyserRef = useRef(null); + const mediaSourceRef = useRef(null); + const spectrumDataRef = useRef(null); + const spectrumFrameRef = useRef(null); + const currentTimeRef = useRef(0); + const volumeRef = useRef(volume); + const spectrumSeedRef = useRef(Math.random() * Math.PI * 2); + const qualitySwitchRequestRef = useRef(0); + const currentSongRef = useRef(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; + }) => ( + + {getSourceDisplayLabel(source)} + + ); + + 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(); + 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) => { + const newTime = (parseFloat(e.target.value) / 100) * duration; + setCurrentTime(newTime); + if (audioRef.current) { + audioRef.current.currentTime = newTime; + } + }; + + // 音量调节 + const handleVolumeChange = (e: React.ChangeEvent) => { + const newVolume = parseInt(e.target.value); + setVolume(newVolume); + if (audioRef.current) { + audioRef.current.volume = newVolume / 100; + } + }; + + // 触摸/鼠标滑动音量调节(移动端兼容) + const handleVolumeSliderInteraction = (e: React.MouseEvent | React.TouchEvent) => { + 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 ( +
+ <> + + + {resolvingCount > 0 && ( +
+
+
+
+
+
解析中
+
{resolvingCount}
+
+
+
+ )} + {/* Header */} +
+
+
+
+ +
+ + + +
+ 音乐 +
+
+ +
+
+ {musicSources.map((source) => ( + + ))} +
+
+
+ {(currentView === 'songs' || currentView === 'search' || currentView === 'myPlaylists') && ( + + )} +
+
+ + + +
+ 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="搜索歌曲或艺术家..." + /> +
+ +
+
+
+ + {/* Main Content */} +
+
+ {loading && ( + + )} + + {/* Playlists View */} + {currentView === 'playlists' && !loading && ( +
+
+

排行榜

+ +
+ {playlists.length > 0 ? ( +
+ {playlists.map((playlist, index) => ( + + ))} +
+ ) : ( +
+
当前音源暂无排行榜
+
+ 你可以切换其它音源,或使用上方搜索继续找歌。 +
+
+ )} +
+ )} + + {/* Songs View */} + {(currentView === 'songs' || currentView === 'search') && !loading && ( +
+ {currentView === 'search' && ( +
+
+
+ + + +
+ 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="搜索歌曲或艺术家..." + /> +
+ +
+ )} +
+
+

+ {currentPlaylistTitle} +

+ {songs[0]?.platform ? : null} + + {songs.length} 首歌曲 + +
+ +
+
+ {songs.map((song, index) => ( +
+
playSong(song, index)} + > + {index + 1} +
+
playSong(song, index)} + > +
{song.name}
+
{song.artist}
+
+
playSong(song, index)} + > + {song.artist} +
+
playSong(song, index)} + > + +
+
+ + +
+
+ ))} +
+ {currentView === 'search' && activeSearchKeyword && ( +
+ {searchHasMore ? ( + loadingMoreSearch ? ( +
+ +
+ ) : ( +
+ 继续向下滚动加载更多 +
+ ) + ) : songs.length > 0 ? ( +
没有更多搜索结果了
+ ) : null} +
+ )} +
+ )} + {/* My Playlists View */} + {currentView === 'myPlaylists' && ( +
+ {/* Playlists List */} +
+
+

歌单列表

+ {loadingUserPlaylists ? ( + + ) : userPlaylists.length === 0 ? ( +
+ 还没有歌单 +
+ +
+ ) : ( +
+ {userPlaylists.map((playlist) => ( +
handleSelectUserPlaylist(playlist)} + > +
+ {playlist.cover ? ( + {playlist.name} + ) : ( +
+ + + +
+ )} +
+
{playlist.name}
+ {playlist.description && ( +
{playlist.description}
+ )} +
+
+
+ ))} +
+ )} +
+
+ + {/* Playlist Songs */} +
+ {selectedUserPlaylist ? ( +
+
+
+

{selectedUserPlaylist.name}

+ {selectedUserPlaylist.description && ( +

{selectedUserPlaylist.description}

+ )} +
+
+ + +
+
+ + {loadingUserPlaylistSongs ? ( + + ) : userPlaylistSongs.length === 0 ? ( +
歌单为空
+ ) : ( +
+ {userPlaylistSongs.map((song, index) => ( +
+
{index + 1}
+ {song.pic && ( + {song.name} + )} +
+
+
{song.name}
+ +
+
{song.artist}
+
+ + +
+ ))} +
+ )} +
+ ) : ( +
+
+ + + +

选择一个歌单查看详情

+
+
+ )} +
+
+ )} +
+
+ + {/* Player */} + {showPlayer && currentSong && ( +
+ {showSpectrum && ( +
+ +
+ )} + +
+ {/* Progress Bar */} +
+
+ {showStreamBuffering && ( +
+ )} + +
+ +
+ {/* Song Info */} +
+
setShowLyrics(true)} + > + {currentSong.pic ? ( + {currentSong.name} { + // 图片加载失败时显示默认图标 + e.currentTarget.style.display = 'none'; + }} + /> + ) : ( + + + + )} +
+
+
+
{currentSong.name}
+ + {showStreamBuffering && ( + + + 缓冲中 + + )} +
+
{currentSong.artist}
+
+
+ + {/* Controls */} +
+ + + +
+ + {/* Right Controls */} +
+
+ +
+ + + +
+
+
+
+ )} + + {/* Audio Element */} +
+ ); +} diff --git a/src/app/music/layout.tsx b/src/app/music/layout.tsx new file mode 100644 index 0000000..0964e6c --- /dev/null +++ b/src/app/music/layout.tsx @@ -0,0 +1,5 @@ +import MusicClient from './MusicClient'; + +export default function MusicLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/app/music/my-playlists/page.tsx b/src/app/music/my-playlists/page.tsx new file mode 100644 index 0000000..e9dde71 --- /dev/null +++ b/src/app/music/my-playlists/page.tsx @@ -0,0 +1,3 @@ +export default function MusicMyPlaylistsPage() { + return null; +} diff --git a/src/app/music/page.tsx b/src/app/music/page.tsx index 84c0e45..971752b 100644 --- a/src/app/music/page.tsx +++ b/src/app/music/page.tsx @@ -1,3923 +1,5 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -'use client'; +import { redirect } from 'next/navigation'; -import { useRouter } 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 ( -
-
- {[0, 1, 2].map((index) => ( - - - - ))} -
- {text ? {text} : null} -
- ); -} - -function AudioSpectrumCanvas({ - bars, - compact = false, -}: { - bars: number[]; - compact?: boolean; -}) { - const canvasRef = useRef(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 ( - - ); -} - - -const VINYL_NEEDLE_SVG = `url('data:image/svg+xml;utf8,')`; - -function VinylTurntable({ - cover, - title, - isPlaying, - className = '', -}: { - cover?: string; - title: string; - isPlaying: boolean; - className?: string; -}) { - return ( -
-
-
-
-
-
- {cover ? ( - {title} { - e.currentTarget.style.display = 'none'; - }} - /> - ) : ( - - - - )} -
-
-
-
- ); -} - -// 扩展 Window 接口以支持 Document PiP API -declare global { - interface Window { - documentPictureInPicture?: { - requestWindow: (options: { width: number; height: number }) => Promise; - window: Window | null; - }; - } -} - -export default function MusicPage() { - const router = useRouter(); - const [currentSource, setCurrentSource] = useState('wy'); - const [playlists, setPlaylists] = useState([]); - const [songs, setSongs] = useState([]); - 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(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('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([]); - const [currentLyricIndex, setCurrentLyricIndex] = useState(-1); - const [currentSongUrl, setCurrentSongUrl] = useState(''); - const [playRecords, setPlayRecords] = useState([]); // 播放记录(只存平台和ID) - const [playlist, setPlaylist] = useState([]); // 完整歌曲信息(用于显示) - 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(null); // 要添加到歌单的歌曲 - - // 我的歌单相关状态 - const [userPlaylists, setUserPlaylists] = useState([]); - const [selectedUserPlaylist, setSelectedUserPlaylist] = useState(null); - const [userPlaylistSongs, setUserPlaylistSongs] = useState([]); - const [loadingUserPlaylists, setLoadingUserPlaylists] = useState(false); - const [loadingUserPlaylistSongs, setLoadingUserPlaylistSongs] = useState(false); - const [loadingPlayAll, setLoadingPlayAll] = useState(false); // 播放全部加载状态 - const [loadingCurrentPlayAll, setLoadingCurrentPlayAll] = useState(false); // 当前排行榜/详情页播放全部加载状态 - const [deletingPlaylistId, setDeletingPlaylistId] = useState(null); // 正在删除的歌单ID - - useEffect(() => { - if (typeof window !== 'undefined' && !(window as any).RUNTIME_CONFIG?.MUSIC_ENABLED) { - router.replace('/'); - } - }, [router]); - - // Toast 和 Confirm Modal 状态 - const [toast, setToast] = useState(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( - () => Array.from({ length: SPECTRUM_BIN_COUNT }, () => SPECTRUM_IDLE_LEVEL) - ); - - const audioRef = useRef(null); - const lyricsContainerRef = useRef(null); - const searchLoadMoreRef = useRef(null); - const lastSaveTimeRef = useRef(0); - const restoredTimeRef = useRef(0); - const songStartTimeRef = useRef(0); // 歌曲开始播放的时间戳 - const audioContextRef = useRef(null); - const analyserRef = useRef(null); - const mediaSourceRef = useRef(null); - const spectrumDataRef = useRef(null); - const spectrumFrameRef = useRef(null); - const currentTimeRef = useRef(0); - const volumeRef = useRef(volume); - const spectrumSeedRef = useRef(Math.random() * Math.PI * 2); - const qualitySwitchRequestRef = useRef(0); - const currentSongRef = useRef(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; - }) => ( - - {getSourceDisplayLabel(source)} - - ); - - 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(); - 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) => { - const newTime = (parseFloat(e.target.value) / 100) * duration; - setCurrentTime(newTime); - if (audioRef.current) { - audioRef.current.currentTime = newTime; - } - }; - - // 音量调节 - const handleVolumeChange = (e: React.ChangeEvent) => { - const newVolume = parseInt(e.target.value); - setVolume(newVolume); - if (audioRef.current) { - audioRef.current.volume = newVolume / 100; - } - }; - - // 触摸/鼠标滑动音量调节(移动端兼容) - const handleVolumeSliderInteraction = (e: React.MouseEvent | React.TouchEvent) => { - 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 ( -
- <> - - - {resolvingCount > 0 && ( -
-
-
-
-
-
解析中
-
{resolvingCount}
-
-
-
- )} - {/* Header */} -
-
-
-
- -
- - - -
- 音乐 -
-
- -
-
- {musicSources.map((source) => ( - - ))} -
-
-
- {(currentView === 'songs' || currentView === 'myPlaylists') && ( - - )} -
-
- - - -
- 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="搜索歌曲或艺术家..." - /> -
- -
-
-
- - {/* Main Content */} -
-
- {loading && ( - - )} - - {/* Playlists View */} - {currentView === 'playlists' && !loading && ( -
-
-

排行榜

- -
- {playlists.length > 0 ? ( -
- {playlists.map((playlist, index) => ( - - ))} -
- ) : ( -
-
当前音源暂无排行榜
-
- 你可以切换其它音源,或使用上方搜索继续找歌。 -
-
- )} -
- )} - - {/* Songs View */} - {currentView === 'songs' && !loading && ( -
-
-
-

- {currentPlaylistTitle} -

- {songs[0]?.platform ? : null} - - {songs.length} 首歌曲 - -
- -
-
- {songs.map((song, index) => ( -
-
playSong(song, index)} - > - {index + 1} -
-
playSong(song, index)} - > -
{song.name}
-
{song.artist}
-
-
playSong(song, index)} - > - {song.artist} -
-
playSong(song, index)} - > - -
-
- - -
-
- ))} -
- {activeSearchKeyword && ( -
- {searchHasMore ? ( - loadingMoreSearch ? ( -
- -
- ) : ( -
- 继续向下滚动加载更多 -
- ) - ) : songs.length > 0 ? ( -
没有更多搜索结果了
- ) : null} -
- )} -
- )} - {/* My Playlists View */} - {currentView === 'myPlaylists' && ( -
- {/* Playlists List */} -
-
-

歌单列表

- {loadingUserPlaylists ? ( - - ) : userPlaylists.length === 0 ? ( -
- 还没有歌单 -
- -
- ) : ( -
- {userPlaylists.map((playlist) => ( -
handleSelectUserPlaylist(playlist)} - > -
- {playlist.cover ? ( - {playlist.name} - ) : ( -
- - - -
- )} -
-
{playlist.name}
- {playlist.description && ( -
{playlist.description}
- )} -
-
-
- ))} -
- )} -
-
- - {/* Playlist Songs */} -
- {selectedUserPlaylist ? ( -
-
-
-

{selectedUserPlaylist.name}

- {selectedUserPlaylist.description && ( -

{selectedUserPlaylist.description}

- )} -
-
- - -
-
- - {loadingUserPlaylistSongs ? ( - - ) : userPlaylistSongs.length === 0 ? ( -
歌单为空
- ) : ( -
- {userPlaylistSongs.map((song, index) => ( -
-
{index + 1}
- {song.pic && ( - {song.name} - )} -
-
-
{song.name}
- -
-
{song.artist}
-
- - -
- ))} -
- )} -
- ) : ( -
-
- - - -

选择一个歌单查看详情

-
-
- )} -
-
- )} -
-
- - {/* Player */} - {showPlayer && currentSong && ( -
- {showSpectrum && ( -
- -
- )} - -
- {/* Progress Bar */} -
-
- {showStreamBuffering && ( -
- )} - -
- -
- {/* Song Info */} -
-
setShowLyrics(true)} - > - {currentSong.pic ? ( - {currentSong.name} { - // 图片加载失败时显示默认图标 - e.currentTarget.style.display = 'none'; - }} - /> - ) : ( - - - - )} -
-
-
-
{currentSong.name}
- - {showStreamBuffering && ( - - - 缓冲中 - - )} -
-
{currentSong.artist}
-
-
- - {/* Controls */} -
- - - -
- - {/* Right Controls */} -
-
- -
- - - -
-
-
-
- )} - - {/* Audio Element */} -