morille 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/dist/app.d.ts +5 -0
- package/dist/app.js +66 -0
- package/dist/components/animated-line.d.ts +12 -0
- package/dist/components/animated-line.js +14 -0
- package/dist/components/browse-detail-view.d.ts +14 -0
- package/dist/components/browse-detail-view.js +31 -0
- package/dist/components/keyboard-hints.d.ts +12 -0
- package/dist/components/keyboard-hints.js +8 -0
- package/dist/components/lyrics-panel.d.ts +10 -0
- package/dist/components/lyrics-panel.js +38 -0
- package/dist/components/lyrics-view.d.ts +14 -0
- package/dist/components/lyrics-view.js +50 -0
- package/dist/components/panel-content.d.ts +10 -0
- package/dist/components/panel-content.js +22 -0
- package/dist/components/playback-status.d.ts +16 -0
- package/dist/components/playback-status.js +22 -0
- package/dist/components/player.d.ts +9 -0
- package/dist/components/player.d.ts.map +1 -0
- package/dist/components/player.js +215 -0
- package/dist/components/player.js.map +1 -0
- package/dist/components/progress-bar.d.ts +11 -0
- package/dist/components/progress-bar.js +13 -0
- package/dist/components/queue-view.d.ts +9 -0
- package/dist/components/queue-view.js +54 -0
- package/dist/components/search-panel.d.ts +8 -0
- package/dist/components/search-panel.js +152 -0
- package/dist/components/shimmer.d.ts +12 -0
- package/dist/components/shimmer.js +34 -0
- package/dist/components/side-panel.d.ts +12 -0
- package/dist/components/side-panel.js +12 -0
- package/dist/components/track-info-skeleton.d.ts +9 -0
- package/dist/components/track-info-skeleton.js +10 -0
- package/dist/components/track-info.d.ts +10 -0
- package/dist/components/track-info.js +15 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +65 -0
- package/dist/config.js.map +1 -0
- package/dist/contexts/lyrics-context.d.ts +29 -0
- package/dist/contexts/lyrics-context.js +44 -0
- package/dist/contexts/panel-mode-context.d.ts +24 -0
- package/dist/contexts/panel-mode-context.js +45 -0
- package/dist/contexts/queue-context.d.ts +32 -0
- package/dist/contexts/queue-context.js +68 -0
- package/dist/contexts/search-context.d.ts +59 -0
- package/dist/contexts/search-context.js +338 -0
- package/dist/hooks/use-album-art.d.ts +8 -0
- package/dist/hooks/use-album-art.js +56 -0
- package/dist/hooks/use-browse.d.ts +29 -0
- package/dist/hooks/use-browse.js +98 -0
- package/dist/hooks/use-lyrics.d.ts +12 -0
- package/dist/hooks/use-lyrics.js +51 -0
- package/dist/hooks/use-playback.d.ts +24 -0
- package/dist/hooks/use-playback.js +282 -0
- package/dist/hooks/use-player-input.d.ts +18 -0
- package/dist/hooks/use-player-input.js +201 -0
- package/dist/hooks/use-queue.d.ts +28 -0
- package/dist/hooks/use-queue.js +194 -0
- package/dist/hooks/use-search.d.ts +16 -0
- package/dist/hooks/use-search.js +77 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +10 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +6 -0
- package/dist/spotify/auth.d.ts +36 -0
- package/dist/spotify/auth.js +183 -0
- package/dist/spotify/client.d.ts +18 -0
- package/dist/spotify/client.js +48 -0
- package/dist/spotify/fetch-with-retry.d.ts +6 -0
- package/dist/spotify/fetch-with-retry.js +70 -0
- package/dist/spotify/lyrics.d.ts +25 -0
- package/dist/spotify/lyrics.js +130 -0
- package/dist/spotify/playback.d.ts +115 -0
- package/dist/spotify/playback.js +201 -0
- package/dist/spotify/search.d.ts +79 -0
- package/dist/spotify/search.js +143 -0
- package/package.json +33 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type LyricLine = {
|
|
2
|
+
timeMs: number;
|
|
3
|
+
text: string;
|
|
4
|
+
};
|
|
5
|
+
export type Lyrics = {
|
|
6
|
+
synced: LyricLine[];
|
|
7
|
+
plain: string | null;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Fetches lyrics for a track from LRCLIB (https://lrclib.net).
|
|
11
|
+
* Deduplicates concurrent requests for the same track - if a fetch is already
|
|
12
|
+
* in flight, returns the same promise instead of starting a new request.
|
|
13
|
+
* @param trackName - Name of the track
|
|
14
|
+
* @param artistName - Name of the artist
|
|
15
|
+
* @param durationMs - Duration of the track in milliseconds
|
|
16
|
+
* @returns Lyrics object, or null if not found
|
|
17
|
+
*/
|
|
18
|
+
export declare function fetchLyrics(trackName: string, artistName: string, durationMs: number): Promise<Lyrics | null>;
|
|
19
|
+
/**
|
|
20
|
+
* Finds the index of the current lyric line based on playback progress.
|
|
21
|
+
* @param lines - Sorted array of synced lyric lines
|
|
22
|
+
* @param progressMs - Current playback position in milliseconds
|
|
23
|
+
* @returns Index of the current line, or -1 if before the first line
|
|
24
|
+
*/
|
|
25
|
+
export declare function getCurrentLineIndex(lines: LyricLine[], progressMs: number, offsetMs?: number): number;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { CONFIG_DIR } from '../config.js';
|
|
5
|
+
import { fetchWithRetry } from './fetch-with-retry.js';
|
|
6
|
+
const LYRICS_CACHE_DIR = join(CONFIG_DIR, 'lyrics-cache');
|
|
7
|
+
function getCachePath(trackName, artistName) {
|
|
8
|
+
const hash = createHash('sha256').update(`${trackName}::${artistName}`).digest('hex').slice(0, 16);
|
|
9
|
+
return join(LYRICS_CACHE_DIR, `${hash}.json`);
|
|
10
|
+
}
|
|
11
|
+
function readDiskCache(trackName, artistName) {
|
|
12
|
+
const path = getCachePath(trackName, artistName);
|
|
13
|
+
if (!existsSync(path))
|
|
14
|
+
return null;
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function writeDiskCache(trackName, artistName, lyrics) {
|
|
23
|
+
mkdirSync(LYRICS_CACHE_DIR, {
|
|
24
|
+
recursive: true,
|
|
25
|
+
});
|
|
26
|
+
writeFileSync(getCachePath(trackName, artistName), JSON.stringify(lyrics));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Parses a single LRC-format line into a timestamped lyric.
|
|
30
|
+
* Format: "[mm:ss.xx] text"
|
|
31
|
+
* @returns Parsed lyric line, or null if the line doesn't match
|
|
32
|
+
*/
|
|
33
|
+
function parseLrcLine(line) {
|
|
34
|
+
const match = /^\[(\d{2}):(\d{2})\.(\d{2,3})\]\s*(.*)$/.exec(line);
|
|
35
|
+
if (!match)
|
|
36
|
+
return null;
|
|
37
|
+
const minutes = Number.parseInt(match[1], 10);
|
|
38
|
+
const seconds = Number.parseInt(match[2], 10);
|
|
39
|
+
const centiseconds = match[3].length === 2 ? Number.parseInt(match[3], 10) * 10 : Number.parseInt(match[3], 10);
|
|
40
|
+
const timeMs = minutes * 60_000 + seconds * 1000 + centiseconds;
|
|
41
|
+
const text = match[4];
|
|
42
|
+
return {
|
|
43
|
+
timeMs,
|
|
44
|
+
text,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Parses a full LRC-format string into an array of timestamped lyrics.
|
|
49
|
+
* @param lrc - LRC format string with one "[mm:ss.xx] text" per line
|
|
50
|
+
* @returns Sorted array of lyric lines
|
|
51
|
+
*/
|
|
52
|
+
function parseLrc(lrc) {
|
|
53
|
+
const lines = [];
|
|
54
|
+
for (const line of lrc.split('\n')) {
|
|
55
|
+
const parsed = parseLrcLine(line.trim());
|
|
56
|
+
if (parsed) {
|
|
57
|
+
lines.push(parsed);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return lines.sort((a, b) => a.timeMs - b.timeMs);
|
|
61
|
+
}
|
|
62
|
+
const inflight = new Map();
|
|
63
|
+
/**
|
|
64
|
+
* Fetches lyrics for a track from LRCLIB (https://lrclib.net).
|
|
65
|
+
* Deduplicates concurrent requests for the same track - if a fetch is already
|
|
66
|
+
* in flight, returns the same promise instead of starting a new request.
|
|
67
|
+
* @param trackName - Name of the track
|
|
68
|
+
* @param artistName - Name of the artist
|
|
69
|
+
* @param durationMs - Duration of the track in milliseconds
|
|
70
|
+
* @returns Lyrics object, or null if not found
|
|
71
|
+
*/
|
|
72
|
+
export function fetchLyrics(trackName, artistName, durationMs) {
|
|
73
|
+
const cached = readDiskCache(trackName, artistName);
|
|
74
|
+
if (cached)
|
|
75
|
+
return Promise.resolve(cached);
|
|
76
|
+
const key = `${trackName}::${artistName}`;
|
|
77
|
+
const pending = inflight.get(key);
|
|
78
|
+
if (pending)
|
|
79
|
+
return pending;
|
|
80
|
+
const promise = doFetch(trackName, artistName, durationMs).finally(() => {
|
|
81
|
+
inflight.delete(key);
|
|
82
|
+
});
|
|
83
|
+
inflight.set(key, promise);
|
|
84
|
+
return promise;
|
|
85
|
+
}
|
|
86
|
+
async function doFetch(trackName, artistName, durationMs) {
|
|
87
|
+
const durationSecs = Math.round(durationMs / 1000);
|
|
88
|
+
const params = new URLSearchParams({
|
|
89
|
+
track_name: trackName,
|
|
90
|
+
artist_name: artistName,
|
|
91
|
+
duration: durationSecs.toString(),
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
const response = await fetchWithRetry(`https://lrclib.net/api/get?${params.toString()}`);
|
|
95
|
+
if (!response.ok)
|
|
96
|
+
return null;
|
|
97
|
+
const data = (await response.json());
|
|
98
|
+
const synced = data.syncedLyrics ? parseLrc(data.syncedLyrics) : [];
|
|
99
|
+
const plain = data.plainLyrics ?? null;
|
|
100
|
+
if (synced.length === 0 && !plain)
|
|
101
|
+
return null;
|
|
102
|
+
const lyrics = {
|
|
103
|
+
synced,
|
|
104
|
+
plain,
|
|
105
|
+
};
|
|
106
|
+
writeDiskCache(trackName, artistName, lyrics);
|
|
107
|
+
return lyrics;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Finds the index of the current lyric line based on playback progress.
|
|
115
|
+
* @param lines - Sorted array of synced lyric lines
|
|
116
|
+
* @param progressMs - Current playback position in milliseconds
|
|
117
|
+
* @returns Index of the current line, or -1 if before the first line
|
|
118
|
+
*/
|
|
119
|
+
export function getCurrentLineIndex(lines, progressMs, offsetMs = 0) {
|
|
120
|
+
let index = -1;
|
|
121
|
+
for (let i = 0; i < lines.length; i++) {
|
|
122
|
+
if (lines[i].timeMs - offsetMs <= progressMs) {
|
|
123
|
+
index = i;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return index;
|
|
130
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { PlaybackState, SpotifyApi } from '@spotify/web-api-ts-sdk';
|
|
2
|
+
export type RepeatMode = 'off' | 'context' | 'track';
|
|
3
|
+
export type PlaybackContext = {
|
|
4
|
+
type: string;
|
|
5
|
+
uri: string;
|
|
6
|
+
};
|
|
7
|
+
export type TrackInfo = {
|
|
8
|
+
name: string;
|
|
9
|
+
artist: string;
|
|
10
|
+
album: string;
|
|
11
|
+
isPlaying: boolean;
|
|
12
|
+
progressMs: number;
|
|
13
|
+
durationMs: number;
|
|
14
|
+
deviceId: string | null;
|
|
15
|
+
volume: number | null;
|
|
16
|
+
shuffle: boolean;
|
|
17
|
+
repeat: RepeatMode;
|
|
18
|
+
albumImageUrl: string | null;
|
|
19
|
+
context: PlaybackContext | null;
|
|
20
|
+
uri: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Fetches the current playback state from Spotify.
|
|
24
|
+
* @param client - Authenticated Spotify API client
|
|
25
|
+
* @returns The current playback state, or `null` if no device is active
|
|
26
|
+
*/
|
|
27
|
+
export declare function getPlaybackState(client: SpotifyApi): Promise<PlaybackState | null>;
|
|
28
|
+
/**
|
|
29
|
+
* Resumes playback on the specified device.
|
|
30
|
+
* @param client - Authenticated Spotify API client
|
|
31
|
+
* @param deviceId - Target device ID
|
|
32
|
+
*/
|
|
33
|
+
export declare function play(client: SpotifyApi, deviceId: string): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Plays a specific track within a playlist/album/collection context.
|
|
36
|
+
* Uses URI-based offset to jump directly to the track in one API call.
|
|
37
|
+
* @param client - Authenticated Spotify API client
|
|
38
|
+
* @param deviceId - Target device ID
|
|
39
|
+
* @param contextUri - Spotify URI of the context (e.g. "spotify:playlist:..." or "spotify:album:...")
|
|
40
|
+
* @param trackUri - Spotify URI of the track to play within the context
|
|
41
|
+
*/
|
|
42
|
+
export declare function playInContext(client: SpotifyApi, deviceId: string, contextUri: string, trackUri: string): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Starts playback of an entire context (album, playlist, or collection) from the beginning.
|
|
45
|
+
* @param client - Authenticated Spotify API client
|
|
46
|
+
* @param deviceId - Target device ID
|
|
47
|
+
* @param contextUri - Spotify URI of the context (e.g. "spotify:album:..." or "spotify:playlist:...")
|
|
48
|
+
*/
|
|
49
|
+
export declare function playContext(client: SpotifyApi, deviceId: string, contextUri: string): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Starts playback of a specific track by URI on the specified device.
|
|
52
|
+
* @param client - Authenticated Spotify API client
|
|
53
|
+
* @param deviceId - Target device ID
|
|
54
|
+
* @param uri - Spotify URI of the track to play (e.g. "spotify:track:...")
|
|
55
|
+
*/
|
|
56
|
+
export declare function playUri(client: SpotifyApi, deviceId: string, uri: string): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Pauses playback on the specified device.
|
|
59
|
+
* @param client - Authenticated Spotify API client
|
|
60
|
+
* @param deviceId - Target device ID
|
|
61
|
+
*/
|
|
62
|
+
export declare function pause(client: SpotifyApi, deviceId: string): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Skips to the next track on the specified device.
|
|
65
|
+
* @param client - Authenticated Spotify API client
|
|
66
|
+
* @param deviceId - Target device ID
|
|
67
|
+
*/
|
|
68
|
+
export declare function skipNext(client: SpotifyApi, deviceId: string): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Skips to the previous track on the specified device.
|
|
71
|
+
* @param client - Authenticated Spotify API client
|
|
72
|
+
* @param deviceId - Target device ID
|
|
73
|
+
*/
|
|
74
|
+
export declare function skipPrevious(client: SpotifyApi, deviceId: string): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Seeks to a position in the currently playing track.
|
|
77
|
+
* @param client - Authenticated Spotify API client
|
|
78
|
+
* @param positionMs - Position in milliseconds to seek to
|
|
79
|
+
* @param deviceId - Target device ID
|
|
80
|
+
*/
|
|
81
|
+
export declare function seek(client: SpotifyApi, positionMs: number, deviceId?: string): Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* Sets the playback volume.
|
|
84
|
+
* @param client - Authenticated Spotify API client
|
|
85
|
+
* @param volumePercent - Volume level from 0 to 100
|
|
86
|
+
* @param deviceId - Target device ID
|
|
87
|
+
*/
|
|
88
|
+
export declare function setVolume(client: SpotifyApi, volumePercent: number, deviceId?: string): Promise<void>;
|
|
89
|
+
/**
|
|
90
|
+
* Sets the shuffle mode.
|
|
91
|
+
* @param client - Authenticated Spotify API client
|
|
92
|
+
* @param state - Whether shuffle should be enabled
|
|
93
|
+
* @param deviceId - Target device ID
|
|
94
|
+
*/
|
|
95
|
+
export declare function setShuffle(client: SpotifyApi, state: boolean, deviceId?: string): Promise<void>;
|
|
96
|
+
/**
|
|
97
|
+
* Sets the repeat mode.
|
|
98
|
+
* @param client - Authenticated Spotify API client
|
|
99
|
+
* @param state - Repeat mode: 'off', 'context' (playlist/album), or 'track'
|
|
100
|
+
* @param deviceId - Target device ID
|
|
101
|
+
*/
|
|
102
|
+
export declare function setRepeat(client: SpotifyApi, state: RepeatMode, deviceId?: string): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* Toggles playback between playing and paused on the active device.
|
|
105
|
+
* @param client - Authenticated Spotify API client
|
|
106
|
+
* @returns `true` if playback is now playing, `false` if paused
|
|
107
|
+
* @throws When no active Spotify device is found
|
|
108
|
+
*/
|
|
109
|
+
export declare function togglePlayback(client: SpotifyApi): Promise<boolean>;
|
|
110
|
+
/**
|
|
111
|
+
* Fetches simplified info about the currently playing track.
|
|
112
|
+
* @param client - Authenticated Spotify API client
|
|
113
|
+
* @returns Current track info, or `null` if nothing is playing
|
|
114
|
+
*/
|
|
115
|
+
export declare function getCurrentTrackInfo(client: SpotifyApi): Promise<TrackInfo | null>;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches the current playback state from Spotify.
|
|
3
|
+
* @param client - Authenticated Spotify API client
|
|
4
|
+
* @returns The current playback state, or `null` if no device is active
|
|
5
|
+
*/
|
|
6
|
+
export async function getPlaybackState(client) {
|
|
7
|
+
try {
|
|
8
|
+
const state = await client.player.getPlaybackState();
|
|
9
|
+
return state ?? null;
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
// The SDK's deserializer calls JSON.parse on any non-204 response.
|
|
13
|
+
// Spotify can briefly return non-JSON bodies during state transitions
|
|
14
|
+
// (e.g. right after pause/skip). Treat parse failures as "no state available".
|
|
15
|
+
if (err instanceof SyntaxError) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resumes playback on the specified device.
|
|
23
|
+
* @param client - Authenticated Spotify API client
|
|
24
|
+
* @param deviceId - Target device ID
|
|
25
|
+
*/
|
|
26
|
+
export async function play(client, deviceId) {
|
|
27
|
+
await ignoreSdkParseError(() => client.player.startResumePlayback(deviceId));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Plays a specific track within a playlist/album/collection context.
|
|
31
|
+
* Uses URI-based offset to jump directly to the track in one API call.
|
|
32
|
+
* @param client - Authenticated Spotify API client
|
|
33
|
+
* @param deviceId - Target device ID
|
|
34
|
+
* @param contextUri - Spotify URI of the context (e.g. "spotify:playlist:..." or "spotify:album:...")
|
|
35
|
+
* @param trackUri - Spotify URI of the track to play within the context
|
|
36
|
+
*/
|
|
37
|
+
export async function playInContext(client, deviceId, contextUri, trackUri) {
|
|
38
|
+
await ignoreSdkParseError(() => client.player.startResumePlayback(deviceId, contextUri, undefined, {
|
|
39
|
+
uri: trackUri,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Starts playback of an entire context (album, playlist, or collection) from the beginning.
|
|
44
|
+
* @param client - Authenticated Spotify API client
|
|
45
|
+
* @param deviceId - Target device ID
|
|
46
|
+
* @param contextUri - Spotify URI of the context (e.g. "spotify:album:..." or "spotify:playlist:...")
|
|
47
|
+
*/
|
|
48
|
+
export async function playContext(client, deviceId, contextUri) {
|
|
49
|
+
await ignoreSdkParseError(() => client.player.startResumePlayback(deviceId, contextUri));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Starts playback of a specific track by URI on the specified device.
|
|
53
|
+
* @param client - Authenticated Spotify API client
|
|
54
|
+
* @param deviceId - Target device ID
|
|
55
|
+
* @param uri - Spotify URI of the track to play (e.g. "spotify:track:...")
|
|
56
|
+
*/
|
|
57
|
+
export async function playUri(client, deviceId, uri) {
|
|
58
|
+
await ignoreSdkParseError(() => client.player.startResumePlayback(deviceId, undefined, [
|
|
59
|
+
uri,
|
|
60
|
+
]));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Pauses playback on the specified device.
|
|
64
|
+
* @param client - Authenticated Spotify API client
|
|
65
|
+
* @param deviceId - Target device ID
|
|
66
|
+
*/
|
|
67
|
+
export async function pause(client, deviceId) {
|
|
68
|
+
await ignoreSdkParseError(() => client.player.pausePlayback(deviceId));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Skips to the next track on the specified device.
|
|
72
|
+
* @param client - Authenticated Spotify API client
|
|
73
|
+
* @param deviceId - Target device ID
|
|
74
|
+
*/
|
|
75
|
+
export async function skipNext(client, deviceId) {
|
|
76
|
+
await ignoreSdkParseError(() => client.player.skipToNext(deviceId));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Skips to the previous track on the specified device.
|
|
80
|
+
* @param client - Authenticated Spotify API client
|
|
81
|
+
* @param deviceId - Target device ID
|
|
82
|
+
*/
|
|
83
|
+
export async function skipPrevious(client, deviceId) {
|
|
84
|
+
await ignoreSdkParseError(() => client.player.skipToPrevious(deviceId));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Seeks to a position in the currently playing track.
|
|
88
|
+
* @param client - Authenticated Spotify API client
|
|
89
|
+
* @param positionMs - Position in milliseconds to seek to
|
|
90
|
+
* @param deviceId - Target device ID
|
|
91
|
+
*/
|
|
92
|
+
export async function seek(client, positionMs, deviceId) {
|
|
93
|
+
await ignoreSdkParseError(() => client.player.seekToPosition(positionMs, deviceId));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Sets the playback volume.
|
|
97
|
+
* @param client - Authenticated Spotify API client
|
|
98
|
+
* @param volumePercent - Volume level from 0 to 100
|
|
99
|
+
* @param deviceId - Target device ID
|
|
100
|
+
*/
|
|
101
|
+
export async function setVolume(client, volumePercent, deviceId) {
|
|
102
|
+
await ignoreSdkParseError(() => client.player.setPlaybackVolume(volumePercent, deviceId));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Sets the shuffle mode.
|
|
106
|
+
* @param client - Authenticated Spotify API client
|
|
107
|
+
* @param state - Whether shuffle should be enabled
|
|
108
|
+
* @param deviceId - Target device ID
|
|
109
|
+
*/
|
|
110
|
+
export async function setShuffle(client, state, deviceId) {
|
|
111
|
+
await ignoreSdkParseError(() => client.player.togglePlaybackShuffle(state, deviceId));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Sets the repeat mode.
|
|
115
|
+
* @param client - Authenticated Spotify API client
|
|
116
|
+
* @param state - Repeat mode: 'off', 'context' (playlist/album), or 'track'
|
|
117
|
+
* @param deviceId - Target device ID
|
|
118
|
+
*/
|
|
119
|
+
export async function setRepeat(client, state, deviceId) {
|
|
120
|
+
await ignoreSdkParseError(() => client.player.setRepeatMode(state, deviceId));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Wraps an SDK call to swallow SyntaxErrors from the deserializer.
|
|
124
|
+
* The SDK calls JSON.parse on any non-204 response body. Spotify's player
|
|
125
|
+
* control endpoints sometimes return 200 with a non-JSON body instead of 204,
|
|
126
|
+
* which causes a SyntaxError. The action still succeeds server-side.
|
|
127
|
+
*/
|
|
128
|
+
async function ignoreSdkParseError(fn) {
|
|
129
|
+
try {
|
|
130
|
+
await fn();
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
if (err instanceof SyntaxError) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Translate the SDK's misleading 403 message for player restriction errors
|
|
137
|
+
if (err instanceof Error && err.message.includes('Restriction violated')) {
|
|
138
|
+
console.error('Action failed due to Spotify playback restrictions:', err);
|
|
139
|
+
throw new Error('Action not available for the current playback context');
|
|
140
|
+
}
|
|
141
|
+
console.error('Unexpected error during playback control:', err);
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Toggles playback between playing and paused on the active device.
|
|
147
|
+
* @param client - Authenticated Spotify API client
|
|
148
|
+
* @returns `true` if playback is now playing, `false` if paused
|
|
149
|
+
* @throws When no active Spotify device is found
|
|
150
|
+
*/
|
|
151
|
+
export async function togglePlayback(client) {
|
|
152
|
+
const state = await getPlaybackState(client);
|
|
153
|
+
if (!state) {
|
|
154
|
+
throw new Error('No active Spotify device found. Start playing on any Spotify client first.');
|
|
155
|
+
}
|
|
156
|
+
const deviceId = state.device.id;
|
|
157
|
+
if (!deviceId) {
|
|
158
|
+
throw new Error('Active device has no ID.');
|
|
159
|
+
}
|
|
160
|
+
if (state.is_playing) {
|
|
161
|
+
await pause(client, deviceId);
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
await play(client, deviceId);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Fetches simplified info about the currently playing track.
|
|
169
|
+
* @param client - Authenticated Spotify API client
|
|
170
|
+
* @returns Current track info, or `null` if nothing is playing
|
|
171
|
+
*/
|
|
172
|
+
export async function getCurrentTrackInfo(client) {
|
|
173
|
+
const state = await getPlaybackState(client);
|
|
174
|
+
if (!state?.item) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const { item } = state;
|
|
178
|
+
const artist = 'artists' in item ? item.artists.map((a) => a.name).join(', ') : 'Unknown';
|
|
179
|
+
const album = 'album' in item ? item.album.name : '';
|
|
180
|
+
const albumImageUrl = 'album' in item && item.album.images.length > 0 ? item.album.images[item.album.images.length - 1].url : null;
|
|
181
|
+
return {
|
|
182
|
+
name: item.name,
|
|
183
|
+
artist,
|
|
184
|
+
album,
|
|
185
|
+
isPlaying: state.is_playing,
|
|
186
|
+
progressMs: state.progress_ms,
|
|
187
|
+
durationMs: item.duration_ms,
|
|
188
|
+
deviceId: state.device.id,
|
|
189
|
+
volume: state.device.volume_percent,
|
|
190
|
+
shuffle: state.shuffle_state,
|
|
191
|
+
repeat: state.repeat_state,
|
|
192
|
+
albumImageUrl,
|
|
193
|
+
context: state.context
|
|
194
|
+
? {
|
|
195
|
+
type: state.context.type,
|
|
196
|
+
uri: state.context.uri,
|
|
197
|
+
}
|
|
198
|
+
: null,
|
|
199
|
+
uri: item.uri,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { SpotifyApi } from '@spotify/web-api-ts-sdk';
|
|
2
|
+
export type SearchResultTrack = {
|
|
3
|
+
name: string;
|
|
4
|
+
artist: string;
|
|
5
|
+
album: string;
|
|
6
|
+
uri: string;
|
|
7
|
+
durationMs: number;
|
|
8
|
+
albumUri: string;
|
|
9
|
+
};
|
|
10
|
+
export type SearchResultAlbum = {
|
|
11
|
+
name: string;
|
|
12
|
+
artist: string;
|
|
13
|
+
uri: string;
|
|
14
|
+
id: string;
|
|
15
|
+
imageUrl: string | null;
|
|
16
|
+
totalTracks: number;
|
|
17
|
+
};
|
|
18
|
+
export type SearchResultPlaylist = {
|
|
19
|
+
name: string;
|
|
20
|
+
owner: string;
|
|
21
|
+
uri: string;
|
|
22
|
+
id: string;
|
|
23
|
+
totalTracks: number;
|
|
24
|
+
};
|
|
25
|
+
export type SearchResultArtist = {
|
|
26
|
+
name: string;
|
|
27
|
+
uri: string;
|
|
28
|
+
id: string;
|
|
29
|
+
};
|
|
30
|
+
export type SearchResults = {
|
|
31
|
+
tracks: SearchResultTrack[];
|
|
32
|
+
albums: SearchResultAlbum[];
|
|
33
|
+
artists: SearchResultArtist[];
|
|
34
|
+
playlists: SearchResultPlaylist[];
|
|
35
|
+
};
|
|
36
|
+
export type BrowseTrack = {
|
|
37
|
+
name: string;
|
|
38
|
+
artist: string;
|
|
39
|
+
uri: string;
|
|
40
|
+
durationMs: number;
|
|
41
|
+
};
|
|
42
|
+
export type AlbumDetail = {
|
|
43
|
+
name: string;
|
|
44
|
+
artist: string;
|
|
45
|
+
uri: string;
|
|
46
|
+
tracks: BrowseTrack[];
|
|
47
|
+
};
|
|
48
|
+
export type PlaylistDetail = {
|
|
49
|
+
name: string;
|
|
50
|
+
owner: string;
|
|
51
|
+
uri: string;
|
|
52
|
+
tracks: BrowseTrack[];
|
|
53
|
+
};
|
|
54
|
+
export declare function searchSpotify(client: SpotifyApi, query: string, limit?: number): Promise<SearchResults>;
|
|
55
|
+
/**
|
|
56
|
+
* Fetches full album details including track list.
|
|
57
|
+
* @param client - Authenticated Spotify API client
|
|
58
|
+
* @param albumId - Spotify album ID
|
|
59
|
+
* @returns Album metadata and track list
|
|
60
|
+
*/
|
|
61
|
+
export declare function fetchAlbumTracks(client: SpotifyApi, albumId: string): Promise<AlbumDetail>;
|
|
62
|
+
/**
|
|
63
|
+
* Fetches full playlist details including track list.
|
|
64
|
+
* @param client - Authenticated Spotify API client
|
|
65
|
+
* @param playlistId - Spotify playlist ID
|
|
66
|
+
* @returns Playlist metadata and track list
|
|
67
|
+
*/
|
|
68
|
+
export declare function fetchPlaylistTracks(client: SpotifyApi, playlistId: string): Promise<PlaylistDetail>;
|
|
69
|
+
/**
|
|
70
|
+
* Fetches the current user's playlists.
|
|
71
|
+
* @param client - Authenticated Spotify API client
|
|
72
|
+
* @param limit - Max number of playlists to return (default: USER_PLAYLISTS_LIMIT)
|
|
73
|
+
* @param offset - Pagination offset
|
|
74
|
+
* @returns List of simplified playlist items and total count
|
|
75
|
+
*/
|
|
76
|
+
export declare function fetchUserPlaylists(client: SpotifyApi, limit?: number, offset?: number): Promise<{
|
|
77
|
+
items: SearchResultPlaylist[];
|
|
78
|
+
total: number;
|
|
79
|
+
}>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { SEARCH_RESULTS_LIMIT, USER_PLAYLISTS_LIMIT } from '../config.js';
|
|
2
|
+
/**
|
|
3
|
+
* Searches Spotify for tracks, albums, artists, and playlists matching the query.
|
|
4
|
+
* @param client - Authenticated Spotify API client
|
|
5
|
+
* @param query - Search query string
|
|
6
|
+
* @param limit - Max results per category (default: SEARCH_RESULTS_LIMIT)
|
|
7
|
+
* @returns Categorized search results
|
|
8
|
+
*/
|
|
9
|
+
// Multi-type search reliably rejects values above this with `400 Invalid limit`,
|
|
10
|
+
// even though the Spotify docs claim `max=50` for single-type searches.
|
|
11
|
+
const MULTI_TYPE_SEARCH_MAX_LIMIT = 20;
|
|
12
|
+
export async function searchSpotify(client, query, limit = SEARCH_RESULTS_LIMIT) {
|
|
13
|
+
const safeLimit = Math.max(1, Math.min(limit, MULTI_TYPE_SEARCH_MAX_LIMIT));
|
|
14
|
+
const data = await client.search(query, [
|
|
15
|
+
'track',
|
|
16
|
+
'album',
|
|
17
|
+
'artist',
|
|
18
|
+
'playlist',
|
|
19
|
+
], undefined, safeLimit);
|
|
20
|
+
// SDK returns null when the access token is empty or an error is silently handled
|
|
21
|
+
if (!data) {
|
|
22
|
+
return {
|
|
23
|
+
tracks: [],
|
|
24
|
+
albums: [],
|
|
25
|
+
artists: [],
|
|
26
|
+
playlists: [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// Spotify search can return null entries in items arrays — filter them out
|
|
30
|
+
const tracks = (data.tracks?.items ?? []).filter(Boolean).map((t) => ({
|
|
31
|
+
name: t.name,
|
|
32
|
+
artist: t.artists.map((a) => a.name).join(', '),
|
|
33
|
+
album: t.album.name,
|
|
34
|
+
uri: t.uri,
|
|
35
|
+
durationMs: t.duration_ms,
|
|
36
|
+
albumUri: t.album.uri,
|
|
37
|
+
}));
|
|
38
|
+
const albums = (data.albums?.items ?? []).filter(Boolean).map((a) => ({
|
|
39
|
+
name: a.name,
|
|
40
|
+
artist: a.artists.map((ar) => ar.name).join(', '),
|
|
41
|
+
uri: a.uri,
|
|
42
|
+
id: a.id,
|
|
43
|
+
imageUrl: a.images.length > 0 ? a.images[a.images.length - 1].url : null,
|
|
44
|
+
totalTracks: a.total_tracks,
|
|
45
|
+
}));
|
|
46
|
+
const artists = (data.artists?.items ?? []).filter(Boolean).map((ar) => ({
|
|
47
|
+
name: ar.name,
|
|
48
|
+
uri: ar.uri,
|
|
49
|
+
id: ar.id,
|
|
50
|
+
}));
|
|
51
|
+
// SDK types search playlists as PlaylistBase (no tracks field),
|
|
52
|
+
// but the API response includes tracks.total — cast to access it.
|
|
53
|
+
const playlists = (data.playlists?.items ?? []).filter(Boolean).map((p) => {
|
|
54
|
+
const trackRef = p.tracks;
|
|
55
|
+
return {
|
|
56
|
+
name: p.name,
|
|
57
|
+
owner: p.owner.display_name ?? p.owner.id,
|
|
58
|
+
uri: p.uri,
|
|
59
|
+
id: p.id,
|
|
60
|
+
totalTracks: trackRef?.total ?? 0,
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
tracks,
|
|
65
|
+
albums,
|
|
66
|
+
artists,
|
|
67
|
+
playlists,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Fetches full album details including track list.
|
|
72
|
+
* @param client - Authenticated Spotify API client
|
|
73
|
+
* @param albumId - Spotify album ID
|
|
74
|
+
* @returns Album metadata and track list
|
|
75
|
+
*/
|
|
76
|
+
export async function fetchAlbumTracks(client, albumId) {
|
|
77
|
+
const album = await client.albums.get(albumId);
|
|
78
|
+
if (!album)
|
|
79
|
+
throw new Error('Failed to fetch album');
|
|
80
|
+
return {
|
|
81
|
+
name: album.name,
|
|
82
|
+
artist: album.artists.map((a) => a.name).join(', '),
|
|
83
|
+
uri: album.uri,
|
|
84
|
+
tracks: album.tracks.items.filter(Boolean).map((t) => ({
|
|
85
|
+
name: t.name,
|
|
86
|
+
artist: t.artists.map((a) => a.name).join(', '),
|
|
87
|
+
uri: t.uri,
|
|
88
|
+
durationMs: t.duration_ms,
|
|
89
|
+
})),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Fetches full playlist details including track list.
|
|
94
|
+
* @param client - Authenticated Spotify API client
|
|
95
|
+
* @param playlistId - Spotify playlist ID
|
|
96
|
+
* @returns Playlist metadata and track list
|
|
97
|
+
*/
|
|
98
|
+
export async function fetchPlaylistTracks(client, playlistId) {
|
|
99
|
+
const playlist = await client.playlists.getPlaylist(playlistId);
|
|
100
|
+
if (!playlist)
|
|
101
|
+
throw new Error('Failed to fetch playlist');
|
|
102
|
+
return {
|
|
103
|
+
name: playlist.name,
|
|
104
|
+
owner: playlist.owner.display_name ?? playlist.owner.id,
|
|
105
|
+
uri: playlist.uri,
|
|
106
|
+
tracks: playlist.tracks.items
|
|
107
|
+
.filter((item) => item.track)
|
|
108
|
+
.map((item) => {
|
|
109
|
+
const t = item.track;
|
|
110
|
+
return {
|
|
111
|
+
name: t.name,
|
|
112
|
+
artist: 'artists' in t ? t.artists.map((a) => a.name).join(', ') : 'Unknown',
|
|
113
|
+
uri: t.uri,
|
|
114
|
+
durationMs: t.duration_ms,
|
|
115
|
+
};
|
|
116
|
+
}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Fetches the current user's playlists.
|
|
121
|
+
* @param client - Authenticated Spotify API client
|
|
122
|
+
* @param limit - Max number of playlists to return (default: USER_PLAYLISTS_LIMIT)
|
|
123
|
+
* @param offset - Pagination offset
|
|
124
|
+
* @returns List of simplified playlist items and total count
|
|
125
|
+
*/
|
|
126
|
+
export async function fetchUserPlaylists(client, limit = USER_PLAYLISTS_LIMIT, offset = 0) {
|
|
127
|
+
const page = await client.currentUser.playlists.playlists(limit, offset);
|
|
128
|
+
if (!page)
|
|
129
|
+
return {
|
|
130
|
+
items: [],
|
|
131
|
+
total: 0,
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
items: page.items.filter(Boolean).map((p) => ({
|
|
135
|
+
name: p.name,
|
|
136
|
+
owner: p.owner.display_name ?? p.owner.id,
|
|
137
|
+
uri: p.uri,
|
|
138
|
+
id: p.id,
|
|
139
|
+
totalTracks: p.tracks?.total ?? 0,
|
|
140
|
+
})),
|
|
141
|
+
total: page.total,
|
|
142
|
+
};
|
|
143
|
+
}
|