@involvex/youtube-music-cli 0.0.9 → 0.0.12
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/CHANGELOG.md +18 -0
- package/dist/source/components/common/Help.js +1 -1
- package/dist/source/components/common/ShortcutsBar.js +2 -8
- package/dist/source/components/layouts/MainLayout.js +1 -1
- package/dist/source/components/layouts/SearchLayout.js +22 -12
- package/dist/source/components/playlist/PlaylistList.js +58 -2
- package/dist/source/components/search/SearchResults.d.ts +2 -1
- package/dist/source/components/search/SearchResults.js +47 -3
- package/dist/source/components/settings/Settings.js +45 -4
- package/dist/source/services/config/config.service.js +4 -0
- package/dist/source/services/download/download.service.d.ts +38 -0
- package/dist/source/services/download/download.service.js +299 -0
- package/dist/source/services/player/player.service.d.ts +2 -1
- package/dist/source/services/player/player.service.js +26 -15
- package/dist/source/services/youtube-music/api.js +77 -105
- package/dist/source/stores/player.store.js +10 -2
- package/dist/source/types/config.types.d.ts +4 -0
- package/dist/source/utils/constants.d.ts +1 -0
- package/dist/source/utils/constants.js +1 -0
- package/dist/youtube-music-cli.exe +0 -0
- package/package.json +17 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
## [0.0.12](https://github.com/involvex/youtube-music-cli/compare/v0.0.11...v0.0.12) (2026-02-18)
|
|
2
|
+
|
|
3
|
+
## [0.0.11](https://github.com/involvex/youtube-music-cli/compare/v0.0.10...v0.0.11) (2026-02-18)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- **download:** add track, artist, and playlist downloads ([42298d2](https://github.com/involvex/youtube-music-cli/commit/42298d2fd35ba71ad841f58e140f4c53b3222ed7))
|
|
8
|
+
|
|
9
|
+
## [0.0.10](https://github.com/involvex/youtube-music-cli/compare/v0.0.9...v0.0.10) (2026-02-18)
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
- mpv resume not working and double-process on track change ([fd04bda](https://github.com/involvex/youtube-music-cli/commit/fd04bdaa852f366eb92d265415ec18794ea5dfc3))
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
- dynamic mix playlist creation from search results ([d019048](https://github.com/involvex/youtube-music-cli/commit/d019048c4450d633003c8026ac43e92a743d87a0))
|
|
18
|
+
|
|
1
19
|
## [0.0.9](https://github.com/involvex/youtube-music-cli/compare/v0.0.8...v0.0.9) (2026-02-18)
|
|
2
20
|
|
|
3
21
|
### Features
|
|
@@ -6,5 +6,5 @@ import { useNavigation } from "../../hooks/useNavigation.js";
|
|
|
6
6
|
export default function Help() {
|
|
7
7
|
const { theme } = useTheme();
|
|
8
8
|
const { dispatch: _dispatch } = useNavigation();
|
|
9
|
-
return (_jsxs(Box, { flexDirection: "column", gap: 1, padding: 1, children: [_jsx(Box, { borderStyle: "single", borderColor: theme.colors.secondary, paddingX: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Keyboard Shortcuts" }) }), _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, color: theme.colors.secondary, children: "Global" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "q / Esc" }), " - Quit", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "?" }), " - Help", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "/" }), " - Search", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Shift+P" }), " - Playlists", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "p" }), " - Plugins", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "g" }), " - Suggestions", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "," }), " - Settings"] }) }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "Player" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "Space" }), " - Play/Pause", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "n" }), " - Next", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "b" }), " - Previous", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "=" }), " - Volume Up", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "-" }), " - Volume Down", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "s" }), " - Toggle Shuffle", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "r" }), " - Toggle Repeat"] }) }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "Navigation" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "Up" }), " /", _jsx(Text, { children: " " }), _jsx(Text, { color: theme.colors.text, children: "k" }), " - Move Up", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Down" }), " /", _jsx(Text, { children: " " }), _jsx(Text, { color: theme.colors.text, children: "j" }), " - Move Down", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Enter" }), " - Select", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Esc" }), " - Go Back"] }) }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "Search" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "Tab" }), " - Switch Search Type", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Esc" }), " - Clear Search", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "[ / ]" }), " - Results Limit"] }) }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "Playlist" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "a" }), " - Add to Playlist", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "d" }), " - Remove from Playlist", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "c" }), " - Create Playlist", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "D" }), " - Delete Playlist"] }) }), _jsxs(Text, { color: theme.colors.dim, children: ["Press ", _jsx(Text, { color: theme.colors.text, children: "Esc" }), " or", ' ', _jsx(Text, { color: theme.colors.text, children: "?" }), " to close"] })] })] }));
|
|
9
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, padding: 1, children: [_jsx(Box, { borderStyle: "single", borderColor: theme.colors.secondary, paddingX: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Keyboard Shortcuts" }) }), _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, color: theme.colors.secondary, children: "Global" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "q / Esc" }), " - Quit", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "?" }), " - Help", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "/" }), " - Search", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Shift+P" }), " - Playlists", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "p" }), " - Plugins", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "g" }), " - Suggestions", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "," }), " - Settings"] }) }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "Player" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "Space" }), " - Play/Pause", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "n" }), " - Next", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "b" }), " - Previous", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "=" }), " - Volume Up", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "-" }), " - Volume Down", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "s" }), " - Toggle Shuffle", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "r" }), " - Toggle Repeat"] }) }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "Navigation" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "Up" }), " /", _jsx(Text, { children: " " }), _jsx(Text, { color: theme.colors.text, children: "k" }), " - Move Up", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Down" }), " /", _jsx(Text, { children: " " }), _jsx(Text, { color: theme.colors.text, children: "j" }), " - Move Down", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Enter" }), " - Select", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Esc" }), " - Go Back"] }) }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "Search" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "Tab" }), " - Switch Search Type", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "m" }), " - Create Mix Playlist", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Shift+D" }), " - Download selection", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Esc" }), " - Clear Search", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "[ / ]" }), " - Results Limit"] }) }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "Playlist" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "a" }), " - Add to Playlist", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "d" }), " - Remove from Playlist", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "c" }), " - Create Playlist", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Shift+D" }), " - Download Playlist", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "D" }), " - Delete Playlist"] }) }), _jsx(Text, { bold: true, color: theme.colors.secondary, children: "View" }), _jsx(Box, { paddingX: 2, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.text, children: "M" }), " - Toggle Mini Player", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "l" }), " - Lyrics", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "T" }), " - Trending", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "e" }), " - Explore"] }) }), _jsxs(Text, { color: theme.colors.dim, children: ["Press ", _jsx(Text, { color: theme.colors.text, children: "Esc" }), " or", ' ', _jsx(Text, { color: theme.colors.text, children: "?" }), " to close"] })] })] }));
|
|
10
10
|
}
|
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// Shortcuts bar component
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
|
-
import { useCallback } from 'react';
|
|
5
4
|
import { usePlayer } from "../../hooks/usePlayer.js";
|
|
6
|
-
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
7
5
|
import { useTheme } from "../../hooks/useTheme.js";
|
|
8
6
|
import { useKeyBinding } from "../../hooks/useKeyboard.js";
|
|
9
7
|
import { KEYBINDINGS } from "../../utils/constants.js";
|
|
10
8
|
export default function ShortcutsBar() {
|
|
11
9
|
const { theme } = useTheme();
|
|
12
|
-
const { dispatch: navDispatch } = useNavigation();
|
|
13
10
|
const { state: playerState, pause, resume, next, previous, volumeUp, volumeDown, volumeFineUp, volumeFineDown, } = usePlayer();
|
|
14
11
|
// Register key bindings globally
|
|
15
12
|
const handlePlayPause = () => {
|
|
@@ -20,9 +17,6 @@ export default function ShortcutsBar() {
|
|
|
20
17
|
resume();
|
|
21
18
|
}
|
|
22
19
|
};
|
|
23
|
-
const goConfig = useCallback(() => {
|
|
24
|
-
navDispatch({ category: 'NAVIGATE', view: 'config' });
|
|
25
|
-
}, [navDispatch]);
|
|
26
20
|
useKeyBinding(KEYBINDINGS.PLAY_PAUSE, handlePlayPause);
|
|
27
21
|
useKeyBinding(KEYBINDINGS.NEXT, next);
|
|
28
22
|
useKeyBinding(KEYBINDINGS.PREVIOUS, previous);
|
|
@@ -30,6 +24,6 @@ export default function ShortcutsBar() {
|
|
|
30
24
|
useKeyBinding(KEYBINDINGS.VOLUME_DOWN, volumeDown);
|
|
31
25
|
useKeyBinding(KEYBINDINGS.VOLUME_FINE_UP, volumeFineUp);
|
|
32
26
|
useKeyBinding(KEYBINDINGS.VOLUME_FINE_DOWN, volumeFineDown);
|
|
33
|
-
|
|
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: "\u2192" }), " Next |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u2190" }), "
|
|
27
|
+
// Note: SETTINGS keybinding handled by MainLayout to avoid double-dispatch
|
|
28
|
+
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: "\u2192" }), " Next |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u2190" }), " Prev |", ' ', _jsx(Text, { color: theme.colors.text, children: "Shift+P" }), " Playlists |", ' ', _jsx(Text, { color: theme.colors.text, children: "Shift+D" }), " Download |", ' ', _jsx(Text, { color: theme.colors.text, children: "m" }), " Mix |", ' ', _jsx(Text, { color: theme.colors.text, children: "M" }), " Mini |", ' ', _jsx(Text, { color: theme.colors.text, children: "/" }), " Search |", ' ', _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, "%"] })] })] }));
|
|
35
29
|
}
|
|
@@ -76,7 +76,7 @@ function MainLayout() {
|
|
|
76
76
|
useKeyBinding(KEYBINDINGS.SUGGESTIONS, goToSuggestions);
|
|
77
77
|
useKeyBinding(KEYBINDINGS.SETTINGS, goToSettings);
|
|
78
78
|
useKeyBinding(KEYBINDINGS.HELP, goToHelp);
|
|
79
|
-
useKeyBinding(['
|
|
79
|
+
useKeyBinding(['M'], togglePlayerMode);
|
|
80
80
|
useKeyBinding(['l'], goToLyrics);
|
|
81
81
|
useKeyBinding(['T'], goToTrending);
|
|
82
82
|
useKeyBinding(['e'], goToExplore);
|
|
@@ -17,8 +17,8 @@ function SearchLayout() {
|
|
|
17
17
|
const [results, setResults] = useState([]);
|
|
18
18
|
const [isTyping, setIsTyping] = useState(true);
|
|
19
19
|
const [isSearching, setIsSearching] = useState(false);
|
|
20
|
-
const [
|
|
21
|
-
const
|
|
20
|
+
const [actionMessage, setActionMessage] = useState(null);
|
|
21
|
+
const actionTimeoutRef = useRef(null);
|
|
22
22
|
// Handle search action
|
|
23
23
|
const performSearch = useCallback(async (query) => {
|
|
24
24
|
if (!query || isSearching)
|
|
@@ -74,19 +74,29 @@ function SearchLayout() {
|
|
|
74
74
|
}, [isTyping, dispatch]);
|
|
75
75
|
useKeyBinding(KEYBINDINGS.BACK, goBack);
|
|
76
76
|
const handleMixCreated = useCallback((message) => {
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
clearTimeout(
|
|
77
|
+
setActionMessage(message);
|
|
78
|
+
if (actionTimeoutRef.current) {
|
|
79
|
+
clearTimeout(actionTimeoutRef.current);
|
|
80
80
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
actionTimeoutRef.current = setTimeout(() => {
|
|
82
|
+
setActionMessage(null);
|
|
83
|
+
actionTimeoutRef.current = null;
|
|
84
|
+
}, 4000);
|
|
85
|
+
}, []);
|
|
86
|
+
const handleDownloadStatus = useCallback((message) => {
|
|
87
|
+
setActionMessage(message);
|
|
88
|
+
if (actionTimeoutRef.current) {
|
|
89
|
+
clearTimeout(actionTimeoutRef.current);
|
|
90
|
+
}
|
|
91
|
+
actionTimeoutRef.current = setTimeout(() => {
|
|
92
|
+
setActionMessage(null);
|
|
93
|
+
actionTimeoutRef.current = null;
|
|
84
94
|
}, 4000);
|
|
85
95
|
}, []);
|
|
86
96
|
useEffect(() => {
|
|
87
97
|
return () => {
|
|
88
|
-
if (
|
|
89
|
-
clearTimeout(
|
|
98
|
+
if (actionTimeoutRef.current) {
|
|
99
|
+
clearTimeout(actionTimeoutRef.current);
|
|
90
100
|
}
|
|
91
101
|
};
|
|
92
102
|
}, []);
|
|
@@ -100,8 +110,8 @@ function SearchLayout() {
|
|
|
100
110
|
}, [dispatch]);
|
|
101
111
|
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 => {
|
|
102
112
|
void performSearch(input);
|
|
103
|
-
} }), (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, onMixCreated: handleMixCreated })), !isLoading && navState.hasSearched && results.length === 0 && !error && (_jsx(Text, { color: theme.colors.dim, children: "No results found" })),
|
|
113
|
+
} }), (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, onMixCreated: handleMixCreated, onDownloadStatus: handleDownloadStatus })), !isLoading && navState.hasSearched && results.length === 0 && !error && (_jsx(Text, { color: theme.colors.dim, children: "No results found" })), actionMessage && (_jsx(Text, { color: theme.colors.accent, children: actionMessage })), _jsx(Text, { color: theme.colors.dim, children: isTyping
|
|
104
114
|
? 'Type to search, Enter to start, Esc to clear'
|
|
105
|
-
: `Arrows to navigate, Enter to play, M
|
|
115
|
+
: `Arrows to navigate, Enter to play, M mix, Shift+D download, ]/[ more/fewer results (${navState.searchLimit}), H history, Esc to type` })] }));
|
|
106
116
|
}
|
|
107
117
|
export default React.memo(SearchLayout);
|
|
@@ -10,15 +10,19 @@ import { usePlaylist } from "../../hooks/usePlaylist.js";
|
|
|
10
10
|
import { useTheme } from "../../hooks/useTheme.js";
|
|
11
11
|
import { useKeyboardBlocker } from "../../hooks/useKeyboardBlocker.js";
|
|
12
12
|
import { KEYBINDINGS } from "../../utils/constants.js";
|
|
13
|
+
import { getDownloadService } from "../../services/download/download.service.js";
|
|
13
14
|
export default function PlaylistList() {
|
|
14
15
|
const { theme } = useTheme();
|
|
15
16
|
const { play, setQueue } = usePlayer();
|
|
16
17
|
const { dispatch } = useNavigation();
|
|
17
|
-
const
|
|
18
|
+
const downloadService = getDownloadService();
|
|
19
|
+
const { playlists, createPlaylist, renamePlaylist, deletePlaylist } = usePlaylist();
|
|
18
20
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
19
21
|
const [lastCreated, setLastCreated] = useState(null);
|
|
20
22
|
const [renamingPlaylistId, setRenamingPlaylistId] = useState(null);
|
|
21
23
|
const [renameValue, setRenameValue] = useState('');
|
|
24
|
+
const [downloadStatus, setDownloadStatus] = useState(null);
|
|
25
|
+
const [isDownloading, setIsDownloading] = useState(false);
|
|
22
26
|
useKeyboardBlocker(renamingPlaylistId !== null);
|
|
23
27
|
const handleCreate = useCallback(() => {
|
|
24
28
|
const name = `Playlist ${playlists.length + 1}`;
|
|
@@ -67,16 +71,68 @@ export default function PlaylistList() {
|
|
|
67
71
|
}
|
|
68
72
|
dispatch({ category: 'GO_BACK' });
|
|
69
73
|
}, [dispatch, renamingPlaylistId]);
|
|
74
|
+
const handleDelete = useCallback(() => {
|
|
75
|
+
if (renamingPlaylistId)
|
|
76
|
+
return;
|
|
77
|
+
const playlist = playlists[selectedIndex];
|
|
78
|
+
if (!playlist)
|
|
79
|
+
return;
|
|
80
|
+
deletePlaylist(playlist.playlistId);
|
|
81
|
+
setSelectedIndex(prev => Math.max(0, prev - 1));
|
|
82
|
+
}, [deletePlaylist, playlists, renamingPlaylistId, selectedIndex]);
|
|
83
|
+
const handleDownload = useCallback(async () => {
|
|
84
|
+
if (renamingPlaylistId)
|
|
85
|
+
return;
|
|
86
|
+
if (isDownloading) {
|
|
87
|
+
setDownloadStatus('Download already in progress. Please wait.');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const playlist = playlists[selectedIndex];
|
|
91
|
+
if (!playlist)
|
|
92
|
+
return;
|
|
93
|
+
const config = downloadService.getConfig();
|
|
94
|
+
if (!config.enabled) {
|
|
95
|
+
setDownloadStatus('Downloads are disabled. Enable Download Feature in Settings.');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const target = downloadService.resolvePlaylistTarget(playlist);
|
|
99
|
+
if (target.tracks.length === 0) {
|
|
100
|
+
setDownloadStatus(`No tracks to download in "${playlist.name}".`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
setDownloadStatus(`Downloading ${target.tracks.length} track(s) from "${playlist.name}"... this can take a few minutes.`);
|
|
104
|
+
try {
|
|
105
|
+
setIsDownloading(true);
|
|
106
|
+
const summary = await downloadService.downloadTracks(target.tracks);
|
|
107
|
+
setDownloadStatus(`Downloaded ${summary.downloaded}, skipped ${summary.skipped}, failed ${summary.failed}.`);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
setDownloadStatus(error instanceof Error ? error.message : 'Failed to download playlist.');
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
setIsDownloading(false);
|
|
114
|
+
}
|
|
115
|
+
}, [
|
|
116
|
+
downloadService,
|
|
117
|
+
isDownloading,
|
|
118
|
+
playlists,
|
|
119
|
+
renamingPlaylistId,
|
|
120
|
+
selectedIndex,
|
|
121
|
+
]);
|
|
70
122
|
useKeyBinding(KEYBINDINGS.UP, navigateUp);
|
|
71
123
|
useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
|
|
72
124
|
useKeyBinding(KEYBINDINGS.SELECT, startPlaylist);
|
|
73
125
|
useKeyBinding(['r'], handleRename);
|
|
74
126
|
useKeyBinding(KEYBINDINGS.CREATE_PLAYLIST, handleCreate);
|
|
127
|
+
useKeyBinding(KEYBINDINGS.DELETE_PLAYLIST, handleDelete);
|
|
75
128
|
useKeyBinding(KEYBINDINGS.BACK, handleBack);
|
|
129
|
+
useKeyBinding(KEYBINDINGS.DOWNLOAD, () => {
|
|
130
|
+
void handleDownload();
|
|
131
|
+
});
|
|
76
132
|
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Playlists" }) }), playlists.length === 0 ? (_jsx(Text, { color: theme.colors.dim, children: "No playlists yet" })) : (playlists.map((playlist, index) => {
|
|
77
133
|
const isSelected = index === selectedIndex;
|
|
78
134
|
const isRenaming = renamingPlaylistId === playlist.playlistId && isSelected;
|
|
79
135
|
const rowBackground = isSelected ? theme.colors.secondary : undefined;
|
|
80
136
|
return (_jsxs(Box, { paddingX: 1, backgroundColor: rowBackground, children: [_jsxs(Text, { color: isSelected ? theme.colors.background : theme.colors.primary, bold: isSelected, children: [index + 1, "."] }), _jsx(Text, { children: " " }), _jsxs(Box, { flexDirection: "column", children: [isRenaming ? (_jsx(TextInput, { value: renameValue, onChange: setRenameValue, onSubmit: handleRenameSubmit, placeholder: "Playlist name", focus: true })) : (_jsx(Text, { color: isSelected ? theme.colors.background : theme.colors.text, bold: isSelected, children: playlist.name })), _jsx(Text, { color: theme.colors.dim, children: ` (${playlist.tracks.length} tracks)` })] })] }, playlist.playlistId));
|
|
81
|
-
})), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: theme.colors.dim, children: [_jsx(Text, { color: theme.colors.text, children: "Enter" }), " to play
|
|
137
|
+
})), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: theme.colors.dim, children: [_jsx(Text, { color: theme.colors.text, children: "Enter" }), " to play |", ' ', _jsx(Text, { color: theme.colors.text, children: "r" }), " rename |", ' ', _jsx(Text, { color: theme.colors.text, children: "c" }), " create |", ' ', _jsx(Text, { color: theme.colors.text, children: "Shift+D" }), " download |", ' ', _jsx(Text, { color: theme.colors.text, children: "D" }), " delete |", ' ', _jsx(Text, { color: theme.colors.text, children: "Esc" }), " back"] }), lastCreated && (_jsxs(Text, { color: theme.colors.accent, children: [" Created ", lastCreated] })), downloadStatus && (_jsx(Text, { color: theme.colors.accent, children: downloadStatus }))] })] }));
|
|
82
138
|
}
|
|
@@ -5,7 +5,8 @@ type Props = {
|
|
|
5
5
|
selectedIndex: number;
|
|
6
6
|
isActive?: boolean;
|
|
7
7
|
onMixCreated?: (message: string) => void;
|
|
8
|
+
onDownloadStatus?: (message: string) => void;
|
|
8
9
|
};
|
|
9
|
-
declare function SearchResults({ results, selectedIndex, isActive, onMixCreated, }: Props): import("react/jsx-runtime").JSX.Element | null;
|
|
10
|
+
declare function SearchResults({ results, selectedIndex, isActive, onMixCreated, onDownloadStatus, }: Props): import("react/jsx-runtime").JSX.Element | null;
|
|
10
11
|
declare const _default: React.MemoExoticComponent<typeof SearchResults>;
|
|
11
12
|
export default _default;
|
|
@@ -7,23 +7,28 @@ import { useNavigation } from "../../hooks/useNavigation.js";
|
|
|
7
7
|
import { useKeyBinding } from "../../hooks/useKeyboard.js";
|
|
8
8
|
import { usePlayer } from "../../hooks/usePlayer.js";
|
|
9
9
|
import { usePlaylist } from "../../hooks/usePlaylist.js";
|
|
10
|
-
import { KEYBINDINGS } from "../../utils/constants.js";
|
|
10
|
+
import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
|
|
11
11
|
import { truncate } from "../../utils/format.js";
|
|
12
|
-
import { useCallback, useRef, useEffect } from 'react';
|
|
12
|
+
import { useCallback, useRef, useEffect, useState } from 'react';
|
|
13
13
|
import { logger } from "../../services/logger/logger.service.js";
|
|
14
14
|
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
15
15
|
import { getMusicService } from "../../services/youtube-music/api.js";
|
|
16
|
+
import { getDownloadService } from "../../services/download/download.service.js";
|
|
16
17
|
// Generate unique component instance ID
|
|
17
18
|
let instanceCounter = 0;
|
|
18
|
-
function SearchResults({ results, selectedIndex, isActive = true, onMixCreated, }) {
|
|
19
|
+
function SearchResults({ results, selectedIndex, isActive = true, onMixCreated, onDownloadStatus, }) {
|
|
19
20
|
const { theme } = useTheme();
|
|
20
21
|
const { dispatch } = useNavigation();
|
|
21
22
|
const { play, dispatch: playerDispatch } = usePlayer();
|
|
22
23
|
const { columns } = useTerminalSize();
|
|
23
24
|
const musicService = getMusicService();
|
|
25
|
+
const downloadService = getDownloadService();
|
|
24
26
|
const { createPlaylist } = usePlaylist();
|
|
27
|
+
const [isDownloading, setIsDownloading] = useState(false);
|
|
25
28
|
const mixCreatedRef = useRef(onMixCreated);
|
|
26
29
|
mixCreatedRef.current = onMixCreated;
|
|
30
|
+
const downloadStatusRef = useRef(onDownloadStatus);
|
|
31
|
+
downloadStatusRef.current = onDownloadStatus;
|
|
27
32
|
// Track component instance and last action time for debouncing
|
|
28
33
|
const instanceIdRef = useRef(++instanceCounter);
|
|
29
34
|
const lastSelectTime = useRef(0);
|
|
@@ -206,21 +211,60 @@ function SearchResults({ results, selectedIndex, isActive = true, onMixCreated,
|
|
|
206
211
|
if (firstTrack) {
|
|
207
212
|
playerDispatch({ category: 'PLAY', track: firstTrack });
|
|
208
213
|
}
|
|
214
|
+
// Navigate to player view so the user lands on the queue/player
|
|
215
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.PLAYER });
|
|
209
216
|
mixCreatedRef.current?.(`Created mix "${playlist.name}" with ${uniqueTracks.length} tracks — playing now.`);
|
|
210
217
|
}, [
|
|
211
218
|
createPlaylist,
|
|
219
|
+
dispatch,
|
|
212
220
|
isActive,
|
|
213
221
|
musicService,
|
|
214
222
|
playerDispatch,
|
|
215
223
|
results,
|
|
216
224
|
selectedIndex,
|
|
217
225
|
]);
|
|
226
|
+
const downloadSelected = useCallback(async () => {
|
|
227
|
+
if (!isActive)
|
|
228
|
+
return;
|
|
229
|
+
if (isDownloading) {
|
|
230
|
+
downloadStatusRef.current?.('Download already in progress. Please wait.');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const selected = results[selectedIndex];
|
|
234
|
+
if (!selected)
|
|
235
|
+
return;
|
|
236
|
+
const config = downloadService.getConfig();
|
|
237
|
+
if (!config.enabled) {
|
|
238
|
+
downloadStatusRef.current?.('Downloads are disabled. Enable Download Feature in Settings.');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
setIsDownloading(true);
|
|
243
|
+
const target = await downloadService.resolveSearchTarget(selected);
|
|
244
|
+
if (target.tracks.length === 0) {
|
|
245
|
+
downloadStatusRef.current?.(`No tracks found for "${target.name}".`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
downloadStatusRef.current?.(`Downloading ${target.tracks.length} track(s) from "${target.name}"... this can take a few minutes.`);
|
|
249
|
+
const summary = await downloadService.downloadTracks(target.tracks);
|
|
250
|
+
downloadStatusRef.current?.(`Downloaded ${summary.downloaded}, skipped ${summary.skipped}, failed ${summary.failed}.`);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
downloadStatusRef.current?.(error instanceof Error ? error.message : 'Download failed.');
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
setIsDownloading(false);
|
|
257
|
+
}
|
|
258
|
+
}, [downloadService, isActive, isDownloading, results, selectedIndex]);
|
|
218
259
|
useKeyBinding(KEYBINDINGS.UP, navigateUp);
|
|
219
260
|
useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
|
|
220
261
|
useKeyBinding(KEYBINDINGS.SELECT, handleSelect);
|
|
221
262
|
useKeyBinding(KEYBINDINGS.CREATE_MIX, () => {
|
|
222
263
|
void createMixPlaylist();
|
|
223
264
|
});
|
|
265
|
+
useKeyBinding(KEYBINDINGS.DOWNLOAD, () => {
|
|
266
|
+
void downloadSelected();
|
|
267
|
+
});
|
|
224
268
|
// Note: Removed redundant useEffect that was syncing selectedIndex to dispatch
|
|
225
269
|
// This was causing unnecessary re-renders. The selectedIndex is already managed
|
|
226
270
|
// by the parent component (SearchLayout) and passed down as a prop.
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
// Settings component
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import { Box, Text } from 'ink';
|
|
5
|
+
import TextInput from 'ink-text-input';
|
|
5
6
|
import { useTheme } from "../../hooks/useTheme.js";
|
|
6
7
|
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
7
8
|
import { getConfigService } from "../../services/config/config.service.js";
|
|
@@ -9,12 +10,17 @@ import { useKeyBinding } from "../../hooks/useKeyboard.js";
|
|
|
9
10
|
import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
|
|
10
11
|
import { useSleepTimer } from "../../hooks/useSleepTimer.js";
|
|
11
12
|
import { formatTime } from "../../utils/format.js";
|
|
13
|
+
import { useKeyboardBlocker } from "../../hooks/useKeyboardBlocker.js";
|
|
12
14
|
const QUALITIES = ['low', 'medium', 'high'];
|
|
15
|
+
const DOWNLOAD_FORMATS = ['mp3', 'm4a'];
|
|
13
16
|
const SETTINGS_ITEMS = [
|
|
14
17
|
'Stream Quality',
|
|
15
18
|
'Audio Normalization',
|
|
16
19
|
'Notifications',
|
|
17
20
|
'Discord Rich Presence',
|
|
21
|
+
'Downloads Enabled',
|
|
22
|
+
'Download Folder',
|
|
23
|
+
'Download Format',
|
|
18
24
|
'Sleep Timer',
|
|
19
25
|
'Custom Keybindings',
|
|
20
26
|
'Manage Plugins',
|
|
@@ -28,7 +34,12 @@ export default function Settings() {
|
|
|
28
34
|
const [audioNormalization, setAudioNormalization] = useState(config.get('audioNormalization') ?? false);
|
|
29
35
|
const [notifications, setNotifications] = useState(config.get('notifications') ?? false);
|
|
30
36
|
const [discordRpc, setDiscordRpc] = useState(config.get('discordRichPresence') ?? false);
|
|
37
|
+
const [downloadsEnabled, setDownloadsEnabled] = useState(config.get('downloadsEnabled') ?? false);
|
|
38
|
+
const [downloadDirectory, setDownloadDirectory] = useState(config.get('downloadDirectory') ?? '');
|
|
39
|
+
const [downloadFormat, setDownloadFormat] = useState(config.get('downloadFormat') ?? 'mp3');
|
|
40
|
+
const [isEditingDownloadDirectory, setIsEditingDownloadDirectory] = useState(false);
|
|
31
41
|
const { isActive, activeMinutes, remainingSeconds, startTimer, cancelTimer, presets, } = useSleepTimer();
|
|
42
|
+
useKeyboardBlocker(isEditingDownloadDirectory);
|
|
32
43
|
const navigateUp = () => {
|
|
33
44
|
setSelectedIndex(prev => Math.max(0, prev - 1));
|
|
34
45
|
};
|
|
@@ -56,6 +67,17 @@ export default function Settings() {
|
|
|
56
67
|
setDiscordRpc(next);
|
|
57
68
|
config.set('discordRichPresence', next);
|
|
58
69
|
};
|
|
70
|
+
const toggleDownloadsEnabled = () => {
|
|
71
|
+
const next = !downloadsEnabled;
|
|
72
|
+
setDownloadsEnabled(next);
|
|
73
|
+
config.set('downloadsEnabled', next);
|
|
74
|
+
};
|
|
75
|
+
const cycleDownloadFormat = () => {
|
|
76
|
+
const currentIndex = DOWNLOAD_FORMATS.indexOf(downloadFormat);
|
|
77
|
+
const nextFormat = DOWNLOAD_FORMATS[(currentIndex + 1) % DOWNLOAD_FORMATS.length];
|
|
78
|
+
setDownloadFormat(nextFormat);
|
|
79
|
+
config.set('downloadFormat', nextFormat);
|
|
80
|
+
};
|
|
59
81
|
const cycleSleepTimer = () => {
|
|
60
82
|
if (isActive) {
|
|
61
83
|
cancelTimer();
|
|
@@ -82,12 +104,21 @@ export default function Settings() {
|
|
|
82
104
|
toggleDiscordRpc();
|
|
83
105
|
}
|
|
84
106
|
else if (selectedIndex === 4) {
|
|
85
|
-
|
|
107
|
+
toggleDownloadsEnabled();
|
|
86
108
|
}
|
|
87
109
|
else if (selectedIndex === 5) {
|
|
88
|
-
|
|
110
|
+
setIsEditingDownloadDirectory(true);
|
|
89
111
|
}
|
|
90
112
|
else if (selectedIndex === 6) {
|
|
113
|
+
cycleDownloadFormat();
|
|
114
|
+
}
|
|
115
|
+
else if (selectedIndex === 7) {
|
|
116
|
+
cycleSleepTimer();
|
|
117
|
+
}
|
|
118
|
+
else if (selectedIndex === 8) {
|
|
119
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.KEYBINDINGS });
|
|
120
|
+
}
|
|
121
|
+
else if (selectedIndex === 9) {
|
|
91
122
|
dispatch({ category: 'NAVIGATE', view: VIEW.PLUGINS });
|
|
92
123
|
}
|
|
93
124
|
};
|
|
@@ -97,9 +128,19 @@ export default function Settings() {
|
|
|
97
128
|
const sleepTimerLabel = isActive && remainingSeconds !== null
|
|
98
129
|
? `Sleep Timer: ${formatTime(remainingSeconds)} remaining (Enter to cancel)`
|
|
99
130
|
: 'Sleep Timer: Off (Enter to set)';
|
|
100
|
-
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Settings" }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 0 ? theme.colors.primary : undefined, color: selectedIndex === 0 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 0, children: ["Stream Quality: ", quality.toUpperCase()] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 1 ? theme.colors.primary : undefined, color: selectedIndex === 1 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 1, children: ["Audio Normalization: ", audioNormalization ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 2 ? theme.colors.primary : undefined, color: selectedIndex === 2 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 2, children: ["Desktop Notifications: ", notifications ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 3 ? theme.colors.primary : undefined, color: selectedIndex === 3 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 3, children: ["Discord Rich Presence: ", discordRpc ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children:
|
|
131
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Settings" }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 0 ? theme.colors.primary : undefined, color: selectedIndex === 0 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 0, children: ["Stream Quality: ", quality.toUpperCase()] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 1 ? theme.colors.primary : undefined, color: selectedIndex === 1 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 1, children: ["Audio Normalization: ", audioNormalization ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 2 ? theme.colors.primary : undefined, color: selectedIndex === 2 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 2, children: ["Desktop Notifications: ", notifications ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 3 ? theme.colors.primary : undefined, color: selectedIndex === 3 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 3, children: ["Discord Rich Presence: ", discordRpc ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 4 ? theme.colors.primary : undefined, color: selectedIndex === 4 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 4, children: ["Download Feature: ", downloadsEnabled ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: isEditingDownloadDirectory && selectedIndex === 5 ? (_jsx(TextInput, { value: downloadDirectory, onChange: setDownloadDirectory, onSubmit: value => {
|
|
132
|
+
const normalized = value.trim();
|
|
133
|
+
if (!normalized) {
|
|
134
|
+
setDownloadDirectory(config.get('downloadDirectory') ?? '');
|
|
135
|
+
setIsEditingDownloadDirectory(false);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
setDownloadDirectory(normalized);
|
|
139
|
+
config.set('downloadDirectory', normalized);
|
|
140
|
+
setIsEditingDownloadDirectory(false);
|
|
141
|
+
}, placeholder: "Download directory", focus: true })) : (_jsxs(Text, { backgroundColor: selectedIndex === 5 ? theme.colors.primary : undefined, color: selectedIndex === 5 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 5, children: ["Download Folder: ", downloadDirectory] })) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 6 ? theme.colors.primary : undefined, color: selectedIndex === 6 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 6, children: ["Download Format: ", downloadFormat.toUpperCase()] }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 7 ? theme.colors.primary : undefined, color: selectedIndex === 7
|
|
101
142
|
? theme.colors.background
|
|
102
143
|
: isActive
|
|
103
144
|
? theme.colors.accent
|
|
104
|
-
: theme.colors.text, bold: selectedIndex ===
|
|
145
|
+
: theme.colors.text, bold: selectedIndex === 7, children: sleepTimerLabel }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 8 ? theme.colors.primary : undefined, color: selectedIndex === 8 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 8, children: "Custom Keybindings \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 9 ? theme.colors.primary : undefined, color: selectedIndex === 9 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 9, children: "Manage Plugins" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: "Arrows to navigate, Enter to select, Esc/q to go back" }) })] }));
|
|
105
146
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { CONFIG_DIR, CONFIG_FILE } from "../../utils/constants.js";
|
|
3
3
|
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
4
4
|
import { BUILTIN_THEMES, DEFAULT_THEME } from "../../config/themes.config.js";
|
|
5
|
+
import path from 'node:path';
|
|
5
6
|
class ConfigService {
|
|
6
7
|
configPath;
|
|
7
8
|
configDir;
|
|
@@ -27,6 +28,9 @@ class ConfigService {
|
|
|
27
28
|
audioNormalization: false,
|
|
28
29
|
notifications: false,
|
|
29
30
|
discordRichPresence: false,
|
|
31
|
+
downloadsEnabled: false,
|
|
32
|
+
downloadDirectory: path.join(CONFIG_DIR, 'downloads'),
|
|
33
|
+
downloadFormat: 'mp3',
|
|
30
34
|
};
|
|
31
35
|
}
|
|
32
36
|
load() {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DownloadFormat } from '../../types/config.types.ts';
|
|
2
|
+
import type { Playlist, SearchResult, Track } from '../../types/youtube-music.types.ts';
|
|
3
|
+
type DownloadResult = {
|
|
4
|
+
downloaded: number;
|
|
5
|
+
skipped: number;
|
|
6
|
+
failed: number;
|
|
7
|
+
errors: string[];
|
|
8
|
+
};
|
|
9
|
+
type DownloadTarget = {
|
|
10
|
+
name: string;
|
|
11
|
+
tracks: Track[];
|
|
12
|
+
};
|
|
13
|
+
declare class DownloadService {
|
|
14
|
+
private ffmpegChecked;
|
|
15
|
+
private ffmpegAvailable;
|
|
16
|
+
private activeDownload;
|
|
17
|
+
private readonly config;
|
|
18
|
+
private readonly musicService;
|
|
19
|
+
getConfig(): {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
directory: string;
|
|
22
|
+
format: DownloadFormat;
|
|
23
|
+
};
|
|
24
|
+
resolveSearchTarget(result: SearchResult): Promise<DownloadTarget>;
|
|
25
|
+
resolvePlaylistTarget(playlist: Playlist): DownloadTarget;
|
|
26
|
+
downloadTracks(tracks: Track[]): Promise<DownloadResult>;
|
|
27
|
+
private uniqueTracks;
|
|
28
|
+
private getDestinationPath;
|
|
29
|
+
private sanitizeFilename;
|
|
30
|
+
private fetchAudio;
|
|
31
|
+
private ensureFfmpeg;
|
|
32
|
+
private convertAudio;
|
|
33
|
+
private runFfmpeg;
|
|
34
|
+
private recordViaYtDlp;
|
|
35
|
+
private recordViaMpv;
|
|
36
|
+
}
|
|
37
|
+
export declare function getDownloadService(): DownloadService;
|
|
38
|
+
export {};
|