475 lines
13 KiB
TypeScript
475 lines
13 KiB
TypeScript
import { createContext, useCallback, useEffect, useState, PropsWithChildren } from 'react';
|
|
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<SpotifyContextType | null>(null);
|
|
|
|
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|
const { roles } = useAuth();
|
|
const isAdmin = roles?.includes('Admin') || false;
|
|
const config = useConfig();
|
|
|
|
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 [capturedState, setCapturedState] = useState<SpotifyPlaybackSnapshot | null>(null);
|
|
const [isCaptureLoading, setIsCaptureLoading] = useState(false);
|
|
const [isResumeLoading, setIsResumeLoading] = useState(false);
|
|
|
|
const spotifyAuth = new SpotifyAuth(
|
|
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) {
|
|
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 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]);
|
|
|
|
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 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 : 'Failed to capture playback state');
|
|
} finally {
|
|
setIsCaptureLoading(false);
|
|
}
|
|
}, [authState.isAuthenticated]);
|
|
|
|
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 : 'Failed to resume playback state');
|
|
} finally {
|
|
setIsResumeLoading(false);
|
|
}
|
|
}, [authState.isAuthenticated, capturedState, refreshPlaybackState]);
|
|
|
|
const clearCapturedState = useCallback(() => {
|
|
setCapturedState(null);
|
|
}, []);
|
|
|
|
const searchTracks = useCallback(async (query: string): Promise<SpotifyTrack[]> => {
|
|
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 = {
|
|
...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,
|
|
};
|
|
|
|
if (!isAdmin) {
|
|
return <>{children}</>;
|
|
}
|
|
|
|
return (
|
|
<SpotifyContext.Provider value={contextValue}>
|
|
{children}
|
|
</SpotifyContext.Provider>
|
|
);
|
|
}; |