diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index 5cc3237..b6947a1 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -29,7 +29,9 @@ import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/a 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 ApiSpotifyResumeServerRouteImport } from './routes/api/spotify/resume' import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback' +import { ServerRoute as ApiSpotifyCaptureServerRouteImport } from './routes/api/spotify/capture' 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.$' @@ -131,12 +133,22 @@ const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({ path: '/api/spotify/token', getParentRoute: () => rootServerRouteImport, } as any) +const ApiSpotifyResumeServerRoute = ApiSpotifyResumeServerRouteImport.update({ + id: '/api/spotify/resume', + path: '/api/spotify/resume', + getParentRoute: () => rootServerRouteImport, +} as any) const ApiSpotifyPlaybackServerRoute = ApiSpotifyPlaybackServerRouteImport.update({ id: '/api/spotify/playback', path: '/api/spotify/playback', getParentRoute: () => rootServerRouteImport, } as any) +const ApiSpotifyCaptureServerRoute = ApiSpotifyCaptureServerRouteImport.update({ + id: '/api/spotify/capture', + path: '/api/spotify/capture', + getParentRoute: () => rootServerRouteImport, +} as any) const ApiSpotifyCallbackServerRoute = ApiSpotifyCallbackServerRouteImport.update({ id: '/api/spotify/callback', @@ -276,7 +288,9 @@ export interface FileServerRoutesByFullPath { '/api/auth/$': typeof ApiAuthSplatServerRoute '/api/events/$': typeof ApiEventsSplatServerRoute '/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute + '/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute '/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute + '/api/spotify/resume': typeof ApiSpotifyResumeServerRoute '/api/spotify/token': typeof ApiSpotifyTokenServerRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute @@ -285,7 +299,9 @@ export interface FileServerRoutesByTo { '/api/auth/$': typeof ApiAuthSplatServerRoute '/api/events/$': typeof ApiEventsSplatServerRoute '/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute + '/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute '/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute + '/api/spotify/resume': typeof ApiSpotifyResumeServerRoute '/api/spotify/token': typeof ApiSpotifyTokenServerRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute @@ -295,7 +311,9 @@ export interface FileServerRoutesById { '/api/auth/$': typeof ApiAuthSplatServerRoute '/api/events/$': typeof ApiEventsSplatServerRoute '/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute + '/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute '/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute + '/api/spotify/resume': typeof ApiSpotifyResumeServerRoute '/api/spotify/token': typeof ApiSpotifyTokenServerRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute @@ -306,7 +324,9 @@ export interface FileServerRouteTypes { | '/api/auth/$' | '/api/events/$' | '/api/spotify/callback' + | '/api/spotify/capture' | '/api/spotify/playback' + | '/api/spotify/resume' | '/api/spotify/token' | '/api/tournaments/upload-logo' | '/api/files/$collection/$recordId/$file' @@ -315,7 +335,9 @@ export interface FileServerRouteTypes { | '/api/auth/$' | '/api/events/$' | '/api/spotify/callback' + | '/api/spotify/capture' | '/api/spotify/playback' + | '/api/spotify/resume' | '/api/spotify/token' | '/api/tournaments/upload-logo' | '/api/files/$collection/$recordId/$file' @@ -324,7 +346,9 @@ export interface FileServerRouteTypes { | '/api/auth/$' | '/api/events/$' | '/api/spotify/callback' + | '/api/spotify/capture' | '/api/spotify/playback' + | '/api/spotify/resume' | '/api/spotify/token' | '/api/tournaments/upload-logo' | '/api/files/$collection/$recordId/$file' @@ -334,7 +358,9 @@ export interface RootServerRouteChildren { ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute ApiSpotifyCallbackServerRoute: typeof ApiSpotifyCallbackServerRoute + ApiSpotifyCaptureServerRoute: typeof ApiSpotifyCaptureServerRoute ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute + ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute @@ -472,6 +498,13 @@ declare module '@tanstack/react-start/server' { preLoaderRoute: typeof ApiSpotifyTokenServerRouteImport parentRoute: typeof rootServerRouteImport } + '/api/spotify/resume': { + id: '/api/spotify/resume' + path: '/api/spotify/resume' + fullPath: '/api/spotify/resume' + preLoaderRoute: typeof ApiSpotifyResumeServerRouteImport + parentRoute: typeof rootServerRouteImport + } '/api/spotify/playback': { id: '/api/spotify/playback' path: '/api/spotify/playback' @@ -479,6 +512,13 @@ declare module '@tanstack/react-start/server' { preLoaderRoute: typeof ApiSpotifyPlaybackServerRouteImport parentRoute: typeof rootServerRouteImport } + '/api/spotify/capture': { + id: '/api/spotify/capture' + path: '/api/spotify/capture' + fullPath: '/api/spotify/capture' + preLoaderRoute: typeof ApiSpotifyCaptureServerRouteImport + parentRoute: typeof rootServerRouteImport + } '/api/spotify/callback': { id: '/api/spotify/callback' path: '/api/spotify/callback' @@ -566,7 +606,9 @@ const rootServerRouteChildren: RootServerRouteChildren = { ApiAuthSplatServerRoute: ApiAuthSplatServerRoute, ApiEventsSplatServerRoute: ApiEventsSplatServerRoute, ApiSpotifyCallbackServerRoute: ApiSpotifyCallbackServerRoute, + ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute, ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute, + ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute, ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute, ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute, ApiFilesCollectionRecordIdFileServerRoute: diff --git a/src/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx index 76fefae..a1a97ae 100644 --- a/src/app/routes/_authed/admin/tournaments/run.$id.tsx +++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx @@ -1,6 +1,5 @@ -import { createFileRoute, redirect, useRouter } from "@tanstack/react-router"; +import { createFileRoute, redirect } from "@tanstack/react-router"; import { - tournamentKeys, tournamentQueries, useTournament, } from "@/features/tournaments/queries"; @@ -11,9 +10,6 @@ import { useMemo } from "react"; import { BracketData } from "@/features/bracket/types"; import { Match } from "@/features/matches/types"; 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")({ @@ -30,6 +26,7 @@ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({ }, loader: ({ context }) => ({ fullWidth: true, + withPadding: false, showSpotifyPanel: true, header: { withBackButton: true, @@ -78,7 +75,7 @@ function RouteComponent() { }, [tournament.matches]); return ( - + {tournament.matches?.length ? ( diff --git a/src/app/routes/api/spotify/capture.ts b/src/app/routes/api/spotify/capture.ts new file mode 100644 index 0000000..7952277 --- /dev/null +++ b/src/app/routes/api/spotify/capture.ts @@ -0,0 +1,59 @@ +import { createServerFileRoute } from '@tanstack/react-start/server' +import { SpotifyWebApiClient } from '@/lib/spotify/client' +import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types' + +export const ServerRoute = createServerFileRoute('/api/spotify/capture').methods({ + POST: async ({ request }: { request: Request }) => { + try { + // Get access token from cookies + const cookies = request.headers.get('Cookie') || '' + const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/) + + if (!accessTokenMatch) { + return new Response( + JSON.stringify({ error: 'No access token found' }), + { + status: 401, + headers: { 'Content-Type': 'application/json' } + } + ) + } + + const accessToken = decodeURIComponent(accessTokenMatch[1]) + const spotifyClient = new SpotifyWebApiClient(accessToken) + + // Create a snapshot of the current playback state + const snapshot = await spotifyClient.createPlaybackSnapshot() + + if (!snapshot) { + return new Response( + JSON.stringify({ error: 'No active playback to capture' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' } + } + ) + } + + return new Response( + JSON.stringify({ snapshot }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ) + } catch (error) { + console.error('Spotify capture error:', error) + + const errorMessage = error instanceof Error ? error.message : 'Failed to capture playback state' + + return new Response( + JSON.stringify({ error: errorMessage }), + { + status: 500, + headers: { 'Content-Type': 'application/json' } + } + ) + } + }, +}) \ No newline at end of file diff --git a/src/app/routes/api/spotify/resume.ts b/src/app/routes/api/spotify/resume.ts new file mode 100644 index 0000000..38646be --- /dev/null +++ b/src/app/routes/api/spotify/resume.ts @@ -0,0 +1,72 @@ +import { createServerFileRoute } from '@tanstack/react-start/server' +import { SpotifyWebApiClient } from '@/lib/spotify/client' +import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types' + +export const ServerRoute = createServerFileRoute('/api/spotify/resume').methods({ + POST: async ({ request }: { request: Request }) => { + try { + // Get access token from cookies + const cookies = request.headers.get('Cookie') || '' + const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/) + + if (!accessTokenMatch) { + return new Response( + JSON.stringify({ error: 'No access token found' }), + { + status: 401, + headers: { 'Content-Type': 'application/json' } + } + ) + } + + const accessToken = decodeURIComponent(accessTokenMatch[1]) + const spotifyClient = new SpotifyWebApiClient(accessToken) + + // Parse the request body to get the snapshot + const body = await request.json() + const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot } + + if (!snapshot) { + return new Response( + JSON.stringify({ error: 'No snapshot provided' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' } + } + ) + } + + // Restore the playback state from the snapshot + await spotifyClient.restorePlaybackSnapshot(snapshot) + + return new Response( + JSON.stringify({ success: true }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ) + } catch (error) { + console.error('Spotify resume error:', error) + + let errorMessage = 'Failed to resume playback state' + + // Handle common Spotify Premium requirement error + if (error instanceof Error) { + if (error.message.includes('Premium') || error.message.includes('403')) { + errorMessage = 'Spotify Premium required for playback control' + } else { + errorMessage = error.message + } + } + + return new Response( + JSON.stringify({ error: errorMessage }), + { + status: 500, + headers: { 'Content-Type': 'application/json' } + } + ) + } + }, +}) \ No newline at end of file diff --git a/src/contexts/spotify-context.tsx b/src/contexts/spotify-context.tsx index aaffdc4..7722b15 100644 --- a/src/contexts/spotify-context.tsx +++ b/src/contexts/spotify-context.tsx @@ -6,6 +6,7 @@ import type { SpotifyAuthState, SpotifyDevice, SpotifyPlaybackState, + SpotifyPlaybackSnapshot, SpotifyTrack, } from '@/lib/spotify/types'; @@ -34,6 +35,10 @@ export const SpotifyProvider: React.FC = ({ children }) => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [capturedState, setCapturedState] = useState(null); + const [isCaptureLoading, setIsCaptureLoading] = useState(false); + const [isResumeLoading, setIsResumeLoading] = useState(false); + const spotifyAuth = new SpotifyAuth( import.meta.env.VITE_SPOTIFY_CLIENT_ID!, import.meta.env.VITE_SPOTIFY_REDIRECT_URI! @@ -63,7 +68,6 @@ export const SpotifyProvider: React.FC = ({ children }) => { scopes: [], }); - // Load initial data await Promise.all([getDevices(), refreshPlaybackState()]); } } @@ -340,6 +344,51 @@ export const SpotifyProvider: React.FC = ({ children }) => { return () => clearInterval(interval); }, [authState.isAuthenticated, refreshPlaybackState]); + const capturePlaybackState = useCallback(async () => { + if (!authState.isAuthenticated) return; + + setIsCaptureLoading(true); + setError(null); + + try { + const response = await makeSpotifyRequest('capture', { + method: 'POST', + }); + + if (response.snapshot) { + setCapturedState(response.snapshot); + } + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to capture playback state'); + } finally { + setIsCaptureLoading(false); + } + }, [authState.isAuthenticated]); + + const resumePlaybackState = useCallback(async () => { + if (!authState.isAuthenticated || !capturedState) return; + + setIsResumeLoading(true); + setError(null); + + try { + await makeSpotifyRequest('resume', { + method: 'POST', + body: JSON.stringify({ snapshot: capturedState }), + }); + + setTimeout(refreshPlaybackState, 1000); + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to resume playback state'); + } finally { + setIsResumeLoading(false); + } + }, [authState.isAuthenticated, capturedState, refreshPlaybackState]); + + const clearCapturedState = useCallback(() => { + setCapturedState(null); + }, []); + const contextValue: SpotifyContextType = { ...authState, currentTrack, @@ -348,6 +397,10 @@ export const SpotifyProvider: React.FC = ({ children }) => { activeDevice, isLoading, error, + // Capture/Resume state + capturedState, + isCaptureLoading, + isResumeLoading, login, logout, play, @@ -358,6 +411,10 @@ export const SpotifyProvider: React.FC = ({ children }) => { getDevices, setActiveDevice, refreshPlaybackState, + // Capture/Resume methods + capturePlaybackState, + resumePlaybackState, + clearCapturedState, }; if (!isAdmin) { diff --git a/src/features/spotify/components/spotify-controls-bar.tsx b/src/features/spotify/components/spotify-controls-bar.tsx index fb2fb21..e2bb294 100644 --- a/src/features/spotify/components/spotify-controls-bar.tsx +++ b/src/features/spotify/components/spotify-controls-bar.tsx @@ -7,6 +7,8 @@ import { SkipForwardIcon, GearIcon, SpotifyLogoIcon, + FloppyDiskIcon, + ClockCounterClockwiseIcon, } from '@phosphor-icons/react'; import { useSpotify } from '@/lib/spotify/hooks'; import { useAuth } from '@/contexts/auth-context'; @@ -28,6 +30,11 @@ const SpotifyControlsBar = () => { skipNext, skipPrevious, activeDevice, + capturedState, + isCaptureLoading, + isResumeLoading, + capturePlaybackState, + resumePlaybackState, } = useSpotify(); if (!isAdmin) return null; @@ -129,6 +136,34 @@ const SpotifyControlsBar = () => { + + + + + + + + + + + + + + {activeDevice && ( @@ -149,10 +184,6 @@ const SpotifyControlsBar = () => { - - {isLoading && ( - - )} {error && ( diff --git a/src/features/spotify/components/spotify-sheet.tsx b/src/features/spotify/components/spotify-sheet.tsx index deca945..05030ed 100644 --- a/src/features/spotify/components/spotify-sheet.tsx +++ b/src/features/spotify/components/spotify-sheet.tsx @@ -61,13 +61,9 @@ const SpotifySheet: React.FC = ({ opened, onClose }) => { - - - Spotify Controls - - {!isAuthenticated ? ( <> diff --git a/src/lib/spotify/client.ts b/src/lib/spotify/client.ts index 4ccfb79..6eefa8f 100644 --- a/src/lib/spotify/client.ts +++ b/src/lib/spotify/client.ts @@ -2,6 +2,7 @@ import type { SpotifyDevice, SpotifyDevicesResponse, SpotifyPlaybackState, + SpotifyPlaybackSnapshot, SpotifyError, } from './types'; @@ -125,4 +126,74 @@ export class SpotifyWebApiClient { updateAccessToken(accessToken: string): void { this.accessToken = accessToken; } + + async createPlaybackSnapshot(): Promise { + const state = await this.getPlaybackState(); + if (!state || !state.item) { + return null; + } + + const snapshot: SpotifyPlaybackSnapshot = { + contextUri: state.context?.uri, + trackUri: state.item.external_urls.spotify, + trackId: state.item.id, + positionMs: state.progress_ms || 0, + shuffleState: state.shuffle_state, + repeatState: state.repeat_state, + volumePercent: state.device.volume_percent || undefined, + deviceId: state.device.id, + timestamp: Date.now(), + isPlaying: state.is_playing, + trackName: state.item.name, + artistName: state.item.artists.map(a => a.name).join(', '), + albumName: state.item.album.name, + albumImageUrl: state.item.album.images[2]?.url, + }; + + return snapshot; + } + + async restorePlaybackSnapshot(snapshot: SpotifyPlaybackSnapshot): Promise { + if (snapshot.deviceId) { + await this.transferPlayback(snapshot.deviceId, false); + await new Promise(resolve => setTimeout(resolve, 500)); + } + + const playbackRequest: any = { + position_ms: snapshot.positionMs, + }; + + if (snapshot.contextUri && snapshot.trackId) { + playbackRequest.context_uri = snapshot.contextUri; + playbackRequest.offset = { uri: `spotify:track:${snapshot.trackId}` }; + } else if (snapshot.trackId) { + playbackRequest.uris = [`spotify:track:${snapshot.trackId}`]; + } + + const endpoint = snapshot.deviceId + ? `/me/player/play?device_id=${snapshot.deviceId}` + : '/me/player/play'; + + await this.request(endpoint, { + method: 'PUT', + body: JSON.stringify(playbackRequest), + }); + + await Promise.all([ + this.request(`/me/player/shuffle?state=${snapshot.shuffleState}`, { + method: 'PUT', + }), + this.request(`/me/player/repeat?state=${snapshot.repeatState}`, { + method: 'PUT', + }), + snapshot.volumePercent !== undefined + ? this.setVolume(snapshot.volumePercent) + : Promise.resolve(), + ]); + + if (!snapshot.isPlaying) { + await new Promise(resolve => setTimeout(resolve, 1000)); + await this.pause(); + } + } } \ No newline at end of file diff --git a/src/lib/spotify/types.ts b/src/lib/spotify/types.ts index b59a639..5ca684c 100644 --- a/src/lib/spotify/types.ts +++ b/src/lib/spotify/types.ts @@ -82,6 +82,10 @@ export interface SpotifyContextType extends SpotifyAuthState { isLoading: boolean; error: string | null; + capturedState: SpotifyPlaybackSnapshot | null; + isCaptureLoading: boolean; + isResumeLoading: boolean; + login: () => void; logout: () => void; @@ -95,6 +99,10 @@ export interface SpotifyContextType extends SpotifyAuthState { setActiveDevice: (deviceId: string) => Promise; refreshPlaybackState: () => Promise; + + capturePlaybackState: () => Promise; + resumePlaybackState: () => Promise; + clearCapturedState: () => void; } export interface PKCEState { @@ -105,4 +113,21 @@ export interface PKCEState { export interface SpotifyDevicesResponse { devices: SpotifyDevice[]; +} + +export interface SpotifyPlaybackSnapshot { + contextUri?: string; + trackUri?: string; + trackId?: string; + positionMs: number; + shuffleState: boolean; + repeatState: 'off' | 'context' | 'track'; + volumePercent?: number; + deviceId?: string; + timestamp: number; + isPlaying: boolean; + trackName?: string; + artistName?: string; + albumName?: string; + albumImageUrl?: string; } \ No newline at end of file