jefflix-website/components/music/music-provider.tsx

425 lines
15 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'
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
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: '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 '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
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
}
// 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
useEffect(() => {
if (state.queueIndex < 0 || state.queue.length <= state.queueIndex + 1) 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 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,
removeFromQueue,
moveInQueue,
jumpTo,
clearQueue,
toggleShuffle,
outputDevices,
currentOutputId,
setOutputDevice,
}}>
{children}
</MusicContext.Provider>
)
}