460 lines
16 KiB
TypeScript
460 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import React, { createContext, useContext, useReducer, useRef, useEffect, useCallback, useState } from 'react'
|
|
import { getTrackBlob } from '@/lib/offline-db'
|
|
import { precacheUpcoming } from '@/lib/precache'
|
|
|
|
interface TrackBase {
|
|
id: string
|
|
title: string
|
|
artist: string
|
|
album: string
|
|
albumId: string
|
|
duration: number
|
|
coverArt: string
|
|
}
|
|
|
|
export interface MusicTrack extends TrackBase {
|
|
type?: 'music'
|
|
}
|
|
|
|
export interface RadioTrack extends TrackBase {
|
|
type: 'radio'
|
|
streamUrl: string
|
|
}
|
|
|
|
export type Track = MusicTrack | RadioTrack
|
|
|
|
export function isRadioTrack(track: Track): track is RadioTrack {
|
|
return track.type === 'radio'
|
|
}
|
|
|
|
interface PlayerState {
|
|
currentTrack: Track | null
|
|
queue: Track[]
|
|
queueIndex: number
|
|
isPlaying: boolean
|
|
progress: number
|
|
duration: number
|
|
volume: number
|
|
isFullScreen: boolean
|
|
shuffleEnabled: boolean
|
|
originalQueue: Track[] | null
|
|
}
|
|
|
|
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 }
|
|
| { type: 'ADD_ALL_TO_QUEUE'; tracks: Track[] }
|
|
| { type: 'REMOVE_FROM_QUEUE'; index: number }
|
|
| { type: 'MOVE_IN_QUEUE'; from: number; to: number }
|
|
| { type: 'JUMP_TO'; index: number }
|
|
| { type: 'CLEAR_QUEUE' }
|
|
| { type: 'TOGGLE_SHUFFLE' }
|
|
|
|
const initialState: PlayerState = {
|
|
currentTrack: null,
|
|
queue: [],
|
|
queueIndex: -1,
|
|
isPlaying: false,
|
|
progress: 0,
|
|
duration: 0,
|
|
volume: 0.8,
|
|
isFullScreen: false,
|
|
shuffleEnabled: false,
|
|
originalQueue: null,
|
|
}
|
|
|
|
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, shuffleEnabled: false, originalQueue: null }
|
|
}
|
|
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': {
|
|
// Insert after current track so it plays next
|
|
const insertAt = state.queueIndex + 1
|
|
const q2 = [...state.queue]
|
|
q2.splice(insertAt, 0, action.track)
|
|
// If shuffle is on, also add to originalQueue at end
|
|
const origQ = state.originalQueue ? [...state.originalQueue, action.track] : null
|
|
return { ...state, queue: q2, originalQueue: origQ }
|
|
}
|
|
case 'ADD_ALL_TO_QUEUE': {
|
|
const insertAt = state.queueIndex + 1
|
|
const q3 = [...state.queue]
|
|
q3.splice(insertAt, 0, ...action.tracks)
|
|
const origQ2 = state.originalQueue ? [...state.originalQueue, ...action.tracks] : null
|
|
return { ...state, queue: q3, originalQueue: origQ2 }
|
|
}
|
|
case 'REMOVE_FROM_QUEUE': {
|
|
const newQueue = [...state.queue]
|
|
newQueue.splice(action.index, 1)
|
|
let newIndex = state.queueIndex
|
|
if (action.index < state.queueIndex) {
|
|
newIndex--
|
|
} else if (action.index === state.queueIndex) {
|
|
// Removing current track: play next (or clamp)
|
|
const clamped = Math.min(newIndex, newQueue.length - 1)
|
|
if (clamped < 0) return { ...state, queue: [], queueIndex: -1, currentTrack: null, isPlaying: false }
|
|
return { ...state, queue: newQueue, queueIndex: clamped, currentTrack: newQueue[clamped], progress: 0 }
|
|
}
|
|
return { ...state, queue: newQueue, queueIndex: newIndex }
|
|
}
|
|
case 'MOVE_IN_QUEUE': {
|
|
const q = [...state.queue]
|
|
const [moved] = q.splice(action.from, 1)
|
|
q.splice(action.to, 0, moved)
|
|
// Track the currently-playing song's new position
|
|
let idx = state.queueIndex
|
|
if (action.from === idx) {
|
|
idx = action.to
|
|
} else {
|
|
if (action.from < idx) idx--
|
|
if (action.to <= idx) idx++
|
|
}
|
|
return { ...state, queue: q, queueIndex: idx }
|
|
}
|
|
case 'JUMP_TO': {
|
|
if (action.index < 0 || action.index >= state.queue.length) return state
|
|
return { ...state, currentTrack: state.queue[action.index], queueIndex: action.index, isPlaying: true, progress: 0 }
|
|
}
|
|
case 'CLEAR_QUEUE': {
|
|
// Keep current track, remove everything after it
|
|
if (state.queueIndex < 0 || !state.currentTrack) return { ...state, queue: [], queueIndex: -1 }
|
|
return { ...state, queue: [state.currentTrack], queueIndex: 0 }
|
|
}
|
|
case 'TOGGLE_SHUFFLE': {
|
|
if (state.shuffleEnabled) {
|
|
// OFF: restore original order, find current track in it
|
|
if (!state.originalQueue) return { ...state, shuffleEnabled: false }
|
|
const currentId = state.currentTrack?.id
|
|
const idx = state.originalQueue.findIndex((t) => t.id === currentId)
|
|
return { ...state, shuffleEnabled: false, queue: state.originalQueue, queueIndex: idx >= 0 ? idx : 0, originalQueue: null }
|
|
} else {
|
|
// ON: save original queue, Fisher-Yates shuffle items after current index
|
|
const shuffled = [...state.queue]
|
|
for (let i = shuffled.length - 1; i > state.queueIndex + 1; i--) {
|
|
const j = state.queueIndex + 1 + Math.floor(Math.random() * (i - state.queueIndex))
|
|
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
|
}
|
|
return { ...state, shuffleEnabled: true, originalQueue: [...state.queue], queue: shuffled }
|
|
}
|
|
}
|
|
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
|
|
addAllToQueue: (tracks: Track[]) => void
|
|
removeFromQueue: (index: number) => void
|
|
moveInQueue: (from: number, to: number) => void
|
|
jumpTo: (index: number) => void
|
|
clearQueue: () => void
|
|
toggleShuffle: () => 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
|
|
}
|
|
|
|
// Radio streams: use streamUrl directly, skip offline/IndexedDB
|
|
if (isRadioTrack(state.currentTrack)) {
|
|
audio.src = state.currentTrack.streamUrl
|
|
playWithRetry(audio)
|
|
requestAnimationFrame(() => { transitioningRef.current = false })
|
|
return
|
|
}
|
|
|
|
// Music: 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])
|
|
|
|
// Pre-cache next 3 tracks in queue when current track changes (skip for radio)
|
|
useEffect(() => {
|
|
if (state.queueIndex < 0 || state.queue.length <= state.queueIndex + 1) return
|
|
if (state.currentTrack && isRadioTrack(state.currentTrack)) return
|
|
|
|
const ac = new AbortController()
|
|
const delay = setTimeout(() => {
|
|
precacheUpcoming(state.queue, state.queueIndex, 3, ac.signal)
|
|
}, 2000)
|
|
|
|
return () => {
|
|
clearTimeout(delay)
|
|
ac.abort()
|
|
}
|
|
}, [state.queueIndex, state.queue])
|
|
|
|
// 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 }), [])
|
|
const addAllToQueue = useCallback((tracks: Track[]) => dispatch({ type: 'ADD_ALL_TO_QUEUE', tracks }), [])
|
|
const removeFromQueue = useCallback((index: number) => dispatch({ type: 'REMOVE_FROM_QUEUE', index }), [])
|
|
const moveInQueue = useCallback((from: number, to: number) => dispatch({ type: 'MOVE_IN_QUEUE', from, to }), [])
|
|
const jumpTo = useCallback((index: number) => dispatch({ type: 'JUMP_TO', index }), [])
|
|
const clearQueue = useCallback(() => dispatch({ type: 'CLEAR_QUEUE' }), [])
|
|
const toggleShuffle = useCallback(() => dispatch({ type: 'TOGGLE_SHUFFLE' }), [])
|
|
|
|
return (
|
|
<MusicContext.Provider value={{
|
|
state,
|
|
playTrack,
|
|
togglePlay,
|
|
seek,
|
|
setVolume,
|
|
nextTrack,
|
|
prevTrack,
|
|
setFullScreen,
|
|
addToQueue,
|
|
addAllToQueue,
|
|
removeFromQueue,
|
|
moveInQueue,
|
|
jumpTo,
|
|
clearQueue,
|
|
toggleShuffle,
|
|
outputDevices,
|
|
currentOutputId,
|
|
setOutputDevice,
|
|
}}>
|
|
{children}
|
|
</MusicContext.Provider>
|
|
)
|
|
}
|