| 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' |
|
|
| |
| const PLAY_ERROR_TIMEOUT_MS = 3000 |
|
|
| |
| const LOAD_TIMEOUT_MS = 18_000 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| ;(Howler as typeof Howler & { _canPlayEvent: 'canplay' | 'canplaythrough' })._canPlayEvent = 'canplay' |
|
|
| |
| |
| const STALLED_TIMEOUT_MS = 8000 |
|
|
| |
| const SCHEDULED_START_FADE_MS = 120 |
| const TRACK_CROSSFADE_MS = 80 |
|
|
| interface ScheduledStart { |
| howl: Howl |
| targetTime: number |
| executeAt: number |
| onCommit?: () => void |
| } |
|
|
| |
| |
| |
| |
| |
| 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) |
|
|
| |
| const volume = usePlayerStore((s) => s.volume) |
| const playbackTempoSyncEnabled = useSettingsStore((s) => s.playbackTempoSyncEnabled) |
|
|
| |
| 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) |
|
|
| |
| |
| 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 |
| } |
| |
| } else { |
| |
| 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 { |
| |
| } |
| }, []) |
|
|
| 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?.() |
| } |
|
|
| |
| |
| 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 |
|
|
| |
| |
| 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], |
| ) |
|
|
| |
| const loadTrack = useCallback( |
| (track: Track, seekTo?: number, deferCommit = false) => { |
| cancelScheduledPlayback() |
| if (unmuteTimerRef.current) { |
| clearTimeout(unmuteTimerRef.current) |
| unmuteTimerRef.current = null |
| } |
| |
| |
| if (playErrorTimerRef.current) { |
| clearTimeout(playErrorTimerRef.current) |
| playErrorTimerRef.current = null |
| } |
| if (loadTimerRef.current) { |
| clearTimeout(loadTimerRef.current) |
| loadTimerRef.current = null |
| } |
|
|
| if (howlRef.current) { |
| |
| |
| |
| 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'] |
|
|
| |
| |
| |
| const canTimeStretch = prepareDirectStreamForTimeStretch(playbackUrl) |
|
|
| const howl = new Howl({ |
| src: [playbackUrl], |
| html5: true, |
| format: streamFormat, |
| volume: 0, |
| onload: () => { |
| if (howlRef.current !== howl) return |
| 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 (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) { |
| |
| 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 |
| 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], |
| ) |
|
|
| |
| 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]) |
|
|
| |
| 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, |
| } |
| } |
|
|