import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react'; import { useLingui } from '@lingui/react/macro'; import { SpotifyAuth } from '@/lib/spotify/auth'; import { useAuth } from './auth-context'; import { useConfig } from '@/hooks/use-config'; import type { SpotifyContextType, SpotifyAuthState, SpotifyDevice, SpotifyPlaybackState, SpotifyPlaybackSnapshot, SpotifyTrack, } from '@/lib/spotify/types'; const defaultSpotifyState: SpotifyAuthState = { isAuthenticated: false, accessToken: null, refreshToken: null, expiresAt: null, scopes: [], }; export const SpotifyContext = createContext(null); const deepEqual = (a: unknown, b: unknown): boolean => { if (a === b) return true; if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') { return false; } if (Array.isArray(a) !== Array.isArray(b)) return false; const aKeys = Object.keys(a as Record); const bKeys = Object.keys(b as Record); if (aKeys.length !== bKeys.length) return false; return aKeys.every((key) => deepEqual( (a as Record)[key], (b as Record)[key] ) ); }; export const SpotifyProvider: React.FC = ({ children }) => { const { roles } = useAuth(); const isAdmin = roles?.includes('Admin') || false; const config = useConfig(); const { t } = useLingui(); // Defined inside the provider so error messages can be localized via `t`. const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) => { const response = await fetch(`/api/spotify/${endpoint}`, { ...options, credentials: 'include', headers: { 'Content-Type': 'application/json', ...options.headers, }, }); if (!response.ok) { let errorMessage = t`Request failed`; try { const errorData = await response.json(); errorMessage = errorData.error || errorMessage; } catch { errorMessage = t`HTTP ${response.status}: ${response.statusText}`; } throw new Error(errorMessage); } if (response.status === 204 || response.headers.get('content-length') === '0') { return {}; } const contentType = response.headers.get('content-type') || ''; if (!contentType.includes('application/json')) { console.warn(`Non-JSON response from ${endpoint}:`, contentType); return {}; } try { return await response.json(); } catch (error) { console.warn(`Failed to parse JSON response from ${endpoint}:`, error); return {}; } }; const [authState, setAuthState] = useState(defaultSpotifyState); const [currentTrack, setCurrentTrack] = useState(null); const [playbackState, setPlaybackState] = useState(null); const [devices, setDevices] = useState([]); const [activeDevice, setActiveDeviceState] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [capturedState, setCapturedState] = useState(null); const [isCaptureLoading, setIsCaptureLoading] = useState(false); const [isResumeLoading, setIsResumeLoading] = useState(false); const spotifyAuth = useMemo( () => new SpotifyAuth(config.spotifyClientId, config.spotifyRedirectUri), [config.spotifyClientId, config.spotifyRedirectUri] ); useEffect(() => { if (isAdmin) { checkExistingAuth(); handleOAuthCallback(); } }, [isAdmin]); const checkExistingAuth = async () => { try { const response = await fetch('/api/spotify/token', { credentials: 'include', }); if (response.ok) { const data = await response.json(); if (data.access_token) { setAuthState({ isAuthenticated: true, accessToken: data.access_token, refreshToken: data.refresh_token, expiresAt: Date.now() + (3600 * 1000), scopes: [], }); await Promise.all([getDevices(), refreshPlaybackState()]); } } } catch (error) { console.error('Failed to check existing auth:', error); } }; const handleOAuthCallback = () => { const urlParams = new URLSearchParams(window.location.search); const spotifyAuth = urlParams.get('spotify_auth'); const error = urlParams.get('spotify_error'); const details = urlParams.get('details'); if (spotifyAuth === 'success') { checkExistingAuth(); const newUrl = new URL(window.location.href); newUrl.searchParams.delete('spotify_auth'); newUrl.searchParams.delete('state'); window.history.replaceState({}, '', newUrl.toString()); } if (error) { const errorMessage = details ? t`Authentication failed: ${error} - ${decodeURIComponent(details)}` : t`Authentication failed: ${error}`; setError(errorMessage); console.error('Spotify OAuth Error:', { error, details }); const newUrl = new URL(window.location.href); newUrl.searchParams.delete('spotify_error'); newUrl.searchParams.delete('details'); window.history.replaceState({}, '', newUrl.toString()); } }; const login = useCallback(() => { if (!isAdmin) return; spotifyAuth.startAuthFlow(window.location.pathname); }, [isAdmin, spotifyAuth]); const logout = useCallback(() => { setAuthState(defaultSpotifyState); setCurrentTrack(null); setPlaybackState(null); setDevices([]); setActiveDeviceState(null); setError(null); document.cookie = 'spotify_access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; document.cookie = 'spotify_refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; }, []); const play = useCallback(async (deviceId?: string) => { if (!authState.isAuthenticated) return; setIsLoading(true); setError(null); try { await makeSpotifyRequest('playback', { method: 'POST', body: JSON.stringify({ action: 'play', deviceId }), }); setTimeout(refreshPlaybackState, 500); } catch (error) { if (error instanceof Error && !error.message.includes('JSON')) { setError(error.message); } console.warn('Playback action completed with warning:', error); } finally { setIsLoading(false); } }, [authState.isAuthenticated, t]); const playTrack = useCallback(async (trackId: string, deviceId?: string, positionMs?: number) => { if (!authState.isAuthenticated) return; setIsLoading(true); setError(null); try { await makeSpotifyRequest('playback', { method: 'POST', body: JSON.stringify({ action: 'playTrack', trackId, deviceId, positionMs }), }); setTimeout(refreshPlaybackState, 500); } catch (error) { if (error instanceof Error && !error.message.includes('JSON')) { setError(error.message); } console.warn('Track playback action completed with warning:', error); } finally { setIsLoading(false); } }, [authState.isAuthenticated, t]); const pause = useCallback(async () => { if (!authState.isAuthenticated) return; setIsLoading(true); setError(null); try { await makeSpotifyRequest('playback', { method: 'POST', body: JSON.stringify({ action: 'pause' }), }); setTimeout(refreshPlaybackState, 500); } catch (error) { if (error instanceof Error && !error.message.includes('JSON')) { setError(error.message); } console.warn('Playback action completed with warning:', error); } finally { setIsLoading(false); } }, [authState.isAuthenticated, t]); const skipNext = useCallback(async () => { if (!authState.isAuthenticated) return; setIsLoading(true); setError(null); try { await makeSpotifyRequest('playback', { method: 'POST', body: JSON.stringify({ action: 'next' }), }); setTimeout(refreshPlaybackState, 500); } catch (error) { if (error instanceof Error && !error.message.includes('JSON')) { setError(error.message); } console.warn('Playback action completed with warning:', error); } finally { setIsLoading(false); } }, [authState.isAuthenticated, t]); const skipPrevious = useCallback(async () => { if (!authState.isAuthenticated) return; setIsLoading(true); setError(null); try { await makeSpotifyRequest('playback', { method: 'POST', body: JSON.stringify({ action: 'previous' }), }); setTimeout(refreshPlaybackState, 500); } catch (error) { if (error instanceof Error && !error.message.includes('JSON')) { setError(error.message); } console.warn('Playback action completed with warning:', error); } finally { setIsLoading(false); } }, [authState.isAuthenticated, t]); const setVolume = useCallback(async (volumePercent: number) => { if (!authState.isAuthenticated) return; setIsLoading(true); setError(null); try { await makeSpotifyRequest('playback', { method: 'POST', body: JSON.stringify({ action: 'volume', volumePercent }), }); } catch (error) { setError(error instanceof Error ? error.message : t`Failed to set volume`); } finally { setIsLoading(false); } }, [authState.isAuthenticated, t]); const getDevices = useCallback(async () => { if (!authState.isAuthenticated) return; setIsLoading(true); setError(null); try { const data = await makeSpotifyRequest('playback?type=devices'); setDevices(data.devices || []); const active = data.devices?.find((d: SpotifyDevice) => d.is_active); if (active) { setActiveDeviceState(active); } } catch (error) { setError(error instanceof Error ? error.message : t`Failed to get devices`); } finally { setIsLoading(false); } }, [authState.isAuthenticated, t]); const setActiveDevice = useCallback(async (deviceId: string) => { if (!authState.isAuthenticated) return; setIsLoading(true); setError(null); try { await makeSpotifyRequest('playback', { method: 'POST', body: JSON.stringify({ action: 'transfer', deviceId }), }); const device = devices.find(d => d.id === deviceId); if (device) { setActiveDeviceState(device); } setTimeout(getDevices, 1000); } catch (error) { setError(error instanceof Error ? error.message : t`Failed to set active device`); } finally { setIsLoading(false); } }, [authState.isAuthenticated, devices, t]); const refreshPlaybackState = useCallback(async () => { if (!authState.isAuthenticated) return; try { const data = await makeSpotifyRequest('playback?type=state'); const state = data.playbackState; setPlaybackState((prev) => (deepEqual(prev, state ?? null) ? prev : state)); setCurrentTrack((prev) => { const next = state?.item || null; return deepEqual(prev, next) ? prev : next; }); if (state?.device) { setActiveDeviceState((prev) => deepEqual(prev, state.device) ? prev : state.device ); } } catch (error) { console.warn('Failed to refresh playback state:', error); } }, [authState.isAuthenticated, t]); useEffect(() => { if (!authState.isAuthenticated) return; const poll = () => { if (document.hidden) return; refreshPlaybackState(); }; const handleVisibilityChange = () => { if (!document.hidden) { refreshPlaybackState(); } }; const interval = setInterval(poll, 5000); document.addEventListener('visibilitychange', handleVisibilityChange); return () => { clearInterval(interval); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [authState.isAuthenticated, refreshPlaybackState]); const capturePlaybackState = useCallback(async () => { if (!authState.isAuthenticated) return; setIsCaptureLoading(true); setError(null); try { const response = await makeSpotifyRequest('capture', { method: 'POST', }); if (response.snapshot) { setCapturedState(response.snapshot); } } catch (error) { setError(error instanceof Error ? error.message : t`Failed to capture playback state`); } finally { setIsCaptureLoading(false); } }, [authState.isAuthenticated, t]); const resumePlaybackState = useCallback(async () => { if (!authState.isAuthenticated || !capturedState) return; setIsResumeLoading(true); setError(null); try { await makeSpotifyRequest('resume', { method: 'POST', body: JSON.stringify({ snapshot: capturedState }), }); setTimeout(refreshPlaybackState, 1000); } catch (error) { setError(error instanceof Error ? error.message : t`Failed to resume playback state`); } finally { setIsResumeLoading(false); } }, [authState.isAuthenticated, capturedState, refreshPlaybackState, t]); const clearCapturedState = useCallback(() => { setCapturedState(null); }, []); const searchTracks = useCallback(async (query: string): Promise => { if (!query.trim()) return []; try { const response = await fetch(`/api/spotify/search?q=${encodeURIComponent(query)}`); if (!response.ok) { throw new Error('Search failed'); } const data = await response.json(); return data.tracks || []; } catch (error) { console.error('Failed to search tracks:', error); return []; } }, []); const contextValue: SpotifyContextType = useMemo(() => ({ ...authState, currentTrack, playbackState, devices, activeDevice, isLoading, error, // Capture/Resume state capturedState, isCaptureLoading, isResumeLoading, login, logout, play, playTrack, pause, skipNext, skipPrevious, setVolume, getDevices, setActiveDevice, refreshPlaybackState, // Capture/Resume methods capturePlaybackState, resumePlaybackState, clearCapturedState, // Search searchTracks, }), [ authState, currentTrack, playbackState, devices, activeDevice, isLoading, error, capturedState, isCaptureLoading, isResumeLoading, login, logout, play, playTrack, pause, skipNext, skipPrevious, setVolume, getDevices, setActiveDevice, refreshPlaybackState, capturePlaybackState, resumePlaybackState, clearCapturedState, searchTracks, ]); if (!isAdmin) { return <>{children}; } return ( {children} ); };