From 4992b9fffb4923904dd894c4e7fb5382fcbc9d72 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 27 May 2026 00:04:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BD=BB=E5=BA=95=E6=8A=BD=E7=A6=BBmusic?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=92=8C=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/music/MusicClient.tsx | 927 +----------------- src/app/music/my-playlists/page.tsx | 11 +- src/app/music/playlists/page.tsx | 5 - .../rankings/[source]/[playlistId]/page.tsx | 5 +- src/app/music/rankings/page.tsx | 3 +- src/app/music/search/page.tsx | 5 +- .../music/MusicLoadingIndicator.tsx | 31 + src/components/music/MusicSidebarDrawer.tsx | 111 +++ src/{app => components}/music/SongList.tsx | 2 +- src/lib/music/actions.ts | 2 +- src/lib/music/errors.ts | 8 + src/lib/music/shared.ts | 46 +- src/lib/music/types.ts | 23 + 13 files changed, 245 insertions(+), 934 deletions(-) delete mode 100644 src/app/music/playlists/page.tsx create mode 100644 src/components/music/MusicLoadingIndicator.tsx create mode 100644 src/components/music/MusicSidebarDrawer.tsx rename src/{app => components}/music/SongList.tsx (98%) create mode 100644 src/lib/music/errors.ts create mode 100644 src/lib/music/types.ts diff --git a/src/app/music/MusicClient.tsx b/src/app/music/MusicClient.tsx index b582412..ae51846 100644 --- a/src/app/music/MusicClient.tsx +++ b/src/app/music/MusicClient.tsx @@ -1,12 +1,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ 'use client'; -import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { usePathname, 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'; +import MusicSidebarDrawer from '@/components/music/MusicSidebarDrawer'; +import { getSourceDisplayLabel, normalizeSource, SourcePill } from '@/lib/music/shared'; +import type { MusicQuality, MusicSource, Song } from '@/lib/music/types'; const SPECTRUM_BIN_COUNT = 96; const SPECTRUM_IDLE_LEVEL = 0.02; @@ -15,30 +18,6 @@ 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; -} - -export type MusicSource = 'wy' | 'tx' | 'kw' | 'kg' | 'mg'; -export type MusicQuality = '128k' | '320k' | 'flac' | 'flac24bit'; - -export 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; @@ -53,14 +32,6 @@ interface LyricLine { translation?: string; } -export interface Playlist { - id: string; - name: string; - pic?: string; - source?: MusicSource; - updateFrequency?: string; -} - interface DbRecord { source: MusicSource; songId: string; @@ -77,38 +48,6 @@ interface DbRecord { songmid?: string; } -export 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, @@ -274,17 +213,7 @@ declare global { 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); @@ -295,7 +224,6 @@ export default function MusicClient({ children: _children }: { children?: React. 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(() => { @@ -310,7 +238,6 @@ export default function MusicClient({ children: _children }: { children?: React. 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); // 待播放的歌曲信息 @@ -318,16 +245,6 @@ export default function MusicClient({ children: _children }: { children?: React. 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('/'); @@ -364,7 +281,6 @@ export default function MusicClient({ children: _children }: { children?: React. const audioRef = useRef(null); const lyricsContainerRef = useRef(null); - const searchLoadMoreRef = useRef(null); const lastSaveTimeRef = useRef(0); const restoredTimeRef = useRef(0); const songStartTimeRef = useRef(0); // 歌曲开始播放的时间戳 @@ -380,66 +296,6 @@ export default function MusicClient({ children: _children }: { children?: React. 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, @@ -548,10 +404,7 @@ export default function MusicClient({ children: _children }: { children?: React. const playState = { currentSong, currentSongIndex, - songs, - currentPlaylistTitle, currentSource, - currentView, quality, playMode, volume, @@ -645,10 +498,7 @@ export default function MusicClient({ children: _children }: { children?: React. 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); @@ -763,7 +613,7 @@ export default function MusicClient({ children: _children }: { children?: React. if (currentSong) { savePlayState(); } - }, [currentSong, currentSongIndex, songs, currentPlaylistTitle, currentSource, currentView, quality, playMode, volume, currentSongUrl, lyrics, playRecords, playlistIndex]); + }, [currentSong, currentSongIndex, currentSource, quality, playMode, volume, currentSongUrl, lyrics, playRecords, playlistIndex]); useEffect(() => { currentSongRef.current = currentSong; @@ -792,518 +642,7 @@ export default function MusicClient({ children: _children }: { children?: React. } }, [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 handlePlayAllCurrentSongsWith = async (targetSongs: Song[], title: string) => { - setLoadingCurrentPlayAll(true); - try { if (targetSongs.length === 0) { setToast({ message: '当前列表为空', type: 'error', onClose: () => setToast(null) }); @@ -1350,8 +689,6 @@ export default function MusicClient({ children: _children }: { children?: React. } catch (error) { console.error('播放全部失败:', error); setToast({ message: '播放全部失败', type: 'error', onClose: () => setToast(null) }); - } finally { - setLoadingCurrentPlayAll(false); } }; @@ -1588,27 +925,19 @@ export default function MusicClient({ children: _children }: { children?: React. // 上一曲 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); } }; @@ -1821,15 +1150,6 @@ export default function MusicClient({ children: _children }: { children?: React. 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; @@ -1843,64 +1163,6 @@ export default function MusicClient({ children: _children }: { children?: React. 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 keyword = searchParams.get('q') || ''; - setCurrentView('search'); - setCurrentPlaylistTitle(keyword ? `搜索: ${keyword}` : '搜索'); - setSearchKeyword(keyword); - setSongs([]); - setActiveSearchKeyword(''); - setSearchPage(1); - setSearchHasMore(false); - return; - } - - if (pathname === '/music/my-playlists') { - setCurrentView('myPlaylists'); - return; - } - }, [pathname, searchParams]); - // 音频事件监听 useEffect(() => { const audio = audioRef.current; @@ -2008,14 +1270,10 @@ export default function MusicClient({ children: _children }: { children?: React. 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(); @@ -2049,19 +1307,7 @@ export default function MusicClient({ children: _children }: { children?: React. 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]); + }, [playMode, currentSongIndex, lyrics, currentSong, playlistIndex, playRecords, quality]); // 歌词自动滚动 useEffect(() => { @@ -2077,34 +1323,6 @@ export default function MusicClient({ children: _children }: { children?: React. } }, [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; @@ -2229,13 +1447,7 @@ export default function MusicClient({ children: _children }: { children?: React. }; const getSourceLabel = () => { - switch (currentSource) { - case 'wy': return '网易云'; - case 'tx': return 'QQ音乐'; - case 'kw': return '酷我'; - case 'kg': return '酷狗'; - case 'mg': return '咪咕'; - } + return getSourceDisplayLabel(currentSource, false); }; const formatTime = (seconds: number) => { @@ -2377,9 +1589,6 @@ export default function MusicClient({ children: _children }: { children?: React. const handlePlayAllEvent = (event: Event) => { const detail = (event as CustomEvent<{ songs: Song[]; title?: string }>).detail; if (!detail?.songs?.length) return; - setSongs(detail.songs); - setCurrentPlaylistTitle(detail.title || '当前列表'); - setActiveSearchKeyword(''); void handlePlayAllCurrentSongsWith(detail.songs, detail.title || '当前列表'); }; @@ -2407,7 +1616,7 @@ export default function MusicClient({ children: _children }: { children?: React. window.removeEventListener('music:add-to-playlist', handleAddToPlaylistEvent); window.removeEventListener('music:play-later', handlePlayLaterEvent); }; - }, [songs, playlist, playRecords, currentSong, quality, currentSource]); + }, [playlist, playRecords, currentSong, quality, currentSource]); return (
@@ -2702,7 +1911,7 @@ export default function MusicClient({ children: _children }: { children?: React.
{currentSong.name}
- +
{currentSong.artist}
@@ -2871,7 +2080,7 @@ export default function MusicClient({ children: _children }: { children?: React.

{currentSong.name}

- +

{currentSong.artist}

@@ -3234,7 +2443,7 @@ export default function MusicClient({ children: _children }: { children?: React. }`}> {song.name} - +
{song.artist}
@@ -3422,95 +2631,13 @@ export default function MusicClient({ children: _children }: { children?: React. )} - {showSidebarDrawer && ( -
- -
- - - -
-
- -
- - {/* Ambient Background Effect */} -
- -
- )} + setShowSidebarDrawer(false)} + onNavigate={(href) => router.push(href)} + /> {/* Add to Playlist Modal */}
diff --git a/src/app/music/my-playlists/page.tsx b/src/app/music/my-playlists/page.tsx index d00f140..46eb24d 100644 --- a/src/app/music/my-playlists/page.tsx +++ b/src/app/music/my-playlists/page.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from 'react'; import { playMusicList, playMusicSong } from '@/lib/music/actions'; -import { MusicLoadingIndicator } from '../MusicClient'; +import MusicLoadingIndicator from '@/components/music/MusicLoadingIndicator'; +import { getApiErrorMessage } from '@/lib/music/errors'; import { mapSong, SourcePill } from '@/lib/music/shared'; export default function MusicMyPlaylistsPage() { @@ -14,14 +15,6 @@ export default function MusicMyPlaylistsPage() { const [deletingPlaylistId, setDeletingPlaylistId] = useState(null); const [removingSongId, setRemovingSongId] = useState(null); - const getApiErrorMessage = (error: unknown, fallback: string) => { - if (typeof error === 'string') return error; - if (error && typeof error === 'object' && 'message' in error && typeof (error as { message?: unknown }).message === 'string') { - return (error as { message: string }).message; - } - return fallback; - }; - const loadUserPlaylists = useCallback(() => { setLoadingUserPlaylists(true); fetch('/api/music/v2/playlists') diff --git a/src/app/music/playlists/page.tsx b/src/app/music/playlists/page.tsx deleted file mode 100644 index 69f30d4..0000000 --- a/src/app/music/playlists/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function LegacyMusicPlaylistsPage() { - redirect('/music/rankings'); -} diff --git a/src/app/music/rankings/[source]/[playlistId]/page.tsx b/src/app/music/rankings/[source]/[playlistId]/page.tsx index 4dad364..496938f 100644 --- a/src/app/music/rankings/[source]/[playlistId]/page.tsx +++ b/src/app/music/rankings/[source]/[playlistId]/page.tsx @@ -3,9 +3,10 @@ import { useEffect, useState } from 'react'; import { useParams, useSearchParams } from 'next/navigation'; import { playMusicList } from '@/lib/music/actions'; -import { MusicLoadingIndicator, type Song } from '../../../MusicClient'; -import SongList from '../../../SongList'; +import MusicLoadingIndicator from '@/components/music/MusicLoadingIndicator'; +import SongList from '@/components/music/SongList'; import { mapSong, normalizeSource } from '@/lib/music/shared'; +import type { Song } from '@/lib/music/types'; export default function MusicRankingDetailPage() { const params = useParams<{ source: string; playlistId: string }>(); diff --git a/src/app/music/rankings/page.tsx b/src/app/music/rankings/page.tsx index ae1c918..ed340ca 100644 --- a/src/app/music/rankings/page.tsx +++ b/src/app/music/rankings/page.tsx @@ -2,8 +2,9 @@ import { useEffect, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { MusicLoadingIndicator, type Playlist } from '../MusicClient'; +import MusicLoadingIndicator from '@/components/music/MusicLoadingIndicator'; import { musicSources, normalizeSource } from '@/lib/music/shared'; +import type { Playlist } from '@/lib/music/types'; export default function MusicRankingsPage() { const router = useRouter(); diff --git a/src/app/music/search/page.tsx b/src/app/music/search/page.tsx index c7ec6fb..7566e3d 100644 --- a/src/app/music/search/page.tsx +++ b/src/app/music/search/page.tsx @@ -3,9 +3,10 @@ import { useEffect, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { playMusicList } from '@/lib/music/actions'; -import { MusicLoadingIndicator, type Song } from '../MusicClient'; -import SongList from '../SongList'; +import MusicLoadingIndicator from '@/components/music/MusicLoadingIndicator'; +import SongList from '@/components/music/SongList'; import { mapSong, musicSources, normalizeSource } from '@/lib/music/shared'; +import type { Song } from '@/lib/music/types'; export default function MusicSearchPage() { const router = useRouter(); diff --git a/src/components/music/MusicLoadingIndicator.tsx b/src/components/music/MusicLoadingIndicator.tsx new file mode 100644 index 0000000..c97a53b --- /dev/null +++ b/src/components/music/MusicLoadingIndicator.tsx @@ -0,0 +1,31 @@ +export default 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} +
+ ); +} diff --git a/src/components/music/MusicSidebarDrawer.tsx b/src/components/music/MusicSidebarDrawer.tsx new file mode 100644 index 0000000..ac4bd31 --- /dev/null +++ b/src/components/music/MusicSidebarDrawer.tsx @@ -0,0 +1,111 @@ +import type { MusicSource } from '@/lib/music/types'; + +interface MusicSidebarDrawerProps { + currentSource: MusicSource; + isOpen: boolean; + pathname: string | null; + onClose: () => void; + onNavigate: (href: string) => void; +} + +const musicNavItems = [ + { key: 'rankings', label: '排行榜', href: '/music/rankings', icon: 'M3 4h18M8 8h13M3 12h18M8 16h13M3 20h18' }, + { key: 'search', label: '搜索', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' }, + { key: 'my-playlists', label: '我的歌单', href: '/music/my-playlists', icon: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z' }, +]; + +export default function MusicSidebarDrawer({ + currentSource, + isOpen, + pathname, + onClose, + onNavigate, +}: MusicSidebarDrawerProps) { + if (!isOpen) return null; + + const navigate = (href: string) => { + onClose(); + onNavigate(href); + }; + + return ( +
+ +
+ + + +
+
+ +
+ +
+ +
+ ); +} diff --git a/src/app/music/SongList.tsx b/src/components/music/SongList.tsx similarity index 98% rename from src/app/music/SongList.tsx rename to src/components/music/SongList.tsx index 372f107..aed51f8 100644 --- a/src/app/music/SongList.tsx +++ b/src/components/music/SongList.tsx @@ -1,8 +1,8 @@ 'use client'; import { addMusicSongToPlaylist, playMusicLater, playMusicSong } from '@/lib/music/actions'; -import type { Song } from './MusicClient'; import { SourcePill } from '@/lib/music/shared'; +import type { Song } from '@/lib/music/types'; export default function SongList({ songs }: { songs: Song[] }) { return ( diff --git a/src/lib/music/actions.ts b/src/lib/music/actions.ts index 44bcf42..273905d 100644 --- a/src/lib/music/actions.ts +++ b/src/lib/music/actions.ts @@ -1,6 +1,6 @@ 'use client'; -import type { Song } from '@/app/music/MusicClient'; +import type { Song } from '@/lib/music/types'; export function playMusicSong(song: Song, index = -1) { window.dispatchEvent(new CustomEvent('music:play-song', { detail: { song, index } })); diff --git a/src/lib/music/errors.ts b/src/lib/music/errors.ts new file mode 100644 index 0000000..2c58047 --- /dev/null +++ b/src/lib/music/errors.ts @@ -0,0 +1,8 @@ +export 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; +} diff --git a/src/lib/music/shared.ts b/src/lib/music/shared.ts index 331c1c6..3bdb10c 100644 --- a/src/lib/music/shared.ts +++ b/src/lib/music/shared.ts @@ -1,5 +1,5 @@ import React from 'react'; -import type { MusicSource, Song } from '@/app/music/MusicClient'; +import type { MusicSource, Song } from '@/lib/music/types'; export const musicSources: Array<{ key: MusicSource; label: string }> = [ { key: 'wy', label: '网易云' }, @@ -10,12 +10,15 @@ export const musicSources: Array<{ key: MusicSource; label: string }> = [ ]; export function normalizeSource(source: string | undefined | null): MusicSource { + if (source === 'netease') return 'wy'; + if (source === 'qq') return 'tx'; + if (source === 'kuwo') return 'kw'; if (source === 'wy' || source === 'tx' || source === 'kw' || source === 'kg' || source === 'mg') return source; return 'wy'; } export function mapSong(song: any): Song { - const rawSource = song.platform || song.source || song.vendor || song.origin; + const rawSource = song.source || song.platform || song.vendor || song.origin; return { id: String(song.id ?? song.songId ?? song.rid ?? song.mid ?? ''), name: song.name || song.title || '未知歌曲', @@ -23,19 +26,48 @@ export function mapSong(song: any): Song { album: song.album || song.albumName, pic: song.pic || song.cover || song.img, platform: normalizeSource(rawSource), - duration: song.duration, - durationText: song.durationText, + duration: song.durationSec || song.duration, + durationText: song.durationText || song.interval, songmid: song.songmid || song.mid, }; } -export function SourcePill({ source, className = '' }: { source?: MusicSource | string; className?: string }) { +export function getSourceDisplayLabel(source?: MusicSource | string, compact = true): string { + switch (normalizeSource(source)) { + case 'wy': + return compact ? '网易' : '网易云'; + case 'tx': + return compact ? 'QQ' : 'QQ音乐'; + case 'kw': + return '酷我'; + case 'kg': + return '酷狗'; + case 'mg': + return '咪咕'; + } +} + +export function SourcePill({ + source, + className = '', + variant = 'subtle', +}: { + source?: MusicSource | string; + className?: string; + variant?: 'subtle' | 'accent'; +}) { const normalized = normalizeSource(source); - const label = musicSources.find((item) => item.key === normalized)?.label || source || '未知'; + const label = variant === 'accent' + ? getSourceDisplayLabel(normalized) + : musicSources.find((item) => item.key === normalized)?.label || source || '未知'; + const variantClass = variant === 'accent' + ? 'rounded-lg border-red-500/50 bg-red-500/20 leading-none text-red-400' + : 'rounded-full border-white/10 bg-white/5 text-zinc-400'; + return React.createElement( 'span', { - className: `inline-flex shrink-0 items-center rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] font-medium text-zinc-400 ${className}`, + className: `inline-flex shrink-0 items-center border px-2 py-0.5 text-[10px] font-medium ${variantClass} ${className}`, }, label ); diff --git a/src/lib/music/types.ts b/src/lib/music/types.ts new file mode 100644 index 0000000..d457fe4 --- /dev/null +++ b/src/lib/music/types.ts @@ -0,0 +1,23 @@ +export type MusicSource = 'wy' | 'tx' | 'kw' | 'kg' | 'mg'; + +export type MusicQuality = '128k' | '320k' | 'flac' | 'flac24bit'; + +export interface Song { + id: string; + name: string; + artist: string; + album?: string; + pic?: string; + platform: MusicSource; + duration?: number; + durationText?: string; + songmid?: string; +} + +export interface Playlist { + id: string; + name: string; + pic?: string; + source?: MusicSource; + updateFrequency?: string; +}