'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(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(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(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([]) 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 } 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 ( {children} ) }