spotify controls

This commit is contained in:
yohlo
2025-09-12 11:08:21 -05:00
parent 9d92a8a510
commit 0169468114
15 changed files with 1655 additions and 28 deletions

View File

@@ -28,6 +28,9 @@ import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authe
import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id' import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id'
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$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 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 ApiEventsSplatServerRouteImport } from './routes/api/events.$'
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$' import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
import { ServerRoute as ApiFilesCollectionRecordIdFileServerRouteImport } from './routes/api/files/$collection/$recordId/$file' import { ServerRoute as ApiFilesCollectionRecordIdFileServerRouteImport } from './routes/api/files/$collection/$recordId/$file'
@@ -123,6 +126,23 @@ const ApiTournamentsUploadLogoServerRoute =
path: '/api/tournaments/upload-logo', path: '/api/tournaments/upload-logo',
getParentRoute: () => rootServerRouteImport, getParentRoute: () => rootServerRouteImport,
} as any) } 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({ const ApiEventsSplatServerRoute = ApiEventsSplatServerRouteImport.update({
id: '/api/events/$', id: '/api/events/$',
path: '/api/events/$', path: '/api/events/$',
@@ -255,12 +275,18 @@ export interface RootRouteChildren {
export interface FileServerRoutesByFullPath { export interface FileServerRoutesByFullPath {
'/api/auth/$': typeof ApiAuthSplatServerRoute '/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute '/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/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
} }
export interface FileServerRoutesByTo { export interface FileServerRoutesByTo {
'/api/auth/$': typeof ApiAuthSplatServerRoute '/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute '/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/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
} }
@@ -268,6 +294,9 @@ export interface FileServerRoutesById {
__root__: typeof rootServerRouteImport __root__: typeof rootServerRouteImport
'/api/auth/$': typeof ApiAuthSplatServerRoute '/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute '/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/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
} }
@@ -276,18 +305,27 @@ export interface FileServerRouteTypes {
fullPaths: fullPaths:
| '/api/auth/$' | '/api/auth/$'
| '/api/events/$' | '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/playback'
| '/api/spotify/token'
| '/api/tournaments/upload-logo' | '/api/tournaments/upload-logo'
| '/api/files/$collection/$recordId/$file' | '/api/files/$collection/$recordId/$file'
fileServerRoutesByTo: FileServerRoutesByTo fileServerRoutesByTo: FileServerRoutesByTo
to: to:
| '/api/auth/$' | '/api/auth/$'
| '/api/events/$' | '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/playback'
| '/api/spotify/token'
| '/api/tournaments/upload-logo' | '/api/tournaments/upload-logo'
| '/api/files/$collection/$recordId/$file' | '/api/files/$collection/$recordId/$file'
id: id:
| '__root__' | '__root__'
| '/api/auth/$' | '/api/auth/$'
| '/api/events/$' | '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/playback'
| '/api/spotify/token'
| '/api/tournaments/upload-logo' | '/api/tournaments/upload-logo'
| '/api/files/$collection/$recordId/$file' | '/api/files/$collection/$recordId/$file'
fileServerRoutesById: FileServerRoutesById fileServerRoutesById: FileServerRoutesById
@@ -295,6 +333,9 @@ export interface FileServerRouteTypes {
export interface RootServerRouteChildren { export interface RootServerRouteChildren {
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
ApiSpotifyCallbackServerRoute: typeof ApiSpotifyCallbackServerRoute
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
} }
@@ -424,6 +465,27 @@ declare module '@tanstack/react-start/server' {
preLoaderRoute: typeof ApiTournamentsUploadLogoServerRouteImport preLoaderRoute: typeof ApiTournamentsUploadLogoServerRouteImport
parentRoute: typeof rootServerRouteImport 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/$': { '/api/events/$': {
id: '/api/events/$' id: '/api/events/$'
path: '/api/events/$' path: '/api/events/$'
@@ -503,6 +565,9 @@ export const routeTree = rootRouteImport
const rootServerRouteChildren: RootServerRouteChildren = { const rootServerRouteChildren: RootServerRouteChildren = {
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute, ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute, ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
ApiSpotifyCallbackServerRoute: ApiSpotifyCallbackServerRoute,
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute, ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
ApiFilesCollectionRecordIdFileServerRoute: ApiFilesCollectionRecordIdFileServerRoute:
ApiFilesCollectionRecordIdFileServerRoute, ApiFilesCollectionRecordIdFileServerRoute,

View File

@@ -14,6 +14,7 @@ import BracketView from "@/features/bracket/components/bracket-view";
import { startMatch } from "@/features/matches/server"; import { startMatch } from "@/features/matches/server";
import { useServerMutation } from "@/lib/tanstack-query/hooks"; import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { SpotifyControlsBar } from "@/features/spotify/components";
export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -29,6 +30,7 @@ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
}, },
loader: ({ context }) => ({ loader: ({ context }) => ({
fullWidth: true, fullWidth: true,
showSpotifyPanel: true,
header: { header: {
withBackButton: true, withBackButton: true,
title: `Run ${context.tournament.name}`, title: `Run ${context.tournament.name}`,
@@ -77,6 +79,7 @@ function RouteComponent() {
return ( return (
<Container size="md"> <Container size="md">
<SpotifyControlsBar />
{tournament.matches?.length ? ( {tournament.matches?.length ? (
<BracketView bracket={bracket} showControls /> <BracketView bracket={bracket} showControls />
) : ( ) : (

View File

@@ -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',
},
})
}
},
})

View File

@@ -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' },
}
)
}
},
})

View File

@@ -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' },
}
)
}
},
})

View 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>
);
};

