diff --git a/src/app/music/MusicClient.tsx b/src/app/music/MusicClient.tsx index a202f18..00243b1 100644 --- a/src/app/music/MusicClient.tsx +++ b/src/app/music/MusicClient.tsx @@ -24,10 +24,10 @@ function getApiErrorMessage(error: unknown, fallback: string): string { return fallback; } -type MusicSource = 'wy' | 'tx' | 'kw' | 'kg' | 'mg'; -type MusicQuality = '128k' | '320k' | 'flac' | 'flac24bit'; +export type MusicSource = 'wy' | 'tx' | 'kw' | 'kg' | 'mg'; +export type MusicQuality = '128k' | '320k' | 'flac' | 'flac24bit'; -interface Song { +export interface Song { id: string; name: string; artist: string; @@ -53,7 +53,7 @@ interface LyricLine { translation?: string; } -interface Playlist { +export interface Playlist { id: string; name: string; pic?: string; @@ -77,7 +77,7 @@ interface DbRecord { songmid?: string; } -function MusicLoadingIndicator({ +export function MusicLoadingIndicator({ text, size = 'md', className = '', @@ -1300,6 +1300,79 @@ export default function MusicClient({ children: _children }: { children?: React. }); }; + + const handlePlayAllCurrentSongsWith = async (targetSongs: Song[], title: string) => { + setLoadingCurrentPlayAll(true); + + try { + if (targetSongs.length === 0) { + setToast({ message: '当前列表为空', type: 'error', onClose: () => setToast(null) }); + return; + } + + await fetch('/api/music/v2/history', { method: 'DELETE' }); + const baseTime = Date.now(); + const recordsToAdd = targetSongs.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[] = targetSongs.map((song, i) => ({ + platform: song.platform, + id: song.id, + playTime: 0, + duration: song.duration || 0, + timestamp: baseTime + i, + })); + setPlayRecords(newRecords); + setPlaylist(targetSongs); + setPlaylistIndex(0); + await playSong(targetSongs[0], 0); + setToast({ message: `已开始播放 ${title}`, type: 'success', onClose: () => setToast(null) }); + } catch (error) { + console.error('播放全部失败:', error); + setToast({ message: '播放全部失败', type: 'error', onClose: () => setToast(null) }); + } finally { + setLoadingCurrentPlayAll(false); + } + }; + + const addSongToQueue = async (song: Song) => { + if (!currentSong && playlist.length === 0 && playRecords.length === 0) { + await 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); + setToast({ message: '已添加到稍后播放', type: 'success', onClose: () => setToast(null) }); + }; + // 播放歌曲 const playSong = async (song: Song, index: number) => { beginResolving(); @@ -2285,6 +2358,48 @@ export default function MusicClient({ children: _children }: { children?: React. }; }, []); + + useEffect(() => { + const handlePlaySongEvent = (event: Event) => { + const detail = (event as CustomEvent<{ song: Song; index?: number }>).detail; + if (detail?.song) void playSong(detail.song, detail.index ?? -1); + }; + + 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 || '当前列表'); + }; + + const handleAddToPlaylistEvent = (event: Event) => { + const detail = (event as CustomEvent<{ song: Song }>).detail; + if (detail?.song) { + setSongToAddToPlaylist(detail.song); + setShowAddToPlaylistModal(true); + } + }; + + const handlePlayLaterEvent = (event: Event) => { + const detail = (event as CustomEvent<{ song: Song }>).detail; + if (!detail?.song) return; + void addSongToQueue(detail.song); + }; + + window.addEventListener('music:play-song', handlePlaySongEvent); + window.addEventListener('music:play-all', handlePlayAllEvent); + window.addEventListener('music:add-to-playlist', handleAddToPlaylistEvent); + window.addEventListener('music:play-later', handlePlayLaterEvent); + return () => { + window.removeEventListener('music:play-song', handlePlaySongEvent); + window.removeEventListener('music:play-all', handlePlayAllEvent); + window.removeEventListener('music:add-to-playlist', handleAddToPlaylistEvent); + window.removeEventListener('music:play-later', handlePlayLaterEvent); + }; + }, [songs, playlist, playRecords, currentSong, quality, currentSource]); + return (
<> @@ -2493,7 +2608,7 @@ export default function MusicClient({ children: _children }: { children?: React. )} {/* Header */}
-
+
-
- {(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}
-
- - -
- ))} -
- )} -
- ) : ( -
-
- - - -

选择一个歌单查看详情

-
-
- )} -
-
- )} + {_children}
@@ -3004,19 +2736,13 @@ export default function MusicClient({ children: _children }: { children?: React.
{currentSong.name}
- {showStreamBuffering && ( - - - 缓冲中 - - )}
{currentSong.artist}
{/* Controls */} -
+
+ {showStreamBuffering && ( + + + 缓冲中 + + )}
{/* Right Controls */} @@ -3238,7 +2970,7 @@ export default function MusicClient({ children: _children }: { children?: React. {/* Mini Player Controls */}
{/* 上排:播放控制按钮 */} -
+
+ {showStreamBuffering && ( + + + 缓冲中 + + )}
{/* 下排:其他按钮(小一号) */} @@ -3417,14 +3155,6 @@ export default function MusicClient({ children: _children }: { children?: React. {/* 进度条 */}
- {showStreamBuffering && ( -
- - - 缓冲中 - -
- )} {showSpectrum && (
{formatTime(currentTime)} @@ -3571,7 +3301,7 @@ export default function MusicClient({ children: _children }: { children?: React. console.error('删除播放记录失败:', error); } }} - className="w-8 h-8 rounded-lg bg-red-500/20 hover:bg-red-500/30 flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100 shrink-0" + className="w-8 h-8 rounded-lg border border-red-500/30 bg-red-500/15 hover:bg-red-500/30 flex items-center justify-center transition-colors opacity-100 shrink-0" title="删除" > diff --git a/src/app/music/SongList.tsx b/src/app/music/SongList.tsx new file mode 100644 index 0000000..372f107 --- /dev/null +++ b/src/app/music/SongList.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { addMusicSongToPlaylist, playMusicLater, playMusicSong } from '@/lib/music/actions'; +import type { Song } from './MusicClient'; +import { SourcePill } from '@/lib/music/shared'; + +export default function SongList({ songs }: { songs: Song[] }) { + return ( +
+ {songs.map((song, index) => ( +
+
playMusicSong(song, index)}> + {index + 1} +
+
playMusicSong(song, index)}> +
{song.name}
+
{song.artist}
+
+
playMusicSong(song, index)}> + {song.artist} +
+
playMusicSong(song, index)}> + +
+
+ + +
+
+ ))} +
+ ); +} diff --git a/src/app/music/my-playlists/page.tsx b/src/app/music/my-playlists/page.tsx index e9dde71..d00f140 100644 --- a/src/app/music/my-playlists/page.tsx +++ b/src/app/music/my-playlists/page.tsx @@ -1,3 +1,185 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { playMusicList, playMusicSong } from '@/lib/music/actions'; +import { MusicLoadingIndicator } from '../MusicClient'; +import { mapSong, SourcePill } from '@/lib/music/shared'; + export default function MusicMyPlaylistsPage() { - return 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 [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') + .then((res) => res.json()) + .then((data) => setUserPlaylists(data.data?.playlists || [])) + .catch(() => setUserPlaylists([])) + .finally(() => setLoadingUserPlaylists(false)); + }, []); + + useEffect(() => { + loadUserPlaylists(); + }, [loadUserPlaylists]); + + const normalizePlaylistSong = (song: any) => mapSong({ + ...song, + id: song.songId || song.id, + platform: song.source || song.platform, + pic: song.cover || song.pic, + duration: song.durationSec || song.duration, + }); + + const loadUserPlaylistSongs = useCallback((playlistId: string) => { + setLoadingUserPlaylistSongs(true); + fetch(`/api/music/v2/playlists/${playlistId}/songs`) + .then((res) => res.json()) + .then((data) => setUserPlaylistSongs(data.data?.songs || [])) + .catch(() => setUserPlaylistSongs([])) + .finally(() => setLoadingUserPlaylistSongs(false)); + }, []); + + const selectPlaylist = (playlist: any) => { + setSelectedUserPlaylist(playlist); + loadUserPlaylistSongs(playlist.id); + }; + + const deleteUserPlaylist = async (playlistId: string) => { + if (!window.confirm('确定要删除这个歌单吗?')) return; + + setDeletingPlaylistId(playlistId); + try { + const response = await fetch(`/api/music/v2/playlists/${playlistId}`, { method: 'DELETE' }); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + window.alert(getApiErrorMessage(data.error, '删除失败')); + return; + } + + if (selectedUserPlaylist?.id === playlistId) { + setSelectedUserPlaylist(null); + setUserPlaylistSongs([]); + } + loadUserPlaylists(); + } catch (error) { + console.error('删除歌单失败:', error); + window.alert('删除歌单失败'); + } finally { + setDeletingPlaylistId(null); + } + }; + + const removeSongFromUserPlaylist = async (song: any) => { + if (!selectedUserPlaylist) return; + if (!window.confirm(`确定要从歌单中移除 "${song.name}" 吗?`)) return; + + setRemovingSongId(song.id); + try { + const response = await fetch( + `/api/music/v2/playlists/${selectedUserPlaylist.id}/songs?songId=${encodeURIComponent(song.id)}`, + { method: 'DELETE' } + ); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + window.alert(getApiErrorMessage(data.error, '移除失败')); + return; + } + + loadUserPlaylistSongs(selectedUserPlaylist.id); + } catch (error) { + console.error('移除歌曲失败:', error); + window.alert('移除歌曲失败'); + } finally { + setRemovingSongId(null); + } + }; + + const mappedSongs = userPlaylistSongs.map(normalizePlaylistSong); + + return ( +
+
+
+

歌单列表

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

{selectedUserPlaylist.name}

+ {selectedUserPlaylist.description &&

{selectedUserPlaylist.description}

} +
+
+ + +
+
+ {loadingUserPlaylistSongs ? : mappedSongs.length === 0 ?
歌单为空
: ( +
+ {mappedSongs.map((song, index) => ( +
+
{index + 1}
+ {song.pic && {song.name}} +
+
{song.name}
+
{song.artist}
+
+ + +
+ ))} +
+ )} +
+ ) : ( +
选择一个歌单查看详情
+ )} +
+
+ ); } diff --git a/src/app/music/rankings/[source]/[playlistId]/page.tsx b/src/app/music/rankings/[source]/[playlistId]/page.tsx index 5bd6892..4dad364 100644 --- a/src/app/music/rankings/[source]/[playlistId]/page.tsx +++ b/src/app/music/rankings/[source]/[playlistId]/page.tsx @@ -1,3 +1,47 @@ +'use client'; + +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 { mapSong, normalizeSource } from '@/lib/music/shared'; + export default function MusicRankingDetailPage() { - return null; + const params = useParams<{ source: string; playlistId: string }>(); + const searchParams = useSearchParams(); + const source = normalizeSource(params.source); + const playlistId = decodeURIComponent(params.playlistId); + const title = searchParams.get('name') || '排行榜'; + const [songs, setSongs] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + fetch(`/api/music/v2/discovery/board-songs?source=${source}&boardId=${encodeURIComponent(playlistId)}`) + .then((res) => res.json()) + .then((data) => setSongs((data.data?.list || []).map(mapSong))) + .catch(() => setSongs([])) + .finally(() => setLoading(false)); + }, [source, playlistId]); + + return loading ? : ( +
+
+
+

{title}

+ {songs.length} 首歌曲 +
+ +
+ +
+ ); } diff --git a/src/app/music/rankings/page.tsx b/src/app/music/rankings/page.tsx index afbce2d..7582b25 100644 --- a/src/app/music/rankings/page.tsx +++ b/src/app/music/rankings/page.tsx @@ -1,3 +1,77 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { MusicLoadingIndicator, type Playlist } from '../MusicClient'; +import { musicSources, normalizeSource } from '@/lib/music/shared'; + export default function MusicRankingsPage() { - return null; + const router = useRouter(); + const searchParams = useSearchParams(); + const [currentSource, setCurrentSource] = useState(normalizeSource(searchParams.get('source'))); + const [playlists, setPlaylists] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const source = normalizeSource(searchParams.get('source')); + setCurrentSource(source); + setLoading(true); + fetch(`/api/music/v2/discovery/boards?source=${source}`) + .then((res) => res.json()) + .then((data) => { + if (data.success) { + setPlaylists((data.data?.list || []).map((item: any) => ({ + id: item.id, + name: item.name, + source: normalizeSource(item.source || data.data?.source || source), + updateFrequency: item.updateFrequency || item.description || '', + }))); + } else { + setPlaylists([]); + } + }) + .catch(() => setPlaylists([])) + .finally(() => setLoading(false)); + }, [searchParams]); + + return ( +
+
+ {musicSources.map((source) => ( + + ))} +
+
+

排行榜

+
+ {loading ? : playlists.length > 0 ? ( +
+ {playlists.map((playlist, index) => ( + + ))} +
+ ) : ( +
当前音源暂无排行榜
+ )} +
+ ); } diff --git a/src/app/music/search/page.tsx b/src/app/music/search/page.tsx index 1394ff2..f041d47 100644 --- a/src/app/music/search/page.tsx +++ b/src/app/music/search/page.tsx @@ -1,3 +1,59 @@ +'use client'; + +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 { mapSong, normalizeSource } from '@/lib/music/shared'; + export default function MusicSearchPage() { - return null; + const router = useRouter(); + const searchParams = useSearchParams(); + const source = normalizeSource(searchParams.get('source')); + const q = searchParams.get('q') || ''; + const [keyword, setKeyword] = useState(q); + const [songs, setSongs] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setKeyword(q); + if (!q) { + setSongs([]); + return; + } + setLoading(true); + fetch(`/api/music/v2/search?source=${source}&q=${encodeURIComponent(q)}&page=1&limit=20`) + .then((res) => res.json()) + .then((data) => setSongs((data.data?.list || []).map(mapSong))) + .catch(() => setSongs([])) + .finally(() => setLoading(false)); + }, [source, q]); + + const submit = () => { + const next = keyword.trim(); + if (next) router.push(`/music/search?source=${source}&q=${encodeURIComponent(next)}`); + }; + + return ( +
+
+
+
+ +
+ setKeyword(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} className="h-full w-full border-0 bg-transparent pl-10 pr-4 text-sm text-white outline-none placeholder:text-zinc-500" placeholder="搜索歌曲或艺术家..." /> +
+ +
+
+
+

{q ? `搜索: ${q}` : '搜索'}

+ {songs.length} 首歌曲 +
+ +
+ {loading ? : q ? :
输入关键词开始搜索
} +
+ ); } diff --git a/src/lib/music/actions.ts b/src/lib/music/actions.ts new file mode 100644 index 0000000..44bcf42 --- /dev/null +++ b/src/lib/music/actions.ts @@ -0,0 +1,19 @@ +'use client'; + +import type { Song } from '@/app/music/MusicClient'; + +export function playMusicSong(song: Song, index = -1) { + window.dispatchEvent(new CustomEvent('music:play-song', { detail: { song, index } })); +} + +export function playMusicList(songs: Song[], title?: string) { + window.dispatchEvent(new CustomEvent('music:play-all', { detail: { songs, title } })); +} + +export function addMusicSongToPlaylist(song: Song) { + window.dispatchEvent(new CustomEvent('music:add-to-playlist', { detail: { song } })); +} + +export function playMusicLater(song: Song) { + window.dispatchEvent(new CustomEvent('music:play-later', { detail: { song } })); +} diff --git a/src/lib/music/shared.ts b/src/lib/music/shared.ts new file mode 100644 index 0000000..331c1c6 --- /dev/null +++ b/src/lib/music/shared.ts @@ -0,0 +1,42 @@ +import React from 'react'; +import type { MusicSource, Song } from '@/app/music/MusicClient'; + +export const musicSources: Array<{ key: MusicSource; label: string }> = [ + { key: 'wy', label: '网易云' }, + { key: 'tx', label: 'QQ' }, + { key: 'kw', label: '酷我' }, + { key: 'kg', label: '酷狗' }, + { key: 'mg', label: '咪咕' }, +]; + +export function normalizeSource(source: string | undefined | null): MusicSource { + 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; + return { + id: String(song.id ?? song.songId ?? song.rid ?? song.mid ?? ''), + name: song.name || song.title || '未知歌曲', + artist: song.artist || song.singer || song.artists || '未知艺术家', + album: song.album || song.albumName, + pic: song.pic || song.cover || song.img, + platform: normalizeSource(rawSource), + duration: song.duration, + durationText: song.durationText, + songmid: song.songmid || song.mid, + }; +} + +export function SourcePill({ source, className = '' }: { source?: MusicSource | string; className?: string }) { + const normalized = normalizeSource(source); + const label = musicSources.find((item) => item.key === normalized)?.label || source || '未知'; + 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}`, + }, + label + ); +}