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,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
+ };