From 01694681145eb349757f41545ff485e15fbe5ced Mon Sep 17 00:00:00 2001 From: yohlo Date: Fri, 12 Sep 2025 11:08:21 -0500 Subject: [PATCH] spotify controls --- src/app/routeTree.gen.ts | 65 +++ .../_authed/admin/tournaments/run.$id.tsx | 3 + src/app/routes/api/spotify/callback.ts | 135 +++++++ src/app/routes/api/spotify/playback.ts | 195 +++++++++ src/app/routes/api/spotify/token.ts | 127 ++++++ src/contexts/spotify-context.tsx | 372 ++++++++++++++++++ src/features/core/components/providers.tsx | 11 +- src/features/spotify/components/index.ts | 2 + .../components/spotify-controls-bar.tsx | 179 +++++++++ .../spotify/components/spotify-sheet.tsx | 197 ++++++++++ src/features/teams/server.ts | 24 -- src/lib/spotify/auth.ts | 74 ++++ src/lib/spotify/client.ts | 128 ++++++ src/lib/spotify/hooks.ts | 63 +++ src/lib/spotify/types.ts | 108 +++++ 15 files changed, 1655 insertions(+), 28 deletions(-) create mode 100644 src/app/routes/api/spotify/callback.ts create mode 100644 src/app/routes/api/spotify/playback.ts create mode 100644 src/app/routes/api/spotify/token.ts create mode 100644 src/contexts/spotify-context.tsx create mode 100644 src/features/spotify/components/index.ts create mode 100644 src/features/spotify/components/spotify-controls-bar.tsx create mode 100644 src/features/spotify/components/spotify-sheet.tsx create mode 100644 src/lib/spotify/auth.ts create mode 100644 src/lib/spotify/client.ts create mode 100644 src/lib/spotify/hooks.ts create mode 100644 src/lib/spotify/types.ts diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index e10dfeb..5cc3237 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -28,6 +28,9 @@ import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authe import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id' import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id' import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo' +import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token' +import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback' +import { ServerRoute as ApiSpotifyCallbackServerRouteImport } from './routes/api/spotify/callback' import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$' import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$' import { ServerRoute as ApiFilesCollectionRecordIdFileServerRouteImport } from './routes/api/files/$collection/$recordId/$file' @@ -123,6 +126,23 @@ const ApiTournamentsUploadLogoServerRoute = path: '/api/tournaments/upload-logo', getParentRoute: () => rootServerRouteImport, } as any) +const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({ + id: '/api/spotify/token', + path: '/api/spotify/token', + getParentRoute: () => rootServerRouteImport, +} as any) +const ApiSpotifyPlaybackServerRoute = + ApiSpotifyPlaybackServerRouteImport.update({ + id: '/api/spotify/playback', + path: '/api/spotify/playback', + getParentRoute: () => rootServerRouteImport, + } as any) +const ApiSpotifyCallbackServerRoute = + ApiSpotifyCallbackServerRouteImport.update({ + id: '/api/spotify/callback', + path: '/api/spotify/callback', + getParentRoute: () => rootServerRouteImport, + } as any) const ApiEventsSplatServerRoute = ApiEventsSplatServerRouteImport.update({ id: '/api/events/$', path: '/api/events/$', @@ -255,12 +275,18 @@ export interface RootRouteChildren { export interface FileServerRoutesByFullPath { '/api/auth/$': typeof ApiAuthSplatServerRoute '/api/events/$': typeof ApiEventsSplatServerRoute + '/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute + '/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute + '/api/spotify/token': typeof ApiSpotifyTokenServerRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute } export interface FileServerRoutesByTo { '/api/auth/$': typeof ApiAuthSplatServerRoute '/api/events/$': typeof ApiEventsSplatServerRoute + '/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute + '/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute + '/api/spotify/token': typeof ApiSpotifyTokenServerRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute } @@ -268,6 +294,9 @@ export interface FileServerRoutesById { __root__: typeof rootServerRouteImport '/api/auth/$': typeof ApiAuthSplatServerRoute '/api/events/$': typeof ApiEventsSplatServerRoute + '/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute + '/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute + '/api/spotify/token': typeof ApiSpotifyTokenServerRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute } @@ -276,18 +305,27 @@ export interface FileServerRouteTypes { fullPaths: | '/api/auth/$' | '/api/events/$' + | '/api/spotify/callback' + | '/api/spotify/playback' + | '/api/spotify/token' | '/api/tournaments/upload-logo' | '/api/files/$collection/$recordId/$file' fileServerRoutesByTo: FileServerRoutesByTo to: | '/api/auth/$' | '/api/events/$' + | '/api/spotify/callback' + | '/api/spotify/playback' + | '/api/spotify/token' | '/api/tournaments/upload-logo' | '/api/files/$collection/$recordId/$file' id: | '__root__' | '/api/auth/$' | '/api/events/$' + | '/api/spotify/callback' + | '/api/spotify/playback' + | '/api/spotify/token' | '/api/tournaments/upload-logo' | '/api/files/$collection/$recordId/$file' fileServerRoutesById: FileServerRoutesById @@ -295,6 +333,9 @@ export interface FileServerRouteTypes { export interface RootServerRouteChildren { ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute + ApiSpotifyCallbackServerRoute: typeof ApiSpotifyCallbackServerRoute + ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute + ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute } @@ -424,6 +465,27 @@ declare module '@tanstack/react-start/server' { preLoaderRoute: typeof ApiTournamentsUploadLogoServerRouteImport parentRoute: typeof rootServerRouteImport } + '/api/spotify/token': { + id: '/api/spotify/token' + path: '/api/spotify/token' + fullPath: '/api/spotify/token' + preLoaderRoute: typeof ApiSpotifyTokenServerRouteImport + parentRoute: typeof rootServerRouteImport + } + '/api/spotify/playback': { + id: '/api/spotify/playback' + path: '/api/spotify/playback' + fullPath: '/api/spotify/playback' + preLoaderRoute: typeof ApiSpotifyPlaybackServerRouteImport + parentRoute: typeof rootServerRouteImport + } + '/api/spotify/callback': { + id: '/api/spotify/callback' + path: '/api/spotify/callback' + fullPath: '/api/spotify/callback' + preLoaderRoute: typeof ApiSpotifyCallbackServerRouteImport + parentRoute: typeof rootServerRouteImport + } '/api/events/$': { id: '/api/events/$' path: '/api/events/$' @@ -503,6 +565,9 @@ export const routeTree = rootRouteImport const rootServerRouteChildren: RootServerRouteChildren = { ApiAuthSplatServerRoute: ApiAuthSplatServerRoute, ApiEventsSplatServerRoute: ApiEventsSplatServerRoute, + ApiSpotifyCallbackServerRoute: ApiSpotifyCallbackServerRoute, + ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute, + ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute, ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute, ApiFilesCollectionRecordIdFileServerRoute: ApiFilesCollectionRecordIdFileServerRoute, diff --git a/src/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx index 2636879..76fefae 100644 --- a/src/app/routes/_authed/admin/tournaments/run.$id.tsx +++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx @@ -14,6 +14,7 @@ import BracketView from "@/features/bracket/components/bracket-view"; import { startMatch } from "@/features/matches/server"; import { useServerMutation } from "@/lib/tanstack-query/hooks"; import { useQueryClient } from "@tanstack/react-query"; +import { SpotifyControlsBar } from "@/features/spotify/components"; export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({ beforeLoad: async ({ context, params }) => { @@ -29,6 +30,7 @@ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({ }, loader: ({ context }) => ({ fullWidth: true, + showSpotifyPanel: true, header: { withBackButton: true, title: `Run ${context.tournament.name}`, @@ -77,6 +79,7 @@ function RouteComponent() { return ( + {tournament.matches?.length ? ( ) : ( diff --git a/src/app/routes/api/spotify/callback.ts b/src/app/routes/api/spotify/callback.ts new file mode 100644 index 0000000..f1b887b --- /dev/null +++ b/src/app/routes/api/spotify/callback.ts @@ -0,0 +1,135 @@ +import { createServerFileRoute } from '@tanstack/react-start/server' +import { SpotifyAuth } from '@/lib/spotify/auth' + +const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID! +const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET! +const SPOTIFY_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI! + +export const ServerRoute = createServerFileRoute('/api/spotify/callback').methods({ + GET: async ({ request }: { request: Request }) => { + // Helper function to get return path from state parameter + const getReturnPath = (state: string | null): string => { + if (!state) return '/'; + try { + const decodedState = JSON.parse(atob(state)); + return decodedState.returnPath || '/'; + } catch { + return '/'; + } + }; + + try { + const url = new URL(request.url) + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + const error = url.searchParams.get('error') + + const returnPath = getReturnPath(state); + + // Check for OAuth errors + if (error) { + console.error('Spotify OAuth error:', error) + return new Response(null, { + status: 302, + headers: { + 'Location': returnPath + '?spotify_error=' + encodeURIComponent(error), + }, + }) + } + + if (!code || !state) { + console.error('Missing code or state:', { code: !!code, state: !!state }) + return new Response(null, { + status: 302, + headers: { + 'Location': returnPath + '?spotify_error=missing_code_or_state', + }, + }) + } + + console.log('Token exchange attempt:', { + client_id: SPOTIFY_CLIENT_ID, + redirect_uri: SPOTIFY_REDIRECT_URI, + has_code: !!code, + has_state: !!state, + }) + + // Exchange code for tokens + const tokenResponse = await fetch('https://accounts.spotify.com/api/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: SPOTIFY_REDIRECT_URI, + }), + }) + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text() + console.error('Token exchange error:', { + status: tokenResponse.status, + statusText: tokenResponse.statusText, + body: errorText, + redirect_uri: SPOTIFY_REDIRECT_URI, + }) + + // Return more detailed error info + const errorParam = encodeURIComponent(`${tokenResponse.status}: ${errorText}`) + return new Response(null, { + status: 302, + headers: { + 'Location': `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`, + }, + }) + } + + const tokens = await tokenResponse.json() + + console.log('Token exchange successful:', { + has_access_token: !!tokens.access_token, + has_refresh_token: !!tokens.refresh_token, + expires_in: tokens.expires_in, + }) + + console.log('Decoded return path:', returnPath); + + // Create response with redirect to original path + const response = new Response(null, { + status: 302, + headers: { + 'Location': returnPath + '?spotify_auth=success', + }, + }) + + // Set secure cookies for tokens + const isSecure = process.env.NODE_ENV === 'production' + const cookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${tokens.expires_in}` + + response.headers.append('Set-Cookie', `spotify_access_token=${tokens.access_token}; ${cookieOptions}`) + + if (tokens.refresh_token) { + // Refresh token doesn't expire, set longer max age + const refreshCookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${60 * 60 * 24 * 30}` // 30 days + response.headers.append('Set-Cookie', `spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`) + } + + return response + } catch (error) { + console.error('Spotify callback error:', error) + // Try to get return path from query params if available, otherwise use default + const url = new URL(request.url); + const state = url.searchParams.get('state'); + const returnPath = getReturnPath(state); + return new Response(null, { + status: 302, + headers: { + 'Location': returnPath + '?spotify_error=callback_failed', + }, + }) + } + }, +}) \ No newline at end of file diff --git a/src/app/routes/api/spotify/playback.ts b/src/app/routes/api/spotify/playback.ts new file mode 100644 index 0000000..682b753 --- /dev/null +++ b/src/app/routes/api/spotify/playback.ts @@ -0,0 +1,195 @@ +import { createServerFileRoute } from '@tanstack/react-start/server' +import { SpotifyWebApiClient } from '@/lib/spotify/client' + +// Helper function to get access token from cookies +function getAccessTokenFromCookies(request: Request): string | null { + const cookieHeader = request.headers.get('cookie') + if (!cookieHeader) return null + + const cookies = Object.fromEntries( + cookieHeader.split('; ').map(c => c.split('=')) + ) + + return cookies.spotify_access_token || null +} + +export const ServerRoute = createServerFileRoute('/api/spotify/playback').methods({ + POST: async ({ request }: { request: Request }) => { + try { + const accessToken = getAccessTokenFromCookies(request) + if (!accessToken) { + return new Response( + JSON.stringify({ error: 'No access token found' }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + const body = await request.json() + const { action, deviceId, volumePercent } = body + + const spotifyClient = new SpotifyWebApiClient(accessToken) + + switch (action) { + case 'play': + await spotifyClient.play(deviceId) + break + case 'pause': + await spotifyClient.pause() + break + case 'next': + await spotifyClient.skipToNext() + break + case 'previous': + await spotifyClient.skipToPrevious() + break + case 'volume': + if (typeof volumePercent !== 'number') { + return new Response( + JSON.stringify({ error: 'volumePercent must be a number' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + await spotifyClient.setVolume(volumePercent) + break + case 'transfer': + if (!deviceId) { + return new Response( + JSON.stringify({ error: 'deviceId is required for transfer action' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + await spotifyClient.transferPlayback(deviceId) + break + default: + return new Response( + JSON.stringify({ error: 'Invalid action' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + return new Response( + JSON.stringify({ success: true }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + } catch (error) { + console.error('Playback control error:', error) + + // Handle specific Spotify API errors + if (error instanceof Error) { + if (error.message.includes('NO_ACTIVE_DEVICE')) { + return new Response( + JSON.stringify({ error: 'No active device found. Please select a device first.' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + if (error.message.includes('PREMIUM_REQUIRED')) { + return new Response( + JSON.stringify({ error: 'Spotify Premium is required for playback control.' }), + { + status: 403, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + // Log the full error details for debugging + console.error('Full error details:', { + message: error.message, + stack: error.stack, + name: error.name, + }) + } + + return new Response( + JSON.stringify({ error: 'Playback control failed', details: error instanceof Error ? error.message : 'Unknown error' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + }, + + // GET endpoint for retrieving current playback state and devices + GET: async ({ request }: { request: Request }) => { + try { + const accessToken = getAccessTokenFromCookies(request) + if (!accessToken) { + return new Response( + JSON.stringify({ error: 'No access token found' }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + const url = new URL(request.url) + const type = url.searchParams.get('type') // 'state' or 'devices' + + const spotifyClient = new SpotifyWebApiClient(accessToken) + + if (type === 'devices') { + const devices = await spotifyClient.getDevices() + return new Response( + JSON.stringify({ devices }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + } else if (type === 'state') { + const playbackState = await spotifyClient.getPlaybackState() + return new Response( + JSON.stringify({ playbackState }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + } else { + // Return both by default + const [devices, playbackState] = await Promise.all([ + spotifyClient.getDevices(), + spotifyClient.getPlaybackState(), + ]) + + return new Response( + JSON.stringify({ devices, playbackState }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + } catch (error) { + console.error('Get playback data error:', error) + return new Response( + JSON.stringify({ error: 'Failed to get playback data' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + }, +}) \ No newline at end of file diff --git a/src/app/routes/api/spotify/token.ts b/src/app/routes/api/spotify/token.ts new file mode 100644 index 0000000..49b7aae --- /dev/null +++ b/src/app/routes/api/spotify/token.ts @@ -0,0 +1,127 @@ +import { createServerFileRoute } from '@tanstack/react-start/server' + +const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID! +const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET! + +export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({ + POST: async ({ request }: { request: Request }) => { + try { + const body = await request.json() + const { refresh_token } = body + + if (!refresh_token) { + return new Response( + JSON.stringify({ error: 'refresh_token is required' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + // Refresh access token + const tokenResponse = await fetch('https://accounts.spotify.com/api/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`, + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token, + }), + }) + + if (!tokenResponse.ok) { + const error = await tokenResponse.json() + console.error('Token refresh error:', error) + return new Response( + JSON.stringify({ error: 'Failed to refresh token', details: error }), + { + status: tokenResponse.status, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + const tokens = await tokenResponse.json() + + // Return new tokens + return new Response( + JSON.stringify({ + access_token: tokens.access_token, + expires_in: tokens.expires_in, + scope: tokens.scope, + token_type: tokens.token_type, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + } catch (error) { + console.error('Token refresh endpoint error:', error) + return new Response( + JSON.stringify({ error: 'Internal server error' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + }, + + // GET endpoint to retrieve current tokens from cookies + GET: async ({ request }: { request: Request }) => { + try { + const cookieHeader = request.headers.get('cookie') + if (!cookieHeader) { + return new Response( + JSON.stringify({ error: 'No cookies found' }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + const cookies = Object.fromEntries( + cookieHeader.split('; ').map((c: string) => c.split('=')) + ) + + const accessToken = cookies.spotify_access_token + const refreshToken = cookies.spotify_refresh_token + + if (!accessToken && !refreshToken) { + return new Response( + JSON.stringify({ error: 'No Spotify tokens found' }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + return new Response( + JSON.stringify({ + access_token: accessToken || null, + refresh_token: refreshToken || null, + has_tokens: !!(accessToken || refreshToken), + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + } catch (error) { + console.error('Get tokens endpoint error:', error) + return new Response( + JSON.stringify({ error: 'Internal server error' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + }, +}) \ No newline at end of file diff --git a/src/contexts/spotify-context.tsx b/src/contexts/spotify-context.tsx new file mode 100644 index 0000000..aaffdc4 --- /dev/null +++ b/src/contexts/spotify-context.tsx @@ -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(null); + +export const SpotifyProvider: React.FC = ({ children }) => { + const { roles } = useAuth(); + const isAdmin = roles?.includes('Admin') || false; + + 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 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 ( + + {children} + + ); +}; \ No newline at end of file diff --git a/src/features/core/components/providers.tsx b/src/features/core/components/providers.tsx index 2a95a0f..7eea703 100644 --- a/src/features/core/components/providers.tsx +++ b/src/features/core/components/providers.tsx @@ -1,14 +1,17 @@ import { AuthProvider } from "@/contexts/auth-context" +import { SpotifyProvider } from "@/contexts/spotify-context" import MantineProvider from "@/lib/mantine/mantine-provider" import { Toaster } from "sonner" const Providers = ({ children }: { children: React.ReactNode }) => { return ( - - - {children} - + + + + {children} + + ) } diff --git a/src/features/spotify/components/index.ts b/src/features/spotify/components/index.ts new file mode 100644 index 0000000..875c763 --- /dev/null +++ b/src/features/spotify/components/index.ts @@ -0,0 +1,2 @@ +export { default as SpotifyControlsBar } from './spotify-controls-bar'; +export { default as SpotifySheet } from './spotify-sheet'; \ No newline at end of file diff --git a/src/features/spotify/components/spotify-controls-bar.tsx b/src/features/spotify/components/spotify-controls-bar.tsx new file mode 100644 index 0000000..fb2fb21 --- /dev/null +++ b/src/features/spotify/components/spotify-controls-bar.tsx @@ -0,0 +1,179 @@ +import { ActionIcon, Box, Group, Loader, Text, Tooltip } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { + PlayIcon, + PauseIcon, + SkipBackIcon, + SkipForwardIcon, + GearIcon, + SpotifyLogoIcon, +} from '@phosphor-icons/react'; +import { useSpotify } from '@/lib/spotify/hooks'; +import { useAuth } from '@/contexts/auth-context'; +import SpotifySheet from './spotify-sheet'; + +const SpotifyControlsBar = () => { + const { roles } = useAuth(); + const isAdmin = roles?.includes('Admin') || false; + const [sheetOpened, { open: openSheet, close: closeSheet }] = useDisclosure(false); + + const { + isAuthenticated, + playbackState, + currentTrack, + isLoading, + error, + play, + pause, + skipNext, + skipPrevious, + activeDevice, + } = useSpotify(); + + if (!isAdmin) return null; + + if (!isAuthenticated) { + return ( + + + + + Connect Spotify to control music during tournaments + + + + + + + + ); + } + + const isPlaying = playbackState?.is_playing || false; + const hasActiveDevice = !!activeDevice; + + return ( + + + {currentTrack && ( + + {currentTrack.album.images[2] && ( + {currentTrack.album.name} + )} + + + + {currentTrack.name} + + + {currentTrack.artists.map(a => a.name).join(', ')} + + + {currentTrack.album.name} + + + + )} + + + + + + + + + + isPlaying ? pause() : play()} + disabled={!hasActiveDevice || isLoading} + loading={isLoading} + > + {isPlaying ? : } + + + + + + + + + + + + {activeDevice && ( + + + Playing on {activeDevice.name} + + + )} + + + + + + + + + {isLoading && ( + + )} + + + {error && ( + + + {error} + + + )} + + {isAuthenticated && !hasActiveDevice && !isLoading && ( + + + No active device. Please select a device in settings. + + + )} + + + + ); +}; + +export default SpotifyControlsBar; \ No newline at end of file diff --git a/src/features/spotify/components/spotify-sheet.tsx b/src/features/spotify/components/spotify-sheet.tsx new file mode 100644 index 0000000..deca945 --- /dev/null +++ b/src/features/spotify/components/spotify-sheet.tsx @@ -0,0 +1,197 @@ +import { + ActionIcon, + Box, + Button, + Divider, + Group, + NativeSelect, + Stack, + Text, + Title +} from '@mantine/core'; +import { + SpotifyLogoIcon, + DevicesIcon, + SignOutIcon, + ArrowsClockwiseIcon +} from '@phosphor-icons/react'; +import { useSpotify } from '@/lib/spotify/hooks'; +import { useAuth } from '@/contexts/auth-context'; +import Sheet from '@/components/sheet/sheet'; + +interface SpotifySheetProps { + opened: boolean; + onClose: () => void; +} + +const SpotifySheet: React.FC = ({ opened, onClose }) => { + const { roles } = useAuth(); + const isAdmin = roles?.includes('Admin') || false; + + const { + isAuthenticated, + login, + logout, + devices, + activeDevice, + currentTrack, + getDevices, + setActiveDevice, + isLoading, + error, + } = useSpotify(); + + if (!isAdmin) return null; + + const handleDeviceChange = (deviceId: string) => { + if (deviceId && deviceId !== activeDevice?.id) { + setActiveDevice(deviceId); + } + }; + + const refreshDevices = () => { + getDevices(); + }; + + const handleChange = (opened: boolean) => { + if (!opened) onClose(); + }; + + return ( + + + + + Spotify Controls + + + {!isAuthenticated ? ( + <> + + + Connect your Spotify account + + + + + + ) : ( + <> + {/* Current track display */} + {currentTrack && ( + <> + + Now Playing + + {currentTrack.album.images[2] && ( + {currentTrack.album.name} + )} + + + {currentTrack.name} + + + {currentTrack.artists.map(a => a.name).join(', ')} + + + {currentTrack.album.name} + + + + + + + )} + + + + + <Group gap="xs"> + <DevicesIcon size={20} /> + Select Device + </Group> + + + + + + + {devices.length > 0 ? ( + handleDeviceChange(event.currentTarget.value)} + data={[ + { value: '', label: 'Select a device...' }, + ...devices.map(device => ({ + value: device.id, + label: `${device.name} ${device.is_active ? '(Active)' : ''} - ${device.type}`, + })), + ]} + disabled={isLoading} + /> + ) : ( + + No devices found. + + )} + + {activeDevice && ( + + Active device: {activeDevice.name} + {activeDevice.volume_percent !== null && + ` (Volume: ${activeDevice.volume_percent}%)` + } + + )} + + + {error && ( + <> + + + + {error} + + + + )} + + + + + )} + + + ); +}; + +export default SpotifySheet; \ No newline at end of file diff --git a/src/features/teams/server.ts b/src/features/teams/server.ts index dc4d0b3..39f615e 100644 --- a/src/features/teams/server.ts +++ b/src/features/teams/server.ts @@ -6,11 +6,6 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { teamInputSchema, teamUpdateSchema } from "./types"; import { logger } from "@/lib/logger"; -export const listTeams = createServerFn() - .middleware([superTokensFunctionMiddleware]) - .handler(async () => - toServerResult(() => pbAdmin.listTeams()) - ); export const listTeamInfos = createServerFn() .middleware([superTokensFunctionMiddleware]) @@ -40,22 +35,10 @@ export const createTeam = createServerFn() const userId = context.userAuthId; const isAdmin = context.roles.includes("Admin"); - // Check if user is trying to create a team with themselves as a player if (!isAdmin && !data.players.includes(userId)) { throw new Error("You can only create teams that include yourself as a player"); } - // Additional validation: ensure user is not already on another team - if (!isAdmin) { - const userTeams = await pbAdmin.listTeams(); - const existingTeam = userTeams.find(team => - team.players.some(player => player.id === userId) - ); - if (existingTeam) { - throw new Error(`You are already a member of team "${existingTeam.name}"`); - } - } - logger.info("Creating team", { name: data.name, userId, isAdmin }); return pbAdmin.createTeam(data); }) @@ -88,10 +71,3 @@ export const updateTeam = createServerFn() return pbAdmin.updateTeam(id, updates); }) ); - -export const deleteTeam = createServerFn() - .validator(z.string()) - .middleware([superTokensAdminFunctionMiddleware]) - .handler(async ({ data: teamId }) => - toServerResult(() => pbAdmin.deleteTeam(teamId)) - ); diff --git a/src/lib/spotify/auth.ts b/src/lib/spotify/auth.ts new file mode 100644 index 0000000..332e06a --- /dev/null +++ b/src/lib/spotify/auth.ts @@ -0,0 +1,74 @@ +import type { PKCEState, SpotifyTokenResponse } from './types'; + +const SPOTIFY_AUTH_BASE = 'https://accounts.spotify.com'; +const SPOTIFY_SCOPES = [ + 'user-read-playback-state', + 'user-modify-playback-state', + 'user-read-currently-playing', + 'streaming', +].join(' '); + +function generateRandomString(length: number): string { + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const values = crypto.getRandomValues(new Uint8Array(length)); + return values.reduce((acc, x) => acc + possible[x % possible.length], ''); +} + +export class SpotifyAuth { + private clientId: string; + private redirectUri: string; + + constructor(clientId: string, redirectUri: string) { + this.clientId = clientId; + this.redirectUri = redirectUri; + } + + async startAuthFlow(returnPath: string = window.location.pathname): Promise { + const randomState = generateRandomString(16); + + const stateWithPath = btoa(JSON.stringify({ + state: randomState, + returnPath: returnPath + })); + + sessionStorage.setItem('spotify_state', randomState); + + const params = new URLSearchParams({ + response_type: 'code', + client_id: this.clientId, + scope: SPOTIFY_SCOPES, + redirect_uri: this.redirectUri, + state: stateWithPath, + }); + + const authUrl = `${SPOTIFY_AUTH_BASE}/authorize?${params.toString()}`; + window.location.href = authUrl; + } + + async refreshAccessToken(refreshToken: string): Promise { + const response = await fetch('/api/spotify/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + refresh_token: refreshToken, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Token refresh failed: ${error.error || 'Unknown error'}`); + } + + return response.json(); + } + + getReturnPath(): string { + return '/'; + } + + clearStoredData(): void { + sessionStorage.removeItem('spotify_state'); + } +} \ No newline at end of file diff --git a/src/lib/spotify/client.ts b/src/lib/spotify/client.ts new file mode 100644 index 0000000..4ccfb79 --- /dev/null +++ b/src/lib/spotify/client.ts @@ -0,0 +1,128 @@ +import type { + SpotifyDevice, + SpotifyDevicesResponse, + SpotifyPlaybackState, + SpotifyError, +} from './types'; + +const SPOTIFY_API_BASE = 'https://api.spotify.com/v1'; + +export class SpotifyWebApiClient { + private accessToken: string; + + constructor(accessToken: string) { + this.accessToken = accessToken; + } + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${SPOTIFY_API_BASE}${endpoint}`; + + const response = await fetch(url, { + ...options, + headers: { + 'Authorization': `Bearer ${this.accessToken}`, + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + try { + const error: SpotifyError = await response.json(); + throw new Error(`Spotify API Error: ${error.error?.message || 'Unknown error'}`); + } catch (parseError) { + throw new Error(`Spotify API Error: ${response.status} ${response.statusText}`); + } + } + + if (response.status === 204 || response.status === 202) { + return {} as T; + } + + const contentLength = response.headers.get('content-length'); + if (contentLength === '0') { + return {} as T; + } + + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + console.warn('Non-JSON response from Spotify API:', contentType, response.status); + return {} as T; + } + + try { + return await response.json(); + } catch (error) { + console.warn('Failed to parse Spotify API JSON response:', error); + return {} as T; + } + } + + async getDevices(): Promise { + const response = await this.request('/me/player/devices'); + return response.devices; + } + + async transferPlayback(deviceId: string, play: boolean = false): Promise { + await this.request('/me/player', { + method: 'PUT', + body: JSON.stringify({ + device_ids: [deviceId], + play, + }), + }); + } + + async getPlaybackState(): Promise { + try { + return await this.request('/me/player'); + } catch (error) { + if (error instanceof Error && error.message.includes('204')) { + return null; + } + throw error; + } + } + + async play(deviceId?: string): Promise { + const endpoint = deviceId ? `/me/player/play?device_id=${deviceId}` : '/me/player/play'; + await this.request(endpoint, { + method: 'PUT', + }); + } + + async pause(): Promise { + await this.request('/me/player/pause', { + method: 'PUT', + }); + } + + async skipToNext(): Promise { + await this.request('/me/player/next', { + method: 'POST', + }); + } + + async skipToPrevious(): Promise { + await this.request('/me/player/previous', { + method: 'POST', + }); + } + + async setVolume(volumePercent: number): Promise { + await this.request(`/me/player/volume?volume_percent=${volumePercent}`, { + method: 'PUT', + }); + } + + async getCurrentUser(): Promise<{ id: string; display_name: string }> { + return this.request<{ id: string; display_name: string }>('/me'); + } + + updateAccessToken(accessToken: string): void { + this.accessToken = accessToken; + } +} \ No newline at end of file diff --git a/src/lib/spotify/hooks.ts b/src/lib/spotify/hooks.ts new file mode 100644 index 0000000..b55f1d9 --- /dev/null +++ b/src/lib/spotify/hooks.ts @@ -0,0 +1,63 @@ +import { useContext } from 'react'; +import { SpotifyContext } from '@/contexts/spotify-context'; +import type { SpotifyContextType } from './types'; + +export const useSpotify = (): SpotifyContextType => { + const context = useContext(SpotifyContext); + + if (!context) { + throw new Error('useSpotify must be used within a SpotifyProvider'); + } + + return context; +}; + +export const useSpotifyAuth = () => { + const { isAuthenticated, login, logout } = useSpotify(); + return { isAuthenticated, login, logout }; +}; + +export const useSpotifyPlayback = () => { + const { + playbackState, + currentTrack, + play, + pause, + skipNext, + skipPrevious, + setVolume, + refreshPlaybackState, + isLoading, + } = useSpotify(); + + return { + playbackState, + currentTrack, + play, + pause, + skipNext, + skipPrevious, + setVolume, + refreshPlaybackState, + isLoading, + isPlaying: playbackState?.is_playing || false, + }; +}; + +export const useSpotifyDevices = () => { + const { + devices, + activeDevice, + getDevices, + setActiveDevice, + isLoading, + } = useSpotify(); + + return { + devices, + activeDevice, + getDevices, + setActiveDevice, + isLoading, + }; +}; diff --git a/src/lib/spotify/types.ts b/src/lib/spotify/types.ts new file mode 100644 index 0000000..b59a639 --- /dev/null +++ b/src/lib/spotify/types.ts @@ -0,0 +1,108 @@ +export interface SpotifyDevice { + id: string; + is_active: boolean; + is_private_session: boolean; + is_restricted: boolean; + name: string; + type: string; + volume_percent: number | null; + supports_volume: boolean; +} + +export interface SpotifyTrack { + id: string; + name: string; + artists: Array<{ + id: string; + name: string; + }>; + album: { + id: string; + name: string; + images: Array<{ + url: string; + height: number; + width: number; + }>; + }; + duration_ms: number; + external_urls: { + spotify: string; + }; +} + +export interface SpotifyPlaybackState { + device: SpotifyDevice; + repeat_state: 'off' | 'context' | 'track'; + shuffle_state: boolean; + context: { + type: string; + href: string; + external_urls: { + spotify: string; + }; + uri: string; + } | null; + timestamp: number; + progress_ms: number | null; + is_playing: boolean; + item: SpotifyTrack | null; + currently_playing_type: 'track' | 'episode' | 'ad' | 'unknown'; +} + +export interface SpotifyTokenResponse { + access_token: string; + token_type: 'Bearer'; + scope: string; + expires_in: number; + refresh_token?: string; + state?: string; +} + +export interface SpotifyError { + error: { + status: number; + message: string; + }; +} + +export interface SpotifyAuthState { + isAuthenticated: boolean; + accessToken: string | null; + refreshToken: string | null; + expiresAt: number | null; + scopes: string[]; +} + +export interface SpotifyContextType extends SpotifyAuthState { + currentTrack: SpotifyTrack | null; + playbackState: SpotifyPlaybackState | null; + devices: SpotifyDevice[]; + activeDevice: SpotifyDevice | null; + isLoading: boolean; + error: string | null; + + login: () => void; + logout: () => void; + + play: (deviceId?: string) => Promise; + pause: () => Promise; + skipNext: () => Promise; + skipPrevious: () => Promise; + setVolume: (volumePercent: number) => Promise; + + getDevices: () => Promise; + setActiveDevice: (deviceId: string) => Promise; + + refreshPlaybackState: () => Promise; +} + +export interface PKCEState { + codeVerifier: string; + codeChallenge: string; + state: string; +} + +export interface SpotifyDevicesResponse { + devices: SpotifyDevice[]; +} \ No newline at end of file