View File

@@ -1,14 +1,17 @@
import { AuthProvider } from "@/contexts/auth-context" import { AuthProvider } from "@/contexts/auth-context"
import { SpotifyProvider } from "@/contexts/spotify-context"
import MantineProvider from "@/lib/mantine/mantine-provider" import MantineProvider from "@/lib/mantine/mantine-provider"
import { Toaster } from "sonner" import { Toaster } from "sonner"
const Providers = ({ children }: { children: React.ReactNode }) => { const Providers = ({ children }: { children: React.ReactNode }) => {
return ( return (
<AuthProvider> <AuthProvider>
<SpotifyProvider>
<MantineProvider> <MantineProvider>
<Toaster position='top-center' /> <Toaster position='top-center' />
{children} {children}
</MantineProvider> </MantineProvider>
</SpotifyProvider>
</AuthProvider> </AuthProvider>
) )
} }

View File

@@ -0,0 +1,2 @@
export { default as SpotifyControlsBar } from './spotify-controls-bar';
export { default as SpotifySheet } from './spotify-sheet';

View File

@@ -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 (
<Box py="md" mb="md">
<Group justify="center" gap="sm">
<SpotifyLogoIcon size={24} color="var(--mantine-color-green-6)" />
<Text size="sm" c="dimmed">
Connect Spotify to control music during tournaments
</Text>
<ActionIcon
variant="light"
color="green"
size="lg"
onClick={openSheet}
loading={isLoading}
>
<GearIcon size={18} />
</ActionIcon>
</Group>
<SpotifySheet opened={sheetOpened} onClose={closeSheet} />
</Box>
);
}
const isPlaying = playbackState?.is_playing || false;
const hasActiveDevice = !!activeDevice;
return (
<Box py="md" mb="md">
<Group justify="center" gap="md" align="center">
{currentTrack && (
<Group gap="sm" style={{ maxWidth: 400 }}>
{currentTrack.album.images[2] && (
<img
src={currentTrack.album.images[2].url}
alt={currentTrack.album.name}
style={{
width: 48,
height: 48,
borderRadius: 4,
flexShrink: 0
}}
/>
)}
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={600} truncate>
{currentTrack.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{currentTrack.artists.map(a => a.name).join(', ')}
</Text>
<Text size="xs" c="dimmed" truncate>
{currentTrack.album.name}
</Text>
</Box>
</Group>
)}
<Group gap="xs">
<Tooltip label="Previous track">
<ActionIcon
variant="light"
size="lg"
onClick={skipPrevious}
disabled={!hasActiveDevice || isLoading}
loading={isLoading}
>
<SkipBackIcon size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label={isPlaying ? 'Pause' : 'Play'}>
<ActionIcon
variant="filled"
color="green"
size="xl"
onClick={() => isPlaying ? pause() : play()}
disabled={!hasActiveDevice || isLoading}
loading={isLoading}
>
{isPlaying ? <PauseIcon size={24} /> : <PlayIcon size={24} />}
</ActionIcon>
</Tooltip>
<Tooltip label="Next track">
<ActionIcon
variant="light"
size="lg"
onClick={skipNext}
disabled={!hasActiveDevice || isLoading}
loading={isLoading}
>
<SkipForwardIcon size={18} />
</ActionIcon>
</Tooltip>
</Group>
<Group gap="xs">
{activeDevice && (
<Box>
<Text size="xs" c="dimmed">
Playing on {activeDevice.name}
</Text>
</Box>
)}
<Tooltip label="Spotify settings">
<ActionIcon
variant="light"
color="green"
size="lg"
onClick={openSheet}
>
<GearIcon size={18} />
</ActionIcon>
</Tooltip>
</Group>
{isLoading && (
<Loader size="sm" color="green" />
)}
</Group>
{error && (
<Group justify="center" mt="xs">
<Text size="xs" c="red">
{error}
</Text>
</Group>
)}
{isAuthenticated && !hasActiveDevice && !isLoading && (
<Group justify="center" mt="xs">
<Text size="xs" c="orange">
No active device. Please select a device in settings.
</Text>
</Group>
)}
<SpotifySheet opened={sheetOpened} onClose={closeSheet} />
</Box>
);
};
export default SpotifyControlsBar;

