216 lines
6.1 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
} |