Files
flxn-app/src/contexts/spotify-context.tsx
T
kyle 0a7b5fa00f
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Failing after 10s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
i18n
2026-08-08 15:29:40 -07:00

541 lines
15 KiB
TypeScript

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<SpotifyContextType | null>(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<string, unknown>);
const bKeys = Object.keys(b as Record<string, unknown>);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((key) =>
deepEqual(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key]
)
);
};
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ 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<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 = 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<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 = 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 (
<SpotifyContext.Provider value={contextValue}>
{children}
</SpotifyContext.Provider>
);
};