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
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# morille
|
|
2
|
+
|
|
3
|
+
A terminal-based Spotify player built with TypeScript, ink (React for the terminal), and the Spotify Web API.
|
|
4
|
+
|
|
5
|
+
## Install & run
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx morille
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
That's it. morille ships with a default Spotify Client ID baked into the binary,
|
|
12
|
+
so the app works out of the box — no registration required for most users.
|
|
13
|
+
|
|
14
|
+
> Spotify Premium is required for playback control.
|
|
15
|
+
|
|
16
|
+
### Running from source
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pnpm install
|
|
20
|
+
pnpm build
|
|
21
|
+
node dist/index.js
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Using your own Client ID
|
|
25
|
+
|
|
26
|
+
If you want to use your own Spotify app (for development, higher rate limits,
|
|
27
|
+
or access to more users via [extended quota mode][quota]), override the
|
|
28
|
+
embedded default with an environment variable:
|
|
29
|
+
|
|
30
|
+
1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
|
|
31
|
+
2. Create a new app
|
|
32
|
+
3. In your app settings, add `http://127.0.0.1:8888/callback` as a Redirect URI
|
|
33
|
+
4. Copy the **Client ID** from your app's overview page
|
|
34
|
+
5. Export it before running morille:
|
|
35
|
+
```sh
|
|
36
|
+
export SPOTIFY_CLIENT_ID=your_client_id_here
|
|
37
|
+
npx morille
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`SPOTIFY_CLIENT_ID` always takes precedence over the embedded default. To
|
|
41
|
+
change the embedded default, edit `DEFAULT_SPOTIFY_CLIENT_ID` in `src/config.ts`
|
|
42
|
+
and rebuild.
|
|
43
|
+
|
|
44
|
+
[quota]: https://developer.spotify.com/documentation/web-api/concepts/quota-modes
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
- **Space** - toggle play/pause
|
|
49
|
+
- **n** / **Right arrow** - next track
|
|
50
|
+
- **p** / **Left arrow** - previous track
|
|
51
|
+
- **.** - seek forward 10s
|
|
52
|
+
- **,** - seek backward 10s
|
|
53
|
+
- **Up arrow** - volume up 5%
|
|
54
|
+
- **Down arrow** - volume down 5%
|
|
55
|
+
- **s** - toggle shuffle
|
|
56
|
+
- **r** - cycle repeat (off / context / track)
|
|
57
|
+
- **l** - toggle lyrics display
|
|
58
|
+
- **d** - toggle queue view (up/down to navigate, enter to select)
|
|
59
|
+
- **/** - search tracks, albums, artists, playlists (type to search, left/right to switch category, enter to select)
|
|
60
|
+
- **b** - browse your playlists (enter to view tracks, enter to play)
|
|
61
|
+
- **q** - quit
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
pnpm install
|
|
67
|
+
pnpm dev # watch mode
|
|
68
|
+
pnpm build # compile
|
|
69
|
+
pnpm test:ts # type-check
|
|
70
|
+
pnpm test:biome # lint/format check
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Roadmap
|
|
74
|
+
|
|
75
|
+
- Add track to queue
|
|
76
|
+
- Device selection / transfer playback
|
|
77
|
+
- ~~Search tracks, albums, artists, and playlists~~
|
|
78
|
+
- ~~Browse and play playlists~~
|
|
79
|
+
- ~~Browse and play albums~~
|
|
80
|
+
- Show recently played tracks
|
|
81
|
+
- Like / unlike tracks (save to library)
|
|
82
|
+
- Keyboard shortcuts customization
|
package/dist/app.d.ts
ADDED
package/dist/app.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { Player } from './components/player.js';
|
|
5
|
+
import { createSpotifyClient, getClientId } from './spotify/client.js';
|
|
6
|
+
/**
|
|
7
|
+
* Root application component. Handles Spotify authentication on mount,
|
|
8
|
+
* then renders the {@link Player} once connected.
|
|
9
|
+
*/
|
|
10
|
+
export function App() {
|
|
11
|
+
const [client, setClient] = useState(null);
|
|
12
|
+
const [authError, setAuthError] = useState(null);
|
|
13
|
+
const [authUrl, setAuthUrl] = useState(null);
|
|
14
|
+
const openBrowserRef = useRef(null);
|
|
15
|
+
const { exit } = useApp();
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
let cancelled = false;
|
|
18
|
+
async function init() {
|
|
19
|
+
try {
|
|
20
|
+
const clientId = getClientId();
|
|
21
|
+
const spotify = await createSpotifyClient(clientId, {
|
|
22
|
+
onAuthUrl: (url, openFn) => {
|
|
23
|
+
if (!cancelled) {
|
|
24
|
+
setAuthUrl(url);
|
|
25
|
+
openBrowserRef.current = openFn;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
if (!cancelled) {
|
|
30
|
+
setClient(spotify);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
if (!cancelled) {
|
|
35
|
+
setAuthError(err instanceof Error ? err.message : 'Authentication failed');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
init();
|
|
40
|
+
return () => {
|
|
41
|
+
cancelled = true;
|
|
42
|
+
};
|
|
43
|
+
}, []);
|
|
44
|
+
useInput((input) => {
|
|
45
|
+
if (input === 'o' && openBrowserRef.current) {
|
|
46
|
+
openBrowserRef.current();
|
|
47
|
+
}
|
|
48
|
+
}, {
|
|
49
|
+
isActive: !client && !!authUrl,
|
|
50
|
+
});
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (authError) {
|
|
53
|
+
exit();
|
|
54
|
+
}
|
|
55
|
+
}, [
|
|
56
|
+
authError,
|
|
57
|
+
exit,
|
|
58
|
+
]);
|
|
59
|
+
if (authError) {
|
|
60
|
+
return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "red", children: authError }) }));
|
|
61
|
+
}
|
|
62
|
+
if (!client) {
|
|
63
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { dimColor: true, children: "Authenticating with Spotify..." }), authUrl && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Open this URL in your browser, or press ", _jsx(Text, { bold: true, children: "o" }), " to open automatically:"] }), _jsx(Text, { color: "cyan", children: authUrl })] }))] }));
|
|
64
|
+
}
|
|
65
|
+
return _jsx(Player, { client: client });
|
|
66
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type AnimatedLineProps = {
|
|
2
|
+
text: string;
|
|
3
|
+
progress: number;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Renders a text line with a karaoke-style sweep animation.
|
|
7
|
+
* Characters that have "passed" are dark green, upcoming characters are bright green.
|
|
8
|
+
* @param text - The lyric line text
|
|
9
|
+
* @param progress - Fraction of the line elapsed, from 0 to 1
|
|
10
|
+
*/
|
|
11
|
+
export declare function AnimatedLine({ text, progress }: AnimatedLineProps): import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
/**
|
|
4
|
+
* Renders a text line with a karaoke-style sweep animation.
|
|
5
|
+
* Characters that have "passed" are dark green, upcoming characters are bright green.
|
|
6
|
+
* @param text - The lyric line text
|
|
7
|
+
* @param progress - Fraction of the line elapsed, from 0 to 1
|
|
8
|
+
*/
|
|
9
|
+
export function AnimatedLine({ text, progress }) {
|
|
10
|
+
const splitIndex = Math.round(Math.min(Math.max(progress, 0), 1) * text.length);
|
|
11
|
+
const passed = text.slice(0, splitIndex);
|
|
12
|
+
const upcoming = text.slice(splitIndex);
|
|
13
|
+
return (_jsxs(Text, { bold: true, children: [_jsx(Text, { color: "#0f570f", children: passed }), _jsx(Text, { color: "#0cb80c", children: upcoming })] }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { BrowseData } from '../hooks/use-browse.js';
|
|
2
|
+
type BrowseDetailViewProps = {
|
|
3
|
+
data: BrowseData;
|
|
4
|
+
selectedIndex: number;
|
|
5
|
+
currentTrackUri: string | null;
|
|
6
|
+
height: number;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Renders album or playlist track lists with a scrolling selection window.
|
|
10
|
+
* Reuses the windowing pattern from queue-view for consistent navigation.
|
|
11
|
+
* Highlights the currently playing track in green with a play icon.
|
|
12
|
+
*/
|
|
13
|
+
export declare function BrowseDetailView({ data, selectedIndex, currentTrackUri, height }: BrowseDetailViewProps): import("react/jsx-runtime").JSX.Element;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
/**
|
|
4
|
+
* Renders album or playlist track lists with a scrolling selection window.
|
|
5
|
+
* Reuses the windowing pattern from queue-view for consistent navigation.
|
|
6
|
+
* Highlights the currently playing track in green with a play icon.
|
|
7
|
+
*/
|
|
8
|
+
export function BrowseDetailView({ data, selectedIndex, currentTrackUri, height }) {
|
|
9
|
+
const headerLines = 2;
|
|
10
|
+
const listHeight = Math.max(height - headerLines, 2);
|
|
11
|
+
const items = data.tracks;
|
|
12
|
+
const halfHeight = Math.floor(listHeight / 2);
|
|
13
|
+
const startIndex = Math.max(0, Math.min(selectedIndex - halfHeight, items.length - listHeight));
|
|
14
|
+
const visibleCount = Math.min(listHeight, items.length);
|
|
15
|
+
return (_jsxs(Box, { flexDirection: "column", height: height, overflow: "hidden", children: [_jsxs(Box, { gap: 1, borderColor: "gray", borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: [_jsx(Text, { bold: true, children: data.title }), data.subtitle && _jsxs(Text, { dimColor: true, children: ["- ", data.subtitle] })] }), items.length === 0 && _jsx(Text, { dimColor: true, children: "No tracks found" }), Array.from({
|
|
16
|
+
length: visibleCount,
|
|
17
|
+
}, (_, i) => {
|
|
18
|
+
const idx = startIndex + i;
|
|
19
|
+
const item = items[idx];
|
|
20
|
+
if (!item)
|
|
21
|
+
return _jsx(Text, { children: " " }, idx);
|
|
22
|
+
const label = `${item.artist} - ${item.name}`;
|
|
23
|
+
const isSelected = idx === selectedIndex;
|
|
24
|
+
const isPlaying = currentTrackUri !== null && item.uri === currentTrackUri;
|
|
25
|
+
const icon = isPlaying ? '\u25B6 ' : isSelected ? '> ' : ' ';
|
|
26
|
+
if (isPlaying) {
|
|
27
|
+
return (_jsxs(Text, { bold: true, color: "green", inverse: isSelected, children: [icon, label] }, item.uri + idx));
|
|
28
|
+
}
|
|
29
|
+
return (_jsxs(Text, { inverse: isSelected, children: [icon, label] }, item.uri + idx));
|
|
30
|
+
})] }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type Hint = {
|
|
2
|
+
key: string;
|
|
3
|
+
label: string;
|
|
4
|
+
};
|
|
5
|
+
type KeyboardHintsProps = {
|
|
6
|
+
hints: Hint[];
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Renders a row of keyboard shortcut hints in dimmed text.
|
|
10
|
+
*/
|
|
11
|
+
export declare function KeyboardHints({ hints }: KeyboardHintsProps): import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
/**
|
|
4
|
+
* Renders a row of keyboard shortcut hints in dimmed text.
|
|
5
|
+
*/
|
|
6
|
+
export function KeyboardHints({ hints }) {
|
|
7
|
+
return (_jsx(Box, { marginTop: 1, gap: 1, flexWrap: "wrap", children: hints.map((hint) => (_jsxs(Text, { dimColor: true, children: [_jsx(Text, { bold: true, dimColor: true, children: hint.key }), ":", hint.label] }, hint.key))) }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type LyricsPanelProps = {
|
|
2
|
+
progressMs: number;
|
|
3
|
+
height: number;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Renders lyrics content from LyricsContext.
|
|
7
|
+
* Shows loading state, synced lyrics view, plain text fallback, or no-lyrics message.
|
|
8
|
+
*/
|
|
9
|
+
export declare function LyricsPanel({ progressMs, height }: LyricsPanelProps): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useLyricsContext } from '../contexts/lyrics-context.js';
|
|
4
|
+
import { LyricsView } from './lyrics-view.js';
|
|
5
|
+
/**
|
|
6
|
+
* Renders plain text lyrics in a scrollable view.
|
|
7
|
+
* Scrolls via keyboard (up/down arrows).
|
|
8
|
+
*/
|
|
9
|
+
function PlainLyricsView({ text, scrollPosition, height }) {
|
|
10
|
+
const lines = text.split('\n');
|
|
11
|
+
const maxStart = Math.max(lines.length - height, 0);
|
|
12
|
+
const startIndex = Math.min(scrollPosition, maxStart);
|
|
13
|
+
const visible = lines.slice(startIndex, startIndex + height);
|
|
14
|
+
return (_jsx(Box, { flexDirection: "column", height: height, overflow: "hidden", children: visible.map((line, offset) => {
|
|
15
|
+
const lineIndex = startIndex + offset;
|
|
16
|
+
return (_jsx(Text, { dimColor: true, children: line || ' ' }, lineIndex));
|
|
17
|
+
}) }));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Renders lyrics content from LyricsContext.
|
|
21
|
+
* Shows loading state, synced lyrics view, plain text fallback, or no-lyrics message.
|
|
22
|
+
*/
|
|
23
|
+
export function LyricsPanel({ progressMs, height }) {
|
|
24
|
+
const { lyrics, isLoading, offset, scrollPosition } = useLyricsContext();
|
|
25
|
+
if (isLoading) {
|
|
26
|
+
return _jsx(Text, { dimColor: true, children: "Loading lyrics..." });
|
|
27
|
+
}
|
|
28
|
+
if (!lyrics) {
|
|
29
|
+
return _jsx(Text, { dimColor: true, children: "No lyrics available" });
|
|
30
|
+
}
|
|
31
|
+
if (lyrics.synced.length > 0) {
|
|
32
|
+
return _jsx(LyricsView, { lyrics: lyrics.synced, progressMs: progressMs, height: height, offsetMs: offset });
|
|
33
|
+
}
|
|
34
|
+
if (lyrics.plain) {
|
|
35
|
+
return _jsx(PlainLyricsView, { text: lyrics.plain, scrollPosition: scrollPosition, height: height });
|
|
36
|
+
}
|
|
37
|
+
return _jsx(Text, { dimColor: true, children: "No lyrics available" });
|
|
38
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { LyricLine } from '../spotify/lyrics.js';
|
|
2
|
+
type LyricsViewProps = {
|
|
3
|
+
lyrics: LyricLine[];
|
|
4
|
+
progressMs: number;
|
|
5
|
+
height: number;
|
|
6
|
+
offsetMs: number;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Scrolling lyrics display synchronized with playback progress.
|
|
10
|
+
* Current line has a karaoke sweep animation (dark green → bright green).
|
|
11
|
+
* Past lines are gray (dimColor), upcoming lines are white.
|
|
12
|
+
*/
|
|
13
|
+
export declare function LyricsView({ lyrics, progressMs, height, offsetMs }: LyricsViewProps): import("react/jsx-runtime").JSX.Element;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { getCurrentLineIndex } from '../spotify/lyrics.js';
|
|
4
|
+
import { AnimatedLine } from './animated-line.js';
|
|
5
|
+
function LyricSlot({ line, isCurrent, isPast, lineProgress }) {
|
|
6
|
+
if (isCurrent) {
|
|
7
|
+
return _jsx(AnimatedLine, { text: line.text || '\u266A', progress: lineProgress });
|
|
8
|
+
}
|
|
9
|
+
if (isPast) {
|
|
10
|
+
return _jsx(Text, { dimColor: true, children: line.text || ' ' });
|
|
11
|
+
}
|
|
12
|
+
return _jsx(Text, { children: line.text || ' ' });
|
|
13
|
+
}
|
|
14
|
+
function computeLineProgress(lyrics, index, progressMs, offsetMs) {
|
|
15
|
+
const currentTime = lyrics[index].timeMs;
|
|
16
|
+
const nextLine = index + 1 < lyrics.length ? lyrics[index + 1] : undefined;
|
|
17
|
+
const lineDuration = nextLine ? nextLine.timeMs - currentTime : 5000;
|
|
18
|
+
return lineDuration > 0 ? (progressMs - currentTime + offsetMs) / lineDuration : 1;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Scrolling lyrics display synchronized with playback progress.
|
|
22
|
+
* Current line has a karaoke sweep animation (dark green → bright green).
|
|
23
|
+
* Past lines are gray (dimColor), upcoming lines are white.
|
|
24
|
+
*/
|
|
25
|
+
export function LyricsView({ lyrics, progressMs, height, offsetMs }) {
|
|
26
|
+
const currentIndex = getCurrentLineIndex(lyrics, progressMs, offsetMs);
|
|
27
|
+
const halfHeight = Math.floor(height / 2);
|
|
28
|
+
const startIndex = currentIndex - halfHeight;
|
|
29
|
+
const slots = [];
|
|
30
|
+
for (let i = 0; i < height; i++) {
|
|
31
|
+
const idx = startIndex + i;
|
|
32
|
+
if (idx >= 0 && idx < lyrics.length) {
|
|
33
|
+
slots.push({
|
|
34
|
+
index: idx,
|
|
35
|
+
line: lyrics[idx],
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
slots.push({
|
|
40
|
+
index: idx,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return (_jsx(Box, { flexDirection: "column", height: height, overflow: "hidden", children: slots.map((slot) => {
|
|
45
|
+
if (!slot.line) {
|
|
46
|
+
return _jsx(Text, { children: " " }, slot.index);
|
|
47
|
+
}
|
|
48
|
+
return (_jsx(LyricSlot, { line: slot.line, isCurrent: slot.index === currentIndex, isPast: slot.index < currentIndex, lineProgress: slot.index === currentIndex ? computeLineProgress(lyrics, slot.index, progressMs, offsetMs) : 0 }, slot.index));
|
|
49
|
+
}) }));
|
|
50
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type PanelContentProps = {
|
|
2
|
+
progressMs: number;
|
|
3
|
+
height: number;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Renders the active panel content based on the current panel mode.
|
|
7
|
+
* Reads panel mode from context; child components read their own data from their contexts.
|
|
8
|
+
*/
|
|
9
|
+
export declare function PanelContent({ progressMs, height }: PanelContentProps): import("react/jsx-runtime").JSX.Element | null;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { usePanelMode } from '../contexts/panel-mode-context.js';
|
|
3
|
+
import { LyricsPanel } from './lyrics-panel.js';
|
|
4
|
+
import { QueueView } from './queue-view.js';
|
|
5
|
+
import { SearchPanel } from './search-panel.js';
|
|
6
|
+
/**
|
|
7
|
+
* Renders the active panel content based on the current panel mode.
|
|
8
|
+
* Reads panel mode from context; child components read their own data from their contexts.
|
|
9
|
+
*/
|
|
10
|
+
export function PanelContent({ progressMs, height }) {
|
|
11
|
+
const { panelMode } = usePanelMode();
|
|
12
|
+
if (panelMode === 'lyrics') {
|
|
13
|
+
return _jsx(LyricsPanel, { progressMs: progressMs, height: height });
|
|
14
|
+
}
|
|
15
|
+
if (panelMode === 'queue') {
|
|
16
|
+
return _jsx(QueueView, { height: height });
|
|
17
|
+
}
|
|
18
|
+
if (panelMode === 'search') {
|
|
19
|
+
return _jsx(SearchPanel, { height: height });
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TrackInfo } from '../spotify/playback.js';
|
|
2
|
+
type PlaybackStatusProps = {
|
|
3
|
+
track: TrackInfo | null;
|
|
4
|
+
isLoading: boolean;
|
|
5
|
+
error: string | null;
|
|
6
|
+
art: string | null;
|
|
7
|
+
progressBarWidth: number;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Renders the main playback area: loading skeleton, error, no-playback message,
|
|
11
|
+
* or the current track info with optional album art.
|
|
12
|
+
* During initial load, renders a shimmer skeleton matching the final layout to
|
|
13
|
+
* avoid flicker when data arrives.
|
|
14
|
+
*/
|
|
15
|
+
export declare function PlaybackStatus({ track, isLoading, error, art, progressBarWidth }: PlaybackStatusProps): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { TrackInfoView } from './track-info.js';
|
|
4
|
+
import { TrackInfoSkeleton } from './track-info-skeleton.js';
|
|
5
|
+
/**
|
|
6
|
+
* Renders the main playback area: loading skeleton, error, no-playback message,
|
|
7
|
+
* or the current track info with optional album art.
|
|
8
|
+
* During initial load, renders a shimmer skeleton matching the final layout to
|
|
9
|
+
* avoid flicker when data arrives.
|
|
10
|
+
*/
|
|
11
|
+
export function PlaybackStatus({ track, isLoading, error, art, progressBarWidth }) {
|
|
12
|
+
if (error) {
|
|
13
|
+
return _jsx(Text, { color: "red", children: error });
|
|
14
|
+
}
|
|
15
|
+
if (isLoading && !track) {
|
|
16
|
+
return _jsx(TrackInfoSkeleton, { progressBarWidth: progressBarWidth });
|
|
17
|
+
}
|
|
18
|
+
if (!track) {
|
|
19
|
+
return _jsx(Text, { dimColor: true, children: "No active playback. Start playing on any Spotify client." });
|
|
20
|
+
}
|
|
21
|
+
return (_jsxs(Box, { children: [art && (_jsx(Box, { marginRight: 2, flexShrink: 0, children: _jsx(Text, { children: art }) })), _jsx(TrackInfoView, { track: track, progressBarWidth: progressBarWidth })] }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SpotifyApi } from '@spotify/web-api-ts-sdk';
|
|
2
|
+
type PlayerProps = {
|
|
3
|
+
client: SpotifyApi;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Player component wrapped with context providers.
|
|
7
|
+
*/
|
|
8
|
+
export declare function Player({ client }: PlayerProps): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"player.d.ts","sourceRoot":"","sources":["../../src/components/player.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAiB1D,KAAK,WAAW,GAAG;IACjB,MAAM,EAAE,UAAU,CAAC;CACpB,CAAC;AAmLF;;GAEG;AACH,wBAAgB,MAAM,CAAC,EAAE,MAAM,EAAE,EAAE,WAAW,2CAM7C"}
|