View File

@@ -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<SpotifySheetProps> = ({ 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 (
<Sheet
opened={opened}
onChange={handleChange}
>
<Stack gap="lg">
<Group gap="sm" justify="center">
<SpotifyLogoIcon size={32} color="var(--mantine-color-green-6)" />
<Title order={3}>Spotify Controls</Title>
</Group>
{!isAuthenticated ? (
<>
<Box>
<Text size="sm" c="dimmed" mb="md">
Connect your Spotify account
</Text>
</Box>
<Button
leftSection={<SpotifyLogoIcon size={20} />}
color="green"
size="lg"
onClick={login}
loading={isLoading}
fullWidth
>
Connect with Spotify
</Button>
</>
) : (
<>
{/* Current track display */}
{currentTrack && (
<>
<Box>
<Title order={5} mb="xs">Now Playing</Title>
<Group gap="md">
{currentTrack.album.images[2] && (
<img
src={currentTrack.album.images[2].url}
alt={currentTrack.album.name}
style={{ width: 64, height: 64, borderRadius: 4 }}
/>
)}
<Box flex={1}>
<Text fw={600} size="sm" truncate>
{currentTrack.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{currentTrack.artists.map(a => a.name).join(', ')}
</Text>
<Text size="xs" c="dimmed" truncate>
{currentTrack.album.name}
</Text>
</Box>
</Group>
</Box>
<Divider />
</>
)}
<Box>
<Group justify="space-between" align="center" mb="sm">
<Title order={5}>
<Group gap="xs">
<DevicesIcon size={20} />
Select Device
</Group>
</Title>
<ActionIcon
variant="light"
size="sm"
onClick={refreshDevices}
loading={isLoading}
>
<ArrowsClockwiseIcon size={16} />
</ActionIcon>
</Group>
{devices.length > 0 ? (
<NativeSelect
value={activeDevice?.id || ''}
onChange={(event) => 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}
/>
) : (
<Text size="sm" c="dimmed">
No devices found.
</Text>
)}
{activeDevice && (
<Text size="xs" c="dimmed" mt="xs">
Active device: {activeDevice.name}
{activeDevice.volume_percent !== null &&
` (Volume: ${activeDevice.volume_percent}%)`
}
</Text>
)}
</Box>
{error && (
<>
<Divider />
<Box>
<Text size="sm" c="red">
{error}
</Text>
</Box>
</>
)}
<Divider />
<Button
leftSection={<SignOutIcon size={18} />}
variant="light"
color="red"
onClick={logout}
fullWidth
>
Disconnect Spotify
</Button>
</>
)}
</Stack>
</Sheet>
);
};
export default SpotifySheet;

View File

@@ -6,11 +6,6 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { teamInputSchema, teamUpdateSchema } from "./types"; import { teamInputSchema, teamUpdateSchema } from "./types";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
export const listTeams = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () =>
toServerResult(() => pbAdmin.listTeams())
);
export const listTeamInfos = createServerFn() export const listTeamInfos = createServerFn()
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
@@ -40,22 +35,10 @@ export const createTeam = createServerFn()
const userId = context.userAuthId; const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin"); 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)) { if (!isAdmin && !data.players.includes(userId)) {
throw new Error("You can only create teams that include yourself as a player"); 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 }); logger.info("Creating team", { name: data.name, userId, isAdmin });
return pbAdmin.createTeam(data); return pbAdmin.createTeam(data);
}) })
@@ -88,10 +71,3 @@ export const updateTeam = createServerFn()
return pbAdmin.updateTeam(id, updates); return pbAdmin.updateTeam(id, updates);
}) })
); );
export const deleteTeam = createServerFn()
.validator(z.string())
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.deleteTeam(teamId))
);

