spotify controls

This commit is contained in:
yohlo
2025-09-12 11:08:21 -05:00
parent 9d92a8a510
commit 0169468114
15 changed files with 1655 additions and 28 deletions

74
src/lib/spotify/auth.ts Normal file
View File

@@ -0,0 +1,74 @@
import type { PKCEState, SpotifyTokenResponse } from './types';
const SPOTIFY_AUTH_BASE = 'https://accounts.spotify.com';
const SPOTIFY_SCOPES = [
'user-read-playback-state',
'user-modify-playback-state',
'user-read-currently-playing',
'streaming',
].join(' ');
function generateRandomString(length: number): string {
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const values = crypto.getRandomValues(new Uint8Array(length));
return values.reduce((acc, x) => acc + possible[x % possible.length], '');
}
export class SpotifyAuth {
private clientId: string;
private redirectUri: string;
constructor(clientId: string, redirectUri: string) {
this.clientId = clientId;
this.redirectUri = redirectUri;
}
async startAuthFlow(returnPath: string = window.location.pathname): Promise<void> {
const randomState = generateRandomString(16);
const stateWithPath = btoa(JSON.stringify({
state: randomState,
returnPath: returnPath
}));
sessionStorage.setItem('spotify_state', randomState);
const params = new URLSearchParams({
response_type: 'code',
client_id: this.clientId,
scope: SPOTIFY_SCOPES,
redirect_uri: this.redirectUri,
state: stateWithPath,
});
const authUrl = `${SPOTIFY_AUTH_BASE}/authorize?${params.toString()}`;
window.location.href = authUrl;
}
async refreshAccessToken(refreshToken: string): Promise<SpotifyTokenResponse> {
const response = await fetch('/api/spotify/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Token refresh failed: ${error.error || 'Unknown error'}`);
}
return response.json();
}
getReturnPath(): string {
return '/';
}
clearStoredData(): void {
sessionStorage.removeItem('spotify_state');
}
}

128
src/lib/spotify/client.ts Normal file
View File

@@ -0,0 +1,128 @@
import type {
SpotifyDevice,
SpotifyDevicesResponse,
SpotifyPlaybackState,
SpotifyError,
} from './types';
const SPOTIFY_API_BASE = 'https://api.spotify.com/v1';
export class SpotifyWebApiClient {
private accessToken: string;
constructor(accessToken: string) {
this.accessToken = accessToken;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${SPOTIFY_API_BASE}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
try {
const error: SpotifyError = await response.json();
throw new Error(`Spotify API Error: ${error.error?.message || 'Unknown error'}`);
} catch (parseError) {
throw new Error(`Spotify API Error: ${response.status} ${response.statusText}`);
}
}
if (response.status === 204 || response.status === 202) {
return {} as T;
}
const contentLength = response.headers.get('content-length');
if (contentLength === '0') {
return {} as T;
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
console.warn('Non-JSON response from Spotify API:', contentType, response.status);
return {} as T;
}
try {
return await response.json();
} catch (error) {
console.warn('Failed to parse Spotify API JSON response:', error);
return {} as T;
}
}
async getDevices(): Promise<SpotifyDevice[]> {
const response = await this.request<SpotifyDevicesResponse>('/me/player/devices');
return response.devices;
}
async transferPlayback(deviceId: string, play: boolean = false): Promise<void> {
await this.request('/me/player', {
method: 'PUT',
body: JSON.stringify({
device_ids: [deviceId],
play,
}),
});
}
async getPlaybackState(): Promise<SpotifyPlaybackState | null> {
try {
return await this.request<SpotifyPlaybackState>('/me/player');
} catch (error) {
if (error instanceof Error && error.message.includes('204')) {
return null;
}
throw error;
}
}
async play(deviceId?: string): Promise<void> {
const endpoint = deviceId ? `/me/player/play?device_id=${deviceId}` : '/me/player/play';
await this.request(endpoint, {
method: 'PUT',
});
}
async pause(): Promise<void> {
await this.request('/me/player/pause', {
method: 'PUT',
});
}
async skipToNext(): Promise<void> {
await this.request('/me/player/next', {
method: 'POST',
});
}
async skipToPrevious(): Promise<void> {
await this.request('/me/player/previous', {
method: 'POST',
});
}
async setVolume(volumePercent: number): Promise<void> {
await this.request(`/me/player/volume?volume_percent=${volumePercent}`, {
method: 'PUT',
});
}
async getCurrentUser(): Promise<{ id: string; display_name: string }> {
return this.request<{ id: string; display_name: string }>('/me');
}
updateAccessToken(accessToken: string): void {
this.accessToken = accessToken;
}
}

