spotify state resume/capture

This commit is contained in:
yohlo
2025-09-12 11:34:21 -05:00
parent 0169468114
commit cf09014d50
9 changed files with 366 additions and 16 deletions

View File

@@ -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:

View File

@@ -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 (
<Container size="md">
<Container size="md" px={0}>
<SpotifyControlsBar />
{tournament.matches?.length ? (
<BracketView bracket={bracket} showControls />

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import type {
SpotifyAuthState,
SpotifyDevice,
SpotifyPlaybackState,
SpotifyPlaybackSnapshot,
SpotifyTrack,
} from '@/lib/spotify/types';
@@ -34,6 +35,10 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [capturedState, setCapturedState] = useState<SpotifyPlaybackSnapshot | null>(null);
const [isCaptureLoading, setIsCaptureLoading] = useState(false);
const [isResumeLoading, setIsResumeLoading] = useState(false);
const spotifyAuth = 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<PropsWithChildren> = ({ children }) => {
scopes: [],
});
// Load initial data
await Promise.all([getDevices(), refreshPlaybackState()]);
}
}
@@ -340,6 +344,51 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ 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<PropsWithChildren> = ({ children }) => {
activeDevice,
isLoading,
error,
// Capture/Resume state
capturedState,
isCaptureLoading,
isResumeLoading,
login,
logout,
play,
@@ -358,6 +411,10 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
getDevices,
setActiveDevice,
refreshPlaybackState,
// Capture/Resume methods
capturePlaybackState,
resumePlaybackState,
clearCapturedState,
};
if (!isAdmin) {

View File

@@ -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 = () => {
</Tooltip>
</Group>
<Group gap="xs">
<Tooltip label={'Capture current state'}>
<ActionIcon
variant="light"
color={capturedState ? 'blue' : 'gray'}
size="lg"
onClick={capturePlaybackState}
disabled={!hasActiveDevice || isLoading || isCaptureLoading}
loading={isCaptureLoading}
>
<FloppyDiskIcon size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label={capturedState ? 'Restore captured state' : 'No state captured'}>
<ActionIcon
variant="light"
color={capturedState ? 'blue' : 'gray'}
size="lg"
onClick={resumePlaybackState}
disabled={!capturedState || !hasActiveDevice || isLoading || isResumeLoading}
loading={isResumeLoading}
>
<ClockCounterClockwiseIcon size={18} />
</ActionIcon>
</Tooltip>
</Group>
<Group gap="xs">
{activeDevice && (
<Box>
@@ -149,10 +184,6 @@ const SpotifyControlsBar = () => {
</ActionIcon>
</Tooltip>
</Group>
{isLoading && (
<Loader size="sm" color="green" />
)}
</Group>
{error && (

View File

@@ -61,13 +61,9 @@ const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
<Sheet
opened={opened}
onChange={handleChange}
title="Spotify Controls"
>
<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>

View File

@@ -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<SpotifyPlaybackSnapshot | null> {
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<void> {
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();
}
}
}

View File

@@ -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<void>;
refreshPlaybackState: () => Promise<void>;
capturePlaybackState: () => Promise<void>;
resumePlaybackState: () => Promise<void>;
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;
}