74
src/lib/spotify/auth.ts Normal file
View File

@@ -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<void> {
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<SpotifyTokenResponse> {
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');
}
}

128
src/lib/spotify/client.ts Normal file
View File

@@ -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<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
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<SpotifyDevice[]> {
const response = await this.request<SpotifyDevicesResponse>('/me/player/devices');
return response.devices;
}
async transferPlayback(deviceId: string, play: boolean = false): Promise<void> {
await this.request('/me/player', {
method: 'PUT',
body: JSON.stringify({
device_ids: [deviceId],
play,
}),
});
}
async getPlaybackState(): Promise<SpotifyPlaybackState | null> {
try {
return await this.request<SpotifyPlaybackState>('/me/player');
} catch (error) {
if (error instanceof Error && error.message.includes('204')) {
return null;
}
throw error;
}
}
async play(deviceId?: string): Promise<void> {
const endpoint = deviceId ? `/me/player/play?device_id=${deviceId}` : '/me/player/play';
await this.request(endpoint, {
method: 'PUT',
});
}
async pause(): Promise<void> {
await this.request('/me/player/pause', {
method: 'PUT',
});
}
async skipToNext(): Promise<void> {
await this.request('/me/player/next', {
method: 'POST',
});
}
async skipToPrevious(): Promise<void> {
await this.request('/me/player/previous', {
method: 'POST',
});
}
async setVolume(volumePercent: number): Promise<void> {
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;
}
}

63
src/lib/spotify/hooks.ts Normal file
View File

@@ -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,
};
};

108
src/lib/spotify/types.ts Normal file
View File

@@ -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<void>;
pause: () => Promise<void>;
skipNext: () => Promise<void>;
skipPrevious: () => Promise<void>;
setVolume: (volumePercent: number) => Promise<void>;
getDevices: () => Promise<void>;
setActiveDevice: (deviceId: string) => Promise<void>;
refreshPlaybackState: () => Promise<void>;
}
export interface PKCEState {
codeVerifier: string;
codeChallenge: string;
state: string;
}
export interface SpotifyDevicesResponse {
devices: SpotifyDevice[];
}