diff --git a/src/app/api/music/v2/discovery/album-songs/route.ts b/src/app/api/music/v2/discovery/album-songs/route.ts new file mode 100644 index 0000000..4bb4299 --- /dev/null +++ b/src/app/api/music/v2/discovery/album-songs/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { isMusicSource, lxGetJson, LxServerSong, normalizeLxSong, unwrapLxArray } from '@/lib/music-v2'; +import { badRequest, internalError } from '@/lib/music-v2-api'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id')?.trim() || ''; + const source = searchParams.get('source') || 'wy'; + + if (!id) return badRequest('缺少专辑 ID'); + if (!isMusicSource(source)) return badRequest('不支持的音源'); + + const payload = await lxGetJson(`/api/music/albumSongs?id=${encodeURIComponent(id)}&source=${source}`, 'none'); + const list = unwrapLxArray(payload); + + return NextResponse.json({ + success: true, + data: { + list: list.map(normalizeLxSong), + }, + }); + } catch (error) { + return internalError('获取专辑歌曲失败', (error as Error).message); + } +} diff --git a/src/app/api/music/v2/discovery/artist-songs/route.ts b/src/app/api/music/v2/discovery/artist-songs/route.ts new file mode 100644 index 0000000..aba18c6 --- /dev/null +++ b/src/app/api/music/v2/discovery/artist-songs/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { isMusicSource, lxGetJson, LxServerSong, normalizeLxSong, unwrapLxArray } from '@/lib/music-v2'; +import { badRequest, internalError } from '@/lib/music-v2-api'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id')?.trim() || ''; + const source = searchParams.get('source') || 'wy'; + const order = searchParams.get('order') || 'hot'; + + if (!id) return badRequest('缺少歌手 ID'); + if (!isMusicSource(source)) return badRequest('不支持的音源'); + + const payload = await lxGetJson(`/api/music/artistSongs?id=${encodeURIComponent(id)}&source=${source}&order=${encodeURIComponent(order)}`, 'none'); + const list = unwrapLxArray(payload); + + return NextResponse.json({ + success: true, + data: { + list: list.map(normalizeLxSong), + }, + }); + } catch (error) { + return internalError('获取歌手歌曲失败', (error as Error).message); + } +} diff --git a/src/app/api/music/v2/search/route.ts b/src/app/api/music/v2/search/route.ts index a84e78b..fe0367d 100644 --- a/src/app/api/music/v2/search/route.ts +++ b/src/app/api/music/v2/search/route.ts @@ -10,23 +10,36 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const q = searchParams.get('q')?.trim() || ''; const source = searchParams.get('source') || 'kw'; + const type = searchParams.get('type') || 'song'; const page = Number(searchParams.get('page') || '1'); const limit = Number(searchParams.get('limit') || '20'); if (!q) return badRequest('缺少搜索关键词'); if (!isMusicSource(source)) return badRequest('不支持的音源'); + if (!['song', 'singer', 'album'].includes(type)) return badRequest('不支持的搜索类型'); + if ((type === 'singer' || type === 'album') && source !== 'wy' && source !== 'tx') { + return badRequest('当前音源不支持歌手/专辑搜索'); + } - const list = await lxGetJson(`/api/music/search?name=${encodeURIComponent(q)}&source=${source}&page=${page}&limit=${limit}`, 'none'); + const list = await lxGetJson(`/api/music/search?name=${encodeURIComponent(q)}&source=${source}&type=${type}&page=${page}&limit=${limit}`, 'none'); - return NextResponse.json({ - success: true, - data: { - list: list.map(normalizeLxSong), - page, - limit, - hasMore: Array.isArray(list) && list.length >= limit, + return NextResponse.json( + { + success: true, + data: { + list: type === 'song' ? (list as LxServerSong[]).map(normalizeLxSong) : list, + type, + page, + limit, + hasMore: Array.isArray(list) && list.length >= limit, + }, }, - }); + { + headers: { + 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=3600', + }, + } + ); } catch (error) { return internalError('搜索歌曲失败', (error as Error).message); } diff --git a/src/app/music/search/page.tsx b/src/app/music/search/page.tsx index cb2f395..972c892 100644 --- a/src/app/music/search/page.tsx +++ b/src/app/music/search/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Flame, RefreshCw } from 'lucide-react'; import { playMusicList } from '@/lib/music/actions'; @@ -10,26 +10,140 @@ import { mapSong, musicSources, normalizeSource } from '@/lib/music/shared'; import type { Song } from '@/lib/music/types'; type HotSearchItem = { keyword: string; artist?: string }; +type SearchType = 'song' | 'singer' | 'album'; +type SingerResult = { + id: string | number; + mid?: string; + name: string; + picUrl?: string; + alias?: string[]; + albumSize?: number; + source?: string; +}; +type AlbumResult = { + id: string | number; + mid?: string; + name: string; + picUrl?: string; + artistName?: string; + size?: number; + publishTime?: string | number; + source?: string; +}; const HOT_SEARCH_CACHE_DURATION = 60 * 60 * 1000; const HOT_SEARCH_LIMIT = 20; +const searchTypeOptions: Array<{ key: SearchType; label: string }> = [ + { key: 'song', label: '歌曲' }, + { key: 'singer', label: '歌手' }, + { key: 'album', label: '专辑' }, +]; function getHotSearchCacheKey(source: string) { return `music_hot_search_${source}`; } +function formatPublishTime(value?: string | number) { + if (!value) return ''; + const date = typeof value === 'number' ? new Date(value) : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleDateString(); +} + +function SingerGrid({ singers, onOpen }: { singers: SingerResult[]; onOpen: (singer: SingerResult) => void }) { + if (singers.length === 0) return
暂无歌手结果
; + + return ( +
+ {singers.map((singer, index) => ( +
onOpen(singer)} + className="group flex cursor-pointer flex-col items-center rounded-2xl border border-transparent p-2 transition-all hover:border-emerald-500/30 hover:bg-white/5 hover:shadow-md md:p-4" + > +
+ {singer.picUrl ? ( + {singer.name} + ) : ( +
+ )} +
+
{singer.name}
+ {singer.alias?.[0] && ( +
{singer.alias[0]}
+ )} +
{singer.albumSize || 0} 专辑
+
+ ))} +
+ ); +} + +function AlbumGrid({ albums, onOpen }: { albums: AlbumResult[]; onOpen: (album: AlbumResult) => void }) { + if (albums.length === 0) return
暂无专辑结果
; + + return ( +
+ {albums.map((album, index) => ( +
onOpen(album)} + className="group flex cursor-pointer flex-col rounded-2xl border border-transparent p-3 transition-all hover:border-emerald-500/20 hover:bg-white/5 hover:shadow-lg" + > +
+ {album.picUrl ? ( + {album.name} + ) : ( +
+ )} +
+
{album.name}
+
+ {album.artistName || '未知歌手'} + {formatPublishTime(album.publishTime)} +
+
+ ))} +
+ ); +} + export default function MusicSearchPage() { const router = useRouter(); const searchParams = useSearchParams(); const source = normalizeSource(searchParams.get('source')); const q = searchParams.get('q') || ''; + const searchType = (['song', 'singer', 'album'].includes(searchParams.get('type') || '') + ? searchParams.get('type') + : 'song') as SearchType; const [keyword, setKeyword] = useState(q); const [selectedSource, setSelectedSource] = useState(source); + const [selectedType, setSelectedType] = useState(searchType); const [songs, setSongs] = useState([]); + const [singers, setSingers] = useState([]); + const [albums, setAlbums] = useState([]); const [hotSearches, setHotSearches] = useState([]); const [hotLoading, setHotLoading] = useState(false); const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); const [showSourceMenu, setShowSourceMenu] = useState(false); + const [showTypeMenu, setShowTypeMenu] = useState(false); + const [detailTitle, setDetailTitle] = useState(''); + const loadingMoreRef = useRef(false); + const loadMoreRef = useRef(null); + const userScrolledRef = useRef(false); const loadHotSearch = async (forceRefresh = false) => { const cacheKey = getHotSearchCacheKey(source); @@ -82,41 +196,208 @@ export default function MusicSearchPage() { } }; + const loadSearchPage = useCallback(async (pageNum: number, append = false, signal?: AbortSignal) => { + if (!q) return; + if (append) { + if (loadingMoreRef.current) return; + loadingMoreRef.current = true; + setLoadingMore(true); + } else { + setLoading(true); + } + + try { + const res = await fetch(`/api/music/v2/search?source=${source}&q=${encodeURIComponent(q)}&type=${searchType}&page=${pageNum}&limit=20`, { signal }); + const data = await res.json(); + const list = data.data?.list || []; + const nextHasMore = Boolean(data.data?.hasMore); + + if (searchType === 'singer') { + setSingers((prev) => append ? [...prev, ...list] : list); + if (!append) { + setAlbums([]); + setSongs([]); + } + } else if (searchType === 'album') { + setAlbums((prev) => append ? [...prev, ...list] : list); + if (!append) { + setSingers([]); + setSongs([]); + } + } else { + const nextSongs = list.map(mapSong); + setSongs((prev) => append ? [...prev, ...nextSongs] : nextSongs); + if (!append) { + setSingers([]); + setAlbums([]); + } + } + + setPage(pageNum); + setHasMore(nextHasMore); + } catch (error: any) { + if (error?.name !== 'AbortError') { + if (!append) { + setSongs([]); + setSingers([]); + setAlbums([]); + setHasMore(false); + } + } + } finally { + if (append) { + loadingMoreRef.current = false; + setLoadingMore(false); + } else { + setLoading(false); + } + } + }, [source, q, searchType]); + useEffect(() => { setSelectedSource(source); + setSelectedType(searchType); setKeyword(q); void loadHotSearch(); if (!q) { setSongs([]); + setSingers([]); + setAlbums([]); + setDetailTitle(''); + setPage(1); + setHasMore(false); return; } const controller = new AbortController(); - setLoading(true); - 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((error) => { - if (error?.name !== 'AbortError') setSongs([]); - }) - .finally(() => setLoading(false)); + setDetailTitle(''); + setPage(1); + void loadSearchPage(1, false, controller.signal); return () => controller.abort(); - }, [source, q]); + }, [source, q, searchType, loadSearchPage]); + + useEffect(() => { + userScrolledRef.current = false; + }, [source, q, searchType]); + + useEffect(() => { + if (!q || detailTitle || !hasMore) return; + const target = loadMoreRef.current; + if (!target) return; + + const markUserScrolled = () => { + userScrolledRef.current = true; + }; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry?.isIntersecting) return; + if (!userScrolledRef.current) return; + if (loading || loadingMore || loadingMoreRef.current) return; + void loadSearchPage(page + 1, true); + }, + { root: null, rootMargin: '0px 0px 80px 0px', threshold: 0.1 } + ); + + window.addEventListener('wheel', markUserScrolled, { passive: true }); + window.addEventListener('touchmove', markUserScrolled, { passive: true }); + window.addEventListener('scroll', markUserScrolled, { passive: true }); + observer.observe(target); + + return () => { + observer.disconnect(); + window.removeEventListener('wheel', markUserScrolled); + window.removeEventListener('touchmove', markUserScrolled); + window.removeEventListener('scroll', markUserScrolled); + }; + }, [q, detailTitle, hasMore, loading, loadingMore, page, loadSearchPage]); const submit = () => { const next = keyword.trim(); - if (next) router.push(`/music/search?source=${source}&q=${encodeURIComponent(next)}`); + if (next) router.push(`/music/search?source=${selectedSource}&type=${selectedType}&q=${encodeURIComponent(next)}`); }; const changeSource = (nextSource: string) => { - const normalizedSource = normalizeSource(nextSource); + let normalizedSource = normalizeSource(nextSource); + if ((selectedType === 'singer' || selectedType === 'album') && normalizedSource !== 'wy' && normalizedSource !== 'tx') { + normalizedSource = 'wy'; + } const next = keyword.trim() || q; setSelectedSource(normalizedSource); setShowSourceMenu(false); - router.push(`/music/search?source=${normalizedSource}${next ? `&q=${encodeURIComponent(next)}` : ''}`); + router.push(`/music/search?source=${normalizedSource}&type=${selectedType}${next ? `&q=${encodeURIComponent(next)}` : ''}`); + }; + + const changeType = (nextType: SearchType) => { + let nextSource = selectedSource; + if ((nextType === 'singer' || nextType === 'album') && nextSource !== 'wy' && nextSource !== 'tx') { + nextSource = 'wy'; + setSelectedSource(nextSource); + } + const next = keyword.trim() || q; + setSelectedType(nextType); + setShowTypeMenu(false); + if (next) { + setLoading(true); + if (nextType === 'song') { + setSingers([]); + setAlbums([]); + } else if (nextType === 'singer') { + setSongs([]); + setAlbums([]); + } else { + setSongs([]); + setSingers([]); + } + } + router.push(`/music/search?source=${nextSource}&type=${nextType}${next ? `&q=${encodeURIComponent(next)}` : ''}`); + }; + + const openSinger = async (singer: SingerResult) => { + setDetailTitle(`${singer.name} - 热门歌曲`); + setSingers([]); + setAlbums([]); + setSongs([]); + setHasMore(false); + setLoading(true); + try { + const itemSource = normalizeSource(singer.source || selectedSource); + const res = await fetch(`/api/music/v2/discovery/artist-songs?source=${itemSource}&id=${encodeURIComponent(String(singer.id))}`); + const data = await res.json(); + const nextSongs = (data.data?.list || []).map(mapSong); + setSongs(nextSongs); + } catch { + setSongs([]); + } finally { + setLoading(false); + } + }; + + const openAlbum = async (album: AlbumResult) => { + setDetailTitle(album.name); + setSingers([]); + setAlbums([]); + setSongs([]); + setHasMore(false); + setLoading(true); + try { + const itemSource = normalizeSource(album.source || selectedSource); + const res = await fetch(`/api/music/v2/discovery/album-songs?source=${itemSource}&id=${encodeURIComponent(String(album.id))}`); + const data = await res.json(); + const nextSongs = (data.data?.list || []).map(mapSong); + setSongs(nextSongs); + } catch { + setSongs([]); + } finally { + setLoading(false); + } }; const currentSourceLabel = musicSources.find((item) => item.key === selectedSource)?.label || '音源'; + const currentTypeLabel = searchTypeOptions.find((item) => item.key === selectedType)?.label || '歌曲'; + const resultCount = selectedType === 'song' ? songs.length : selectedType === 'singer' ? singers.length : albums.length; + const resultUnit = selectedType === 'song' ? '首结果' : selectedType === 'singer' ? '个歌手' : '张专辑'; return (
@@ -182,32 +463,88 @@ export default function MusicSearchPage() { )}
+
+ +
+ + + {showTypeMenu && ( + <> + + ); + })} +
+ + )} +

