spotify controls
This commit is contained in:
372
src/contexts/spotify-context.tsx
Normal file
372
src/contexts/spotify-context.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user