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

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>
)
}