import { SERVER_URL } from '@/lib/config' import type { MusicSource, Playlist, Track } from '@music-together/shared' import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' const PAGE_SIZE = 20 type SearchResult = Track | Playlist /** * 搜索逻辑 hook — 管理搜索/翻页/abort/竞态保护。 * 从 SearchDialog 中提取,使 UI 组件只关注渲染。 */ export function useSearch(source: MusicSource, type: 'song' | 'album' | 'playlist' = 'song', roomId?: string) { const [results, setResults] = useState([]) const [loading, setLoading] = useState(false) const [loadingMore, setLoadingMore] = useState(false) const [page, setPage] = useState(1) const [hasMore, setHasMore] = useState(false) const [hasSearched, setHasSearched] = useState(false) const abortRef = useRef(null) const loadMoreAbortRef = useRef(null) const searchIdRef = useRef(0) const lastKeywordRef = useRef('') // Cancel any in-flight requests on unmount useEffect(() => { return () => { abortRef.current?.abort() loadMoreAbortRef.current?.abort() } }, []) const fetchPage = useCallback( async ( searchSource: MusicSource, searchKeyword: string, searchPage: number, signal: AbortSignal, searchType: 'song' | 'album' | 'playlist', ): Promise<{ tracks: SearchResult[]; hasMore: boolean }> => { const params = new URLSearchParams({ source: searchSource, keyword: searchKeyword, limit: String(PAGE_SIZE), page: String(searchPage), type: searchType, }) if (roomId) params.set('roomId', roomId) const res = await fetch(`${SERVER_URL}/api/music/search?${params.toString()}`, { signal, credentials: 'include' }) if (!res.ok) throw new Error('Search failed') const data = await res.json() const tracks = data.tracks || [] return { tracks, hasMore: data.hasMore ?? tracks.length >= PAGE_SIZE } }, [roomId], ) const search = useCallback( (keyword: string) => { const trimmed = keyword.trim() if (!trimmed) return abortRef.current?.abort() loadMoreAbortRef.current?.abort() const controller = new AbortController() abortRef.current = controller const currentSearchId = ++searchIdRef.current lastKeywordRef.current = trimmed setLoading(true) setHasSearched(true) fetchPage(source, trimmed, 1, controller.signal, type) .then((data) => { if (searchIdRef.current !== currentSearchId) return setResults(data.tracks) setPage(1) setHasMore(data.hasMore) }) .catch((err) => { if (err instanceof DOMException && err.name === 'AbortError') return if (searchIdRef.current !== currentSearchId) return toast.error('搜索失败,请重试') setResults([]) setHasMore(false) }) .finally(() => { if (searchIdRef.current === currentSearchId) { setLoading(false) } }) }, [source, type, fetchPage], ) const loadMore = useCallback(() => { if (loadingMore) return loadMoreAbortRef.current?.abort() const controller = new AbortController() loadMoreAbortRef.current = controller const currentSearchId = searchIdRef.current const nextPage = page + 1 setLoadingMore(true) fetchPage(source, lastKeywordRef.current, nextPage, controller.signal, type) .then((data) => { if (searchIdRef.current !== currentSearchId) return setResults((prev) => [...prev, ...data.tracks]) setPage(nextPage) setHasMore(data.hasMore) }) .catch((err) => { if (err instanceof DOMException && err.name === 'AbortError') return if (searchIdRef.current !== currentSearchId) return toast.error('加载失败,请重试') }) .finally(() => { if (searchIdRef.current === currentSearchId) { setLoadingMore(false) } }) }, [loadingMore, page, source, type, fetchPage]) const resetState = useCallback(() => { abortRef.current?.abort() loadMoreAbortRef.current?.abort() searchIdRef.current++ setResults([]) setPage(1) setHasMore(false) setHasSearched(false) setLoading(false) setLoadingMore(false) lastKeywordRef.current = '' }, []) return { results, loading, loadingMore, hasMore, hasSearched, page, search, loadMore, resetState } }