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,282 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { POLL_INTERVAL_MS, SEEK_STEP_MS, TICK_INTERVAL_MS, VOLUME_STEP } from '../config.js';
|
|
3
|
+
import { getCurrentTrackInfo, playUri, seek, setRepeat, setShuffle, setVolume, skipNext as skipNextTrack, skipPrevious as skipPreviousTrack, togglePlayback, } from '../spotify/playback.js';
|
|
4
|
+
const NEXT_REPEAT = {
|
|
5
|
+
off: 'context',
|
|
6
|
+
context: 'track',
|
|
7
|
+
track: 'off',
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* React hook that manages Spotify playback state. Polls the current track
|
|
11
|
+
* at a regular interval and exposes toggle/refresh controls.
|
|
12
|
+
* @param client - Authenticated Spotify API client
|
|
13
|
+
* @returns Playback state, loading/error indicators, and control functions
|
|
14
|
+
*/
|
|
15
|
+
export function usePlayback(client) {
|
|
16
|
+
const [track, setTrack] = useState(null);
|
|
17
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
18
|
+
const [error, setError] = useState(null);
|
|
19
|
+
// Wall clock timestamp when we last received progress from the API
|
|
20
|
+
const syncRef = useRef(null);
|
|
21
|
+
const refresh = useCallback(async () => {
|
|
22
|
+
try {
|
|
23
|
+
const info = await getCurrentTrackInfo(client);
|
|
24
|
+
if (info) {
|
|
25
|
+
syncRef.current = {
|
|
26
|
+
progressMs: info.progressMs,
|
|
27
|
+
timestamp: Date.now(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
setTrack(info);
|
|
31
|
+
setError(null);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
setError(err instanceof Error ? err.message : 'Failed to fetch playback state');
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
setIsLoading(false);
|
|
38
|
+
}
|
|
39
|
+
}, [
|
|
40
|
+
client,
|
|
41
|
+
]);
|
|
42
|
+
const toggle = useCallback(async () => {
|
|
43
|
+
const prevTrack = track;
|
|
44
|
+
try {
|
|
45
|
+
if (track) {
|
|
46
|
+
setTrack({
|
|
47
|
+
...track,
|
|
48
|
+
isPlaying: !track.isPlaying,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
await togglePlayback(client);
|
|
52
|
+
await refresh();
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
setTrack(prevTrack);
|
|
56
|
+
setError(err instanceof Error ? err.message : 'Failed to toggle playback');
|
|
57
|
+
}
|
|
58
|
+
}, [
|
|
59
|
+
client,
|
|
60
|
+
track,
|
|
61
|
+
refresh,
|
|
62
|
+
]);
|
|
63
|
+
const next = useCallback(async () => {
|
|
64
|
+
const deviceId = track?.deviceId;
|
|
65
|
+
if (!deviceId) {
|
|
66
|
+
setError('No active device found.');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
setIsLoading(true);
|
|
71
|
+
await skipNextTrack(client, deviceId);
|
|
72
|
+
await refresh();
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
setIsLoading(false);
|
|
76
|
+
setError(err instanceof Error ? err.message : 'Failed to skip to next track');
|
|
77
|
+
}
|
|
78
|
+
}, [
|
|
79
|
+
client,
|
|
80
|
+
track,
|
|
81
|
+
refresh,
|
|
82
|
+
]);
|
|
83
|
+
const previous = useCallback(async () => {
|
|
84
|
+
const deviceId = track?.deviceId;
|
|
85
|
+
if (!deviceId) {
|
|
86
|
+
setError('No active device found.');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
setIsLoading(true);
|
|
91
|
+
await skipPreviousTrack(client, deviceId);
|
|
92
|
+
await refresh();
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
setIsLoading(false);
|
|
96
|
+
setError(err instanceof Error ? err.message : 'Failed to skip to previous track');
|
|
97
|
+
}
|
|
98
|
+
}, [
|
|
99
|
+
client,
|
|
100
|
+
track,
|
|
101
|
+
refresh,
|
|
102
|
+
]);
|
|
103
|
+
const seekForward = useCallback(async () => {
|
|
104
|
+
if (!track)
|
|
105
|
+
return;
|
|
106
|
+
const newProgress = Math.min(track.progressMs + SEEK_STEP_MS, track.durationMs);
|
|
107
|
+
syncRef.current = {
|
|
108
|
+
progressMs: newProgress,
|
|
109
|
+
timestamp: Date.now(),
|
|
110
|
+
};
|
|
111
|
+
setTrack({
|
|
112
|
+
...track,
|
|
113
|
+
progressMs: newProgress,
|
|
114
|
+
});
|
|
115
|
+
try {
|
|
116
|
+
await seek(client, newProgress, track.deviceId ?? undefined);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
setError(err instanceof Error ? err.message : 'Failed to seek');
|
|
120
|
+
}
|
|
121
|
+
}, [
|
|
122
|
+
client,
|
|
123
|
+
track,
|
|
124
|
+
]);
|
|
125
|
+
const seekBackward = useCallback(async () => {
|
|
126
|
+
if (!track)
|
|
127
|
+
return;
|
|
128
|
+
const newProgress = Math.max(track.progressMs - SEEK_STEP_MS, 0);
|
|
129
|
+
syncRef.current = {
|
|
130
|
+
progressMs: newProgress,
|
|
131
|
+
timestamp: Date.now(),
|
|
132
|
+
};
|
|
133
|
+
setTrack({
|
|
134
|
+
...track,
|
|
135
|
+
progressMs: newProgress,
|
|
136
|
+
});
|
|
137
|
+
try {
|
|
138
|
+
await seek(client, newProgress, track.deviceId ?? undefined);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
setError(err instanceof Error ? err.message : 'Failed to seek');
|
|
142
|
+
}
|
|
143
|
+
}, [
|
|
144
|
+
client,
|
|
145
|
+
track,
|
|
146
|
+
]);
|
|
147
|
+
const volumeUp = useCallback(async () => {
|
|
148
|
+
if (!track || track.volume == null)
|
|
149
|
+
return;
|
|
150
|
+
const newVolume = Math.min(track.volume + VOLUME_STEP, 100);
|
|
151
|
+
setTrack({
|
|
152
|
+
...track,
|
|
153
|
+
volume: newVolume,
|
|
154
|
+
});
|
|
155
|
+
try {
|
|
156
|
+
await setVolume(client, newVolume, track.deviceId ?? undefined);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
setError(err instanceof Error ? err.message : 'Failed to set volume');
|
|
160
|
+
}
|
|
161
|
+
}, [
|
|
162
|
+
client,
|
|
163
|
+
track,
|
|
164
|
+
]);
|
|
165
|
+
const volumeDown = useCallback(async () => {
|
|
166
|
+
if (!track || track.volume == null)
|
|
167
|
+
return;
|
|
168
|
+
const newVolume = Math.max(track.volume - VOLUME_STEP, 0);
|
|
169
|
+
setTrack({
|
|
170
|
+
...track,
|
|
171
|
+
volume: newVolume,
|
|
172
|
+
});
|
|
173
|
+
try {
|
|
174
|
+
await setVolume(client, newVolume, track.deviceId ?? undefined);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
setError(err instanceof Error ? err.message : 'Failed to set volume');
|
|
178
|
+
}
|
|
179
|
+
}, [
|
|
180
|
+
client,
|
|
181
|
+
track,
|
|
182
|
+
]);
|
|
183
|
+
const toggleShuffleMode = useCallback(async () => {
|
|
184
|
+
if (!track)
|
|
185
|
+
return;
|
|
186
|
+
const newShuffle = !track.shuffle;
|
|
187
|
+
setTrack({
|
|
188
|
+
...track,
|
|
189
|
+
shuffle: newShuffle,
|
|
190
|
+
});
|
|
191
|
+
try {
|
|
192
|
+
await setShuffle(client, newShuffle, track.deviceId ?? undefined);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
setError(err instanceof Error ? err.message : 'Failed to toggle shuffle');
|
|
196
|
+
}
|
|
197
|
+
}, [
|
|
198
|
+
client,
|
|
199
|
+
track,
|
|
200
|
+
]);
|
|
201
|
+
const cycleRepeat = useCallback(async () => {
|
|
202
|
+
if (!track)
|
|
203
|
+
return;
|
|
204
|
+
const nextMode = NEXT_REPEAT[track.repeat];
|
|
205
|
+
setTrack({
|
|
206
|
+
...track,
|
|
207
|
+
repeat: nextMode,
|
|
208
|
+
});
|
|
209
|
+
try {
|
|
210
|
+
await setRepeat(client, nextMode, track.deviceId ?? undefined);
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
setError(err instanceof Error ? err.message : 'Failed to set repeat mode');
|
|
214
|
+
}
|
|
215
|
+
}, [
|
|
216
|
+
client,
|
|
217
|
+
track,
|
|
218
|
+
]);
|
|
219
|
+
// Poll Spotify API for real state
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
refresh();
|
|
222
|
+
const id = setInterval(refresh, POLL_INTERVAL_MS);
|
|
223
|
+
return () => clearInterval(id);
|
|
224
|
+
}, [
|
|
225
|
+
refresh,
|
|
226
|
+
]);
|
|
227
|
+
// Locally interpolate progress using wall clock time while playing
|
|
228
|
+
const trackRef = useRef(track);
|
|
229
|
+
trackRef.current = track;
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (!track?.isPlaying || !syncRef.current) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const id = setInterval(() => {
|
|
235
|
+
const current = trackRef.current;
|
|
236
|
+
const sync = syncRef.current;
|
|
237
|
+
if (current?.isPlaying && sync) {
|
|
238
|
+
const elapsed = Date.now() - sync.timestamp;
|
|
239
|
+
const progressMs = Math.min(sync.progressMs + elapsed, current.durationMs);
|
|
240
|
+
setTrack({
|
|
241
|
+
...current,
|
|
242
|
+
progressMs,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}, TICK_INTERVAL_MS);
|
|
246
|
+
return () => clearInterval(id);
|
|
247
|
+
}, [
|
|
248
|
+
track?.isPlaying,
|
|
249
|
+
]);
|
|
250
|
+
const playTrackUri = useCallback(async (uri) => {
|
|
251
|
+
const deviceId = track?.deviceId;
|
|
252
|
+
if (!deviceId)
|
|
253
|
+
return;
|
|
254
|
+
try {
|
|
255
|
+
await playUri(client, deviceId, uri);
|
|
256
|
+
await refresh();
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
setError(err instanceof Error ? err.message : 'Failed to play track');
|
|
260
|
+
}
|
|
261
|
+
}, [
|
|
262
|
+
client,
|
|
263
|
+
track,
|
|
264
|
+
refresh,
|
|
265
|
+
]);
|
|
266
|
+
return {
|
|
267
|
+
track,
|
|
268
|
+
isLoading,
|
|
269
|
+
error,
|
|
270
|
+
toggle,
|
|
271
|
+
next,
|
|
272
|
+
previous,
|
|
273
|
+
seekForward,
|
|
274
|
+
seekBackward,
|
|
275
|
+
volumeUp,
|
|
276
|
+
volumeDown,
|
|
277
|
+
toggleShuffleMode,
|
|
278
|
+
cycleRepeat,
|
|
279
|
+
playTrackUri,
|
|
280
|
+
refresh,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type PlaybackActions = {
|
|
2
|
+
toggle: () => void;
|
|
3
|
+
next: () => void;
|
|
4
|
+
previous: () => void;
|
|
5
|
+
seekForward: () => void;
|
|
6
|
+
seekBackward: () => void;
|
|
7
|
+
volumeUp: () => void;
|
|
8
|
+
volumeDown: () => void;
|
|
9
|
+
toggleShuffleMode: () => void;
|
|
10
|
+
cycleRepeat: () => void;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Handles all keyboard input for the player.
|
|
14
|
+
* Reads panel state from contexts for panel-specific keybindings.
|
|
15
|
+
* Disables shortcuts when a text input is active (isInputMode).
|
|
16
|
+
*/
|
|
17
|
+
export declare function usePlayerInput(playback: PlaybackActions): void;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { useApp, useInput } from 'ink';
|
|
2
|
+
import { useLyricsContext } from '../contexts/lyrics-context.js';
|
|
3
|
+
import { usePanelMode } from '../contexts/panel-mode-context.js';
|
|
4
|
+
import { useQueueContext } from '../contexts/queue-context.js';
|
|
5
|
+
import { useSearchContext } from '../contexts/search-context.js';
|
|
6
|
+
/**
|
|
7
|
+
* Handles all keyboard input for the player.
|
|
8
|
+
* Reads panel state from contexts for panel-specific keybindings.
|
|
9
|
+
* Disables shortcuts when a text input is active (isInputMode).
|
|
10
|
+
*/
|
|
11
|
+
export function usePlayerInput(playback) {
|
|
12
|
+
const { exit } = useApp();
|
|
13
|
+
const { toggleLyrics, toggleQueue, toggleSearch, panelMode, isInputMode, setInputMode } = usePanelMode();
|
|
14
|
+
const { offsetUp, offsetDown, scrollUp, scrollDown, isPlainText } = useLyricsContext();
|
|
15
|
+
const { queueUp, queueDown, queueSelect, refresh: refreshQueue } = useQueueContext();
|
|
16
|
+
const { query, setQuery, navigateUp: searchUp, navigateDown: searchDown, navigateLeft: searchLeft, navigateRight: searchRight, select: searchSelect, goBack: searchGoBack, browseUserPlaylists, canGoBack: searchCanGoBack, } = useSearchContext();
|
|
17
|
+
const isQueueMode = panelMode === 'queue';
|
|
18
|
+
const isSearchMode = panelMode === 'search';
|
|
19
|
+
const isLyricsScrollMode = panelMode === 'lyrics' && isPlainText;
|
|
20
|
+
const handleSearchEscape = searchCanGoBack ? searchGoBack : toggleSearch;
|
|
21
|
+
const exitInputMode = () => {
|
|
22
|
+
if (query.trim().length > 0)
|
|
23
|
+
setInputMode(false);
|
|
24
|
+
};
|
|
25
|
+
// Text input handler for search mode
|
|
26
|
+
useInput((input, key) => {
|
|
27
|
+
if (key.escape) {
|
|
28
|
+
handleSearchEscape?.();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (key.return) {
|
|
32
|
+
exitInputMode();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (key.backspace || key.delete) {
|
|
36
|
+
setQuery?.(query.slice(0, -1));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (key.tab) {
|
|
40
|
+
exitInputMode();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Accept printable characters
|
|
44
|
+
if (input && !key.ctrl && !key.meta) {
|
|
45
|
+
setQuery?.(query + input);
|
|
46
|
+
}
|
|
47
|
+
}, {
|
|
48
|
+
isActive: isInputMode,
|
|
49
|
+
});
|
|
50
|
+
// Each panel mode overrides keybindings to handle only its own actions.
|
|
51
|
+
// `q` (quit) is the one shortcut always available across modes.
|
|
52
|
+
const isLyricsMode = panelMode === 'lyrics';
|
|
53
|
+
const { actions, arrowActions } = buildBindings({
|
|
54
|
+
panelMode,
|
|
55
|
+
isLyricsScrollMode,
|
|
56
|
+
exit,
|
|
57
|
+
playback,
|
|
58
|
+
toggleLyrics,
|
|
59
|
+
toggleQueue,
|
|
60
|
+
toggleSearch,
|
|
61
|
+
browseUserPlaylists,
|
|
62
|
+
refreshQueue,
|
|
63
|
+
search: {
|
|
64
|
+
up: searchUp,
|
|
65
|
+
down: searchDown,
|
|
66
|
+
left: searchLeft,
|
|
67
|
+
right: searchRight,
|
|
68
|
+
select: searchSelect,
|
|
69
|
+
},
|
|
70
|
+
queue: {
|
|
71
|
+
up: queueUp,
|
|
72
|
+
down: queueDown,
|
|
73
|
+
select: queueSelect,
|
|
74
|
+
},
|
|
75
|
+
lyrics: {
|
|
76
|
+
offsetUp,
|
|
77
|
+
offsetDown,
|
|
78
|
+
scrollUp,
|
|
79
|
+
scrollDown,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const handlePanelEscape = () => {
|
|
83
|
+
if (isSearchMode) {
|
|
84
|
+
if (searchCanGoBack) {
|
|
85
|
+
searchGoBack?.();
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
// Re-focus text input instead of closing the panel
|
|
89
|
+
setInputMode(true);
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (isQueueMode) {
|
|
94
|
+
toggleQueue();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (isLyricsMode) {
|
|
98
|
+
toggleLyrics();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
useInput((input, key) => {
|
|
102
|
+
if (key.escape && panelMode !== 'none') {
|
|
103
|
+
handlePanelEscape();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const charAction = actions[input];
|
|
107
|
+
if (charAction) {
|
|
108
|
+
charAction();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
for (const [arrow, action] of Object.entries(arrowActions)) {
|
|
112
|
+
if (key[arrow] && action) {
|
|
113
|
+
action();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}, {
|
|
118
|
+
isActive: !isInputMode,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Builds the keyboard bindings for the current panel mode.
|
|
123
|
+
* Each mode is isolated: only its own panel actions are bound (plus the global `q` quit).
|
|
124
|
+
* Playback shortcuts (space, n, p, s, r, seek, volume) are only active when no panel is open.
|
|
125
|
+
*/
|
|
126
|
+
function buildBindings(params) {
|
|
127
|
+
const { panelMode, isLyricsScrollMode, exit, playback, search, queue, lyrics } = params;
|
|
128
|
+
if (panelMode === 'search') {
|
|
129
|
+
return {
|
|
130
|
+
actions: {
|
|
131
|
+
q: exit,
|
|
132
|
+
'/': params.toggleSearch,
|
|
133
|
+
'\r': search.select,
|
|
134
|
+
},
|
|
135
|
+
arrowActions: {
|
|
136
|
+
upArrow: search.up,
|
|
137
|
+
downArrow: search.down,
|
|
138
|
+
leftArrow: search.left,
|
|
139
|
+
rightArrow: search.right,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (panelMode === 'queue') {
|
|
144
|
+
return {
|
|
145
|
+
actions: {
|
|
146
|
+
q: exit,
|
|
147
|
+
d: params.toggleQueue,
|
|
148
|
+
'\r': queue.select,
|
|
149
|
+
},
|
|
150
|
+
arrowActions: {
|
|
151
|
+
upArrow: queue.up,
|
|
152
|
+
downArrow: queue.down,
|
|
153
|
+
leftArrow: undefined,
|
|
154
|
+
rightArrow: undefined,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (panelMode === 'lyrics') {
|
|
159
|
+
return {
|
|
160
|
+
actions: {
|
|
161
|
+
q: exit,
|
|
162
|
+
l: params.toggleLyrics,
|
|
163
|
+
'+': lyrics.offsetUp,
|
|
164
|
+
'=': lyrics.offsetUp,
|
|
165
|
+
'-': lyrics.offsetDown,
|
|
166
|
+
},
|
|
167
|
+
arrowActions: {
|
|
168
|
+
upArrow: isLyricsScrollMode ? lyrics.scrollUp : undefined,
|
|
169
|
+
downArrow: isLyricsScrollMode ? lyrics.scrollDown : undefined,
|
|
170
|
+
leftArrow: undefined,
|
|
171
|
+
rightArrow: undefined,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
// Default mode (no panel): all playback shortcuts are active
|
|
176
|
+
return {
|
|
177
|
+
actions: {
|
|
178
|
+
q: exit,
|
|
179
|
+
' ': playback.toggle,
|
|
180
|
+
n: playback.next,
|
|
181
|
+
p: playback.previous,
|
|
182
|
+
'.': playback.seekForward,
|
|
183
|
+
',': playback.seekBackward,
|
|
184
|
+
s: () => {
|
|
185
|
+
playback.toggleShuffleMode();
|
|
186
|
+
params.refreshQueue();
|
|
187
|
+
},
|
|
188
|
+
r: playback.cycleRepeat,
|
|
189
|
+
l: params.toggleLyrics,
|
|
190
|
+
d: params.toggleQueue,
|
|
191
|
+
'/': params.toggleSearch,
|
|
192
|
+
b: params.browseUserPlaylists,
|
|
193
|
+
},
|
|
194
|
+
arrowActions: {
|
|
195
|
+
upArrow: playback.volumeUp,
|
|
196
|
+
downArrow: playback.volumeDown,
|
|
197
|
+
leftArrow: playback.seekBackward,
|
|
198
|
+
rightArrow: playback.seekForward,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SpotifyApi } from '@spotify/web-api-ts-sdk';
|
|
2
|
+
import type { PlaybackContext } from '../spotify/playback.js';
|
|
3
|
+
export type QueueItem = {
|
|
4
|
+
name: string;
|
|
5
|
+
artist: string;
|
|
6
|
+
durationMs: number;
|
|
7
|
+
uri: string;
|
|
8
|
+
isCurrent: boolean;
|
|
9
|
+
isPrevious: boolean;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Fetches the current playlist/album content and marks the currently playing track.
|
|
13
|
+
* Falls back to the Spotify queue API if no context is available.
|
|
14
|
+
* @param client - Authenticated Spotify API client
|
|
15
|
+
* @param enabled - Whether to fetch data
|
|
16
|
+
* @param trackKey - Current track identity for refresh triggers
|
|
17
|
+
* @param context - Current playback context (playlist/album)
|
|
18
|
+
* @param currentTrackUri - URI of the currently playing track
|
|
19
|
+
* @returns Queue items with current track marked, loading state, and current track index
|
|
20
|
+
*/
|
|
21
|
+
export declare function useQueue(client: SpotifyApi, enabled: boolean, context: PlaybackContext | null, currentTrackUri: string | null): {
|
|
22
|
+
queue: QueueItem[];
|
|
23
|
+
contextName: string | null;
|
|
24
|
+
contextSubtitle: string | null;
|
|
25
|
+
isLoading: boolean;
|
|
26
|
+
currentIndex: number;
|
|
27
|
+
refresh: () => Promise<void>;
|
|
28
|
+
};
|