372 lines
10 KiB
TypeScript
372 lines
10 KiB
TypeScript
import { createContext, useCallback, useEffect, useState, PropsWithChildren } from 'react';
|
|
import { SpotifyAuth } from '@/lib/spotify/auth';
|
|
import { useAuth } from './auth-context';
|
|
import type {
|
|
SpotifyContextType,
|
|
SpotifyAuthState,
|
|
SpotifyDevice,
|
|
SpotifyPlaybackState,
|
|
SpotifyTrack,
|
|
} from '@/lib/spotify/types';
|
|
|
|
const defaultSpotifyState: SpotifyAuthState = {
|
|
isAuthenticated: false,
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
expiresAt: null,
|
|
scopes: [],
|
|
};
|
|
|
|
export const SpotifyContext = createContext<SpotifyContextType | null>(null);
|
|
|
|
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|
const { roles } = useAuth();
|
|
const isAdmin = roles?.includes('Admin') || false;
|
|
|
|
const [authState, setAuthState] = useState<SpotifyAuthState>(defaultSpotifyState);
|
|
|
|
const [currentTrack, setCurrentTrack] = useState<SpotifyTrack | null>(null);
|
|
const [playbackState, setPlaybackState] = useState<SpotifyPlaybackState | null>(null);
|
|
|
|
const [devices, setDevices] = useState<SpotifyDevice[]>([]);
|
|
const [activeDevice, setActiveDeviceState] = useState<SpotifyDevice | null>(null);
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const spotifyAuth = new SpotifyAuth(
|
|
import.meta.env.VITE_SPOTIFY_CLIENT_ID!,
|
|
import.meta.env.VITE_SPOTIFY_REDIRECT_URI!
|
|
);
|
|
|
|
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: [],
|
|
});
|
|
|
|
// Load initial data
|
|
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) {
|
|
let errorMessage = `Authentication failed: ${error}`;
|
|
if (details) {
|
|
errorMessage += ` - ${decodeURIComponent(details)}`;
|
|
}
|
|
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 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 = 'Request failed';
|
|
try {
|
|
const errorData = await response.json();
|
|
errorMessage = errorData.error || errorMessage;
|
|
} catch {
|
|
errorMessage = `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 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]);
|
|
|
|
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]);
|
|
|
|
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]);
|
|
|
|
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]);
|
|
|
|
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 : 'Failed to set volume');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [authState.isAuthenticated]);
|
|
|
|
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 : 'Failed to get devices');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [authState.isAuthenticated]);
|
|
|
|
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 : 'Failed to set active device');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [authState.isAuthenticated, devices]);
|
|
|
|
const refreshPlaybackState = useCallback(async () => {
|
|
if (!authState.isAuthenticated) return;
|
|
|
|
try {
|
|
const data = await makeSpotifyRequest('playback?type=state');
|
|
const state = data.playbackState;
|
|
|
|
setPlaybackState(state);
|
|
setCurrentTrack(state?.item || null);
|
|
|
|
if (state?.device) {
|
|
setActiveDeviceState(state.device);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to refresh playback state:', error);
|
|
}
|
|
}, [authState.isAuthenticated]);
|
|
|
|
useEffect(() => {
|
|
if (!authState.isAuthenticated) return;
|
|
|
|
const interval = setInterval(refreshPlaybackState, 5000);
|
|
return () => clearInterval(interval);
|
|
}, [authState.isAuthenticated, refreshPlaybackState]);
|
|
|
|
const contextValue: SpotifyContextType = {
|
|
...authState,
|
|
currentTrack,
|
|
playbackState,
|
|
devices,
|
|
activeDevice,
|
|
isLoading,
|
|
error,
|
|
login,
|
|
logout,
|
|
play,
|
|
pause,
|
|
skipNext,
|
|
skipPrevious,
|
|
setVolume,
|
|
getDevices,
|
|
setActiveDevice,
|
|
refreshPlaybackState,
|
|
};
|
|
|
|
if (!isAdmin) {
|
|
return <>{children}</>;
|
|
}
|
|
|
|
return (
|
|
<SpotifyContext.Provider value={contextValue}>
|
|
{children}
|
|
</SpotifyContext.Provider>
|
|
);
|
|
}; |