spotify controls
This commit is contained in:
74
src/lib/spotify/auth.ts
Normal file
74
src/lib/spotify/auth.ts
Normal 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
128
src/lib/spotify/client.ts
Normal 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
63
src/lib/spotify/hooks.ts
Normal 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
108
src/lib/spotify/types.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user