优化音乐界面
This commit is contained in:
@@ -1884,20 +1884,14 @@ export default function MusicClient({ children: _children }: { children?: React.
|
||||
}
|
||||
|
||||
if (pathname === '/music/search') {
|
||||
const source = normalizeSource(searchParams.get('source') || currentSource);
|
||||
const keyword = searchParams.get('q') || '';
|
||||
setCurrentSource(source);
|
||||
setCurrentView('search');
|
||||
setCurrentPlaylistTitle(keyword ? `搜索: ${keyword}` : '搜索');
|
||||
setSearchKeyword(keyword);
|
||||
if (keyword) {
|
||||
void searchSongs(keyword, false);
|
||||
} else {
|
||||
setSongs([]);
|
||||
setActiveSearchKeyword('');
|
||||
setSearchPage(1);
|
||||
setSearchHasMore(false);
|
||||
}
|
||||
setSongs([]);
|
||||
setActiveSearchKeyword('');
|
||||
setSearchPage(1);
|
||||
setSearchHasMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2627,48 +2621,6 @@ export default function MusicClient({ children: _children }: { children?: React.
|
||||
</div>
|
||||
<span className="font-bold text-lg text-white">音乐</span>
|
||||
</div>
|
||||
<div className="md:hidden relative">
|
||||
<button
|
||||
onClick={() => setShowSourceMenu(true)}
|
||||
className="relative h-10 min-w-[132px] rounded-full border border-white/10 bg-gradient-to-r from-white/8 to-white/4 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] px-3"
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-between px-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-6 h-6 rounded-full bg-green-500/15 text-green-400 flex items-center justify-center shrink-0">
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[9px] uppercase tracking-[0.18em] text-zinc-500 leading-none">音源</div>
|
||||
<div className="text-sm font-medium text-white leading-tight truncate">
|
||||
{musicSources.find((source) => source.key === currentSource)?.label || '酷我'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-7 h-7 rounded-full bg-white/6 border border-white/8 flex items-center justify-center text-zinc-300 shrink-0">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden md:flex flex-wrap bg-white/5 rounded-lg p-1 gap-1 border border-white/5">
|
||||
{musicSources.map((source) => (
|
||||
<button
|
||||
key={source.key}
|
||||
onClick={() => switchSource(source.key)}
|
||||
className={`px-3 py-1 md:px-4 rounded text-[10px] font-bold tracking-wider transition-all ${
|
||||
currentSource === source.key
|
||||
? 'bg-green-500 text-white border border-white/30 shadow-lg shadow-green-500/50'
|
||||
: 'text-zinc-400 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{source.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -3530,61 +3482,6 @@ export default function MusicClient({ children: _children }: { children?: React.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSourceMenu && (
|
||||
<div className="md:hidden fixed inset-0 z-[90]">
|
||||
<button
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setShowSourceMenu(false)}
|
||||
aria-label="关闭音源菜单"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 rounded-t-3xl border-t border-white/10 bg-zinc-950/98 px-4 pb-6 pt-4 shadow-2xl">
|
||||
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-white/15" />
|
||||
<div className="mb-3 px-1 text-sm font-medium text-white">切换音源</div>
|
||||
<div className="space-y-2">
|
||||
{musicSources.map((source) => {
|
||||
const active = currentSource === source.key;
|
||||
return (
|
||||
<button
|
||||
key={source.key}
|
||||
onClick={() => {
|
||||
setShowSourceMenu(false);
|
||||
if (!active) switchSource(source.key);
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition-all ${
|
||||
active
|
||||
? 'border-green-500/50 bg-green-500/12 text-white'
|
||||
: 'border-white/8 bg-white/5 text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-full ${active ? 'bg-green-500/20 text-green-400' : 'bg-white/8 text-zinc-400'}`}>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-base font-medium">{source.label}</div>
|
||||
</div>
|
||||
{active ? (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-white">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-zinc-500">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add to Playlist Modal */}
|
||||
<AddToPlaylistModal
|
||||
song={songToAddToPlaylist}
|
||||
|
||||
@@ -36,19 +36,26 @@ export default function MusicRankingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5 hidden flex-wrap gap-2 md:flex">
|
||||
{musicSources.map((source) => (
|
||||
<button
|
||||
key={source.key}
|
||||
onClick={() => router.push(`/music/rankings?source=${source.key}`)}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-bold ${currentSource === source.key ? 'border-green-500 bg-green-500 text-white' : 'border-white/10 bg-white/5 text-zinc-400'}`}
|
||||
>
|
||||
{source.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-2">
|
||||
<h2 className="text-xs font-mono text-white/50 tracking-widest">排行榜</h2>
|
||||
<div className="mb-6 flex items-center justify-between gap-3 border-b border-white/5 pb-3">
|
||||
<h2 className="shrink-0 text-xs font-mono text-white/50 tracking-widest">排行榜</h2>
|
||||
<div className="flex max-w-[calc(100vw-7rem)] justify-end overflow-x-auto rounded-full border border-white/10 bg-white/[0.04] p-1 shadow-inner shadow-black/20 md:max-w-none md:overflow-visible">
|
||||
{musicSources.map((source) => {
|
||||
const active = currentSource === source.key;
|
||||
return (
|
||||
<button
|
||||
key={source.key}
|
||||
onClick={() => router.push(`/music/rankings?source=${source.key}`)}
|
||||
className={`shrink-0 rounded-full px-3 py-1.5 text-xs font-bold transition-all ${
|
||||
active
|
||||
? 'bg-gradient-to-r from-green-500 to-emerald-400 text-white shadow-lg shadow-green-500/25'
|
||||
: 'text-zinc-400 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{source.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{loading ? <MusicLoadingIndicator className="py-8" /> : playlists.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -5,7 +5,7 @@ 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';
|
||||
import { mapSong, musicSources, normalizeSource } from '@/lib/music/shared';
|
||||
|
||||
export default function MusicSearchPage() {
|
||||
const router = useRouter();
|
||||
@@ -13,21 +13,28 @@ export default function MusicSearchPage() {
|
||||
const source = normalizeSource(searchParams.get('source'));
|
||||
const q = searchParams.get('q') || '';
|
||||
const [keyword, setKeyword] = useState(q);
|
||||
const [selectedSource, setSelectedSource] = useState(source);
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showSourceMenu, setShowSourceMenu] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSource(source);
|
||||
setKeyword(q);
|
||||
if (!q) {
|
||||
setSongs([]);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
fetch(`/api/music/v2/search?source=${source}&q=${encodeURIComponent(q)}&page=1&limit=20`)
|
||||
fetch(`/api/music/v2/search?source=${source}&q=${encodeURIComponent(q)}&page=1&limit=20`, { signal: controller.signal })
|
||||
.then((res) => res.json())
|
||||
.then((data) => setSongs((data.data?.list || []).map(mapSong)))
|
||||
.catch(() => setSongs([]))
|
||||
.catch((error) => {
|
||||
if (error?.name !== 'AbortError') setSongs([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [source, q]);
|
||||
|
||||
const submit = () => {
|
||||
@@ -35,16 +42,81 @@ export default function MusicSearchPage() {
|
||||
if (next) router.push(`/music/search?source=${source}&q=${encodeURIComponent(next)}`);
|
||||
};
|
||||
|
||||
const changeSource = (nextSource: string) => {
|
||||
const normalizedSource = normalizeSource(nextSource);
|
||||
const next = keyword.trim() || q;
|
||||
setSelectedSource(normalizedSource);
|
||||
setShowSourceMenu(false);
|
||||
router.push(`/music/search?source=${normalizedSource}${next ? `&q=${encodeURIComponent(next)}` : ''}`);
|
||||
};
|
||||
|
||||
const currentSourceLabel = musicSources.find((item) => item.key === selectedSource)?.label || '音源';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<div className="relative h-11 flex-1 rounded-xl bg-white/10">
|
||||
<div className="relative h-11 flex-1 rounded-xl border border-white/10 bg-white/10">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
|
||||
<svg className="h-4 w-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
</div>
|
||||
<input value={keyword} onChange={(e) => 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="搜索歌曲或艺术家..." />
|
||||
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} className="h-full w-full border-0 bg-transparent pl-10 pr-28 text-sm text-white outline-none placeholder:text-zinc-500" placeholder="搜索歌曲或艺术家..." />
|
||||
<div className="absolute inset-y-0 right-2 flex items-center">
|
||||
<div className="relative h-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSourceMenu((open) => !open)}
|
||||
className="flex h-8 min-w-[84px] items-center justify-between gap-1 rounded-lg border border-white/10 bg-zinc-900/90 pl-3 pr-2 text-xs font-medium text-white shadow-inner shadow-white/5 transition-colors hover:bg-zinc-800"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={showSourceMenu}
|
||||
aria-label="选择音源"
|
||||
>
|
||||
<span>{currentSourceLabel}</span>
|
||||
<svg className={`h-3.5 w-3.5 text-zinc-400 transition-transform ${showSourceMenu ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{showSourceMenu && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-40 cursor-default"
|
||||
onClick={() => setShowSourceMenu(false)}
|
||||
aria-label="关闭音源菜单"
|
||||
/>
|
||||
<div className="absolute right-0 top-10 z-50 w-32 overflow-hidden rounded-xl border border-white/10 bg-zinc-900/95 p-1 shadow-2xl shadow-black/50 backdrop-blur-md" role="listbox">
|
||||
{musicSources.map((item) => {
|
||||
const active = item.key === selectedSource;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => changeSource(item.key)}
|
||||
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-xs transition-colors ${
|
||||
active ? 'bg-green-500/20 text-green-300' : 'text-zinc-300 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{active && (
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={submit} className="h-11 rounded-xl bg-green-600 px-5 text-sm font-medium text-white hover:bg-green-700">搜索</button>
|
||||
<button onClick={submit} className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-green-600 text-white hover:bg-green-700" aria-label="搜索" title="搜索">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-2">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
|
||||
Reference in New Issue
Block a user