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,338 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useBrowse } from '../hooks/use-browse.js';
|
|
4
|
+
import { useSearch } from '../hooks/use-search.js';
|
|
5
|
+
import { playInContext } from '../spotify/playback.js';
|
|
6
|
+
import { usePanelMode } from './panel-mode-context.js';
|
|
7
|
+
const CATEGORIES = [
|
|
8
|
+
'tracks',
|
|
9
|
+
'albums',
|
|
10
|
+
'artists',
|
|
11
|
+
'playlists',
|
|
12
|
+
];
|
|
13
|
+
const SearchContext = createContext(null);
|
|
14
|
+
function getCategoryItems(results, category) {
|
|
15
|
+
if (!results)
|
|
16
|
+
return 0;
|
|
17
|
+
return results[category].length;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Provides search state, navigation stack, and selection controls.
|
|
21
|
+
* Only active when the search panel is open.
|
|
22
|
+
*/
|
|
23
|
+
export function SearchProvider({ client, track, playTrackUri, refresh, children }) {
|
|
24
|
+
const { panelMode, setInputMode, toggleSearch } = usePanelMode();
|
|
25
|
+
const isActive = panelMode === 'search';
|
|
26
|
+
const { query, setQuery, results, isLoading: isSearchLoading, clear: clearSearch } = useSearch(client, isActive);
|
|
27
|
+
const { data: browseData, isLoading: isBrowseLoading, userPlaylists, isPlaylistsLoading, loadAlbum, loadPlaylist, loadUserPlaylists, clear: clearBrowse, } = useBrowse(client);
|
|
28
|
+
const [viewStack, setViewStack] = useState([]);
|
|
29
|
+
const [selectedCategory, setSelectedCategory] = useState('tracks');
|
|
30
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
31
|
+
const [browseSelectedIndex, setBrowseSelectedIndex] = useState(0);
|
|
32
|
+
// Stack of previous search states pushed by query refinements (e.g. selecting an artist
|
|
33
|
+
// replaces the query in place). Pressing Esc pops one entry to restore the prior query.
|
|
34
|
+
const [queryHistory, setQueryHistory] = useState([]);
|
|
35
|
+
const currentView = viewStack.length > 0
|
|
36
|
+
? viewStack[viewStack.length - 1]
|
|
37
|
+
: {
|
|
38
|
+
kind: 'input',
|
|
39
|
+
};
|
|
40
|
+
// Track activation transitions so we only auto-focus the input when the panel
|
|
41
|
+
// first opens — not every time the user pops back to the input view from a drill-down.
|
|
42
|
+
const wasActiveRef = useRef(isActive);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (isActive && !wasActiveRef.current) {
|
|
45
|
+
// Panel just opened: focus the text input only if we landed on the input view
|
|
46
|
+
// (opening via `b` lands directly on the playlists view → stay in browse mode)
|
|
47
|
+
setInputMode(currentView.kind === 'input');
|
|
48
|
+
}
|
|
49
|
+
else if (!isActive && wasActiveRef.current) {
|
|
50
|
+
// Panel just closed: reset all state
|
|
51
|
+
setViewStack([]);
|
|
52
|
+
setQueryHistory([]);
|
|
53
|
+
setSelectedCategory('tracks');
|
|
54
|
+
setSelectedIndex(0);
|
|
55
|
+
setBrowseSelectedIndex(0);
|
|
56
|
+
clearSearch();
|
|
57
|
+
clearBrowse();
|
|
58
|
+
}
|
|
59
|
+
wasActiveRef.current = isActive;
|
|
60
|
+
}, [
|
|
61
|
+
isActive,
|
|
62
|
+
currentView.kind,
|
|
63
|
+
setInputMode,
|
|
64
|
+
clearSearch,
|
|
65
|
+
clearBrowse,
|
|
66
|
+
]);
|
|
67
|
+
// Reset selected index when results change
|
|
68
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: results is intentionally a trigger
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
setSelectedIndex(0);
|
|
71
|
+
}, [
|
|
72
|
+
results,
|
|
73
|
+
]);
|
|
74
|
+
const navigateUp = useCallback(() => {
|
|
75
|
+
if (currentView.kind === 'input') {
|
|
76
|
+
setSelectedIndex((v) => Math.max(0, v - 1));
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
setBrowseSelectedIndex((v) => Math.max(0, v - 1));
|
|
80
|
+
}
|
|
81
|
+
}, [
|
|
82
|
+
currentView.kind,
|
|
83
|
+
]);
|
|
84
|
+
const navigateDown = useCallback(() => {
|
|
85
|
+
if (currentView.kind === 'input') {
|
|
86
|
+
const max = getCategoryItems(results, selectedCategory) - 1;
|
|
87
|
+
setSelectedIndex((v) => Math.min(Math.max(max, 0), v + 1));
|
|
88
|
+
}
|
|
89
|
+
else if (currentView.kind === 'playlists') {
|
|
90
|
+
const max = (userPlaylists?.length ?? 1) - 1;
|
|
91
|
+
setBrowseSelectedIndex((v) => Math.min(Math.max(max, 0), v + 1));
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const max = (browseData?.tracks.length ?? 1) - 1;
|
|
95
|
+
setBrowseSelectedIndex((v) => Math.min(Math.max(max, 0), v + 1));
|
|
96
|
+
}
|
|
97
|
+
}, [
|
|
98
|
+
currentView.kind,
|
|
99
|
+
results,
|
|
100
|
+
selectedCategory,
|
|
101
|
+
browseData,
|
|
102
|
+
userPlaylists,
|
|
103
|
+
]);
|
|
104
|
+
const navigateLeft = useCallback(() => {
|
|
105
|
+
if (currentView.kind === 'input') {
|
|
106
|
+
setSelectedCategory((cat) => {
|
|
107
|
+
const idx = CATEGORIES.indexOf(cat);
|
|
108
|
+
return CATEGORIES[(idx - 1 + CATEGORIES.length) % CATEGORIES.length];
|
|
109
|
+
});
|
|
110
|
+
setSelectedIndex(0);
|
|
111
|
+
}
|
|
112
|
+
}, [
|
|
113
|
+
currentView.kind,
|
|
114
|
+
]);
|
|
115
|
+
const navigateRight = useCallback(() => {
|
|
116
|
+
if (currentView.kind === 'input') {
|
|
117
|
+
setSelectedCategory((cat) => {
|
|
118
|
+
const idx = CATEGORIES.indexOf(cat);
|
|
119
|
+
return CATEGORIES[(idx + 1) % CATEGORIES.length];
|
|
120
|
+
});
|
|
121
|
+
setSelectedIndex(0);
|
|
122
|
+
}
|
|
123
|
+
}, [
|
|
124
|
+
currentView.kind,
|
|
125
|
+
]);
|
|
126
|
+
const pushView = useCallback((view) => {
|
|
127
|
+
setViewStack((v) => [
|
|
128
|
+
...v,
|
|
129
|
+
view,
|
|
130
|
+
]);
|
|
131
|
+
setBrowseSelectedIndex(0);
|
|
132
|
+
}, []);
|
|
133
|
+
const popView = useCallback(() => {
|
|
134
|
+
if (viewStack.length === 0)
|
|
135
|
+
return;
|
|
136
|
+
const nextStack = viewStack.slice(0, -1);
|
|
137
|
+
setViewStack(nextStack);
|
|
138
|
+
setBrowseSelectedIndex(0);
|
|
139
|
+
clearBrowse();
|
|
140
|
+
// When returning to the input view, focus the input only if there's nothing
|
|
141
|
+
// left to browse (e.g. opened via `b`, drilled in, popped all the way back).
|
|
142
|
+
// If results exist, stay in browse mode so the user can keep navigating them.
|
|
143
|
+
if (nextStack.length === 0 && results === null) {
|
|
144
|
+
setInputMode(true);
|
|
145
|
+
}
|
|
146
|
+
}, [
|
|
147
|
+
viewStack,
|
|
148
|
+
clearBrowse,
|
|
149
|
+
results,
|
|
150
|
+
setInputMode,
|
|
151
|
+
]);
|
|
152
|
+
const selectSearchResult = useCallback(async () => {
|
|
153
|
+
if (!results)
|
|
154
|
+
return;
|
|
155
|
+
const items = results[selectedCategory];
|
|
156
|
+
const item = items[selectedIndex];
|
|
157
|
+
if (!item)
|
|
158
|
+
return;
|
|
159
|
+
switch (selectedCategory) {
|
|
160
|
+
case 'tracks': {
|
|
161
|
+
const t = item;
|
|
162
|
+
const deviceId = track?.deviceId;
|
|
163
|
+
if (!deviceId)
|
|
164
|
+
return;
|
|
165
|
+
if (t.albumUri) {
|
|
166
|
+
await playInContext(client, deviceId, t.albumUri, t.uri);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
await playTrackUri(t.uri);
|
|
170
|
+
}
|
|
171
|
+
await refresh();
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case 'albums': {
|
|
175
|
+
const a = item;
|
|
176
|
+
pushView({
|
|
177
|
+
kind: 'album',
|
|
178
|
+
id: a.id,
|
|
179
|
+
name: a.name,
|
|
180
|
+
});
|
|
181
|
+
loadAlbum(a.id);
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case 'playlists': {
|
|
185
|
+
const p = item;
|
|
186
|
+
pushView({
|
|
187
|
+
kind: 'playlist',
|
|
188
|
+
id: p.id,
|
|
189
|
+
name: p.name,
|
|
190
|
+
});
|
|
191
|
+
loadPlaylist(p.id);
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
case 'artists': {
|
|
195
|
+
const ar = item;
|
|
196
|
+
// Save current search state so Esc can restore it
|
|
197
|
+
setQueryHistory((h) => [
|
|
198
|
+
...h,
|
|
199
|
+
{
|
|
200
|
+
query,
|
|
201
|
+
category: selectedCategory,
|
|
202
|
+
},
|
|
203
|
+
]);
|
|
204
|
+
setQuery(ar.name);
|
|
205
|
+
setSelectedCategory('albums');
|
|
206
|
+
setSelectedIndex(0);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}, [
|
|
211
|
+
results,
|
|
212
|
+
selectedCategory,
|
|
213
|
+
selectedIndex,
|
|
214
|
+
track,
|
|
215
|
+
client,
|
|
216
|
+
playTrackUri,
|
|
217
|
+
refresh,
|
|
218
|
+
setQuery,
|
|
219
|
+
query,
|
|
220
|
+
pushView,
|
|
221
|
+
loadAlbum,
|
|
222
|
+
loadPlaylist,
|
|
223
|
+
]);
|
|
224
|
+
const selectBrowseTrack = useCallback(async () => {
|
|
225
|
+
const trackItem = browseData?.tracks[browseSelectedIndex];
|
|
226
|
+
const deviceId = track?.deviceId;
|
|
227
|
+
if (!trackItem || !deviceId || !browseData)
|
|
228
|
+
return;
|
|
229
|
+
await playInContext(client, deviceId, browseData.contextUri, trackItem.uri);
|
|
230
|
+
await refresh();
|
|
231
|
+
}, [
|
|
232
|
+
browseData,
|
|
233
|
+
browseSelectedIndex,
|
|
234
|
+
track,
|
|
235
|
+
client,
|
|
236
|
+
refresh,
|
|
237
|
+
]);
|
|
238
|
+
const selectPlaylist = useCallback(() => {
|
|
239
|
+
const playlist = userPlaylists?.[browseSelectedIndex];
|
|
240
|
+
if (!playlist)
|
|
241
|
+
return;
|
|
242
|
+
pushView({
|
|
243
|
+
kind: 'playlist',
|
|
244
|
+
id: playlist.id,
|
|
245
|
+
name: playlist.name,
|
|
246
|
+
});
|
|
247
|
+
loadPlaylist(playlist.id);
|
|
248
|
+
}, [
|
|
249
|
+
userPlaylists,
|
|
250
|
+
browseSelectedIndex,
|
|
251
|
+
pushView,
|
|
252
|
+
loadPlaylist,
|
|
253
|
+
]);
|
|
254
|
+
const select = useCallback(async () => {
|
|
255
|
+
switch (currentView.kind) {
|
|
256
|
+
case 'input':
|
|
257
|
+
await selectSearchResult();
|
|
258
|
+
break;
|
|
259
|
+
case 'album':
|
|
260
|
+
case 'playlist':
|
|
261
|
+
await selectBrowseTrack();
|
|
262
|
+
break;
|
|
263
|
+
case 'playlists':
|
|
264
|
+
selectPlaylist();
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}, [
|
|
268
|
+
currentView.kind,
|
|
269
|
+
selectSearchResult,
|
|
270
|
+
selectBrowseTrack,
|
|
271
|
+
selectPlaylist,
|
|
272
|
+
]);
|
|
273
|
+
const doBrowseUserPlaylists = useCallback(() => {
|
|
274
|
+
if (!isActive)
|
|
275
|
+
toggleSearch();
|
|
276
|
+
pushView({
|
|
277
|
+
kind: 'playlists',
|
|
278
|
+
});
|
|
279
|
+
loadUserPlaylists();
|
|
280
|
+
}, [
|
|
281
|
+
isActive,
|
|
282
|
+
toggleSearch,
|
|
283
|
+
pushView,
|
|
284
|
+
loadUserPlaylists,
|
|
285
|
+
]);
|
|
286
|
+
const goBack = useCallback(() => {
|
|
287
|
+
if (viewStack.length > 0) {
|
|
288
|
+
popView();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (queryHistory.length > 0) {
|
|
292
|
+
// Restore the previous search state (used after artist refinement)
|
|
293
|
+
const prev = queryHistory[queryHistory.length - 1];
|
|
294
|
+
setQueryHistory((h) => h.slice(0, -1));
|
|
295
|
+
setQuery(prev.query);
|
|
296
|
+
setSelectedCategory(prev.category);
|
|
297
|
+
setSelectedIndex(0);
|
|
298
|
+
}
|
|
299
|
+
}, [
|
|
300
|
+
viewStack.length,
|
|
301
|
+
popView,
|
|
302
|
+
queryHistory,
|
|
303
|
+
setQuery,
|
|
304
|
+
]);
|
|
305
|
+
const value = {
|
|
306
|
+
query,
|
|
307
|
+
results,
|
|
308
|
+
isSearchLoading,
|
|
309
|
+
currentView,
|
|
310
|
+
canGoBack: viewStack.length > 0 || queryHistory.length > 0,
|
|
311
|
+
browseData,
|
|
312
|
+
isBrowseLoading,
|
|
313
|
+
userPlaylists,
|
|
314
|
+
isPlaylistsLoading,
|
|
315
|
+
selectedCategory,
|
|
316
|
+
selectedIndex: currentView.kind === 'input' ? selectedIndex : browseSelectedIndex,
|
|
317
|
+
currentTrackUri: track?.uri ?? null,
|
|
318
|
+
currentContextUri: track?.context?.uri ?? null,
|
|
319
|
+
setQuery: isActive ? setQuery : undefined,
|
|
320
|
+
navigateUp: isActive ? navigateUp : undefined,
|
|
321
|
+
navigateDown: isActive ? navigateDown : undefined,
|
|
322
|
+
navigateLeft: isActive ? navigateLeft : undefined,
|
|
323
|
+
navigateRight: isActive ? navigateRight : undefined,
|
|
324
|
+
select: isActive ? select : undefined,
|
|
325
|
+
goBack: isActive ? goBack : undefined,
|
|
326
|
+
browseUserPlaylists: doBrowseUserPlaylists,
|
|
327
|
+
};
|
|
328
|
+
return _jsx(SearchContext, { value: value, children: children });
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Access search state and navigation controls.
|
|
332
|
+
*/
|
|
333
|
+
export function useSearchContext() {
|
|
334
|
+
const ctx = useContext(SearchContext);
|
|
335
|
+
if (!ctx)
|
|
336
|
+
throw new Error('useSearchContext must be used within SearchProvider');
|
|
337
|
+
return ctx;
|
|
338
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches an album art image and converts it to a terminal-renderable ANSI string.
|
|
3
|
+
* Caches by URL to avoid re-fetching on every render cycle.
|
|
4
|
+
* @param imageUrl - URL of the album art image, or null if unavailable
|
|
5
|
+
* @param height - Desired height in terminal rows
|
|
6
|
+
* @returns The ANSI art string and loading state
|
|
7
|
+
*/
|
|
8
|
+
export declare function useAlbumArt(imageUrl: string | null, height: number): string | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import terminalImage from 'terminal-image';
|
|
3
|
+
/**
|
|
4
|
+
* Fetches an album art image and converts it to a terminal-renderable ANSI string.
|
|
5
|
+
* Caches by URL to avoid re-fetching on every render cycle.
|
|
6
|
+
* @param imageUrl - URL of the album art image, or null if unavailable
|
|
7
|
+
* @param height - Desired height in terminal rows
|
|
8
|
+
* @returns The ANSI art string and loading state
|
|
9
|
+
*/
|
|
10
|
+
export function useAlbumArt(imageUrl, height) {
|
|
11
|
+
const [art, setArt] = useState(null);
|
|
12
|
+
const cacheRef = useRef(null);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!imageUrl) {
|
|
15
|
+
setArt(null);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (cacheRef.current?.url === imageUrl && cacheRef.current.height === height) {
|
|
19
|
+
setArt(cacheRef.current.art);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
async function load() {
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetch(imageUrl);
|
|
26
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
27
|
+
const rendered = await terminalImage.buffer(new Uint8Array(arrayBuffer), {
|
|
28
|
+
height,
|
|
29
|
+
preserveAspectRatio: true,
|
|
30
|
+
preferNativeRender: false,
|
|
31
|
+
});
|
|
32
|
+
if (!cancelled) {
|
|
33
|
+
cacheRef.current = {
|
|
34
|
+
url: imageUrl,
|
|
35
|
+
height,
|
|
36
|
+
art: rendered,
|
|
37
|
+
};
|
|
38
|
+
setArt(rendered);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
if (!cancelled) {
|
|
43
|
+
setArt(null);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
load();
|
|
48
|
+
return () => {
|
|
49
|
+
cancelled = true;
|
|
50
|
+
};
|
|
51
|
+
}, [
|
|
52
|
+
imageUrl,
|
|
53
|
+
height,
|
|
54
|
+
]);
|
|
55
|
+
return art;
|
|
56
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SpotifyApi } from '@spotify/web-api-ts-sdk';
|
|
2
|
+
import type { SearchResultPlaylist } from '../spotify/search.js';
|
|
3
|
+
export type BrowseData = {
|
|
4
|
+
kind: 'album' | 'playlist';
|
|
5
|
+
title: string;
|
|
6
|
+
subtitle: string | null;
|
|
7
|
+
contextUri: string;
|
|
8
|
+
tracks: Array<{
|
|
9
|
+
name: string;
|
|
10
|
+
artist: string;
|
|
11
|
+
uri: string;
|
|
12
|
+
durationMs: number;
|
|
13
|
+
}>;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Manages drill-down browse state for albums, playlists, and user playlist library.
|
|
17
|
+
* @param client - Authenticated Spotify API client
|
|
18
|
+
* @returns Browse state and load actions
|
|
19
|
+
*/
|
|
20
|
+
export declare function useBrowse(client: SpotifyApi): {
|
|
21
|
+
data: BrowseData | null;
|
|
22
|
+
isLoading: boolean;
|
|
23
|
+
userPlaylists: SearchResultPlaylist[] | null;
|
|
24
|
+
isPlaylistsLoading: boolean;
|
|
25
|
+
loadAlbum: (albumId: string) => Promise<void>;
|
|
26
|
+
loadPlaylist: (playlistId: string) => Promise<void>;
|
|
27
|
+
loadUserPlaylists: () => Promise<void>;
|
|
28
|
+
clear: () => void;
|
|
29
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import { fetchAlbumTracks, fetchPlaylistTracks, fetchUserPlaylists } from '../spotify/search.js';
|
|
3
|
+
/**
|
|
4
|
+
* Manages drill-down browse state for albums, playlists, and user playlist library.
|
|
5
|
+
* @param client - Authenticated Spotify API client
|
|
6
|
+
* @returns Browse state and load actions
|
|
7
|
+
*/
|
|
8
|
+
export function useBrowse(client) {
|
|
9
|
+
const [data, setData] = useState(null);
|
|
10
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
11
|
+
const [userPlaylists, setUserPlaylists] = useState(null);
|
|
12
|
+
const [isPlaylistsLoading, setIsPlaylistsLoading] = useState(false);
|
|
13
|
+
const cancelRef = useRef(0);
|
|
14
|
+
const loadAlbum = useCallback(async (albumId) => {
|
|
15
|
+
const token = ++cancelRef.current;
|
|
16
|
+
setIsLoading(true);
|
|
17
|
+
try {
|
|
18
|
+
const album = await fetchAlbumTracks(client, albumId);
|
|
19
|
+
if (cancelRef.current !== token)
|
|
20
|
+
return;
|
|
21
|
+
setData({
|
|
22
|
+
kind: 'album',
|
|
23
|
+
title: album.name,
|
|
24
|
+
subtitle: album.artist,
|
|
25
|
+
contextUri: album.uri,
|
|
26
|
+
tracks: album.tracks,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
if (cancelRef.current !== token)
|
|
31
|
+
return;
|
|
32
|
+
setData(null);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
if (cancelRef.current === token)
|
|
36
|
+
setIsLoading(false);
|
|
37
|
+
}
|
|
38
|
+
}, [
|
|
39
|
+
client,
|
|
40
|
+
]);
|
|
41
|
+
const loadPlaylist = useCallback(async (playlistId) => {
|
|
42
|
+
const token = ++cancelRef.current;
|
|
43
|
+
setIsLoading(true);
|
|
44
|
+
try {
|
|
45
|
+
const playlist = await fetchPlaylistTracks(client, playlistId);
|
|
46
|
+
if (cancelRef.current !== token)
|
|
47
|
+
return;
|
|
48
|
+
setData({
|
|
49
|
+
kind: 'playlist',
|
|
50
|
+
title: playlist.name,
|
|
51
|
+
subtitle: playlist.owner,
|
|
52
|
+
contextUri: playlist.uri,
|
|
53
|
+
tracks: playlist.tracks,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
if (cancelRef.current !== token)
|
|
58
|
+
return;
|
|
59
|
+
setData(null);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
if (cancelRef.current === token)
|
|
63
|
+
setIsLoading(false);
|
|
64
|
+
}
|
|
65
|
+
}, [
|
|
66
|
+
client,
|
|
67
|
+
]);
|
|
68
|
+
const loadUserPlaylists = useCallback(async () => {
|
|
69
|
+
setIsPlaylistsLoading(true);
|
|
70
|
+
try {
|
|
71
|
+
const result = await fetchUserPlaylists(client);
|
|
72
|
+
setUserPlaylists(result.items);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
setUserPlaylists(null);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
setIsPlaylistsLoading(false);
|
|
79
|
+
}
|
|
80
|
+
}, [
|
|
81
|
+
client,
|
|
82
|
+
]);
|
|
83
|
+
const clear = useCallback(() => {
|
|
84
|
+
cancelRef.current++;
|
|
85
|
+
setData(null);
|
|
86
|
+
setIsLoading(false);
|
|
87
|
+
}, []);
|
|
88
|
+
return {
|
|
89
|
+
data,
|
|
90
|
+
isLoading,
|
|
91
|
+
userPlaylists,
|
|
92
|
+
isPlaylistsLoading,
|
|
93
|
+
loadAlbum,
|
|
94
|
+
loadPlaylist,
|
|
95
|
+
loadUserPlaylists,
|
|
96
|
+
clear,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Lyrics } from '../spotify/lyrics.js';
|
|
2
|
+
import type { TrackInfo } from '../spotify/playback.js';
|
|
3
|
+
/**
|
|
4
|
+
* Fetches and caches lyrics for the currently playing track.
|
|
5
|
+
* Refetches when the track name or artist changes.
|
|
6
|
+
* @param track - Current track info, or null if nothing is playing
|
|
7
|
+
* @returns Lyrics data and loading state
|
|
8
|
+
*/
|
|
9
|
+
export declare function useLyrics(track: TrackInfo | null): {
|
|
10
|
+
lyrics: Lyrics | null;
|
|
11
|
+
isLoading: boolean;
|
|
12
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { fetchLyrics } from '../spotify/lyrics.js';
|
|
3
|
+
/**
|
|
4
|
+
* Fetches and caches lyrics for the currently playing track.
|
|
5
|
+
* Refetches when the track name or artist changes.
|
|
6
|
+
* @param track - Current track info, or null if nothing is playing
|
|
7
|
+
* @returns Lyrics data and loading state
|
|
8
|
+
*/
|
|
9
|
+
export function useLyrics(track) {
|
|
10
|
+
const [lyrics, setLyrics] = useState(null);
|
|
11
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
12
|
+
const cacheRef = useRef(null);
|
|
13
|
+
const trackKey = track ? `${track.name}::${track.artist}` : null;
|
|
14
|
+
const trackRef = useRef(track);
|
|
15
|
+
trackRef.current = track;
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!trackKey) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (cacheRef.current?.key === trackKey) {
|
|
21
|
+
setLyrics(cacheRef.current.lyrics);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
let cancelled = false;
|
|
25
|
+
setIsLoading(true);
|
|
26
|
+
async function load() {
|
|
27
|
+
const current = trackRef.current;
|
|
28
|
+
if (!current)
|
|
29
|
+
return;
|
|
30
|
+
const result = await fetchLyrics(current.name, current.artist, current.durationMs);
|
|
31
|
+
if (!cancelled) {
|
|
32
|
+
cacheRef.current = {
|
|
33
|
+
key: trackKey,
|
|
34
|
+
lyrics: result,
|
|
35
|
+
};
|
|
36
|
+
setLyrics(result);
|
|
37
|
+
setIsLoading(false);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
load();
|
|
41
|
+
return () => {
|
|
42
|
+
cancelled = true;
|
|
43
|
+
};
|
|
44
|
+
}, [
|
|
45
|
+
trackKey,
|
|
46
|
+
]);
|
|
47
|
+
return {
|
|
48
|
+
lyrics,
|
|
49
|
+
isLoading,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SpotifyApi } from '@spotify/web-api-ts-sdk';
|
|
2
|
+
import type { TrackInfo } from '../spotify/playback.js';
|
|
3
|
+
/**
|
|
4
|
+
* React hook that manages Spotify playback state. Polls the current track
|
|
5
|
+
* at a regular interval and exposes toggle/refresh controls.
|
|
6
|
+
* @param client - Authenticated Spotify API client
|
|
7
|
+
* @returns Playback state, loading/error indicators, and control functions
|
|
8
|
+
*/
|
|
9
|
+
export declare function usePlayback(client: SpotifyApi): {
|
|
10
|
+
track: TrackInfo | null;
|
|
11
|
+
isLoading: boolean;
|
|
12
|
+
error: string | null;
|
|
13
|
+
toggle: () => Promise<void>;
|
|
14
|
+
next: () => Promise<void>;
|
|
15
|
+
previous: () => Promise<void>;
|
|
16
|
+
seekForward: () => Promise<void>;
|
|
17
|
+
seekBackward: () => Promise<void>;
|
|
18
|
+
volumeUp: () => Promise<void>;
|
|
19
|
+
volumeDown: () => Promise<void>;
|
|
20
|
+
toggleShuffleMode: () => Promise<void>;
|
|
21
|
+
cycleRepeat: () => Promise<void>;
|
|
22
|
+
playTrackUri: (uri: string) => Promise<void>;
|
|
23
|
+
refresh: () => Promise<void>;
|
|
24
|
+
};
|