File size: 17,459 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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
import { useCallback, useEffect, useRef } from 'react'
import { Howl, Howler } from 'howler'
import type { Track } from '@music-together/shared'
import { usePlayerStore } from '@/stores/playerStore'
import { useRoomStore } from '@/stores/roomStore'
import { useSettingsStore } from '@/stores/settingsStore'
import { CURRENT_TIME_THROTTLE_MS } from '@/lib/constants'
import { toast } from 'sonner'
import { getServerTime } from '@/lib/clockSync'
import { SERVER_URL } from '@/lib/config'
import { attachTimeStretch, prepareDirectStreamForTimeStretch, type TimeStretchController } from '@/lib/timeStretch'

/** Max wait (ms) for Howler `unlock` event before giving up and skipping */
const PLAY_ERROR_TIMEOUT_MS = 3000

/** HTMLMediaElement may wait forever when an upstream audio proxy cannot connect. */
const LOAD_TIMEOUT_MS = 18_000

/**
 * Howler uses `canplaythrough` by default for HTML5 Audio. With a proxied
 * Kugou stream that means waiting until the browser estimates the entire
 * track can play uninterrupted, which can be tens of seconds when the server
 * → CDN link is slow. `canplay` starts as soon as the media element has a
 * usable buffer; the existing stalled-stream recovery still handles a later
 * network interruption.
 */
;(Howler as typeof Howler & { _canPlayEvent: 'canplay' | 'canplaythrough' })._canPlayEvent = 'canplay'

/** If playback reports playing() but currentTime doesn't advance for this
 *  many milliseconds, treat it as stalled (network drop mid-stream). */
const STALLED_TIMEOUT_MS = 8000

/** Short fade after a scheduled start masks the decoder's final seek. */
const SCHEDULED_START_FADE_MS = 120
const TRACK_CROSSFADE_MS = 80

interface ScheduledStart {
  howl: Howl
  targetTime: number
  executeAt: number
  onCommit?: () => void
}

/**
 * Manages a Howl audio instance with two-phase loading strategy:
 * Phase 1: Create Howl with volume=0 (silent)
 * Phase 2: onload → seek to target → delay → fade-in unmute
 */
