File size: 4,597 Bytes
00a912e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
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<SearchResult[]>([])
  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<AbortController | null>(null)
  const loadMoreAbortRef = useRef<AbortController | null>(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 }
}