彻底抽离music组件和路由

This commit is contained in:
mtvpls
2026-05-27 00:04:58 +08:00
parent e60f8cc46f
commit 4992b9fffb
13 changed files with 245 additions and 934 deletions
+21 -906
View File
@@ -1,12 +1,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
'use client'; 'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import AddToPlaylistModal from '@/components/AddToPlaylistModal'; import AddToPlaylistModal from '@/components/AddToPlaylistModal';
import Toast, { ToastProps } from '@/components/Toast'; import Toast, { ToastProps } from '@/components/Toast';
import LyricsPiPWindow from '@/components/LyricsPiPWindow'; 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_BIN_COUNT = 96;
const SPECTRUM_IDLE_LEVEL = 0.02; const SPECTRUM_IDLE_LEVEL = 0.02;
@@ -15,30 +18,6 @@ const SPECTRUM_REFERENCE_VOLUME = 10;
const SPECTRUM_MIN_VOLUME = 5; const SPECTRUM_MIN_VOLUME = 5;
const SPECTRUM_MAX_REFERENCE_VOLUME = 15; 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 { interface PlayRecord {
platform: MusicSource; platform: MusicSource;
id: string; id: string;
@@ -53,14 +32,6 @@ interface LyricLine {
translation?: string; translation?: string;
} }
export interface Playlist {
id: string;
name: string;
pic?: string;
source?: MusicSource;
updateFrequency?: string;
}
interface DbRecord { interface DbRecord {
source: MusicSource; source: MusicSource;
songId: string; songId: string;
@@ -77,38 +48,6 @@ interface DbRecord {
songmid?: string; 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 (
<div className={`flex items-center justify-center gap-3 text-zinc-400 ${className}`}>
<div className="flex items-end gap-1.5">
{[0, 1, 2].map((index) => (
<svg
key={index}
className={`${iconSize} text-green-400`}
fill="currentColor"
viewBox="0 0 24 24"
style={{ animation: `music-note-bounce 0.9s ease-in-out ${index * 0.14}s infinite` }}
>
<path d="M12 3v11.55A3.98 3.98 0 0010 14c-2.21 0-4 1.34-4 3s1.79 3 4 3 4-1.34 4-3V8h4V3h-6z" />
</svg>
))}
</div>
{text ? <span className={`${textSize} font-medium tracking-wide`}>{text}</span> : null}
</div>
);
}
function AudioSpectrumCanvas({ function AudioSpectrumCanvas({
bars, bars,
compact = false, compact = false,
@@ -274,17 +213,7 @@ declare global {
export default function MusicClient({ children: _children }: { children?: React.ReactNode }) { export default function MusicClient({ children: _children }: { children?: React.ReactNode }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams();
const [currentSource, setCurrentSource] = useState<MusicSource>('wy'); const [currentSource, setCurrentSource] = useState<MusicSource>('wy');
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [songs, setSongs] = useState<Song[]>([]);
const [currentView, setCurrentView] = useState<'playlists' | 'songs' | 'myPlaylists' | 'search'>('playlists');
const [currentPlaylistTitle, setCurrentPlaylistTitle] = useState('');
const [searchKeyword, setSearchKeyword] = useState('');
const [activeSearchKeyword, setActiveSearchKeyword] = useState('');
const [searchPage, setSearchPage] = useState(1);
const [searchHasMore, setSearchHasMore] = useState(false);
const [loadingMoreSearch, setLoadingMoreSearch] = useState(false);
const [currentSong, setCurrentSong] = useState<Song | null>(null); const [currentSong, setCurrentSong] = useState<Song | null>(null);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = 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 [playMode, setPlayMode] = useState<'loop' | 'single' | 'random'>('loop');
const [currentSongIndex, setCurrentSongIndex] = useState(-1); const [currentSongIndex, setCurrentSongIndex] = useState(-1);
const [showPlayer, setShowPlayer] = useState(false); const [showPlayer, setShowPlayer] = useState(false);
const [loading, setLoading] = useState(false);
const [showLyrics, setShowLyrics] = useState(false); const [showLyrics, setShowLyrics] = useState(false);
const [mobileLyricsView, setMobileLyricsView] = useState<'lyrics' | 'vinyl'>('lyrics'); const [mobileLyricsView, setMobileLyricsView] = useState<'lyrics' | 'vinyl'>('lyrics');
const [musicProxyEnabled, setMusicProxyEnabled] = useState(() => { const [musicProxyEnabled, setMusicProxyEnabled] = useState(() => {
@@ -310,7 +238,6 @@ export default function MusicClient({ children: _children }: { children?: React.
const [showPlaylist, setShowPlaylist] = useState(false); const [showPlaylist, setShowPlaylist] = useState(false);
const [playlistIndex, setPlaylistIndex] = useState(-1); // 当前在播放列表中的索引 const [playlistIndex, setPlaylistIndex] = useState(-1); // 当前在播放列表中的索引
const [showQualityMenu, setShowQualityMenu] = useState(false); // 音质选择菜单 const [showQualityMenu, setShowQualityMenu] = useState(false); // 音质选择菜单
const [showSourceMenu, setShowSourceMenu] = useState(false); // 移动端音源菜单
const [showSidebarDrawer, setShowSidebarDrawer] = useState(false); // 左侧抽屉菜单 const [showSidebarDrawer, setShowSidebarDrawer] = useState(false); // 左侧抽屉菜单
const [showVolumeSlider, setShowVolumeSlider] = useState(false); // 音量滑块显示状态 const [showVolumeSlider, setShowVolumeSlider] = useState(false); // 音量滑块显示状态
const [pendingSongToPlay, setPendingSongToPlay] = useState<{ platform: string; id: string } | null>(null); // 待播放的歌曲信息 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 [showAddToPlaylistModal, setShowAddToPlaylistModal] = useState(false); // 添加到歌单弹窗
const [songToAddToPlaylist, setSongToAddToPlaylist] = useState<Song | null>(null); // 要添加到歌单的歌曲 const [songToAddToPlaylist, setSongToAddToPlaylist] = useState<Song | null>(null); // 要添加到歌单的歌曲
// 我的歌单相关状态
const [userPlaylists, setUserPlaylists] = useState<any[]>([]);
const [selectedUserPlaylist, setSelectedUserPlaylist] = useState<any | null>(null);
const [userPlaylistSongs, setUserPlaylistSongs] = useState<any[]>([]);
const [loadingUserPlaylists, setLoadingUserPlaylists] = useState(false);
const [loadingUserPlaylistSongs, setLoadingUserPlaylistSongs] = useState(false);
const [loadingPlayAll, setLoadingPlayAll] = useState(false); // 播放全部加载状态
const [loadingCurrentPlayAll, setLoadingCurrentPlayAll] = useState(false); // 当前排行榜/详情页播放全部加载状态
const [deletingPlaylistId, setDeletingPlaylistId] = useState<string | null>(null); // 正在删除的歌单ID
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined' && !(window as any).RUNTIME_CONFIG?.MUSIC_ENABLED) { if (typeof window !== 'undefined' && !(window as any).RUNTIME_CONFIG?.MUSIC_ENABLED) {
router.replace('/'); router.replace('/');
@@ -364,7 +281,6 @@ export default function MusicClient({ children: _children }: { children?: React.
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const lyricsContainerRef = useRef<HTMLDivElement>(null); const lyricsContainerRef = useRef<HTMLDivElement>(null);
const searchLoadMoreRef = useRef<HTMLDivElement>(null);
const lastSaveTimeRef = useRef<number>(0); const lastSaveTimeRef = useRef<number>(0);
const restoredTimeRef = useRef<number>(0); const restoredTimeRef = useRef<number>(0);
const songStartTimeRef = useRef<number>(0); // 歌曲开始播放的时间戳 const songStartTimeRef = useRef<number>(0); // 歌曲开始播放的时间戳
@@ -380,66 +296,6 @@ export default function MusicClient({ children: _children }: { children?: React.
const currentSongRef = useRef<Song | null>(null); const currentSongRef = useRef<Song | null>(null);
const currentSourceRef = useRef(currentSource); const currentSourceRef = useRef(currentSource);
const mapSong = (song: any): Song => ({
id: song.songId || song.id,
name: song.name,
artist: song.artist,
album: song.album,
pic: song.cover || song.pic,
platform: normalizeSource(song.source || song.platform),
duration: song.durationSec || song.duration,
durationText: song.durationText || song.interval,
songmid: song.songmid,
});
const normalizeSource = (source: string | undefined): MusicSource => {
switch (source) {
case 'netease': return 'wy';
case 'qq': return 'tx';
case 'kuwo': return 'kw';
case 'wy':
case 'tx':
case 'kw':
case 'kg':
case 'mg':
return source;
default:
return 'wy';
}
};
const musicSources: Array<{ key: MusicSource; label: string }> = [
{ key: 'wy', label: '网易云' },
{ key: 'tx', label: 'QQ' },
{ key: 'kw', label: '酷我' },
{ key: 'kg', label: '酷狗' },
{ key: 'mg', label: '咪咕' },
];
const getSourceDisplayLabel = (source?: MusicSource | string, compact = true) => {
switch (normalizeSource(source)) {
case 'wy': return compact ? '网易' : '网易云';
case 'tx': return compact ? 'QQ' : 'QQ音乐';
case 'kw': return '酷我';
case 'kg': return '酷狗';
case 'mg': return '咪咕';
}
};
const SourcePill = ({
source,
className = '',
}: {
source?: MusicSource | string;
className?: string;
}) => (
<span
className={`inline-flex shrink-0 items-center rounded-lg border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-[10px] font-medium leading-none text-red-400 ${className}`}
>
{getSourceDisplayLabel(source)}
</span>
);
const buildStreamUrl = (song: Song, source: MusicSource, songQuality: MusicQuality) => { const buildStreamUrl = (song: Song, source: MusicSource, songQuality: MusicQuality) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
songId: song.id, songId: song.id,
@@ -548,10 +404,7 @@ export default function MusicClient({ children: _children }: { children?: React.
const playState = { const playState = {
currentSong, currentSong,
currentSongIndex, currentSongIndex,
songs,
currentPlaylistTitle,
currentSource, currentSource,
currentView,
quality, quality,
playMode, playMode,
volume, volume,
@@ -645,10 +498,7 @@ export default function MusicClient({ children: _children }: { children?: React.
const playState = savedPlayState ? JSON.parse(savedPlayState) : {}; const playState = savedPlayState ? JSON.parse(savedPlayState) : {};
// 恢复配置状态(不包括歌曲) // 恢复配置状态(不包括歌曲)
setSongs(playState.songs || []);
setCurrentPlaylistTitle(playState.currentPlaylistTitle || '');
setCurrentSource(normalizeSource(playState.currentSource)); setCurrentSource(normalizeSource(playState.currentSource));
setCurrentView(playState.currentView || 'playlists');
setQuality(playState.quality || '320k'); setQuality(playState.quality || '320k');
setPlayMode(playState.playMode || 'loop'); setPlayMode(playState.playMode || 'loop');
setVolume(playState.volume || 100); setVolume(playState.volume || 100);
@@ -763,7 +613,7 @@ export default function MusicClient({ children: _children }: { children?: React.
if (currentSong) { if (currentSong) {
savePlayState(); 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(() => { useEffect(() => {
currentSongRef.current = currentSong; currentSongRef.current = currentSong;
@@ -792,518 +642,7 @@ export default function MusicClient({ children: _children }: { children?: React.
} }
}, [volume]); }, [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) => { const handlePlayAllCurrentSongsWith = async (targetSongs: Song[], title: string) => {
setLoadingCurrentPlayAll(true);
try { try {
if (targetSongs.length === 0) { if (targetSongs.length === 0) {
setToast({ message: '当前列表为空', type: 'error', onClose: () => setToast(null) }); setToast({ message: '当前列表为空', type: 'error', onClose: () => setToast(null) });
@@ -1350,8 +689,6 @@ export default function MusicClient({ children: _children }: { children?: React.
} catch (error) { } catch (error) {
console.error('播放全部失败:', error); console.error('播放全部失败:', error);
setToast({ message: '播放全部失败', type: 'error', onClose: () => setToast(null) }); setToast({ message: '播放全部失败', type: 'error', onClose: () => setToast(null) });
} finally {
setLoadingCurrentPlayAll(false);
} }
}; };
@@ -1588,27 +925,19 @@ export default function MusicClient({ children: _children }: { children?: React.
// 上一曲 // 上一曲
const playPrev = () => { const playPrev = () => {
// 优先从播放列表切换
if (playlist.length > 0) { if (playlist.length > 0) {
// 如果已经是第一首,循环到最后一首
const prevIndex = playlistIndex > 0 ? playlistIndex - 1 : playlist.length - 1; const prevIndex = playlistIndex > 0 ? playlistIndex - 1 : playlist.length - 1;
setPlaylistIndex(prevIndex); setPlaylistIndex(prevIndex);
playSong(playlist[prevIndex], -1); playSong(playlist[prevIndex], -1);
} else if (currentSongIndex > 0) {
playSong(songs[currentSongIndex - 1], currentSongIndex - 1);
} }
}; };
// 下一曲 // 下一曲
const playNext = () => { const playNext = () => {
// 优先从播放列表切换
if (playlist.length > 0) { if (playlist.length > 0) {
// 如果已经是最后一首,循环到第一首
const nextIndex = playlistIndex < playlist.length - 1 ? playlistIndex + 1 : 0; const nextIndex = playlistIndex < playlist.length - 1 ? playlistIndex + 1 : 0;
setPlaylistIndex(nextIndex); setPlaylistIndex(nextIndex);
playSong(playlist[nextIndex], -1); 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]); setPlayMode(modes[nextIndex]);
}; };
// 返回
const goBack = () => {
if (currentView === 'songs' || currentView === 'search' || currentView === 'myPlaylists') {
router.push('/music/rankings');
} else {
router.back();
}
};
// 下载歌曲 // 下载歌曲
const downloadSong = () => { const downloadSong = () => {
if (!currentSongUrl || !currentSong) return; if (!currentSongUrl || !currentSong) return;
@@ -1843,64 +1163,6 @@ export default function MusicClient({ children: _children }: { children?: React.
document.body.removeChild(link); 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(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
@@ -2008,14 +1270,10 @@ export default function MusicClient({ children: _children }: { children?: React.
audio.currentTime = 0; audio.currentTime = 0;
audio.play(); audio.play();
} else if (playMode === 'random') { } else if (playMode === 'random') {
// 优先从播放列表中随机选择
if (playlist.length > 0) { if (playlist.length > 0) {
const randomIndex = Math.floor(Math.random() * playlist.length); const randomIndex = Math.floor(Math.random() * playlist.length);
setPlaylistIndex(randomIndex); setPlaylistIndex(randomIndex);
playSong(playlist[randomIndex], -1); playSong(playlist[randomIndex], -1);
} else if (songs.length > 0) {
const randomIndex = Math.floor(Math.random() * songs.length);
playSong(songs[randomIndex], randomIndex);
} }
} else { } else {
playNext(); playNext();
@@ -2049,19 +1307,7 @@ export default function MusicClient({ children: _children }: { children?: React.
audio.removeEventListener('durationchange', handleDurationChange); audio.removeEventListener('durationchange', handleDurationChange);
audio.removeEventListener('ended', handleEnded); audio.removeEventListener('ended', handleEnded);
}; };
}, [playMode, songs, currentSongIndex, lyrics, currentSong, playlistIndex, playRecords, quality]); }, [playMode, currentSongIndex, lyrics, currentSong, playlistIndex, playRecords, quality]);
// 初始加载
useEffect(() => {
loadPlaylists(currentSource);
}, [currentSource]);
// 当切换到我的歌单视图时加载歌单列表
useEffect(() => {
if (currentView === 'myPlaylists') {
loadUserPlaylists();
}
}, [currentView]);
// 歌词自动滚动 // 歌词自动滚动
useEffect(() => { useEffect(() => {
@@ -2077,34 +1323,6 @@ export default function MusicClient({ children: _children }: { children?: React.
} }
}, [currentLyricIndex]); }, [currentLyricIndex]);
// 搜索框回车
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
searchSongs();
}
};
useEffect(() => {
const sentinel = searchLoadMoreRef.current;
if (!sentinel || !activeSearchKeyword || !searchHasMore) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
loadMoreSearchSongs();
}
},
{
root: null,
rootMargin: '240px 0px 240px 0px',
threshold: 0,
}
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [activeSearchKeyword, searchHasMore, loadingMoreSearch, searchPage]);
// 进度条拖动 // 进度条拖动
const handleProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = (parseFloat(e.target.value) / 100) * duration; const newTime = (parseFloat(e.target.value) / 100) * duration;
@@ -2229,13 +1447,7 @@ export default function MusicClient({ children: _children }: { children?: React.
}; };
const getSourceLabel = () => { const getSourceLabel = () => {
switch (currentSource) { return getSourceDisplayLabel(currentSource, false);
case 'wy': return '网易云';
case 'tx': return 'QQ音乐';
case 'kw': return '酷我';
case 'kg': return '酷狗';
case 'mg': return '咪咕';
}
}; };
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
@@ -2377,9 +1589,6 @@ export default function MusicClient({ children: _children }: { children?: React.
const handlePlayAllEvent = (event: Event) => { const handlePlayAllEvent = (event: Event) => {
const detail = (event as CustomEvent<{ songs: Song[]; title?: string }>).detail; const detail = (event as CustomEvent<{ songs: Song[]; title?: string }>).detail;
if (!detail?.songs?.length) return; if (!detail?.songs?.length) return;
setSongs(detail.songs);
setCurrentPlaylistTitle(detail.title || '当前列表');
setActiveSearchKeyword('');
void handlePlayAllCurrentSongsWith(detail.songs, detail.title || '当前列表'); 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:add-to-playlist', handleAddToPlaylistEvent);
window.removeEventListener('music:play-later', handlePlayLaterEvent); window.removeEventListener('music:play-later', handlePlayLaterEvent);
}; };
}, [songs, playlist, playRecords, currentSong, quality, currentSource]); }, [playlist, playRecords, currentSong, quality, currentSource]);
return ( return (
<div className="music-theme min-h-screen bg-zinc-950 text-white"> <div className="music-theme min-h-screen bg-zinc-950 text-white">
@@ -2702,7 +1911,7 @@ export default function MusicClient({ children: _children }: { children?: React.
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5"> <div className="flex min-w-0 items-center gap-1.5">
<div className="min-w-0 truncate text-sm font-bold text-white">{currentSong.name}</div> <div className="min-w-0 truncate text-sm font-bold text-white">{currentSong.name}</div>
<SourcePill source={currentSong.platform} className="hidden sm:inline-flex" /> <SourcePill source={currentSong.platform} variant="accent" className="hidden sm:inline-flex" />
</div> </div>
<div className="text-xs text-zinc-500 truncate">{currentSong.artist}</div> <div className="text-xs text-zinc-500 truncate">{currentSong.artist}</div>
</div> </div>
@@ -2871,7 +2080,7 @@ export default function MusicClient({ children: _children }: { children?: React.
</div> </div>
<div className="mb-1 flex min-w-0 items-center justify-center gap-1.5"> <div className="mb-1 flex min-w-0 items-center justify-center gap-1.5">
<h2 className="min-w-0 truncate text-center text-base md:text-xl lg:text-2xl font-bold text-white">{currentSong.name}</h2> <h2 className="min-w-0 truncate text-center text-base md:text-xl lg:text-2xl font-bold text-white">{currentSong.name}</h2>
<SourcePill source={currentSong.platform} /> <SourcePill source={currentSong.platform} variant="accent" />
</div> </div>
<p className="text-xs md:text-sm lg:text-base text-zinc-400 line-clamp-1 text-center">{currentSong.artist}</p> <p className="text-xs md:text-sm lg:text-base text-zinc-400 line-clamp-1 text-center">{currentSong.artist}</p>
</div> </div>
@@ -3234,7 +2443,7 @@ export default function MusicClient({ children: _children }: { children?: React.
}`}> }`}>
{song.name} {song.name}
</div> </div>
<SourcePill source={song.platform} /> <SourcePill source={song.platform} variant="accent" />
</div> </div>
<div className="text-xs text-zinc-500 truncate">{song.artist}</div> <div className="text-xs text-zinc-500 truncate">{song.artist}</div>
</div> </div>
@@ -3422,95 +2631,13 @@ export default function MusicClient({ children: _children }: { children?: React.
)} )}
{showSidebarDrawer && ( <MusicSidebarDrawer
<div className="fixed inset-0 z-[95]"> currentSource={currentSource}
<button isOpen={showSidebarDrawer}
className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-in fade-in duration-300" pathname={pathname}
onClick={() => setShowSidebarDrawer(false)} onClose={() => setShowSidebarDrawer(false)}
aria-label="关闭菜单" onNavigate={(href) => router.push(href)}
/> />
<aside className="absolute left-0 top-0 flex h-full w-72 max-w-[85vw] flex-col border-r border-white/10 bg-zinc-950/80 backdrop-blur-xl px-5 py-6 shadow-[20px_0_40px_rgba(0,0,0,0.5)] animate-in slide-in-from-left duration-300 ease-out">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-green-400 to-emerald-600 text-white shadow-lg shadow-green-500/20">
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
</svg>
</div>
<div>
<div className="text-lg font-bold text-white tracking-wide"></div>
<div className="text-[10px] uppercase tracking-wider text-green-400/80">Music Navigation</div>
</div>
</div>
<button
onClick={() => setShowSidebarDrawer(false)}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/5 text-zinc-400 transition-all hover:bg-white/10 hover:text-white hover:rotate-90"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav className="space-y-3 relative z-10">
{[
{ key: 'rankings', label: '排行榜', href: '/music/rankings', icon: 'M3 4h18M8 8h13M3 12h18M8 16h13M3 20h18' },
{ key: 'search', label: '搜索', href: `/music/search?source=${currentSource}`, icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' },
{ key: 'my-playlists', label: '我的歌单', href: '/music/my-playlists', icon: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z' },
].map((item) => {
const active = item.key === 'rankings'
? pathname?.startsWith('/music/rankings') || pathname === '/music'
: pathname?.startsWith(`/music/${item.key}`);
return (
<button
key={item.key}
onClick={() => {
setShowSidebarDrawer(false);
router.push(item.href);
}}
className={`group flex w-full items-center gap-4 rounded-2xl px-4 py-3.5 text-left transition-all duration-300 ${
active
? 'bg-gradient-to-r from-green-500/20 to-emerald-500/5 text-white shadow-inner border border-green-500/20'
: 'bg-transparent text-zinc-400 hover:bg-white/5 hover:text-white border border-transparent'
}`}
>
<div className={`flex items-center justify-center transition-colors ${active ? 'text-green-400' : 'text-zinc-500 group-hover:text-white'}`}>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d={item.icon} />
</svg>
</div>
<span className="text-sm font-semibold">{item.label}</span>
{active && (
<div className="ml-auto w-1.5 h-1.5 rounded-full bg-green-400 shadow-[0_0_8px_rgba(74,222,128,0.8)] animate-pulse" />
)}
</button>
);
})}
</nav>
<div className="mt-auto relative z-10 pt-6">
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
<button
onClick={() => {
setShowSidebarDrawer(false);
router.push('/');
}}
className="group flex w-full items-center gap-4 rounded-2xl border border-white/5 bg-white/5 px-4 py-3.5 text-left text-zinc-400 transition-all duration-300 hover:bg-white/10 hover:text-white hover:border-white/10 hover:shadow-lg"
>
<div className="flex items-center justify-center text-zinc-500 group-hover:text-white transition-colors">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</div>
<span className="text-sm font-semibold"></span>
</button>
</div>
{/* Ambient Background Effect */}
<div className="pointer-events-none absolute -left-20 -top-20 h-64 w-64 rounded-full bg-green-500/10 blur-[80px]" />
</aside>
</div>
)}
{/* Add to Playlist Modal */} {/* Add to Playlist Modal */}
<AddToPlaylistModal <AddToPlaylistModal
@@ -3585,27 +2712,15 @@ export default function MusicClient({ children: _children }: { children?: React.
<div className="flex justify-end space-x-3"> <div className="flex justify-end space-x-3">
<button <button
onClick={confirmModal.onCancel} onClick={confirmModal.onCancel}
disabled={deletingPlaylistId !== null} className="px-4 py-2 bg-zinc-700 hover:bg-zinc-600 text-white rounded-lg transition-colors"
className="px-4 py-2 bg-zinc-700 hover:bg-zinc-600 disabled:bg-zinc-800 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
> >
</button> </button>
<button <button
onClick={confirmModal.onConfirm} onClick={confirmModal.onConfirm}
disabled={deletingPlaylistId !== null} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors flex items-center gap-2"
className="px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-zinc-700 disabled:cursor-not-allowed text-white rounded-lg transition-colors flex items-center gap-2"
> >
{deletingPlaylistId !== null ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
...
</>
) : (
'确定'
)}
</button> </button>
</div> </div>
</div> </div>
+2 -9
View File
@@ -2,7 +2,8 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { playMusicList, playMusicSong } from '@/lib/music/actions'; 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'; import { mapSong, SourcePill } from '@/lib/music/shared';
export default function MusicMyPlaylistsPage() { export default function MusicMyPlaylistsPage() {
@@ -14,14 +15,6 @@ export default function MusicMyPlaylistsPage() {
const [deletingPlaylistId, setDeletingPlaylistId] = useState<string | null>(null); const [deletingPlaylistId, setDeletingPlaylistId] = useState<string | null>(null);
const [removingSongId, setRemovingSongId] = useState<string | null>(null); const [removingSongId, setRemovingSongId] = useState<string | null>(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(() => { const loadUserPlaylists = useCallback(() => {
setLoadingUserPlaylists(true); setLoadingUserPlaylists(true);
fetch('/api/music/v2/playlists') fetch('/api/music/v2/playlists')
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default function LegacyMusicPlaylistsPage() {
redirect('/music/rankings');
}
@@ -3,9 +3,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation'; import { useParams, useSearchParams } from 'next/navigation';
import { playMusicList } from '@/lib/music/actions'; import { playMusicList } from '@/lib/music/actions';
import { MusicLoadingIndicator, type Song } from '../../../MusicClient'; import MusicLoadingIndicator from '@/components/music/MusicLoadingIndicator';
import SongList from '../../../SongList'; import SongList from '@/components/music/SongList';
import { mapSong, normalizeSource } from '@/lib/music/shared'; import { mapSong, normalizeSource } from '@/lib/music/shared';
import type { Song } from '@/lib/music/types';
export default function MusicRankingDetailPage() { export default function MusicRankingDetailPage() {
const params = useParams<{ source: string; playlistId: string }>(); const params = useParams<{ source: string; playlistId: string }>();
+2 -1
View File
@@ -2,8 +2,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; 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 { musicSources, normalizeSource } from '@/lib/music/shared';
import type { Playlist } from '@/lib/music/types';
export default function MusicRankingsPage() { export default function MusicRankingsPage() {
const router = useRouter(); const router = useRouter();
+3 -2
View File
@@ -3,9 +3,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { playMusicList } from '@/lib/music/actions'; import { playMusicList } from '@/lib/music/actions';
import { MusicLoadingIndicator, type Song } from '../MusicClient'; import MusicLoadingIndicator from '@/components/music/MusicLoadingIndicator';
import SongList from '../SongList'; import SongList from '@/components/music/SongList';
import { mapSong, musicSources, normalizeSource } from '@/lib/music/shared'; import { mapSong, musicSources, normalizeSource } from '@/lib/music/shared';
import type { Song } from '@/lib/music/types';
export default function MusicSearchPage() { export default function MusicSearchPage() {
const router = useRouter(); const router = useRouter();
@@ -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 (
<div className={`flex items-center justify-center gap-3 text-zinc-400 ${className}`}>
<div className="flex items-end gap-1.5">
{[0, 1, 2].map((index) => (
<svg
key={index}
className={`${iconSize} text-green-400`}
fill="currentColor"
viewBox="0 0 24 24"
style={{ animation: `music-note-bounce 0.9s ease-in-out ${index * 0.14}s infinite` }}
>
<path d="M12 3v11.55A3.98 3.98 0 0010 14c-2.21 0-4 1.34-4 3s1.79 3 4 3 4-1.34 4-3V8h4V3h-6z" />
</svg>
))}
</div>
{text ? <span className={`${textSize} font-medium tracking-wide`}>{text}</span> : null}
</div>
);
}
+111
View File
@@ -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 (
<div className="fixed inset-0 z-[95]">
<button
className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-in fade-in duration-300"
onClick={onClose}
aria-label="关闭菜单"
/>
<aside className="absolute left-0 top-0 flex h-full w-72 max-w-[85vw] flex-col border-r border-white/10 bg-zinc-950/80 backdrop-blur-xl px-5 py-6 shadow-[20px_0_40px_rgba(0,0,0,0.5)] animate-in slide-in-from-left duration-300 ease-out">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-green-400 to-emerald-600 text-white shadow-lg shadow-green-500/20">
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
</svg>
</div>
<div>
<div className="text-lg font-bold text-white tracking-wide"></div>
<div className="text-[10px] uppercase tracking-wider text-green-400/80">Music Navigation</div>
</div>
</div>
<button
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/5 text-zinc-400 transition-all hover:bg-white/10 hover:text-white hover:rotate-90"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav className="space-y-3 relative z-10">
{musicNavItems.map((item) => {
const href = item.key === 'search' ? `/music/search?source=${currentSource}` : item.href ?? '/music/rankings';
const active = item.key === 'rankings'
? pathname?.startsWith('/music/rankings') || pathname === '/music'
: pathname?.startsWith(`/music/${item.key}`);
return (
<button
key={item.key}
onClick={() => navigate(href)}
className={`group flex w-full items-center gap-4 rounded-2xl px-4 py-3.5 text-left transition-all duration-300 ${
active
? 'bg-gradient-to-r from-green-500/20 to-emerald-500/5 text-white shadow-inner border border-green-500/20'
: 'bg-transparent text-zinc-400 hover:bg-white/5 hover:text-white border border-transparent'
}`}
>
<div className={`flex items-center justify-center transition-colors ${active ? 'text-green-400' : 'text-zinc-500 group-hover:text-white'}`}>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d={item.icon} />
</svg>
</div>
<span className="text-sm font-semibold">{item.label}</span>
{active && (
<div className="ml-auto w-1.5 h-1.5 rounded-full bg-green-400 shadow-[0_0_8px_rgba(74,222,128,0.8)] animate-pulse" />
)}
</button>
);
})}
</nav>
<div className="mt-auto relative z-10 pt-6">
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
<button
onClick={() => navigate('/')}
className="group flex w-full items-center gap-4 rounded-2xl border border-white/5 bg-white/5 px-4 py-3.5 text-left text-zinc-400 transition-all duration-300 hover:bg-white/10 hover:text-white hover:border-white/10 hover:shadow-lg"
>
<div className="flex items-center justify-center text-zinc-500 group-hover:text-white transition-colors">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</div>
<span className="text-sm font-semibold"></span>
</button>
</div>
<div className="pointer-events-none absolute -left-20 -top-20 h-64 w-64 rounded-full bg-green-500/10 blur-[80px]" />
</aside>
</div>
);
}
@@ -1,8 +1,8 @@
'use client'; 'use client';
import { addMusicSongToPlaylist, playMusicLater, playMusicSong } from '@/lib/music/actions'; import { addMusicSongToPlaylist, playMusicLater, playMusicSong } from '@/lib/music/actions';
import type { Song } from './MusicClient';
import { SourcePill } from '@/lib/music/shared'; import { SourcePill } from '@/lib/music/shared';
import type { Song } from '@/lib/music/types';
export default function SongList({ songs }: { songs: Song[] }) { export default function SongList({ songs }: { songs: Song[] }) {
return ( return (
+1 -1
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import type { Song } from '@/app/music/MusicClient'; import type { Song } from '@/lib/music/types';
export function playMusicSong(song: Song, index = -1) { export function playMusicSong(song: Song, index = -1) {
window.dispatchEvent(new CustomEvent('music:play-song', { detail: { song, index } })); window.dispatchEvent(new CustomEvent('music:play-song', { detail: { song, index } }));
+8
View File
@@ -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;
}
+39 -7
View File
@@ -1,5 +1,5 @@
import React from 'react'; 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 }> = [ export const musicSources: Array<{ key: MusicSource; label: string }> = [
{ key: 'wy', label: '网易云' }, { key: 'wy', label: '网易云' },
@@ -10,12 +10,15 @@ export const musicSources: Array<{ key: MusicSource; label: string }> = [
]; ];
export function normalizeSource(source: string | undefined | null): MusicSource { 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; if (source === 'wy' || source === 'tx' || source === 'kw' || source === 'kg' || source === 'mg') return source;
return 'wy'; return 'wy';
} }
export function mapSong(song: any): Song { 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 { return {
id: String(song.id ?? song.songId ?? song.rid ?? song.mid ?? ''), id: String(song.id ?? song.songId ?? song.rid ?? song.mid ?? ''),
name: song.name || song.title || '未知歌曲', name: song.name || song.title || '未知歌曲',
@@ -23,19 +26,48 @@ export function mapSong(song: any): Song {
album: song.album || song.albumName, album: song.album || song.albumName,
pic: song.pic || song.cover || song.img, pic: song.pic || song.cover || song.img,
platform: normalizeSource(rawSource), platform: normalizeSource(rawSource),
duration: song.duration, duration: song.durationSec || song.duration,
durationText: song.durationText, durationText: song.durationText || song.interval,
songmid: song.songmid || song.mid, 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 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( return React.createElement(
'span', '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 label
); );
+23
View File
@@ -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;
}