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 { 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:
|
||||
|
||||
@@ -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 />
|
||||
|
||||
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,
|
||||
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) {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user