63
src/lib/spotify/hooks.ts Normal file
View File

@@ -0,0 +1,63 @@
import { useContext } from 'react';
import { SpotifyContext } from '@/contexts/spotify-context';
import type { SpotifyContextType } from './types';
export const useSpotify = (): SpotifyContextType => {
const context = useContext(SpotifyContext);
if (!context) {
throw new Error('useSpotify must be used within a SpotifyProvider');
}
return context;
};
export const useSpotifyAuth = () => {
const { isAuthenticated, login, logout } = useSpotify();
return { isAuthenticated, login, logout };
};
export const useSpotifyPlayback = () => {
const {
playbackState,
currentTrack,
play,
pause,
skipNext,
skipPrevious,
setVolume,
refreshPlaybackState,
isLoading,
} = useSpotify();
return {
playbackState,
currentTrack,
play,
pause,
skipNext,
skipPrevious,
setVolume,
refreshPlaybackState,
isLoading,
isPlaying: playbackState?.is_playing || false,
};
};
export const useSpotifyDevices = () => {
const {
devices,
activeDevice,
getDevices,
setActiveDevice,
isLoading,
} = useSpotify();
return {
devices,
activeDevice,
getDevices,
setActiveDevice,
isLoading,
};
};

108
src/lib/spotify/types.ts Normal file
View File

@@ -0,0 +1,108 @@
export interface SpotifyDevice {
id: string;
is_active: boolean;
is_private_session: boolean;
is_restricted: boolean;
name: string;
type: string;
volume_percent: number | null;
supports_volume: boolean;
}
export interface SpotifyTrack {
id: string;
name: string;
artists: Array<{
id: string;
name: string;
}>;
album: {
id: string;
name: string;
images: Array<{
url: string;
height: number;
width: number;
}>;
};
duration_ms: number;
external_urls: {
spotify: string;
};
}
export interface SpotifyPlaybackState {
device: SpotifyDevice;
repeat_state: 'off' | 'context' | 'track';
shuffle_state: boolean;
context: {
type: string;
href: string;
external_urls: {
spotify: string;
};
uri: string;
} | null;
timestamp: number;
progress_ms: number | null;
is_playing: boolean;
item: SpotifyTrack | null;
currently_playing_type: 'track' | 'episode' | 'ad' | 'unknown';
}
export interface SpotifyTokenResponse {
access_token: string;
token_type: 'Bearer';
scope: string;
expires_in: number;
refresh_token?: string;
state?: string;
}
export interface SpotifyError {
error: {
status: number;
message: string;
};
}
export interface SpotifyAuthState {
isAuthenticated: boolean;
accessToken: string | null;
refreshToken: string | null;
expiresAt: number | null;
scopes: string[];
}
export interface SpotifyContextType extends SpotifyAuthState {
currentTrack: SpotifyTrack | null;
playbackState: SpotifyPlaybackState | null;
devices: SpotifyDevice[];
activeDevice: SpotifyDevice | null;
isLoading: boolean;
error: string | null;
login: () => void;
logout: () => void;
play: (deviceId?: string) => Promise<void>;
pause: () => Promise<void>;
skipNext: () => Promise<void>;
skipPrevious: () => Promise<void>;
setVolume: (volumePercent: number) => Promise<void>;
getDevices: () => Promise<void>;
setActiveDevice: (deviceId: string) => Promise<void>;
refreshPlaybackState: () => Promise<void>;
}
export interface PKCEState {
codeVerifier: string;
codeChallenge: string;
state: string;
}
export interface SpotifyDevicesResponse {
devices: SpotifyDevice[];
}