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.
Files changed (78) hide show
  1. package/README.md +82 -0
  2. package/dist/app.d.ts +5 -0
  3. package/dist/app.js +66 -0
  4. package/dist/components/animated-line.d.ts +12 -0
  5. package/dist/components/animated-line.js +14 -0
  6. package/dist/components/browse-detail-view.d.ts +14 -0
  7. package/dist/components/browse-detail-view.js +31 -0
  8. package/dist/components/keyboard-hints.d.ts +12 -0
  9. package/dist/components/keyboard-hints.js +8 -0
  10. package/dist/components/lyrics-panel.d.ts +10 -0
  11. package/dist/components/lyrics-panel.js +38 -0
  12. package/dist/components/lyrics-view.d.ts +14 -0
  13. package/dist/components/lyrics-view.js +50 -0
  14. package/dist/components/panel-content.d.ts +10 -0
  15. package/dist/components/panel-content.js +22 -0
  16. package/dist/components/playback-status.d.ts +16 -0
  17. package/dist/components/playback-status.js +22 -0
  18. package/dist/components/player.d.ts +9 -0
  19. package/dist/components/player.d.ts.map +1 -0
  20. package/dist/components/player.js +215 -0
  21. package/dist/components/player.js.map +1 -0
  22. package/dist/components/progress-bar.d.ts +11 -0
  23. package/dist/components/progress-bar.js +13 -0
  24. package/dist/components/queue-view.d.ts +9 -0
  25. package/dist/components/queue-view.js +54 -0
  26. package/dist/components/search-panel.d.ts +8 -0
  27. package/dist/components/search-panel.js +152 -0
  28. package/dist/components/shimmer.d.ts +12 -0
  29. package/dist/components/shimmer.js +34 -0
  30. package/dist/components/side-panel.d.ts +12 -0
  31. package/dist/components/side-panel.js +12 -0
  32. package/dist/components/track-info-skeleton.d.ts +9 -0
  33. package/dist/components/track-info-skeleton.js +10 -0
  34. package/dist/components/track-info.d.ts +10 -0
  35. package/dist/components/track-info.js +15 -0
  36. package/dist/config.d.ts +33 -0
  37. package/dist/config.d.ts.map +1 -0
  38. package/dist/config.js +65 -0
  39. package/dist/config.js.map +1 -0
  40. package/dist/contexts/lyrics-context.d.ts +29 -0
  41. package/dist/contexts/lyrics-context.js +44 -0
  42. package/dist/contexts/panel-mode-context.d.ts +24 -0
  43. package/dist/contexts/panel-mode-context.js +45 -0
  44. package/dist/contexts/queue-context.d.ts +32 -0
  45. package/dist/contexts/queue-context.js +68 -0
  46. package/dist/contexts/search-context.d.ts +59 -0
  47. package/dist/contexts/search-context.js +338 -0
  48. package/dist/hooks/use-album-art.d.ts +8 -0
  49. package/dist/hooks/use-album-art.js +56 -0
  50. package/dist/hooks/use-browse.d.ts +29 -0
  51. package/dist/hooks/use-browse.js +98 -0
  52. package/dist/hooks/use-lyrics.d.ts +12 -0
  53. package/dist/hooks/use-lyrics.js +51 -0
  54. package/dist/hooks/use-playback.d.ts +24 -0
  55. package/dist/hooks/use-playback.js +282 -0
  56. package/dist/hooks/use-player-input.d.ts +18 -0
  57. package/dist/hooks/use-player-input.js +201 -0
  58. package/dist/hooks/use-queue.d.ts +28 -0
  59. package/dist/hooks/use-queue.js +194 -0
  60. package/dist/hooks/use-search.d.ts +16 -0
  61. package/dist/hooks/use-search.js +77 -0
  62. package/dist/index.d.ts +3 -0
  63. package/dist/index.js +10 -0
  64. package/dist/main.d.ts +1 -0
  65. package/dist/main.js +6 -0
  66. package/dist/spotify/auth.d.ts +36 -0
  67. package/dist/spotify/auth.js +183 -0
  68. package/dist/spotify/client.d.ts +18 -0
  69. package/dist/spotify/client.js +48 -0
  70. package/dist/spotify/fetch-with-retry.d.ts +6 -0
  71. package/dist/spotify/fetch-with-retry.js +70 -0
  72. package/dist/spotify/lyrics.d.ts +25 -0
  73. package/dist/spotify/lyrics.js +130 -0
  74. package/dist/spotify/playback.d.ts +115 -0
  75. package/dist/spotify/playback.js +201 -0
  76. package/dist/spotify/search.d.ts +79 -0
  77. package/dist/spotify/search.js +143 -0
  78. 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
+ };