export function useHowl(onTrackEnd: () => void) {
  const howlRef = useRef<Howl | null>(null)
  const soundIdRef = useRef<number | undefined>(undefined)
  const animFrameRef = useRef<number>(0)
  const syncReadyRef = useRef(false)
  const unmuteTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  const playErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  const loadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  const lastTimeUpdateRef = useRef(0)
  const stalledRef = useRef<{ lastSeek: number; since: number }>({ lastSeek: -1, since: 0 })
  const trackTitleRef = useRef<string>('')
  const retryRef = useRef(false)
  const scheduledStartRef = useRef<ScheduledStart | null>(null)
  const scheduledStartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  const outgoingHowlRef = useRef<Howl | null>(null)
  const outgoingCleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  const preparedTrackRef = useRef<Track | null>(null)
  const stretchRef = useRef<TimeStretchController | null>(null)
  const stretchReadyRef = useRef<Promise<TimeStretchController | null> | null>(null)

  // Use selectors for the one reactive value we need (volume sync effect)
  const volume = usePlayerStore((s) => s.volume)
  const playbackTempoSyncEnabled = useSettingsStore((s) => s.playbackTempoSyncEnabled)

  // Throttled time update loop with stalled detection
  const startTimeUpdate = useCallback(() => {
    cancelAnimationFrame(animFrameRef.current)
    stalledRef.current = { lastSeek: -1, since: 0 }
    const update = () => {
      if (howlRef.current && howlRef.current.playing()) {
        const now = performance.now()
        if (now - lastTimeUpdateRef.current >= CURRENT_TIME_THROTTLE_MS) {
          lastTimeUpdateRef.current = now
          const seekVal = howlRef.current.seek() as number
          usePlayerStore.getState().setCurrentTime(seekVal)

          // Stalled detection: if currentTime hasn't moved for STALLED_TIMEOUT_MS
          // while playing() is true, the stream likely broke mid-playback.
          const st = stalledRef.current
          if (Math.abs(seekVal - st.lastSeek) < 0.05) {
            if (st.since > 0 && now - st.since > STALLED_TIMEOUT_MS) {
              console.warn('Playback stalled, skipping track')
              toast.error('播放中断,已跳到下一首')
              stalledRef.current = { lastSeek: -1, since: 0 }
              onTrackEnd()
              return
            }
            // still stalled but not timed out yet — keep since
          } else {
            // time moved — reset stalled tracker
            stalledRef.current = { lastSeek: seekVal, since: now }
          }
        }
      }
      animFrameRef.current = requestAnimationFrame(update)
    }
    animFrameRef.current = requestAnimationFrame(update)
  }, [onTrackEnd])

  const stopTimeUpdate = useCallback(() => {
    cancelAnimationFrame(animFrameRef.current)
  }, [])

  const cancelScheduledPlayback = useCallback(() => {
    if (scheduledStartTimerRef.current) {
      clearTimeout(scheduledStartTimerRef.current)
      scheduledStartTimerRef.current = null
    }
    scheduledStartRef.current = null
  }, [])

  const disposeHowl = useCallback((howl: Howl | null) => {
    if (!howl) return
    try {
      howl.unload()
    } catch {
      /* ignore */
    }
  }, [])

  const pausePlayback = useCallback(
    (position: number) => {
      const pendingStart = scheduledStartRef.current
      cancelScheduledPlayback()
      const howl = howlRef.current
      if (howl) {
        if (howl.playing()) howl.pause(soundIdRef.current)
        howl.seek(position, soundIdRef.current)
      }
      if (pendingStart && preparedTrackRef.current) {
        usePlayerStore.getState().setCurrentTrack(preparedTrackRef.current)
        usePlayerStore.getState().setCurrentTime(position)
        const duration = howl?.duration()
        if (duration && Number.isFinite(duration)) usePlayerStore.getState().setDuration(duration)
        pendingStart.onCommit?.()
      }

      // A pause received during a pending track transition also stops the
      // outgoing track at the shared pause instant.
      if (outgoingCleanupTimerRef.current) {
        clearTimeout(outgoingCleanupTimerRef.current)
        outgoingCleanupTimerRef.current = null
      }
      disposeHowl(outgoingHowlRef.current)
      outgoingHowlRef.current = null
    },
    [cancelScheduledPlayback, disposeHowl],
  )

  const stopPlayback = useCallback(() => {
    cancelScheduledPlayback()
    if (loadTimerRef.current) {
      clearTimeout(loadTimerRef.current)
      loadTimerRef.current = null
    }
    if (outgoingCleanupTimerRef.current) {
      clearTimeout(outgoingCleanupTimerRef.current)
      outgoingCleanupTimerRef.current = null
    }
    const current = howlRef.current
    const outgoing = outgoingHowlRef.current
    disposeHowl(current)
    if (outgoing !== current) disposeHowl(outgoing)
    howlRef.current = null
    outgoingHowlRef.current = null
    preparedTrackRef.current = null
    soundIdRef.current = undefined
    stopTimeUpdate()
  }, [cancelScheduledPlayback, disposeHowl, stopTimeUpdate])

  const schedulePlayback = useCallback(
    (targetTime: number, executeAt: number, onCommit?: () => void) => {
      cancelScheduledPlayback()
      const howl = howlRef.current
      if (!howl) return

      // Keep the decoder silent until its position has been aligned. Slow
      // clients join the live timeline late instead of playing stale audio.
      howl.volume(0)
      scheduledStartRef.current = { howl, targetTime, executeAt, onCommit }

      const start = () => {
        scheduledStartTimerRef.current = null
        void (async () => {
          await stretchReadyRef.current
          if (scheduledStartRef.current?.howl !== howl || howlRef.current !== howl) return
          if (soundIdRef.current !== undefined) howl.play(soundIdRef.current)
          else soundIdRef.current = howl.play()
        })()
      }

      const delay = Math.max(0, executeAt - getServerTime())
      scheduledStartTimerRef.current = setTimeout(start, delay)
    },
    [cancelScheduledPlayback],
  )

  // Load and play a track
  const loadTrack = useCallback(
    (track: Track, seekTo?: number, deferCommit = false) => {
      cancelScheduledPlayback()
      if (unmuteTimerRef.current) {
        clearTimeout(unmuteTimerRef.current)
        unmuteTimerRef.current = null
      }
      // Clear any pending play-error timeout from the previous track so it
      // doesn't fire onTrackEnd() and skip the new track being loaded.
      if (playErrorTimerRef.current) {
        clearTimeout(playErrorTimerRef.current)
        playErrorTimerRef.current = null
      }
      if (loadTimerRef.current) {
        clearTimeout(loadTimerRef.current)
        loadTimerRef.current = null
      }

      if (howlRef.current) {
        // Keep one audible outgoing track alive while the replacement loads.
        // If another replacement arrives before commit, discard only the
        // superseded silent candidate.
        const outgoing = outgoingHowlRef.current
        if (outgoing && howlRef.current !== outgoing) {
          disposeHowl(howlRef.current)
        } else {
          outgoingHowlRef.current = howlRef.current
        }
        stopTimeUpdate()
      }

      syncReadyRef.current = false
      soundIdRef.current = undefined
      trackTitleRef.current = track.title
      preparedTrackRef.current = track
      stretchRef.current = null
      stretchReadyRef.current = null
      retryRef.current = false

      if (!track.streamUrl) return

      const currentVolume = usePlayerStore.getState().volume
      const roomId = useRoomStore.getState().room?.id ?? ''
      const playbackUrl =
        track.source === 'bilibili'
          ? `${SERVER_URL}/api/music/bilibili-audio-proxy?url=${encodeURIComponent(track.streamUrl)}&bvid=${encodeURIComponent(track.urlId)}&roomId=${encodeURIComponent(roomId)}`
          : track.source === 'kugou' || track.source === 'kugou_concept'
            ? `${SERVER_URL}/api/music/kugou-audio-proxy?url=${encodeURIComponent(track.streamUrl)}`
            : track.streamUrl
      const streamFormat = track.source === 'bilibili' ? [track.streamFormat ?? 'm4a'] : ['flac', 'm4a', 'ogg', 'mp3']

      // Howler creates its HTMLMediaElement synchronously inside the
      // constructor. Configure CORS before it assigns the direct CDN URL so
      // Web Audio is allowed to consume the stream.
      const canTimeStretch = prepareDirectStreamForTimeStretch(playbackUrl)

      const howl = new Howl({
        src: [playbackUrl],
        html5: true,
        format: streamFormat,
        volume: 0,
        onload: () => {
          if (howlRef.current !== howl) return // Stale instance guard
          if (loadTimerRef.current) {
            clearTimeout(loadTimerRef.current)
            loadTimerRef.current = null
          }
          const d = howl.duration()
          if (!deferCommit && Number.isFinite(d) && d > 0) {
            usePlayerStore.getState().setDuration(d)
          }
          if (seekTo && seekTo > 0) howl.seek(seekTo)
          if (scheduledStartRef.current?.howl === howl) howl.volume(0)
          else howl.volume(currentVolume)
          if (!deferCommit) usePlayerStore.getState().setCurrentTime(seekTo ?? 0)
          syncReadyRef.current = true
        },
        onplay: (soundId) => {
          if (howlRef.current !== howl) return

          const scheduledStart = scheduledStartRef.current
          if (scheduledStart?.howl === howl) {
            const lateBy = Math.max(0, (getServerTime() - scheduledStart.executeAt) / 1000)
            const alignedTime = scheduledStart.targetTime + lateBy
            howl.volume(0, soundId)
            howl.seek(alignedTime, soundId)
            usePlayerStore.getState().setCurrentTrack(track)
            usePlayerStore.getState().setCurrentTime(alignedTime)
            const duration = howl.duration()
            if (Number.isFinite(duration) && duration > 0) {
              usePlayerStore.getState().setDuration(duration)
            }
            scheduledStart.onCommit?.()
            scheduledStartRef.current = null
            const outgoing = outgoingHowlRef.current
            if (outgoing && outgoing !== howl) {
              outgoingHowlRef.current = null
              outgoing.fade(outgoing.volume(), 0, TRACK_CROSSFADE_MS)
              if (outgoingCleanupTimerRef.current) clearTimeout(outgoingCleanupTimerRef.current)
              outgoingCleanupTimerRef.current = setTimeout(() => {
                outgoingCleanupTimerRef.current = null
                disposeHowl(outgoing)
              }, TRACK_CROSSFADE_MS)
            }
            if (unmuteTimerRef.current) clearTimeout(unmuteTimerRef.current)
            unmuteTimerRef.current = setTimeout(() => {
              if (howlRef.current !== howl) return
              const latestVolume = usePlayerStore.getState().volume
              howl.fade(0, latestVolume, SCHEDULED_START_FADE_MS, soundId)
              syncReadyRef.current = true
            }, 20)
          }
          usePlayerStore.getState().setIsPlaying(true)
          const dur = howl.duration()
          if (Number.isFinite(dur) && dur > 0) {
            usePlayerStore.getState().setDuration(dur)
          }
          startTimeUpdate()
        },
        onpause: () => {
          if (howlRef.current !== howl) return
          usePlayerStore.getState().setIsPlaying(false)
          stopTimeUpdate()
        },
        onend: () => {
          if (howlRef.current !== howl) return
          usePlayerStore.getState().setIsPlaying(false)
          stopTimeUpdate()
          onTrackEnd()
        },
        onloaderror: (_id, msg) => {
          // If a newer track has been loaded, this Howl is stale — ignore.
          if (howlRef.current !== howl) return
          if (!retryRef.current) {
            retryRef.current = true
            console.warn('Howl load error, retrying:', msg)
            howl.load()
            return
          }
          retryRef.current = false
          if (loadTimerRef.current) {
            clearTimeout(loadTimerRef.current)
            loadTimerRef.current = null
          }
          console.error('Howl load error (after retry):', msg)
          toast.error(`「${trackTitleRef.current}」加载失败,已跳到下一首`)
          onTrackEnd()
        },
        onplayerror: function (soundId: number) {
          // Try to recover via Howler unlock; give up after timeout
          if (playErrorTimerRef.current) clearTimeout(playErrorTimerRef.current)
          playErrorTimerRef.current = setTimeout(() => {
            playErrorTimerRef.current = null
            console.warn('Howl unlock timeout, skipping track')
            toast.error('播放失败,已跳到下一首')
            onTrackEnd()
          }, PLAY_ERROR_TIMEOUT_MS)
          howl.once('unlock', () => {
            if (howlRef.current !== howl) return // Already switched or unmounted
            if (playErrorTimerRef.current) {
              clearTimeout(playErrorTimerRef.current)
              playErrorTimerRef.current = null
            }
            howl.play(soundId)
          })
        },
      })

      howlRef.current = howl
      loadTimerRef.current = setTimeout(() => {
        if (howlRef.current !== howl || syncReadyRef.current) return
        loadTimerRef.current = null
        console.warn('Audio load timed out, skipping track')
        disposeHowl(howl)
        if (howlRef.current === howl) howlRef.current = null
        toast.error(`「${trackTitleRef.current}」加载超时,已跳到下一首`)
        onTrackEnd()
      }, LOAD_TIMEOUT_MS)
      stretchReadyRef.current = (canTimeStretch ? attachTimeStretch(howl) : Promise.resolve(null)).then(
        (controller) => {
          if (howlRef.current === howl) stretchRef.current = controller
          controller?.setEnabled(useSettingsStore.getState().playbackTempoSyncEnabled)
          return controller
        },
      )
      if (!deferCommit) usePlayerStore.getState().setCurrentTrack(track)
    },
    [cancelScheduledPlayback, disposeHowl, onTrackEnd, startTimeUpdate, stopTimeUpdate],
  )

  // Volume sync
  useEffect(() => {
    if (howlRef.current && syncReadyRef.current) {
      howlRef.current.volume(volume)
    }
  }, [volume])

  useEffect(() => {
    const controller = stretchRef.current
    if (!controller) return
    if (!playbackTempoSyncEnabled) controller.reset()
    controller.setEnabled(playbackTempoSyncEnabled)
  }, [playbackTempoSyncEnabled])

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (unmuteTimerRef.current) {
        clearTimeout(unmuteTimerRef.current)
        unmuteTimerRef.current = null
      }
      stopPlayback()
      if (playErrorTimerRef.current) {
        clearTimeout(playErrorTimerRef.current)
        playErrorTimerRef.current = null
      }
      if (outgoingCleanupTimerRef.current) {
        clearTimeout(outgoingCleanupTimerRef.current)
        outgoingCleanupTimerRef.current = null
      }
    }
  }, [stopPlayback])

  const setPlaybackTempo = useCallback((tempo: number) => {
    stretchRef.current?.setTempo(tempo)
  }, [])

  return {
    howlRef,
    soundIdRef,
    loadTrack,
    schedulePlayback,
    cancelScheduledPlayback,
    pausePlayback,
    stopPlayback,
    setPlaybackTempo,
  }
}