- {q ? `搜索: ${q}` : '发现音乐'} + {detailTitle || (q ? `搜索: ${q}` : '发现音乐')}

- {songs.length > 0 && ( + {resultCount > 0 && ( - {songs.length} 首结果 + {resultCount} {resultUnit} )}
- + {(selectedType === 'song' || detailTitle) && ( + + )}
{loading ? ( ) : q ? ( - + selectedType === 'singer' ? ( + detailTitle ? : + ) : selectedType === 'album' ? ( + detailTitle ? : + ) : ( + + ) ) : (
@@ -226,7 +563,7 @@ export default function MusicSearchPage() { key={`${item.keyword}-${index}`} onClick={() => { setKeyword(item.keyword); - router.push(`/music/search?source=${source}&q=${encodeURIComponent(item.keyword)}`); + router.push(`/music/search?source=${source}&type=${selectedType}&q=${encodeURIComponent(item.keyword)}`); }} className="group flex h-14 items-center overflow-hidden rounded-lg border border-white/10 bg-white/5 px-2.5 py-3 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-emerald-400 hover:bg-emerald-500/10 hover:shadow-md" > @@ -254,6 +591,8 @@ export default function MusicSearchPage() {
)} + {loadingMore && } + {q && !detailTitle && hasMore &&
}
); }