"use client"; import { createContext, useContext, useState, useEffect, ReactNode, useMemo, } from "react"; import { api } from "@/lib/api"; function queueDebugEnabled(): boolean { try { return ( typeof window !== "undefined" && window.localStorage?.getItem("lidifyQueueDebug") === "1" ); } catch { return false; } } function queueDebugLog(message: string, data?: Record) { if (!queueDebugEnabled()) return; // eslint-disable-next-line no-console console.log(`[QueueDebug] ${message}`, data || {}); } export type PlayerMode = "full" | "mini" | "overlay"; // Audio features for vibe mode visualization export interface AudioFeatures { bpm?: number | null; energy?: number | null; valence?: number | null; arousal?: number | null; danceability?: number | null; keyScale?: string | null; instrumentalness?: number | null; // ML Mood predictions (Enhanced mode) moodHappy?: number | null; moodSad?: number | null; moodRelaxed?: number | null; moodAggressive?: number | null; moodParty?: number | null; moodAcoustic?: number | null; moodElectronic?: number | null; analysisMode?: string | null; } export interface Track { id: string; title: string; artist: { name: string; id?: string; mbid?: string }; album: { title: string; coverArt?: string; id?: string }; duration: number; filePath?: string; // Audio features for vibe mode visualization audioFeatures?: { bpm?: number | null; energy?: number | null; valence?: number | null; arousal?: number | null; danceability?: number | null; keyScale?: string | null; instrumentalness?: number | null; analysisMode?: string | null; // ML mood predictions moodHappy?: number | null; moodSad?: number | null; moodRelaxed?: number | null; moodAggressive?: number | null; moodParty?: number | null; moodAcoustic?: number | null; moodElectronic?: number | null; } | null; } export interface Audiobook { id: string; title: string; author: string; narrator?: string; coverUrl: string | null; duration: number; progress?: { currentTime: number; progress: number; isFinished: boolean; lastPlayedAt: Date; } | null; } export interface Podcast { id: string; // Format: "podcastId:episodeId" title: string; podcastTitle: string; coverUrl: string | null; duration: number; progress?: { currentTime: number; progress: number; isFinished: boolean; lastPlayedAt: Date; } | null; } type SetStateAction = T | ((prev: T) => T); interface AudioStateContextType { // Media state currentTrack: Track | null; currentAudiobook: Audiobook | null; currentPodcast: Podcast | null; playbackType: "track" | "audiobook" | "podcast" | null; // Queue state queue: Track[]; currentIndex: number; isShuffle: boolean; repeatMode: "off" | "one" | "all"; isRepeat: boolean; shuffleIndices: number[]; // UI state playerMode: PlayerMode; previousPlayerMode: PlayerMode; volume: number; isMuted: boolean; // Vibe mode state vibeMode: boolean; vibeSourceFeatures: AudioFeatures | null; vibeQueueIds: string[]; // Internal state isHydrated: boolean; lastServerSync: Date | null; repeatOneCount: number; // State setters (for controls context) setCurrentTrack: (track: SetStateAction) => void; setCurrentAudiobook: (audiobook: SetStateAction) => void; setCurrentPodcast: (podcast: SetStateAction) => void; setPlaybackType: ( type: SetStateAction<"track" | "audiobook" | "podcast" | null> ) => void; setQueue: (queue: SetStateAction) => void; setCurrentIndex: (index: SetStateAction) => void; setIsShuffle: (shuffle: SetStateAction) => void; setRepeatMode: (mode: SetStateAction<"off" | "one" | "all">) => void; setShuffleIndices: (indices: SetStateAction) => void; setPlayerMode: (mode: SetStateAction) => void; setPreviousPlayerMode: (mode: SetStateAction) => void; setVolume: (volume: SetStateAction) => void; setIsMuted: (muted: SetStateAction) => void; setLastServerSync: (date: SetStateAction) => void; setRepeatOneCount: (count: SetStateAction) => void; setVibeMode: (mode: SetStateAction) => void; setVibeSourceFeatures: (features: SetStateAction) => void; setVibeQueueIds: (ids: SetStateAction) => void; } const AudioStateContext = createContext( undefined ); // LocalStorage keys const STORAGE_KEYS = { CURRENT_TRACK: "lidify_current_track", CURRENT_AUDIOBOOK: "lidify_current_audiobook", CURRENT_PODCAST: "lidify_current_podcast", PLAYBACK_TYPE: "lidify_playback_type", QUEUE: "lidify_queue", CURRENT_INDEX: "lidify_current_index", IS_SHUFFLE: "lidify_is_shuffle", REPEAT_MODE: "lidify_repeat_mode", PLAYER_MODE: "lidify_player_mode", VOLUME: "lidify_volume", IS_MUTED: "lidify_muted", }; export function AudioStateProvider({ children }: { children: ReactNode }) { const [currentTrack, setCurrentTrack] = useState(null); const [currentAudiobook, setCurrentAudiobook] = useState( null ); const [currentPodcast, setCurrentPodcast] = useState(null); const [playbackType, setPlaybackType] = useState< "track" | "audiobook" | "podcast" | null >(null); const [queue, setQueue] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [isShuffle, setIsShuffle] = useState(false); const [shuffleIndices, setShuffleIndices] = useState([]); const [repeatMode, setRepeatMode] = useState<"off" | "one" | "all">("off"); const [repeatOneCount, setRepeatOneCount] = useState(0); const [playerMode, setPlayerMode] = useState("full"); const [previousPlayerMode, setPreviousPlayerMode] = useState("full"); const [volume, setVolume] = useState(0.5); // Default to 50% const [isMuted, setIsMuted] = useState(false); const [isHydrated, setIsHydrated] = useState(false); const [lastServerSync, setLastServerSync] = useState(null); // Vibe mode state const [vibeMode, setVibeMode] = useState(false); const [vibeSourceFeatures, setVibeSourceFeatures] = useState(null); const [vibeQueueIds, setVibeQueueIds] = useState([]); // Restore state from localStorage on mount useEffect(() => { if (typeof window === "undefined") return; try { const savedTrack = localStorage.getItem(STORAGE_KEYS.CURRENT_TRACK); const savedAudiobook = localStorage.getItem( STORAGE_KEYS.CURRENT_AUDIOBOOK ); const savedPodcast = localStorage.getItem( STORAGE_KEYS.CURRENT_PODCAST ); const savedPlaybackType = localStorage.getItem( STORAGE_KEYS.PLAYBACK_TYPE ); const savedQueue = localStorage.getItem(STORAGE_KEYS.QUEUE); const savedIndex = localStorage.getItem(STORAGE_KEYS.CURRENT_INDEX); const savedShuffle = localStorage.getItem(STORAGE_KEYS.IS_SHUFFLE); const savedRepeatMode = localStorage.getItem( STORAGE_KEYS.REPEAT_MODE ); const savedPlayerMode = localStorage.getItem( STORAGE_KEYS.PLAYER_MODE ); const savedVolume = localStorage.getItem(STORAGE_KEYS.VOLUME); const savedMuted = localStorage.getItem(STORAGE_KEYS.IS_MUTED); if (savedTrack) setCurrentTrack(JSON.parse(savedTrack)); // For audiobooks, restore then fetch fresh progress if (savedAudiobook) { const audiobookData = JSON.parse(savedAudiobook); setCurrentAudiobook(audiobookData); api.getAudiobook(audiobookData.id) .then((audiobook: any) => { if (audiobook && audiobook.progress) { setCurrentAudiobook({ ...audiobookData, progress: audiobook.progress, }); } }) .catch((err: any) => { console.error( "[AudioState] Failed to refresh audiobook progress:", err ); }); } // For podcasts, restore then fetch fresh progress if (savedPodcast) { const podcastData = JSON.parse(savedPodcast); setCurrentPodcast(podcastData); const [podcastId, episodeId] = podcastData.id.split(":"); if (podcastId && episodeId) { api.getPodcast(podcastId) .then((podcast: any) => { const episode = podcast.episodes?.find( (ep: any) => ep.id === episodeId ); if (episode && episode.progress) { setCurrentPodcast({ ...podcastData, progress: episode.progress, }); } }) .catch((err: any) => { console.error( "[AudioState] Failed to refresh podcast progress:", err ); }); } } if (savedPlaybackType) setPlaybackType( savedPlaybackType as "track" | "audiobook" | "podcast" ); if (savedQueue) setQueue(JSON.parse(savedQueue)); if (savedIndex) setCurrentIndex(parseInt(savedIndex)); if (savedShuffle) setIsShuffle(savedShuffle === "true"); if (savedRepeatMode) setRepeatMode(savedRepeatMode as "off" | "one" | "all"); if (savedVolume) setVolume(parseFloat(savedVolume)); if (savedMuted) setIsMuted(savedMuted === "true"); if (savedPlayerMode) setPlayerMode(savedPlayerMode as PlayerMode); } catch (error) { console.error("[AudioState] Failed to restore state:", error); } setIsHydrated(true); // Load playback state from server api.getPlaybackState() .then((serverState) => { if (!serverState) return; if ( serverState.playbackType === "track" && serverState.trackId ) { api.getTrack(serverState.trackId) .then((track) => { setCurrentTrack(track); setPlaybackType("track"); setCurrentAudiobook(null); setCurrentPodcast(null); }) .catch(() => { api.clearPlaybackState().catch(() => {}); setCurrentTrack(null); setCurrentAudiobook(null); setCurrentPodcast(null); setPlaybackType(null); setQueue([]); setCurrentIndex(0); }); } else if ( serverState.playbackType === "audiobook" && serverState.audiobookId ) { api.getAudiobook(serverState.audiobookId).then( (audiobook) => { setCurrentAudiobook(audiobook); setPlaybackType("audiobook"); setCurrentTrack(null); setCurrentPodcast(null); } ); } else if ( serverState.playbackType === "podcast" && serverState.podcastId ) { const [podcastId, episodeId] = serverState.podcastId.split(":"); api.getPodcast(podcastId).then((podcast) => { const episode = podcast.episodes?.find( (ep: any) => ep.id === episodeId ); if (episode) { setCurrentPodcast({ id: serverState.podcastId, title: episode.title, podcastTitle: podcast.title, coverUrl: podcast.coverUrl, duration: episode.duration, progress: episode.progress, }); setPlaybackType("podcast"); setCurrentTrack(null); setCurrentAudiobook(null); } }); } if (serverState.queue) setQueue(serverState.queue); if (serverState.currentIndex !== undefined) setCurrentIndex(serverState.currentIndex); if (serverState.isShuffle !== undefined) setIsShuffle(serverState.isShuffle); }) .catch(() => { // No server state available - this is expected on first load }); }, []); // Save state to localStorage whenever it changes useEffect(() => { if (!isHydrated || typeof window === "undefined") return; try { if (currentTrack) { localStorage.setItem( STORAGE_KEYS.CURRENT_TRACK, JSON.stringify(currentTrack) ); } else { localStorage.removeItem(STORAGE_KEYS.CURRENT_TRACK); } if (currentAudiobook) { localStorage.setItem( STORAGE_KEYS.CURRENT_AUDIOBOOK, JSON.stringify(currentAudiobook) ); } else { localStorage.removeItem(STORAGE_KEYS.CURRENT_AUDIOBOOK); } if (currentPodcast) { localStorage.setItem( STORAGE_KEYS.CURRENT_PODCAST, JSON.stringify(currentPodcast) ); } else { localStorage.removeItem(STORAGE_KEYS.CURRENT_PODCAST); } if (playbackType) { localStorage.setItem(STORAGE_KEYS.PLAYBACK_TYPE, playbackType); } else { localStorage.removeItem(STORAGE_KEYS.PLAYBACK_TYPE); } localStorage.setItem(STORAGE_KEYS.QUEUE, JSON.stringify(queue)); localStorage.setItem( STORAGE_KEYS.CURRENT_INDEX, currentIndex.toString() ); localStorage.setItem(STORAGE_KEYS.IS_SHUFFLE, isShuffle.toString()); localStorage.setItem(STORAGE_KEYS.REPEAT_MODE, repeatMode); localStorage.setItem(STORAGE_KEYS.PLAYER_MODE, playerMode); localStorage.setItem(STORAGE_KEYS.VOLUME, volume.toString()); localStorage.setItem(STORAGE_KEYS.IS_MUTED, isMuted.toString()); } catch (error) { console.error("[AudioState] Failed to save state:", error); } }, [ currentTrack, currentAudiobook, currentPodcast, playbackType, queue, currentIndex, isShuffle, repeatMode, playerMode, volume, isMuted, isHydrated, ]); // Save playback state to server useEffect(() => { if (!isHydrated) return; if (!playbackType) return; const saveToServer = async () => { try { // Limit queue to first 100 items to reduce payload size // Backend also limits to 100, so this matches server storage const limitedQueue = queue?.slice(0, 100); const adjustedIndex = Math.min( currentIndex, (limitedQueue?.length || 1) - 1 ); const result = await api.savePlaybackState({ playbackType, trackId: currentTrack?.id, audiobookId: currentAudiobook?.id, podcastId: currentPodcast?.id, queue: limitedQueue, currentIndex: adjustedIndex, isShuffle, }); setLastServerSync(new Date(result.updatedAt)); queueDebugLog("Saved playback state to server", { playbackType, trackId: currentTrack?.id, queueLen: limitedQueue?.length || 0, currentIndex: adjustedIndex, isShuffle, updatedAt: result.updatedAt, }); } catch (err: any) { if (err.message !== "Not authenticated") { console.error( "[AudioState] Failed to save to server:", err ); } } }; const timeoutId = setTimeout(saveToServer, 1000); return () => clearTimeout(timeoutId); }, [ playbackType, currentTrack?.id, currentAudiobook?.id, currentPodcast?.id, queue, currentIndex, isShuffle, isHydrated, ]); // Poll server for changes from other devices (pauses when tab is hidden) useEffect(() => { if (!isHydrated) return; if (typeof document === "undefined") return; let isAuthenticated = true; let mounted = true; let isVisible = !document.hidden; // Handle visibility changes to save battery/resources const handleVisibilityChange = () => { isVisible = !document.hidden; }; document.addEventListener("visibilitychange", handleVisibilityChange); const pollInterval = setInterval(async () => { // Skip polling when tab is hidden, unmounted, or not authenticated if (!isAuthenticated || !mounted || !isVisible) return; try { const serverState = await api.getPlaybackState(); if (!serverState || !mounted) return; const serverUpdatedAt = new Date(serverState.updatedAt); if (lastServerSync && serverUpdatedAt <= lastServerSync) { return; } const serverMediaId = serverState.trackId || serverState.audiobookId || serverState.podcastId; const currentMediaId = currentTrack?.id || currentAudiobook?.id || currentPodcast?.id; if ( serverMediaId !== currentMediaId || serverState.playbackType !== playbackType ) { if ( serverState.playbackType === "track" && serverState.trackId ) { try { const track = await api.getTrack( serverState.trackId ); if (!mounted) return; setCurrentTrack(track); setPlaybackType("track"); setCurrentAudiobook(null); setCurrentPodcast(null); if ( serverState.queue && serverState.queue.length > 0 ) { setQueue(serverState.queue); setCurrentIndex(serverState.currentIndex || 0); setIsShuffle(serverState.isShuffle || false); } } catch (trackErr) { if (!mounted) return; await api.clearPlaybackState().catch(() => {}); setCurrentTrack(null); setCurrentAudiobook(null); setCurrentPodcast(null); setPlaybackType(null); setQueue([]); setCurrentIndex(0); return; } } else if ( serverState.playbackType === "audiobook" && serverState.audiobookId ) { const audiobook = await api.getAudiobook( serverState.audiobookId ); if (!mounted) return; setCurrentAudiobook(audiobook); setPlaybackType("audiobook"); setCurrentTrack(null); setCurrentPodcast(null); } else if ( serverState.playbackType === "podcast" && serverState.podcastId ) { const [podcastId, episodeId] = serverState.podcastId.split(":"); const podcast = await api.getPodcast(podcastId); if (!mounted) return; const episode = podcast.episodes?.find( (ep: any) => ep.id === episodeId ); if (episode) { setCurrentPodcast({ id: serverState.podcastId, title: episode.title, podcastTitle: podcast.title, coverUrl: podcast.coverUrl, duration: episode.duration, progress: episode.progress, }); setPlaybackType("podcast"); setCurrentTrack(null); setCurrentAudiobook(null); } } if (!mounted) return; if ( JSON.stringify(serverState.queue) !== JSON.stringify(queue) ) { queueDebugLog("Polling applied server queue", { serverQueueLen: serverState.queue?.length || 0, localQueueLen: queue?.length || 0, serverCurrentIndex: serverState.currentIndex || 0, localCurrentIndex: currentIndex, serverIsShuffle: serverState.isShuffle, localIsShuffle: isShuffle, serverUpdatedAt: serverState.updatedAt, }); setQueue(serverState.queue || []); setCurrentIndex(serverState.currentIndex || 0); setIsShuffle(serverState.isShuffle || false); } setLastServerSync(serverUpdatedAt); } } catch (err: any) { if (err.message === "Not authenticated") { isAuthenticated = false; clearInterval(pollInterval); } } }, 30000); return () => { mounted = false; document.removeEventListener( "visibilitychange", handleVisibilityChange ); clearInterval(pollInterval); }; }, [ isHydrated, playbackType, currentTrack?.id, currentAudiobook?.id, currentPodcast?.id, queue, lastServerSync, ]); // Memoize the context value to prevent unnecessary re-renders const value = useMemo( () => ({ currentTrack, currentAudiobook, currentPodcast, playbackType, queue, currentIndex, isShuffle, repeatMode, isRepeat: repeatMode !== "off", shuffleIndices, playerMode, previousPlayerMode, volume, isMuted, vibeMode, vibeSourceFeatures, vibeQueueIds, isHydrated, lastServerSync, repeatOneCount, setCurrentTrack, setCurrentAudiobook, setCurrentPodcast, setPlaybackType, setQueue, setCurrentIndex, setIsShuffle, setRepeatMode, setShuffleIndices, setPlayerMode, setPreviousPlayerMode, setVolume, setIsMuted, setLastServerSync, setRepeatOneCount, setVibeMode, setVibeSourceFeatures, setVibeQueueIds, }), [ currentTrack, currentAudiobook, currentPodcast, playbackType, queue, currentIndex, isShuffle, repeatMode, shuffleIndices, playerMode, previousPlayerMode, volume, isMuted, vibeMode, vibeSourceFeatures, vibeQueueIds, isHydrated, lastServerSync, repeatOneCount, ] ); return ( {children} ); } export function useAudioState() { const context = useContext(AudioStateContext); if (!context) { throw new Error("useAudioState must be used within AudioStateProvider"); } return context; }