324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import React, { createContext, useContext, useReducer, useRef, useEffect, useCallback, useState } from 'react'
|
|
import { getTrackBlob } from '@/lib/offline-db'
|
|
|
|
export interface Track {
|
|
id: string
|
|
title: string
|
|
artist: string
|
|
album: string
|
|
albumId: string
|
|
duration: number
|
|
coverArt: string
|
|
}
|
|
|
|
interface PlayerState {
|
|
currentTrack: Track | null
|
|
queue: Track[]
|
|
queueIndex: number
|
|
isPlaying: boolean
|
|
progress: number
|
|
duration: number
|
|
volume: number
|
|
isFullScreen: boolean
|
|
}
|
|
|
|
type PlayerAction =
|
|
| { type: 'PLAY_TRACK'; track: Track; queue?: Track[]; index?: number }
|
|
| { type: 'TOGGLE_PLAY' }
|
|
| { type: 'SET_PLAYING'; playing: boolean }
|
|
| { type: 'SET_PROGRESS'; progress: number }
|
|
| { type: 'SET_DURATION'; duration: number }
|
|
| { type: 'SET_VOLUME'; volume: number }
|
|
| { type: 'NEXT_TRACK' }
|
|
| { type: 'PREV_TRACK' }
|
|
| { type: 'SET_FULLSCREEN'; open: boolean }
|
|
| { type: 'ADD_TO_QUEUE'; track: Track }
|
|
|
|
const initialState: PlayerState = {
|
|
currentTrack: null,
|
|
queue: [],
|
|
queueIndex: -1,
|
|
isPlaying: false,
|
|
progress: 0,
|
|
duration: 0,
|
|
volume: 0.8,
|
|
isFullScreen: false,
|
|
}
|
|
|
|
function playerReducer(state: PlayerState, action: PlayerAction): PlayerState {
|
|
switch (action.type) {
|
|
case 'PLAY_TRACK': {
|
|
const queue = action.queue || [action.track]
|
|
const index = action.index ?? 0
|
|
return { ...state, currentTrack: action.track, queue, queueIndex: index, isPlaying: true, progress: 0 }
|
|
}
|
|
case 'TOGGLE_PLAY':
|
|
return { ...state, isPlaying: !state.isPlaying }
|
|
case 'SET_PLAYING':
|
|
return { ...state, isPlaying: action.playing }
|
|
case 'SET_PROGRESS':
|
|
return { ...state, progress: action.progress }
|
|
case 'SET_DURATION':
|
|
return { ...state, duration: action.duration }
|
|
case 'SET_VOLUME':
|
|
return { ...state, volume: action.volume }
|
|
case 'NEXT_TRACK': {
|
|
const next = state.queueIndex + 1
|
|
if (next >= state.queue.length) return { ...state, isPlaying: false }
|
|
return { ...state, currentTrack: state.queue[next], queueIndex: next, isPlaying: true, progress: 0 }
|
|
}
|
|
case 'PREV_TRACK': {
|
|
// If > 3s in, restart current track
|
|
if (state.progress > 3) return { ...state, progress: 0 }
|
|
const prev = state.queueIndex - 1
|
|
if (prev < 0) return { ...state, progress: 0 }
|
|
return { ...state, currentTrack: state.queue[prev], queueIndex: prev, isPlaying: true, progress: 0 }
|
|
}
|
|
case 'SET_FULLSCREEN':
|
|
return { ...state, isFullScreen: action.open }
|
|
case 'ADD_TO_QUEUE':
|
|
return { ...state, queue: [...state.queue, action.track] }
|
|
default:
|
|
return state
|
|
}
|
|
}
|
|
|
|
export interface AudioOutputDevice {
|
|
deviceId: string
|
|
label: string
|
|
}
|
|
|
|
interface MusicContextValue {
|
|
state: PlayerState
|
|
playTrack: (track: Track, queue?: Track[], index?: number) => void
|
|
togglePlay: () => void
|
|
seek: (time: number) => void
|
|
setVolume: (volume: number) => void
|
|
nextTrack: () => void
|
|
prevTrack: () => void
|
|
setFullScreen: (open: boolean) => void
|
|
addToQueue: (track: Track) => void
|
|
outputDevices: AudioOutputDevice[]
|
|
currentOutputId: string
|
|
setOutputDevice: (deviceId: string) => void
|
|
}
|
|
|
|
const MusicContext = createContext<MusicContextValue | null>(null)
|
|
|
|
export function useMusicPlayer() {
|
|
const ctx = useContext(MusicContext)
|
|
if (!ctx) throw new Error('useMusicPlayer must be used within MusicProvider')
|
|
return ctx
|
|
}
|
|
|
|
export function MusicProvider({ children }: { children: React.ReactNode }) {
|
|
const [state, dispatch] = useReducer(playerReducer, initialState)
|
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
|
// Guard against pause events fired when changing audio.src during track transitions
|
|
const transitioningRef = useRef(false)
|
|
|
|
// Create audio element on mount (client only)
|
|
useEffect(() => {
|
|
const audio = new Audio()
|
|
audio.preload = 'auto'
|
|
audioRef.current = audio
|
|
|
|
const onTimeUpdate = () => dispatch({ type: 'SET_PROGRESS', progress: audio.currentTime })
|
|
const onDurationChange = () => dispatch({ type: 'SET_DURATION', duration: audio.duration || 0 })
|
|
const onEnded = () => {
|
|
transitioningRef.current = true
|
|
dispatch({ type: 'NEXT_TRACK' })
|
|
}
|
|
const onPause = () => {
|
|
// Ignore pause events during track transitions (browser fires pause when src changes)
|
|
if (transitioningRef.current) return
|
|
dispatch({ type: 'SET_PLAYING', playing: false })
|
|
}
|
|
const onPlay = () => dispatch({ type: 'SET_PLAYING', playing: true })
|
|
|
|
audio.addEventListener('timeupdate', onTimeUpdate)
|
|
audio.addEventListener('durationchange', onDurationChange)
|
|
audio.addEventListener('ended', onEnded)
|
|
audio.addEventListener('pause', onPause)
|
|
audio.addEventListener('play', onPlay)
|
|
|
|
return () => {
|
|
audio.removeEventListener('timeupdate', onTimeUpdate)
|
|
audio.removeEventListener('durationchange', onDurationChange)
|
|
audio.removeEventListener('ended', onEnded)
|
|
audio.removeEventListener('pause', onPause)
|
|
audio.removeEventListener('play', onPlay)
|
|
audio.pause()
|
|
audio.src = ''
|
|
}
|
|
}, [])
|
|
|
|
// Retry play with exponential backoff (handles mobile autoplay restrictions)
|
|
const playWithRetry = useCallback((audio: HTMLAudioElement, attempts = 3) => {
|
|
audio.play().catch((err) => {
|
|
if (attempts > 1) {
|
|
setTimeout(() => playWithRetry(audio, attempts - 1), 500)
|
|
} else {
|
|
console.warn('Autoplay blocked after retries:', err.message)
|
|
}
|
|
})
|
|
}, [])
|
|
|
|
// Track blob URL for cleanup
|
|
const blobUrlRef = useRef<string | null>(null)
|
|
|
|
// When currentTrack changes, update audio src (offline-first)
|
|
useEffect(() => {
|
|
const audio = audioRef.current
|
|
if (!audio || !state.currentTrack) return
|
|
const trackId = state.currentTrack.id
|
|
transitioningRef.current = true
|
|
|
|
// Revoke previous blob URL
|
|
if (blobUrlRef.current) {
|
|
URL.revokeObjectURL(blobUrlRef.current)
|
|
blobUrlRef.current = null
|
|
}
|
|
|
|
// Try offline first, fall back to streaming
|
|
getTrackBlob(trackId).then((blob) => {
|
|
// Guard: track may have changed while we awaited
|
|
if (state.currentTrack?.id !== trackId) return
|
|
if (blob) {
|
|
const url = URL.createObjectURL(blob)
|
|
blobUrlRef.current = url
|
|
audio.src = url
|
|
} else {
|
|
audio.src = `/api/music/stream/${trackId}`
|
|
}
|
|
playWithRetry(audio)
|
|
requestAnimationFrame(() => { transitioningRef.current = false })
|
|
}).catch(() => {
|
|
// IndexedDB unavailable, fall back to streaming
|
|
if (state.currentTrack?.id !== trackId) return
|
|
audio.src = `/api/music/stream/${trackId}`
|
|
playWithRetry(audio)
|
|
requestAnimationFrame(() => { transitioningRef.current = false })
|
|
})
|
|
}, [state.currentTrack?.id, playWithRetry]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Sync play/pause
|
|
useEffect(() => {
|
|
const audio = audioRef.current
|
|
if (!audio || !state.currentTrack) return
|
|
if (state.isPlaying) {
|
|
playWithRetry(audio)
|
|
} else {
|
|
audio.pause()
|
|
}
|
|
}, [state.isPlaying, state.currentTrack, playWithRetry])
|
|
|
|
// Sync volume
|
|
useEffect(() => {
|
|
if (audioRef.current) audioRef.current.volume = state.volume
|
|
}, [state.volume])
|
|
|
|
// MediaSession API
|
|
useEffect(() => {
|
|
if (!state.currentTrack || !('mediaSession' in navigator)) return
|
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
title: state.currentTrack.title,
|
|
artist: state.currentTrack.artist,
|
|
album: state.currentTrack.album,
|
|
artwork: state.currentTrack.coverArt
|
|
? [{ src: `/api/music/cover/${state.currentTrack.coverArt}?size=300`, sizes: '300x300', type: 'image/jpeg' }]
|
|
: [],
|
|
})
|
|
navigator.mediaSession.setActionHandler('play', () => dispatch({ type: 'SET_PLAYING', playing: true }))
|
|
navigator.mediaSession.setActionHandler('pause', () => dispatch({ type: 'SET_PLAYING', playing: false }))
|
|
navigator.mediaSession.setActionHandler('previoustrack', () => dispatch({ type: 'PREV_TRACK' }))
|
|
navigator.mediaSession.setActionHandler('nexttrack', () => dispatch({ type: 'NEXT_TRACK' }))
|
|
}, [state.currentTrack])
|
|
|
|
// Keyboard shortcut: Space = play/pause (only when not in input)
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.code === 'Space' && !['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement).tagName)) {
|
|
e.preventDefault()
|
|
dispatch({ type: 'TOGGLE_PLAY' })
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handler)
|
|
return () => window.removeEventListener('keydown', handler)
|
|
}, [])
|
|
|
|
// Audio output device selection
|
|
const [outputDevices, setOutputDevices] = useState<AudioOutputDevice[]>([])
|
|
const [currentOutputId, setCurrentOutputId] = useState('')
|
|
|
|
useEffect(() => {
|
|
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.enumerateDevices) return
|
|
|
|
const enumerate = () => {
|
|
navigator.mediaDevices.enumerateDevices().then((devices) => {
|
|
const outputs = devices
|
|
.filter((d) => d.kind === 'audiooutput')
|
|
.map((d) => ({
|
|
deviceId: d.deviceId,
|
|
label: d.label || (d.deviceId === 'default' ? 'Default' : `Device ${d.deviceId.slice(0, 6)}`),
|
|
}))
|
|
setOutputDevices(outputs)
|
|
}).catch(() => {})
|
|
}
|
|
|
|
enumerate()
|
|
navigator.mediaDevices.addEventListener('devicechange', enumerate)
|
|
return () => navigator.mediaDevices.removeEventListener('devicechange', enumerate)
|
|
}, [])
|
|
|
|
const setOutputDevice = useCallback((deviceId: string) => {
|
|
const audio = audioRef.current as HTMLAudioElement & { setSinkId?: (id: string) => Promise<void> }
|
|
if (!audio?.setSinkId) return
|
|
audio.setSinkId(deviceId).then(() => {
|
|
setCurrentOutputId(deviceId)
|
|
}).catch((err) => {
|
|
console.warn('Failed to set audio output:', err.message)
|
|
})
|
|
}, [])
|
|
|
|
const playTrack = useCallback((track: Track, queue?: Track[], index?: number) => {
|
|
dispatch({ type: 'PLAY_TRACK', track, queue, index })
|
|
}, [])
|
|
|
|
const togglePlay = useCallback(() => dispatch({ type: 'TOGGLE_PLAY' }), [])
|
|
|
|
const seek = useCallback((time: number) => {
|
|
if (audioRef.current) {
|
|
audioRef.current.currentTime = time
|
|
dispatch({ type: 'SET_PROGRESS', progress: time })
|
|
}
|
|
}, [])
|
|
|
|
const setVolume = useCallback((v: number) => dispatch({ type: 'SET_VOLUME', volume: v }), [])
|
|
const nextTrack = useCallback(() => dispatch({ type: 'NEXT_TRACK' }), [])
|
|
const prevTrack = useCallback(() => dispatch({ type: 'PREV_TRACK' }), [])
|
|
const setFullScreen = useCallback((open: boolean) => dispatch({ type: 'SET_FULLSCREEN', open }), [])
|
|
const addToQueue = useCallback((track: Track) => dispatch({ type: 'ADD_TO_QUEUE', track }), [])
|
|
|
|
return (
|
|
<MusicContext.Provider value={{
|
|
state,
|
|
playTrack,
|
|
togglePlay,
|
|
seek,
|
|
setVolume,
|
|
nextTrack,
|
|
prevTrack,
|
|
setFullScreen,
|
|
addToQueue,
|
|
outputDevices,
|
|
currentOutputId,
|
|
setOutputDevice,
|
|
}}>
|
|
{children}
|
|
</MusicContext.Provider>
|
|
)
|
|
}
|