@involvex/youtube-music-cli 0.0.1 → 0.0.3
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/dist/source/components/common/ShortcutsBar.js +3 -1
- package/dist/source/components/config/KeybindingsLayout.d.ts +1 -0
- package/dist/source/components/config/KeybindingsLayout.js +107 -0
- package/dist/source/components/layouts/ExploreLayout.d.ts +1 -0
- package/dist/source/components/layouts/ExploreLayout.js +72 -0
- package/dist/source/components/layouts/LyricsLayout.d.ts +1 -0
- package/dist/source/components/layouts/LyricsLayout.js +89 -0
- package/dist/source/components/layouts/MainLayout.js +39 -1
- package/dist/source/components/layouts/MiniPlayerLayout.d.ts +1 -0
- package/dist/source/components/layouts/MiniPlayerLayout.js +19 -0
- package/dist/source/components/layouts/PlayerLayout.js +1 -2
- package/dist/source/components/layouts/SearchLayout.js +10 -3
- package/dist/source/components/layouts/TrendingLayout.d.ts +1 -0
- package/dist/source/components/layouts/TrendingLayout.js +59 -0
- package/dist/source/components/player/NowPlaying.js +28 -5
- package/dist/source/components/player/PlayerControls.js +4 -2
- package/dist/source/components/player/ProgressBar.js +6 -5
- package/dist/source/components/player/QueueList.d.ts +1 -1
- package/dist/source/components/player/QueueList.js +11 -5
- package/dist/source/components/search/SearchBar.js +4 -1
- package/dist/source/components/search/SearchHistory.d.ts +5 -0
- package/dist/source/components/search/SearchHistory.js +35 -0
- package/dist/source/components/settings/Settings.js +74 -11
- package/dist/source/config/themes.config.js +60 -0
- package/dist/source/hooks/usePlayer.d.ts +5 -0
- package/dist/source/hooks/usePlaylist.d.ts +2 -1
- package/dist/source/hooks/usePlaylist.js +8 -2
- package/dist/source/hooks/useSleepTimer.d.ts +9 -0
- package/dist/source/hooks/useSleepTimer.js +48 -0
- package/dist/source/services/cache/cache.service.d.ts +14 -0
- package/dist/source/services/cache/cache.service.js +67 -0
- package/dist/source/services/config/config.service.d.ts +2 -0
- package/dist/source/services/config/config.service.js +17 -0
- package/dist/source/services/discord/discord-rpc.service.d.ts +17 -0
- package/dist/source/services/discord/discord-rpc.service.js +95 -0
- package/dist/source/services/lyrics/lyrics.service.d.ts +22 -0
- package/dist/source/services/lyrics/lyrics.service.js +93 -0
- package/dist/source/services/mpris/mpris.service.d.ts +20 -0
- package/dist/source/services/mpris/mpris.service.js +78 -0
- package/dist/source/services/notification/notification.service.d.ts +14 -0
- package/dist/source/services/notification/notification.service.js +57 -0
- package/dist/source/services/player/player.service.d.ts +3 -0
- package/dist/source/services/player/player.service.js +20 -3
- package/dist/source/services/plugin/plugin-installer.service.js +2 -1
- package/dist/source/services/scrobbling/scrobbling.service.d.ts +23 -0
- package/dist/source/services/scrobbling/scrobbling.service.js +115 -0
- package/dist/source/services/sleep-timer/sleep-timer.service.d.ts +16 -0
- package/dist/source/services/sleep-timer/sleep-timer.service.js +45 -0
- package/dist/source/services/youtube-music/api.d.ts +6 -0
- package/dist/source/services/youtube-music/api.js +102 -2
- package/dist/source/stores/navigation.store.js +6 -0
- package/dist/source/stores/player.store.d.ts +5 -0
- package/dist/source/stores/player.store.js +151 -27
- package/dist/source/types/actions.d.ts +13 -0
- package/dist/source/types/config.types.d.ts +15 -1
- package/dist/source/types/navigation.types.d.ts +3 -2
- package/dist/source/types/player.types.d.ts +3 -2
- package/dist/source/utils/constants.d.ts +9 -0
- package/dist/source/utils/constants.js +9 -0
- package/dist/youtube-music-cli.exe +0 -0
- package/package.json +5 -2
|
@@ -10,7 +10,7 @@ import { KEYBINDINGS } from "../../utils/constants.js";
|
|
|
10
10
|
export default function ShortcutsBar() {
|
|
11
11
|
const { theme } = useTheme();
|
|
12
12
|
const { dispatch: navDispatch } = useNavigation();
|
|
13
|
-
const { state: playerState, pause, resume, next, previous, volumeUp, volumeDown, } = usePlayer();
|
|
13
|
+
const { state: playerState, pause, resume, next, previous, volumeUp, volumeDown, volumeFineUp, volumeFineDown, } = usePlayer();
|
|
14
14
|
// Register key bindings globally
|
|
15
15
|
const handlePlayPause = () => {
|
|
16
16
|
if (playerState.isPlaying) {
|
|
@@ -28,6 +28,8 @@ export default function ShortcutsBar() {
|
|
|
28
28
|
useKeyBinding(KEYBINDINGS.PREVIOUS, previous);
|
|
29
29
|
useKeyBinding(KEYBINDINGS.VOLUME_UP, volumeUp);
|
|
30
30
|
useKeyBinding(KEYBINDINGS.VOLUME_DOWN, volumeDown);
|
|
31
|
+
useKeyBinding(KEYBINDINGS.VOLUME_FINE_UP, volumeFineUp);
|
|
32
|
+
useKeyBinding(KEYBINDINGS.VOLUME_FINE_DOWN, volumeFineDown);
|
|
31
33
|
useKeyBinding(KEYBINDINGS.SETTINGS, goConfig);
|
|
32
34
|
return (_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.dim, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.colors.dim, children: ["Shortcuts: ", _jsx(Text, { color: theme.colors.text, children: "Space" }), " Play/Pause |", ' ', _jsx(Text, { color: theme.colors.text, children: "n" }), " Next |", ' ', _jsx(Text, { color: theme.colors.text, children: "p" }), " Previous |", ' ', _jsx(Text, { color: theme.colors.text, children: "/" }), " Search |", ' ', _jsx(Text, { color: theme.colors.text, children: "," }), " Settings |", ' ', _jsx(Text, { color: theme.colors.text, children: "?" }), " Help |", ' ', _jsx(Text, { color: theme.colors.text, children: "q" }), " Quit"] }), _jsxs(Text, { color: theme.colors.text, children: [_jsx(Text, { color: theme.colors.dim, children: "[=/" }), "-", _jsx(Text, { color: theme.colors.dim, children: "]" }), " Vol:", ' ', _jsxs(Text, { color: theme.colors.primary, children: [playerState.volume, "%"] })] })] }));
|
|
33
35
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function KeybindingsLayout(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Custom keybindings editor — shows all actions and their bound keys
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
6
|
+
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
7
|
+
import { getConfigService } from "../../services/config/config.service.js";
|
|
8
|
+
import { KEYBINDINGS } from "../../utils/constants.js";
|
|
9
|
+
function buildEntries() {
|
|
10
|
+
const config = getConfigService();
|
|
11
|
+
return Object.entries(KEYBINDINGS).map(([action, defaultKeys]) => {
|
|
12
|
+
const custom = config.getKeybinding(action);
|
|
13
|
+
return {
|
|
14
|
+
action,
|
|
15
|
+
label: action
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/_/g, ' ')
|
|
18
|
+
.replace(/\b\w/g, c => c.toUpperCase()),
|
|
19
|
+
keys: custom ?? [...defaultKeys],
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export default function KeybindingsLayout() {
|
|
24
|
+
const { theme } = useTheme();
|
|
25
|
+
const { dispatch } = useNavigation();
|
|
26
|
+
const [entries, setEntries] = useState(buildEntries);
|
|
27
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
28
|
+
const [isCapturing, setIsCapturing] = useState(false);
|
|
29
|
+
const [statusMessage, setStatusMessage] = useState('');
|
|
30
|
+
useInput((input, key) => {
|
|
31
|
+
if (isCapturing) {
|
|
32
|
+
// Build key string from the pressed key
|
|
33
|
+
const parts = [];
|
|
34
|
+
if (key.ctrl)
|
|
35
|
+
parts.push('ctrl');
|
|
36
|
+
if (key.meta)
|
|
37
|
+
parts.push('meta');
|
|
38
|
+
if (key.shift)
|
|
39
|
+
parts.push('shift');
|
|
40
|
+
let keyName = input;
|
|
41
|
+
if (key.upArrow)
|
|
42
|
+
keyName = 'up';
|
|
43
|
+
else if (key.downArrow)
|
|
44
|
+
keyName = 'down';
|
|
45
|
+
else if (key.leftArrow)
|
|
46
|
+
keyName = 'left';
|
|
47
|
+
else if (key.rightArrow)
|
|
48
|
+
keyName = 'right';
|
|
49
|
+
else if (key.return)
|
|
50
|
+
keyName = 'enter';
|
|
51
|
+
else if (key.tab)
|
|
52
|
+
keyName = 'tab';
|
|
53
|
+
else if (key.backspace || key.delete)
|
|
54
|
+
keyName = 'backspace';
|
|
55
|
+
else if (key.escape) {
|
|
56
|
+
setIsCapturing(false);
|
|
57
|
+
setStatusMessage('Cancelled');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!keyName || keyName.length === 0)
|
|
61
|
+
return;
|
|
62
|
+
parts.push(keyName);
|
|
63
|
+
const newKey = parts.join('+');
|
|
64
|
+
// Persist new binding
|
|
65
|
+
const entry = entries[selectedIndex];
|
|
66
|
+
if (!entry)
|
|
67
|
+
return;
|
|
68
|
+
getConfigService().setKeybinding(entry.action, [newKey]);
|
|
69
|
+
setEntries(buildEntries());
|
|
70
|
+
setIsCapturing(false);
|
|
71
|
+
setStatusMessage(`Bound ${entry.action} to "${newKey}"`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (key.escape) {
|
|
75
|
+
dispatch({ category: 'GO_BACK' });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (key.upArrow || input === 'k') {
|
|
79
|
+
setSelectedIndex(i => Math.max(0, i - 1));
|
|
80
|
+
}
|
|
81
|
+
else if (key.downArrow || input === 'j') {
|
|
82
|
+
setSelectedIndex(i => Math.min(entries.length - 1, i + 1));
|
|
83
|
+
}
|
|
84
|
+
else if (key.return) {
|
|
85
|
+
setIsCapturing(true);
|
|
86
|
+
setStatusMessage('Press any key to bind...');
|
|
87
|
+
}
|
|
88
|
+
else if (input === 'r') {
|
|
89
|
+
// Reset selected binding to default
|
|
90
|
+
const entry = entries[selectedIndex];
|
|
91
|
+
if (!entry)
|
|
92
|
+
return;
|
|
93
|
+
const defaultKeys = KEYBINDINGS[entry.action];
|
|
94
|
+
if (defaultKeys) {
|
|
95
|
+
getConfigService().setKeybinding(entry.action, [
|
|
96
|
+
...defaultKeys,
|
|
97
|
+
]);
|
|
98
|
+
setEntries(buildEntries());
|
|
99
|
+
setStatusMessage(`Reset ${entry.action} to default`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.colors.primary, bold: true, children: "Custom Keybindings" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.colors.dim, children: "\u2191/\u2193 Navigate | Enter Edit | r Reset | Esc Back" }) }), statusMessage ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.colors.secondary, children: statusMessage }) })) : null, entries.map((entry, index) => {
|
|
104
|
+
const isSelected = index === selectedIndex;
|
|
105
|
+
return (_jsxs(Box, { marginBottom: 0, children: [_jsx(Text, { color: isSelected ? theme.colors.primary : theme.colors.text, bold: isSelected, children: isSelected ? '▶ ' : ' ' }), _jsx(Text, { color: isSelected ? theme.colors.primary : theme.colors.text, bold: isSelected, children: entry.label.padEnd(25) }), _jsx(Text, { color: theme.colors.secondary, children: entry.keys.join(', ') })] }, entry.action));
|
|
106
|
+
}), isCapturing ? (_jsxs(Box, { marginTop: 1, borderStyle: "single", borderColor: theme.colors.secondary, padding: 1, children: [_jsxs(Text, { color: theme.colors.secondary, bold: true, children: ["Press any key combination...", ' '] }), _jsx(Text, { color: theme.colors.dim, children: "(Esc to cancel)" })] })) : null] }));
|
|
107
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function ExploreLayout(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// Explore / Genre browsing view — shows curated sections from YouTube Music
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { useState, useEffect } from 'react';
|
|
5
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
6
|
+
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
7
|
+
import { usePlayer } from "../../hooks/usePlayer.js";
|
|
8
|
+
import { getMusicService } from "../../services/youtube-music/api.js";
|
|
9
|
+
export default function ExploreLayout() {
|
|
10
|
+
const { theme } = useTheme();
|
|
11
|
+
const { dispatch } = useNavigation();
|
|
12
|
+
const { play } = usePlayer();
|
|
13
|
+
const [sections, setSections] = useState([]);
|
|
14
|
+
const [sectionIndex, setSectionIndex] = useState(0);
|
|
15
|
+
const [trackIndex, setTrackIndex] = useState(0);
|
|
16
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
17
|
+
const [error, setError] = useState(null);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
let cancelled = false;
|
|
20
|
+
getMusicService()
|
|
21
|
+
.getExploreSections()
|
|
22
|
+
.then(results => {
|
|
23
|
+
if (!cancelled) {
|
|
24
|
+
setSections(results);
|
|
25
|
+
setIsLoading(false);
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
.catch((err) => {
|
|
29
|
+
if (!cancelled) {
|
|
30
|
+
setError(err instanceof Error ? err.message : 'Failed to load explore');
|
|
31
|
+
setIsLoading(false);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
return () => {
|
|
35
|
+
cancelled = true;
|
|
36
|
+
};
|
|
37
|
+
}, []);
|
|
38
|
+
const currentSection = sections[sectionIndex];
|
|
39
|
+
const tracks = currentSection?.tracks ?? [];
|
|
40
|
+
useInput((input, key) => {
|
|
41
|
+
if (key.escape) {
|
|
42
|
+
dispatch({ category: 'GO_BACK' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (key.leftArrow || input === 'h') {
|
|
46
|
+
setSectionIndex(i => Math.max(0, i - 1));
|
|
47
|
+
setTrackIndex(0);
|
|
48
|
+
}
|
|
49
|
+
else if (key.rightArrow || input === 'l') {
|
|
50
|
+
setSectionIndex(i => Math.min(sections.length - 1, i + 1));
|
|
51
|
+
setTrackIndex(0);
|
|
52
|
+
}
|
|
53
|
+
else if (key.upArrow || input === 'k') {
|
|
54
|
+
setTrackIndex(i => Math.max(0, i - 1));
|
|
55
|
+
}
|
|
56
|
+
else if (key.downArrow || input === 'j') {
|
|
57
|
+
setTrackIndex(i => Math.min(tracks.length - 1, i + 1));
|
|
58
|
+
}
|
|
59
|
+
else if (key.return) {
|
|
60
|
+
const track = tracks[trackIndex];
|
|
61
|
+
if (track)
|
|
62
|
+
play(track);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.colors.primary, bold: true, children: "\uD83C\uDFB5 Explore" }) }), isLoading ? (_jsx(Text, { color: theme.colors.dim, children: "Loading explore sections..." })) : error ? (_jsx(Text, { color: theme.colors.error, children: error })) : sections.length === 0 ? (_jsx(Text, { color: theme.colors.dim, children: "No sections found" })) : (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, gap: 2, children: sections.map((section, index) => (_jsx(Text, { color: index === sectionIndex
|
|
66
|
+
? theme.colors.primary
|
|
67
|
+
: theme.colors.dim, bold: index === sectionIndex, underline: index === sectionIndex, children: section.title }, section.title))) }), tracks.map((track, index) => {
|
|
68
|
+
const isSelected = index === trackIndex;
|
|
69
|
+
const artist = track.artists?.[0]?.name ?? 'Unknown';
|
|
70
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? theme.colors.primary : theme.colors.dim, children: isSelected ? '▶ ' : `${String(index + 1).padStart(2)}. ` }), _jsx(Text, { color: isSelected ? theme.colors.primary : theme.colors.text, bold: isSelected, children: track.title }), _jsxs(Text, { color: theme.colors.dim, children: [" \u2014 ", artist] })] }, track.videoId));
|
|
71
|
+
})] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: "\u2190/\u2192 Sections | \u2191/\u2193 Tracks | Enter Play | Esc Back" }) })] }));
|
|
72
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function LyricsLayout(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Lyrics view layout - displays synced or plain lyrics
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { Box, Text } from 'ink';
|
|
5
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
6
|
+
import { usePlayer } from "../../hooks/usePlayer.js";
|
|
7
|
+
import { getLyricsService, } from "../../services/lyrics/lyrics.service.js";
|
|
8
|
+
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
9
|
+
const CONTEXT_LINES = 3; // Lines shown before/after current line
|
|
10
|
+
export default function LyricsLayout() {
|
|
11
|
+
const { theme } = useTheme();
|
|
12
|
+
const { state } = usePlayer();
|
|
13
|
+
const { rows } = useTerminalSize();
|
|
14
|
+
const [lyrics, setLyrics] = useState(null);
|
|
15
|
+
const [loading, setLoading] = useState(false);
|
|
16
|
+
const [error, setError] = useState(null);
|
|
17
|
+
const lyricsService = getLyricsService();
|
|
18
|
+
// Fetch lyrics when track changes
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const track = state.currentTrack;
|
|
21
|
+
let cancelled = false;
|
|
22
|
+
if (!track) {
|
|
23
|
+
queueMicrotask(() => {
|
|
24
|
+
if (!cancelled) {
|
|
25
|
+
setLyrics(null);
|
|
26
|
+
setLoading(false);
|
|
27
|
+
setError(null);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const artist = track.artists?.[0]?.name ?? '';
|
|
33
|
+
queueMicrotask(() => {
|
|
34
|
+
if (!cancelled) {
|
|
35
|
+
setLoading(true);
|
|
36
|
+
setError(null);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
void lyricsService
|
|
40
|
+
.getLyrics(track.title, artist, state.duration || undefined)
|
|
41
|
+
.then(result => {
|
|
42
|
+
if (cancelled) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
setLyrics(result);
|
|
46
|
+
setLoading(false);
|
|
47
|
+
if (!result) {
|
|
48
|
+
setError('No lyrics found');
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
.catch(() => {
|
|
52
|
+
if (cancelled) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
setLoading(false);
|
|
56
|
+
setError('Failed to load lyrics');
|
|
57
|
+
});
|
|
58
|
+
return () => {
|
|
59
|
+
cancelled = true;
|
|
60
|
+
};
|
|
61
|
+
}, [lyricsService, state.currentTrack, state.duration]);
|
|
62
|
+
const track = state.currentTrack;
|
|
63
|
+
const title = track?.title ?? 'No track playing';
|
|
64
|
+
const artist = track?.artists?.map(a => a.name).join(', ') ?? '';
|
|
65
|
+
// Determine current line
|
|
66
|
+
const currentLineIndex = lyrics?.synced
|
|
67
|
+
? lyricsService.getCurrentLineIndex(lyrics.synced, state.progress)
|
|
68
|
+
: -1;
|
|
69
|
+
// Calculate visible lines window
|
|
70
|
+
const visibleLines = (() => {
|
|
71
|
+
if (!lyrics?.synced)
|
|
72
|
+
return null;
|
|
73
|
+
const start = Math.max(0, currentLineIndex - CONTEXT_LINES);
|
|
74
|
+
const maxLines = Math.max(5, rows - 8);
|
|
75
|
+
const end = Math.min(lyrics.synced.length, start + maxLines);
|
|
76
|
+
return lyrics.synced.slice(start, end).map((line, i) => ({
|
|
77
|
+
line,
|
|
78
|
+
globalIndex: start + i,
|
|
79
|
+
}));
|
|
80
|
+
})();
|
|
81
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, children: [_jsx(Text, { bold: true, color: theme.colors.primary, children: title }), artist && _jsxs(Text, { color: theme.colors.secondary, children: [" \u2014 ", artist] })] }), loading && _jsx(Text, { color: theme.colors.accent, children: "Loading lyrics..." }), error && !loading && _jsx(Text, { color: theme.colors.dim, children: error }), !loading && visibleLines && (_jsx(Box, { flexDirection: "column", paddingX: 1, children: visibleLines.map(({ line, globalIndex }) => (_jsxs(Text, { bold: globalIndex === currentLineIndex, color: globalIndex === currentLineIndex
|
|
82
|
+
? theme.colors.primary
|
|
83
|
+
: globalIndex < currentLineIndex
|
|
84
|
+
? theme.colors.dim
|
|
85
|
+
: theme.colors.text, children: [globalIndex === currentLineIndex ? '▶ ' : ' ', line.text || '♪'] }, globalIndex))) })), !loading && !lyrics?.synced && lyrics?.plain && (_jsx(Box, { flexDirection: "column", paddingX: 1, children: lyrics.plain
|
|
86
|
+
.split('\n')
|
|
87
|
+
.slice(0, Math.max(5, rows - 8))
|
|
88
|
+
.map((line, i) => (_jsx(Text, { color: theme.colors.text, children: line || ' ' }, i))) })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.colors.dim, children: ["Press ", _jsx(Text, { color: theme.colors.text, children: "l" }), " or", ' ', _jsx(Text, { color: theme.colors.text, children: "Esc" }), " to go back"] }) })] }));
|
|
89
|
+
}
|
|
@@ -9,11 +9,17 @@ import { useTheme } from "../../hooks/useTheme.js";
|
|
|
9
9
|
import { useKeyBinding } from "../../hooks/useKeyboard.js";
|
|
10
10
|
import SearchLayout from "./SearchLayout.js";
|
|
11
11
|
import PlayerLayout from "./PlayerLayout.js";
|
|
12
|
+
import MiniPlayerLayout from "./MiniPlayerLayout.js";
|
|
12
13
|
import PluginsLayout from "./PluginsLayout.js";
|
|
13
14
|
import Suggestions from "../player/Suggestions.js";
|
|
14
15
|
import Settings from "../settings/Settings.js";
|
|
15
16
|
import ConfigLayout from "../config/ConfigLayout.js";
|
|
16
17
|
import ShortcutsBar from "../common/ShortcutsBar.js";
|
|
18
|
+
import LyricsLayout from "./LyricsLayout.js";
|
|
19
|
+
import SearchHistory from "../search/SearchHistory.js";
|
|
20
|
+
import KeybindingsLayout from "../config/KeybindingsLayout.js";
|
|
21
|
+
import TrendingLayout from "./TrendingLayout.js";
|
|
22
|
+
import ExploreLayout from "./ExploreLayout.js";
|
|
17
23
|
import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
|
|
18
24
|
import { Box } from 'ink';
|
|
19
25
|
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
@@ -47,6 +53,18 @@ function MainLayout() {
|
|
|
47
53
|
// From other views, go back
|
|
48
54
|
dispatch({ category: 'GO_BACK' });
|
|
49
55
|
}, [navState.currentView, dispatch]);
|
|
56
|
+
const goToLyrics = useCallback(() => {
|
|
57
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.LYRICS });
|
|
58
|
+
}, [dispatch]);
|
|
59
|
+
const goToTrending = useCallback(() => {
|
|
60
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.TRENDING });
|
|
61
|
+
}, [dispatch]);
|
|
62
|
+
const goToExplore = useCallback(() => {
|
|
63
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.EXPLORE });
|
|
64
|
+
}, [dispatch]);
|
|
65
|
+
const togglePlayerMode = useCallback(() => {
|
|
66
|
+
dispatch({ category: 'TOGGLE_PLAYER_MODE' });
|
|
67
|
+
}, [dispatch]);
|
|
50
68
|
// Global keyboard bindings
|
|
51
69
|
useKeyBinding(KEYBINDINGS.QUIT, handleQuit);
|
|
52
70
|
useKeyBinding(KEYBINDINGS.SEARCH, goToSearch);
|
|
@@ -54,14 +72,26 @@ function MainLayout() {
|
|
|
54
72
|
useKeyBinding(KEYBINDINGS.SUGGESTIONS, goToSuggestions);
|
|
55
73
|
useKeyBinding(KEYBINDINGS.SETTINGS, goToSettings);
|
|
56
74
|
useKeyBinding(KEYBINDINGS.HELP, goToHelp);
|
|
75
|
+
useKeyBinding(['m'], togglePlayerMode);
|
|
76
|
+
useKeyBinding(['l'], goToLyrics);
|
|
77
|
+
useKeyBinding(['T'], goToTrending);
|
|
78
|
+
useKeyBinding(['e'], goToExplore);
|
|
57
79
|
// Memoize the view component to prevent unnecessary remounts
|
|
58
80
|
// Only recreate when currentView actually changes
|
|
59
81
|
const currentView = useMemo(() => {
|
|
82
|
+
// In mini mode, only show the mini player bar
|
|
83
|
+
if (navState.playerMode === 'mini') {
|
|
84
|
+
return _jsx(MiniPlayerLayout, {}, "mini-player");
|
|
85
|
+
}
|
|
60
86
|
switch (navState.currentView) {
|
|
61
87
|
case 'player':
|
|
62
88
|
return _jsx(PlayerLayout, {}, "player");
|
|
63
89
|
case 'search':
|
|
64
90
|
return _jsx(SearchLayout, {}, "search");
|
|
91
|
+
case 'search_history':
|
|
92
|
+
return (_jsx(SearchHistory, { onSelect: query => {
|
|
93
|
+
dispatch({ category: 'SET_SEARCH_QUERY', query });
|
|
94
|
+
} }, "search_history"));
|
|
65
95
|
case 'playlists':
|
|
66
96
|
return _jsx(PlaylistList, {}, "playlists");
|
|
67
97
|
case 'suggestions':
|
|
@@ -72,12 +102,20 @@ function MainLayout() {
|
|
|
72
102
|
return _jsx(PluginsLayout, {}, "plugins");
|
|
73
103
|
case 'config':
|
|
74
104
|
return _jsx(ConfigLayout, {}, "config");
|
|
105
|
+
case 'lyrics':
|
|
106
|
+
return _jsx(LyricsLayout, {}, "lyrics");
|
|
107
|
+
case 'keybindings':
|
|
108
|
+
return _jsx(KeybindingsLayout, {}, "keybindings");
|
|
109
|
+
case 'trending':
|
|
110
|
+
return _jsx(TrendingLayout, {}, "trending");
|
|
111
|
+
case 'explore':
|
|
112
|
+
return _jsx(ExploreLayout, {}, "explore");
|
|
75
113
|
case 'help':
|
|
76
114
|
return _jsx(Help, {}, "help");
|
|
77
115
|
default:
|
|
78
116
|
return _jsx(PlayerLayout, {}, "player-default");
|
|
79
117
|
}
|
|
80
|
-
}, [navState.currentView]);
|
|
118
|
+
}, [navState.currentView, navState.playerMode, dispatch]);
|
|
81
119
|
return (_jsxs(Box, { flexDirection: "column", paddingX: getPadding(), borderStyle: "single", borderColor: theme.colors.primary, children: [currentView, _jsx(ShortcutsBar, {})] }));
|
|
82
120
|
}
|
|
83
121
|
export default React.memo(MainLayout);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function MiniPlayerLayout(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Mini player layout - compact single-line player
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { usePlayer } from "../../hooks/usePlayer.js";
|
|
5
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
6
|
+
import { formatTime } from "../../utils/format.js";
|
|
7
|
+
export default function MiniPlayerLayout() {
|
|
8
|
+
const { theme } = useTheme();
|
|
9
|
+
const { state } = usePlayer();
|
|
10
|
+
const track = state.currentTrack;
|
|
11
|
+
const artist = track?.artists?.map(a => a.name).join(', ') ?? 'Unknown';
|
|
12
|
+
const title = track?.title ?? 'No track playing';
|
|
13
|
+
const progress = formatTime(state.progress);
|
|
14
|
+
const duration = formatTime(state.duration);
|
|
15
|
+
const playIcon = state.isPlaying ? '▶' : '⏸';
|
|
16
|
+
const vol = `${state.volume}%`;
|
|
17
|
+
const speed = (state.speed ?? 1.0) !== 1.0 ? ` ${(state.speed ?? 1.0).toFixed(2)}x` : '';
|
|
18
|
+
return (_jsxs(Box, { flexDirection: "row", paddingX: 1, gap: 1, children: [_jsx(Text, { color: state.isPlaying ? theme.colors.success : theme.colors.dim, children: playIcon }), _jsx(Text, { bold: true, color: theme.colors.primary, children: title }), _jsx(Text, { color: theme.colors.dim, children: "\u2014" }), _jsx(Text, { color: theme.colors.secondary, children: artist }), _jsx(Text, { color: theme.colors.dim, children: "|" }), _jsxs(Text, { color: theme.colors.text, children: [progress, "/", duration] }), _jsx(Text, { color: theme.colors.dim, children: "|" }), _jsxs(Text, { color: theme.colors.text, children: ["vol:", vol] }), speed && _jsx(Text, { color: theme.colors.accent, children: speed }), state.isLoading && _jsx(Text, { color: theme.colors.accent, children: "Loading..." })] }));
|
|
19
|
+
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { usePlayer } from "../../hooks/usePlayer.js";
|
|
3
3
|
import NowPlaying from "../player/NowPlaying.js";
|
|
4
|
-
import ProgressBar from "../player/ProgressBar.js";
|
|
5
4
|
import QueueList from "../player/QueueList.js";
|
|
6
5
|
import { Box } from 'ink';
|
|
7
6
|
export default function PlayerLayout() {
|
|
8
7
|
const { state: playerState } = usePlayer();
|
|
9
|
-
return (_jsxs(Box, { flexDirection: "column",
|
|
8
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(NowPlaying, {}), playerState.queue.length > 0 && _jsx(QueueList, {})] }));
|
|
10
9
|
}
|
|
@@ -8,7 +8,7 @@ import React from 'react';
|
|
|
8
8
|
import { useTheme } from "../../hooks/useTheme.js";
|
|
9
9
|
import SearchBar from "../search/SearchBar.js";
|
|
10
10
|
import { useKeyBinding } from "../../hooks/useKeyboard.js";
|
|
11
|
-
import { KEYBINDINGS } from "../../utils/constants.js";
|
|
11
|
+
import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
|
|
12
12
|
import { Box, Text } from 'ink';
|
|
13
13
|
function SearchLayout() {
|
|
14
14
|
const { theme } = useTheme();
|
|
@@ -45,6 +45,13 @@ function SearchLayout() {
|
|
|
45
45
|
}, [navState.searchLimit, dispatch]);
|
|
46
46
|
useKeyBinding(KEYBINDINGS.INCREASE_RESULTS, increaseLimit);
|
|
47
47
|
useKeyBinding(KEYBINDINGS.DECREASE_RESULTS, decreaseLimit);
|
|
48
|
+
// Open search history
|
|
49
|
+
const goToHistory = useCallback(() => {
|
|
50
|
+
if (isTyping) {
|
|
51
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.SEARCH_HISTORY });
|
|
52
|
+
}
|
|
53
|
+
}, [isTyping, dispatch]);
|
|
54
|
+
useKeyBinding(['h'], goToHistory);
|
|
48
55
|
// Initial search if query is in state (usually from CLI flags)
|
|
49
56
|
useEffect(() => {
|
|
50
57
|
if (navState.searchQuery && !navState.hasSearched) {
|
|
@@ -75,7 +82,7 @@ function SearchLayout() {
|
|
|
75
82
|
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.secondary, paddingX: 1, children: [_jsx(Text, { bold: true, color: theme.colors.primary, children: "Search" }), _jsxs(Text, { color: theme.colors.dim, children: [' ', "| Limit: ", navState.searchLimit, " (Use [ or ] to adjust)"] })] }), _jsx(SearchBar, { isActive: isTyping && !isSearching, onInput: input => {
|
|
76
83
|
void performSearch(input);
|
|
77
84
|
} }), (isLoading || isSearching) && (_jsx(Text, { color: theme.colors.accent, children: "Searching..." })), error && _jsx(Text, { color: theme.colors.error, children: error }), !isLoading && navState.hasSearched && (_jsx(SearchResults, { results: results, selectedIndex: navState.selectedResult, isActive: !isTyping })), !isLoading && navState.hasSearched && results.length === 0 && !error && (_jsx(Text, { color: theme.colors.dim, children: "No results found" })), _jsx(Text, { color: theme.colors.dim, children: isTyping
|
|
78
|
-
? 'Type to search, Enter to start'
|
|
79
|
-
:
|
|
85
|
+
? 'Type to search, Enter to start, H for history'
|
|
86
|
+
: `Arrows to navigate, Enter to play, ]/[ more/fewer results (${navState.searchLimit}), Esc to type` })] }));
|
|
80
87
|
}
|
|
81
88
|
export default React.memo(SearchLayout);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function TrendingLayout(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Trending tracks view — shows YouTube trending music
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { useState, useEffect } from 'react';
|
|
5
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
6
|
+
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
7
|
+
import { usePlayer } from "../../hooks/usePlayer.js";
|
|
8
|
+
import { getMusicService } from "../../services/youtube-music/api.js";
|
|
9
|
+
export default function TrendingLayout() {
|
|
10
|
+
const { theme } = useTheme();
|
|
11
|
+
const { dispatch } = useNavigation();
|
|
12
|
+
const { play } = usePlayer();
|
|
13
|
+
const [tracks, setTracks] = useState([]);
|
|
14
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
15
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
16
|
+
const [error, setError] = useState(null);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let cancelled = false;
|
|
19
|
+
getMusicService()
|
|
20
|
+
.getTrending()
|
|
21
|
+
.then(results => {
|
|
22
|
+
if (!cancelled) {
|
|
23
|
+
setTracks(results);
|
|
24
|
+
setIsLoading(false);
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
.catch((err) => {
|
|
28
|
+
if (!cancelled) {
|
|
29
|
+
setError(err instanceof Error ? err.message : 'Failed to load trending');
|
|
30
|
+
setIsLoading(false);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return () => {
|
|
34
|
+
cancelled = true;
|
|
35
|
+
};
|
|
36
|
+
}, []);
|
|
37
|
+
useInput((input, key) => {
|
|
38
|
+
if (key.escape) {
|
|
39
|
+
dispatch({ category: 'GO_BACK' });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (key.upArrow || input === 'k') {
|
|
43
|
+
setSelectedIndex(i => Math.max(0, i - 1));
|
|
44
|
+
}
|
|
45
|
+
else if (key.downArrow || input === 'j') {
|
|
46
|
+
setSelectedIndex(i => Math.min(tracks.length - 1, i + 1));
|
|
47
|
+
}
|
|
48
|
+
else if (key.return) {
|
|
49
|
+
const track = tracks[selectedIndex];
|
|
50
|
+
if (track)
|
|
51
|
+
play(track);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.colors.primary, bold: true, children: "\uD83D\uDD25 Trending Music" }) }), isLoading ? (_jsx(Text, { color: theme.colors.dim, children: "Loading trending tracks..." })) : error ? (_jsx(Text, { color: theme.colors.error, children: error })) : tracks.length === 0 ? (_jsx(Text, { color: theme.colors.dim, children: "No trending tracks found" })) : (tracks.map((track, index) => {
|
|
55
|
+
const isSelected = index === selectedIndex;
|
|
56
|
+
const artist = track.artists?.[0]?.name ?? 'Unknown';
|
|
57
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? theme.colors.primary : theme.colors.dim, children: isSelected ? '▶ ' : `${String(index + 1).padStart(2)}. ` }), _jsx(Text, { color: isSelected ? theme.colors.primary : theme.colors.text, bold: isSelected, children: track.title }), _jsxs(Text, { color: theme.colors.dim, children: [" \u2014 ", artist] })] }, track.videoId));
|
|
58
|
+
})), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: "\u2191/\u2193 Navigate | Enter Play | Esc Back" }) })] }));
|
|
59
|
+
}
|
|
@@ -5,17 +5,40 @@ import { usePlayer } from "../../hooks/usePlayer.js";
|
|
|
5
5
|
import { useTheme } from "../../hooks/useTheme.js";
|
|
6
6
|
import { formatTime } from "../../utils/format.js";
|
|
7
7
|
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
8
|
+
import { getSleepTimerService } from "../../services/sleep-timer/sleep-timer.service.js";
|
|
9
|
+
import { useState, useEffect } from 'react';
|
|
8
10
|
export default function NowPlaying() {
|
|
9
11
|
const { theme } = useTheme();
|
|
10
12
|
const { state: playerState } = usePlayer();
|
|
11
13
|
const { columns } = useTerminalSize();
|
|
14
|
+
const sleepTimer = getSleepTimerService();
|
|
15
|
+
const [sleepRemaining, setSleepRemaining] = useState(null);
|
|
16
|
+
// Poll sleep timer remaining every second
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!sleepTimer.isActive()) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const interval = setInterval(() => {
|
|
22
|
+
const remaining = sleepTimer.getRemainingSeconds();
|
|
23
|
+
setSleepRemaining(remaining);
|
|
24
|
+
if (remaining === null || remaining === 0) {
|
|
25
|
+
clearInterval(interval);
|
|
26
|
+
}
|
|
27
|
+
}, 1000);
|
|
28
|
+
return () => {
|
|
29
|
+
clearInterval(interval);
|
|
30
|
+
};
|
|
31
|
+
}, [sleepTimer]);
|
|
12
32
|
if (!playerState.currentTrack) {
|
|
13
|
-
return (_jsx(Box, { borderStyle: "round", borderColor: theme.colors.dim,
|
|
33
|
+
return (_jsx(Box, { borderStyle: "round", borderColor: theme.colors.dim, paddingX: 1, children: _jsx(Text, { color: theme.colors.dim, children: "No track playing" }) }));
|
|
14
34
|
}
|
|
15
35
|
const track = playerState.currentTrack;
|
|
16
36
|
const artists = track.artists?.map(a => a.name).join(', ') || 'Unknown Artist';
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
37
|
+
// Clamp progress to valid range
|
|
38
|
+
const progress = Math.max(0, Math.min(playerState.progress, playerState.duration || 0));
|
|
39
|
+
const duration = playerState.duration || 0;
|
|
40
|
+
const percentage = duration > 0 ? Math.min(100, Math.floor((progress / duration) * 100)) : 0;
|
|
41
|
+
const barWidth = Math.max(10, columns - 8);
|
|
42
|
+
const filledWidth = duration > 0 ? Math.floor((progress / duration) * barWidth) : 0;
|
|
43
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.colors.primary, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.colors.primary, children: track.title }), _jsx(Text, { color: theme.colors.dim, children: " \u2022 " }), _jsx(Text, { color: theme.colors.secondary, children: artists })] }), track.album && _jsx(Text, { color: theme.colors.dim, children: track.album.name }), _jsxs(Box, { children: [_jsx(Text, { color: theme.colors.primary, children: '█'.repeat(Math.min(filledWidth, barWidth)) }), _jsx(Text, { color: theme.colors.dim, children: '░'.repeat(Math.max(0, barWidth - filledWidth)) })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.colors.text, children: formatTime(progress) }), _jsxs(Text, { color: theme.colors.dim, children: [" / ", formatTime(duration), " "] }), _jsxs(Text, { color: theme.colors.dim, children: ["[", percentage, "%]"] }), playerState.isLoading && (_jsx(Text, { color: theme.colors.accent, children: " Loading..." })), !playerState.isPlaying && progress > 0 && (_jsx(Text, { color: theme.colors.dim, children: " \u23F8" })), sleepRemaining !== null && (_jsxs(Text, { color: theme.colors.warning, children: [' ', "\u23FE ", formatTime(sleepRemaining)] }))] }), playerState.error && (_jsx(Text, { color: theme.colors.error, children: playerState.error }))] }));
|
|
21
44
|
}
|
|
@@ -17,7 +17,7 @@ export default function PlayerControls() {
|
|
|
17
17
|
};
|
|
18
18
|
}, [instanceId]);
|
|
19
19
|
const { theme } = useTheme();
|
|
20
|
-
const { state: playerState, pause, resume, next, previous, volumeUp, volumeDown, } = usePlayer();
|
|
20
|
+
const { state: playerState, pause, resume, next, previous, volumeUp, volumeDown, speedUp, speedDown, } = usePlayer();
|
|
21
21
|
// DEBUG: Log when callbacks change (detect instability)
|
|
22
22
|
useEffect(() => {
|
|
23
23
|
// Temporarily output to stderr to debug without triggering Ink re-render
|
|
@@ -37,5 +37,7 @@ export default function PlayerControls() {
|
|
|
37
37
|
useKeyBinding(KEYBINDINGS.PREVIOUS, previous);
|
|
38
38
|
useKeyBinding(KEYBINDINGS.VOLUME_UP, volumeUp);
|
|
39
39
|
useKeyBinding(KEYBINDINGS.VOLUME_DOWN, volumeDown);
|
|
40
|
-
|
|
40
|
+
useKeyBinding(KEYBINDINGS.SPEED_UP, speedUp);
|
|
41
|
+
useKeyBinding(KEYBINDINGS.SPEED_DOWN, speedDown);
|
|
42
|
+
return (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", paddingX: 2, borderStyle: "classic", borderColor: theme.colors.dim, children: [_jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "b" }), "] Prev"] }), _jsx(Text, { color: theme.colors.primary, children: playerState.isPlaying ? (_jsxs(Text, { children: ["[", _jsx(Text, { color: theme.colors.dim, children: "Space" }), "] Pause"] })) : (_jsxs(Text, { children: ["[", _jsx(Text, { color: theme.colors.dim, children: "Space" }), "] Play"] })) }), _jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "n" }), "] Next"] }), _jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "+/-" }), "] Vol: ", playerState.volume, "%"] }), (playerState.speed ?? 1.0) !== 1.0 && (_jsxs(Text, { color: theme.colors.accent, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "<>" }), "]", ' ', (playerState.speed ?? 1.0).toFixed(2), "x"] }))] }));
|
|
41
43
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// Progress bar component
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
4
|
import { useTheme } from "../../hooks/useTheme.js";
|
|
@@ -10,9 +10,10 @@ export default function ProgressBar() {
|
|
|
10
10
|
if (!playerState.currentTrack || !playerState.duration) {
|
|
11
11
|
return null;
|
|
12
12
|
}
|
|
13
|
-
|
|
13
|
+
// Clamp values to valid range
|
|
14
|
+
const progress = Math.max(0, Math.min(playerState.progress, playerState.duration));
|
|
14
15
|
const duration = playerState.duration;
|
|
15
|
-
const percentage = duration > 0 ? Math.floor((progress / duration) * 100) : 0;
|
|
16
|
-
const barWidth = Math.
|
|
17
|
-
return (_jsxs(Box, {
|
|
16
|
+
const percentage = duration > 0 ? Math.min(100, Math.floor((progress / duration) * 100)) : 0;
|
|
17
|
+
const barWidth = Math.min(20, Math.floor(percentage / 5));
|
|
18
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: theme.colors.text, children: formatTime(progress) }), _jsx(Text, { color: theme.colors.dim, children: "/" }), _jsx(Text, { color: theme.colors.text, children: formatTime(duration) }), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.colors.primary, children: '█'.repeat(barWidth) }), _jsx(Text, { color: theme.colors.dim, children: '░'.repeat(20 - barWidth) }), _jsxs(Text, { color: theme.colors.dim, children: [" ", percentage, "%"] })] }));
|
|
18
19
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
declare function QueueList(): import("react/jsx-runtime").JSX.Element;
|
|
2
|
+
declare function QueueList(): import("react/jsx-runtime").JSX.Element | null;
|
|
3
3
|
declare const _default: React.MemoExoticComponent<typeof QueueList>;
|
|
4
4
|
export default _default;
|