spotify state resume/capture
This commit is contained in:
@@ -29,7 +29,9 @@ import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/a
|
|||||||
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
||||||
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
||||||
import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
|
import { ServerRoute as 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 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 ApiSpotifyCallbackServerRouteImport } from './routes/api/spotify/callback'
|
||||||
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
|
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
|
||||||
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
|
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
|
||||||
@@ -131,12 +133,22 @@ const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({
|
|||||||
path: '/api/spotify/token',
|
path: '/api/spotify/token',
|
||||||
getParentRoute: () => rootServerRouteImport,
|
getParentRoute: () => rootServerRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiSpotifyResumeServerRoute = ApiSpotifyResumeServerRouteImport.update({
|
||||||
|
id: '/api/spotify/resume',
|
||||||
|
path: '/api/spotify/resume',
|
||||||
|
getParentRoute: () => rootServerRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiSpotifyPlaybackServerRoute =
|
const ApiSpotifyPlaybackServerRoute =
|
||||||
ApiSpotifyPlaybackServerRouteImport.update({
|
ApiSpotifyPlaybackServerRouteImport.update({
|
||||||
id: '/api/spotify/playback',
|
id: '/api/spotify/playback',
|
||||||
path: '/api/spotify/playback',
|
path: '/api/spotify/playback',
|
||||||
getParentRoute: () => rootServerRouteImport,
|
getParentRoute: () => rootServerRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiSpotifyCaptureServerRoute = ApiSpotifyCaptureServerRouteImport.update({
|
||||||
|
id: '/api/spotify/capture',
|
||||||
|
path: '/api/spotify/capture',
|
||||||
|
getParentRoute: () => rootServerRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiSpotifyCallbackServerRoute =
|
const ApiSpotifyCallbackServerRoute =
|
||||||
ApiSpotifyCallbackServerRouteImport.update({
|
ApiSpotifyCallbackServerRouteImport.update({
|
||||||
id: '/api/spotify/callback',
|
id: '/api/spotify/callback',
|
||||||
@@ -276,7 +288,9 @@ export interface FileServerRoutesByFullPath {
|
|||||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
'/api/events/$': typeof ApiEventsSplatServerRoute
|
||||||
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
||||||
|
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||||
|
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||||
@@ -285,7 +299,9 @@ export interface FileServerRoutesByTo {
|
|||||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
'/api/events/$': typeof ApiEventsSplatServerRoute
|
||||||
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
||||||
|
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||||
|
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||||
@@ -295,7 +311,9 @@ export interface FileServerRoutesById {
|
|||||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
'/api/events/$': typeof ApiEventsSplatServerRoute
|
||||||
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
||||||
|
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||||
|
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||||
@@ -306,7 +324,9 @@ export interface FileServerRouteTypes {
|
|||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
| '/api/spotify/callback'
|
| '/api/spotify/callback'
|
||||||
|
| '/api/spotify/capture'
|
||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
|
| '/api/spotify/resume'
|
||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
@@ -315,7 +335,9 @@ export interface FileServerRouteTypes {
|
|||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
| '/api/spotify/callback'
|
| '/api/spotify/callback'
|
||||||
|
| '/api/spotify/capture'
|
||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
|
| '/api/spotify/resume'
|
||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
@@ -324,7 +346,9 @@ export interface FileServerRouteTypes {
|
|||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
| '/api/spotify/callback'
|
| '/api/spotify/callback'
|
||||||
|
| '/api/spotify/capture'
|
||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
|
| '/api/spotify/resume'
|
||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
@@ -334,7 +358,9 @@ export interface RootServerRouteChildren {
|
|||||||
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
|
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
|
||||||
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
|
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
|
||||||
ApiSpotifyCallbackServerRoute: typeof ApiSpotifyCallbackServerRoute
|
ApiSpotifyCallbackServerRoute: typeof ApiSpotifyCallbackServerRoute
|
||||||
|
ApiSpotifyCaptureServerRoute: typeof ApiSpotifyCaptureServerRoute
|
||||||
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
|
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
|
||||||
|
ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute
|
||||||
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
|
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
|
||||||
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
||||||
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
|
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||||
@@ -472,6 +498,13 @@ declare module '@tanstack/react-start/server' {
|
|||||||
preLoaderRoute: typeof ApiSpotifyTokenServerRouteImport
|
preLoaderRoute: typeof ApiSpotifyTokenServerRouteImport
|
||||||
parentRoute: typeof rootServerRouteImport
|
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': {
|
'/api/spotify/playback': {
|
||||||
id: '/api/spotify/playback'
|
id: '/api/spotify/playback'
|
||||||
path: '/api/spotify/playback'
|
path: '/api/spotify/playback'
|
||||||
@@ -479,6 +512,13 @@ declare module '@tanstack/react-start/server' {
|
|||||||
preLoaderRoute: typeof ApiSpotifyPlaybackServerRouteImport
|
preLoaderRoute: typeof ApiSpotifyPlaybackServerRouteImport
|
||||||
parentRoute: typeof rootServerRouteImport
|
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': {
|
'/api/spotify/callback': {
|
||||||
id: '/api/spotify/callback'
|
id: '/api/spotify/callback'
|
||||||
path: '/api/spotify/callback'
|
path: '/api/spotify/callback'
|
||||||
@@ -566,7 +606,9 @@ const rootServerRouteChildren: RootServerRouteChildren = {
|
|||||||
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
|
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
|
||||||
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
|
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
|
||||||
ApiSpotifyCallbackServerRoute: ApiSpotifyCallbackServerRoute,
|
ApiSpotifyCallbackServerRoute: ApiSpotifyCallbackServerRoute,
|
||||||
|
ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute,
|
||||||
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
|
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
|
||||||
|
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute,
|
||||||
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
|
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
|
||||||
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
||||||
ApiFilesCollectionRecordIdFileServerRoute:
|
ApiFilesCollectionRecordIdFileServerRoute:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createFileRoute, redirect, useRouter } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
tournamentKeys,
|
|
||||||
tournamentQueries,
|
tournamentQueries,
|
||||||
useTournament,
|
useTournament,
|
||||||
} from "@/features/tournaments/queries";
|
} from "@/features/tournaments/queries";
|
||||||
@@ -11,9 +10,6 @@ import { useMemo } from "react";
|
|||||||
import { BracketData } from "@/features/bracket/types";
|
import { BracketData } from "@/features/bracket/types";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import BracketView from "@/features/bracket/components/bracket-view";
|
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";
|
import { SpotifyControlsBar } from "@/features/spotify/components";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
|
export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
|
||||||
@@ -30,6 +26,7 @@ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
|
|||||||
},
|
},
|
||||||
loader: ({ context }) => ({
|
loader: ({ context }) => ({
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
|
withPadding: false,
|
||||||
showSpotifyPanel: true,
|
showSpotifyPanel: true,
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
@@ -78,7 +75,7 @@ function RouteComponent() {
|
|||||||
}, [tournament.matches]);
|
}, [tournament.matches]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="md">
|
<Container size="md" px={0}>
|
||||||
<SpotifyControlsBar />
|
<SpotifyControlsBar />
|
||||||
{tournament.matches?.length ? (
|
{tournament.matches?.length ? (
|
||||||
<BracketView bracket={bracket} showControls />
|
<BracketView bracket={bracket} showControls />
|
||||||
|
|||||||
59
src/app/routes/api/spotify/capture.ts
Normal file
59
src/app/routes/api/spotify/capture.ts
Normal 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' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
72
src/app/routes/api/spotify/resume.ts
Normal file
72
src/app/routes/api/spotify/resume.ts
Normal 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' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
SpotifyAuthState,
|
SpotifyAuthState,
|
||||||
SpotifyDevice,
|
SpotifyDevice,
|
||||||
SpotifyPlaybackState,
|
SpotifyPlaybackState,
|
||||||
|
SpotifyPlaybackSnapshot,
|
||||||
SpotifyTrack,
|
SpotifyTrack,
|
||||||
} from '@/lib/spotify/types';
|
} from '@/lib/spotify/types';
|
||||||
|
|
||||||
@@ -34,6 +35,10 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(
|
const spotifyAuth = new SpotifyAuth(
|
||||||
import.meta.env.VITE_SPOTIFY_CLIENT_ID!,
|
import.meta.env.VITE_SPOTIFY_CLIENT_ID!,
|
||||||
import.meta.env.VITE_SPOTIFY_REDIRECT_URI!
|
import.meta.env.VITE_SPOTIFY_REDIRECT_URI!
|
||||||
@@ -63,7 +68,6 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
scopes: [],
|
scopes: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load initial data
|
|
||||||
await Promise.all([getDevices(), refreshPlaybackState()]);
|
await Promise.all([getDevices(), refreshPlaybackState()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,6 +344,51 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [authState.isAuthenticated, refreshPlaybackState]);
|
}, [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 = {
|
const contextValue: SpotifyContextType = {
|
||||||
...authState,
|
...authState,
|
||||||
currentTrack,
|
currentTrack,
|
||||||
@@ -348,6 +397,10 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
activeDevice,
|
activeDevice,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
// Capture/Resume state
|
||||||
|
capturedState,
|
||||||
|
isCaptureLoading,
|
||||||
|
isResumeLoading,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
play,
|
play,
|
||||||
@@ -358,6 +411,10 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
getDevices,
|
getDevices,
|
||||||
setActiveDevice,
|
setActiveDevice,
|
||||||
refreshPlaybackState,
|
refreshPlaybackState,
|
||||||
|
// Capture/Resume methods
|
||||||
|
capturePlaybackState,
|
||||||
|
resumePlaybackState,
|
||||||
|
clearCapturedState,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
SkipForwardIcon,
|
SkipForwardIcon,
|
||||||
GearIcon,
|
GearIcon,
|
||||||
SpotifyLogoIcon,
|
SpotifyLogoIcon,
|
||||||
|
FloppyDiskIcon,
|
||||||
|
ClockCounterClockwiseIcon,
|
||||||
} from '@phosphor-icons/react';
|
} from '@phosphor-icons/react';
|
||||||
import { useSpotify } from '@/lib/spotify/hooks';
|
import { useSpotify } from '@/lib/spotify/hooks';
|
||||||
import { useAuth } from '@/contexts/auth-context';
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
@@ -28,6 +30,11 @@ const SpotifyControlsBar = () => {
|
|||||||
skipNext,
|
skipNext,
|
||||||
skipPrevious,
|
skipPrevious,
|
||||||
activeDevice,
|
activeDevice,
|
||||||
|
capturedState,
|
||||||
|
isCaptureLoading,
|
||||||
|
isResumeLoading,
|
||||||
|
capturePlaybackState,
|
||||||
|
resumePlaybackState,
|
||||||
} = useSpotify();
|
} = useSpotify();
|
||||||
|
|
||||||
if (!isAdmin) return null;
|
if (!isAdmin) return null;
|
||||||
@@ -129,6 +136,34 @@ const SpotifyControlsBar = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</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">
|
<Group gap="xs">
|
||||||
{activeDevice && (
|
{activeDevice && (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -149,10 +184,6 @@ const SpotifyControlsBar = () => {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<Loader size="sm" color="green" />
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -61,13 +61,9 @@ const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
|
|||||||
<Sheet
|
<Sheet
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
title="Spotify Controls"
|
||||||
>
|
>
|
||||||
<Stack gap="lg">
|
<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 ? (
|
{!isAuthenticated ? (
|
||||||
<>
|
<>
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
SpotifyDevice,
|
SpotifyDevice,
|
||||||
SpotifyDevicesResponse,
|
SpotifyDevicesResponse,
|
||||||
SpotifyPlaybackState,
|
SpotifyPlaybackState,
|
||||||
|
SpotifyPlaybackSnapshot,
|
||||||
SpotifyError,
|
SpotifyError,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -125,4 +126,74 @@ export class SpotifyWebApiClient {
|
|||||||
updateAccessToken(accessToken: string): void {
|
updateAccessToken(accessToken: string): void {
|
||||||
this.accessToken = accessToken;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,10 @@ export interface SpotifyContextType extends SpotifyAuthState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
|
capturedState: SpotifyPlaybackSnapshot | null;
|
||||||
|
isCaptureLoading: boolean;
|
||||||
|
isResumeLoading: boolean;
|
||||||
|
|
||||||
login: () => void;
|
login: () => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
|
||||||
@@ -95,6 +99,10 @@ export interface SpotifyContextType extends SpotifyAuthState {
|
|||||||
setActiveDevice: (deviceId: string) => Promise<void>;
|
setActiveDevice: (deviceId: string) => Promise<void>;
|
||||||
|
|
||||||
refreshPlaybackState: () => Promise<void>;
|
refreshPlaybackState: () => Promise<void>;
|
||||||
|
|
||||||
|
capturePlaybackState: () => Promise<void>;
|
||||||
|
resumePlaybackState: () => Promise<void>;
|
||||||
|
clearCapturedState: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PKCEState {
|
export interface PKCEState {
|
||||||
@@ -105,4 +113,21 @@ export interface PKCEState {
|
|||||||
|
|
||||||
export interface SpotifyDevicesResponse {
|
export interface SpotifyDevicesResponse {
|
||||||
devices: SpotifyDevice[];
|
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;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user