spotify controls
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<Container size="md">
|
||||
<SpotifyControlsBar />
|
||||
{tournament.matches?.length ? (
|
||||
<BracketView bracket={bracket} showControls />
|
||||
) : (
|
||||
|
||||
135
src/app/routes/api/spotify/callback.ts
Normal file
135
src/app/routes/api/spotify/callback.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
195
src/app/routes/api/spotify/playback.ts
Normal file
195
src/app/routes/api/spotify/playback.ts
Normal 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' },
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
127
src/app/routes/api/spotify/token.ts
Normal file
127
src/app/routes/api/spotify/token.ts
Normal 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' },
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
372
src/contexts/spotify-context.tsx
Normal file
372
src/contexts/spotify-context.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { createContext, useCallback, useEffect, useState, PropsWithChildren } from 'react';
|
||||
import { SpotifyAuth } from '@/lib/spotify/auth';
|
||||
import { useAuth } from './auth-context';
|
||||
import type {
|
||||
SpotifyContextType,
|
||||
SpotifyAuthState,
|
||||
SpotifyDevice,
|
||||
SpotifyPlaybackState,
|
||||
SpotifyTrack,
|
||||
} from '@/lib/spotify/types';
|
||||
|
||||
const defaultSpotifyState: SpotifyAuthState = {
|
||||
isAuthenticated: false,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
scopes: [],
|
||||
};
|
||||
|
||||
export const SpotifyContext = createContext<SpotifyContextType | null>(null);
|
||||
|
||||
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const { roles } = useAuth();
|
||||
const isAdmin = roles?.includes('Admin') || false;
|
||||
|
||||
const [authState, setAuthState] = useState<SpotifyAuthState>(defaultSpotifyState);
|
||||
|
||||
const [currentTrack, setCurrentTrack] = useState<SpotifyTrack | null>(null);
|
||||
const [playbackState, setPlaybackState] = useState<SpotifyPlaybackState | null>(null);
|
||||
|
||||
const [devices, setDevices] = useState<SpotifyDevice[]>([]);
|
||||
const [activeDevice, setActiveDeviceState] = useState<SpotifyDevice | null>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const spotifyAuth = new SpotifyAuth(
|
||||
import.meta.env.VITE_SPOTIFY_CLIENT_ID!,
|
||||
import.meta.env.VITE_SPOTIFY_REDIRECT_URI!
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
checkExistingAuth();
|
||||
handleOAuthCallback();
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
const checkExistingAuth = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/spotify/token', {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.access_token) {
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresAt: Date.now() + (3600 * 1000),
|
||||
scopes: [],
|
||||
});
|
||||
|
||||
// Load initial data
|
||||
await Promise.all([getDevices(), refreshPlaybackState()]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check existing auth:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthCallback = () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const spotifyAuth = urlParams.get('spotify_auth');
|
||||
const error = urlParams.get('spotify_error');
|
||||
const details = urlParams.get('details');
|
||||
|
||||
if (spotifyAuth === 'success') {
|
||||
checkExistingAuth();
|
||||
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete('spotify_auth');
|
||||
newUrl.searchParams.delete('state');
|
||||
window.history.replaceState({}, '', newUrl.toString());
|
||||
}
|
||||
|
||||
if (error) {
|
||||
let errorMessage = `Authentication failed: ${error}`;
|
||||
if (details) {
|
||||
errorMessage += ` - ${decodeURIComponent(details)}`;
|
||||
}
|
||||
setError(errorMessage);
|
||||
|
||||
console.error('Spotify OAuth Error:', { error, details });
|
||||
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete('spotify_error');
|
||||
newUrl.searchParams.delete('details');
|
||||
window.history.replaceState({}, '', newUrl.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const login = useCallback(() => {
|
||||
if (!isAdmin) return;
|
||||
spotifyAuth.startAuthFlow(window.location.pathname);
|
||||
}, [isAdmin, spotifyAuth]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setAuthState(defaultSpotifyState);
|
||||
setCurrentTrack(null);
|
||||
setPlaybackState(null);
|
||||
setDevices([]);
|
||||
setActiveDeviceState(null);
|
||||
setError(null);
|
||||
|
||||
document.cookie = 'spotify_access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
document.cookie = 'spotify_refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
}, []);
|
||||
|
||||
const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) => {
|
||||
const response = await fetch(`/api/spotify/${endpoint}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Request failed';
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.error || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
console.warn(`Non-JSON response from ${endpoint}:`, contentType);
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse JSON response from ${endpoint}:`, error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const play = useCallback(async (deviceId?: string) => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await makeSpotifyRequest('playback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'play', deviceId }),
|
||||
});
|
||||
|
||||
setTimeout(refreshPlaybackState, 500);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && !error.message.includes('JSON')) {
|
||||
setError(error.message);
|
||||
}
|
||||
console.warn('Playback action completed with warning:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
|
||||
const pause = useCallback(async () => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await makeSpotifyRequest('playback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'pause' }),
|
||||
});
|
||||
|
||||
setTimeout(refreshPlaybackState, 500);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && !error.message.includes('JSON')) {
|
||||
setError(error.message);
|
||||
}
|
||||
console.warn('Playback action completed with warning:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
|
||||
const skipNext = useCallback(async () => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await makeSpotifyRequest('playback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'next' }),
|
||||
});
|
||||
|
||||
setTimeout(refreshPlaybackState, 500);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && !error.message.includes('JSON')) {
|
||||
setError(error.message);
|
||||
}
|
||||
console.warn('Playback action completed with warning:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
|
||||
const skipPrevious = useCallback(async () => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await makeSpotifyRequest('playback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'previous' }),
|
||||
});
|
||||
|
||||
setTimeout(refreshPlaybackState, 500);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && !error.message.includes('JSON')) {
|
||||
setError(error.message);
|
||||
}
|
||||
console.warn('Playback action completed with warning:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
|
||||
const setVolume = useCallback(async (volumePercent: number) => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await makeSpotifyRequest('playback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'volume', volumePercent }),
|
||||
});
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to set volume');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
|
||||
const getDevices = useCallback(async () => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await makeSpotifyRequest('playback?type=devices');
|
||||
setDevices(data.devices || []);
|
||||
|
||||
const active = data.devices?.find((d: SpotifyDevice) => d.is_active);
|
||||
if (active) {
|
||||
setActiveDeviceState(active);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to get devices');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
|
||||
const setActiveDevice = useCallback(async (deviceId: string) => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await makeSpotifyRequest('playback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'transfer', deviceId }),
|
||||
});
|
||||
|
||||
const device = devices.find(d => d.id === deviceId);
|
||||
if (device) {
|
||||
setActiveDeviceState(device);
|
||||
}
|
||||
|
||||
setTimeout(getDevices, 1000);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to set active device');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated, devices]);
|
||||
|
||||
const refreshPlaybackState = useCallback(async () => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
|
||||
try {
|
||||
const data = await makeSpotifyRequest('playback?type=state');
|
||||
const state = data.playbackState;
|
||||
|
||||
setPlaybackState(state);
|
||||
setCurrentTrack(state?.item || null);
|
||||
|
||||
if (state?.device) {
|
||||
setActiveDeviceState(state.device);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh playback state:', error);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
|
||||
const interval = setInterval(refreshPlaybackState, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [authState.isAuthenticated, refreshPlaybackState]);
|
||||
|
||||
const contextValue: SpotifyContextType = {
|
||||
...authState,
|
||||
currentTrack,
|
||||
playbackState,
|
||||
devices,
|
||||
activeDevice,
|
||||
isLoading,
|
||||
error,
|
||||
login,
|
||||
logout,
|
||||
play,
|
||||
pause,
|
||||
skipNext,
|
||||
skipPrevious,
|
||||
setVolume,
|
||||
getDevices,
|
||||
setActiveDevice,
|
||||
refreshPlaybackState,
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SpotifyContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SpotifyContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<AuthProvider>
|
||||
<MantineProvider>
|
||||
<Toaster position='top-center' />
|
||||
{children}
|
||||
</MantineProvider>
|
||||
<SpotifyProvider>
|
||||
<MantineProvider>
|
||||
<Toaster position='top-center' />
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</SpotifyProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
2
src/features/spotify/components/index.ts
Normal file
2
src/features/spotify/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as SpotifyControlsBar } from './spotify-controls-bar';
|
||||
export { default as SpotifySheet } from './spotify-sheet';
|
||||
179
src/features/spotify/components/spotify-controls-bar.tsx
Normal file
179
src/features/spotify/components/spotify-controls-bar.tsx
Normal 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;
|
||||
197
src/features/spotify/components/spotify-sheet.tsx
Normal file
197
src/features/spotify/components/spotify-sheet.tsx
Normal 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;
|
||||
@@ -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))
|
||||
);
|
||||
|
||||
74
src/lib/spotify/auth.ts
Normal file
74
src/lib/spotify/auth.ts
Normal 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
128
src/lib/spotify/client.ts
Normal 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
63
src/lib/spotify/hooks.ts
Normal 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
108
src/lib/spotify/types.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user