Files
flxn-app/src/lib/spotify/client.ts
2025-09-22 17:57:29 -05:00

216 lines
6.1 KiB
TypeScript

import type {
SpotifyDevice,
SpotifyDevicesResponse,
SpotifyPlaybackState,
SpotifyPlaybackSnapshot,
SpotifyError,
SpotifyTrack,
} 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 playTrack(trackId: string, deviceId?: string, positionMs?: number): Promise<void> {
const endpoint = deviceId ? `/me/player/play?device_id=${deviceId}` : '/me/player/play';
await this.request(endpoint, {
method: 'PUT',
body: JSON.stringify({
uris: [`spotify:track:${trackId}`],
position_ms: positionMs || 0,
}),
});
}
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');
}
async searchTracks(query: string, limit: number = 20): Promise<{ tracks: { items: SpotifyTrack[] } }> {
const encodedQuery = encodeURIComponent(query);
return this.request<{ tracks: { items: SpotifyTrack[] } }>(`/search?q=${encodedQuery}&type=track&limit=${limit}`);
}
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();
}
}
}