@involvex/youtube-music-cli 0.0.8 → 0.0.11
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 +22 -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 +32 -3
- package/dist/source/components/playlist/PlaylistList.js +45 -4
- package/dist/source/components/search/SearchResults.d.ts +3 -1
- package/dist/source/components/search/SearchResults.js +132 -2
- package/dist/source/components/settings/Settings.js +45 -4
- package/dist/source/hooks/usePlaylist.d.ts +1 -1
- package/dist/source/hooks/usePlaylist.js +3 -2
- package/dist/source/services/config/config.service.js +4 -0
- package/dist/source/services/download/download.service.d.ts +35 -0
- package/dist/source/services/download/download.service.js +197 -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 +66 -25
- 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 +2 -0
- package/dist/source/utils/constants.js +2 -0
- package/dist/youtube-music-cli.exe +0 -0
- package/package.json +16 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
## [0.0.11](https://github.com/involvex/youtube-music-cli/compare/v0.0.10...v0.0.11) (2026-02-18)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
- **download:** add track, artist, and playlist downloads ([42298d2](https://github.com/involvex/youtube-music-cli/commit/42298d2fd35ba71ad841f58e140f4c53b3222ed7))
|
|
6
|
+
|
|
7
|
+
## [0.0.10](https://github.com/involvex/youtube-music-cli/compare/v0.0.9...v0.0.10) (2026-02-18)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
- mpv resume not working and double-process on track change ([fd04bda](https://github.com/involvex/youtube-music-cli/commit/fd04bdaa852f366eb92d265415ec18794ea5dfc3))
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
- dynamic mix playlist creation from search results ([d019048](https://github.com/involvex/youtube-music-cli/commit/d019048c4450d633003c8026ac43e92a743d87a0))
|
|
16
|
+
|
|
17
|
+
## [0.0.9](https://github.com/involvex/youtube-music-cli/compare/v0.0.8...v0.0.9) (2026-02-18)
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
- **search:** add dynamic mix creation from search results ([0d50231](https://github.com/involvex/youtube-music-cli/commit/0d5023168c73cec9d22dab9808e0fb2f23b5c1cc))
|
|
22
|
+
|
|
1
23
|
## [0.0.8](https://github.com/involvex/youtube-music-cli/compare/v0.0.7...v0.0.8) (2026-02-18)
|
|
2
24
|
|
|
3
25
|
### 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);
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
4
4
|
import { useYouTubeMusic } from "../../hooks/useYouTubeMusic.js";
|
|
5
5
|
import SearchResults from "../search/SearchResults.js";
|
|
6
|
-
import { useState, useCallback, useEffect } from 'react';
|
|
6
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
7
7
|
import React from 'react';
|
|
8
8
|
import { useTheme } from "../../hooks/useTheme.js";
|
|
9
9
|
import SearchBar from "../search/SearchBar.js";
|
|
@@ -17,6 +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 [actionMessage, setActionMessage] = useState(null);
|
|
21
|
+
const actionTimeoutRef = useRef(null);
|
|
20
22
|
// Handle search action
|
|
21
23
|
const performSearch = useCallback(async (query) => {
|
|
22
24
|
if (!query || isSearching)
|
|
@@ -71,6 +73,33 @@ function SearchLayout() {
|
|
|
71
73
|
}
|
|
72
74
|
}, [isTyping, dispatch]);
|
|
73
75
|
useKeyBinding(KEYBINDINGS.BACK, goBack);
|
|
76
|
+
const handleMixCreated = useCallback((message) => {
|
|
77
|
+
setActionMessage(message);
|
|
78
|
+
if (actionTimeoutRef.current) {
|
|
79
|
+
clearTimeout(actionTimeoutRef.current);
|
|
80
|
+
}
|
|
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;
|
|
94
|
+
}, 4000);
|
|
95
|
+
}, []);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
return () => {
|
|
98
|
+
if (actionTimeoutRef.current) {
|
|
99
|
+
clearTimeout(actionTimeoutRef.current);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}, []);
|
|
74
103
|
// Reset search state when leaving view
|
|
75
104
|
useEffect(() => {
|
|
76
105
|
return () => {
|
|
@@ -81,8 +110,8 @@ function SearchLayout() {
|
|
|
81
110
|
}, [dispatch]);
|
|
82
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 => {
|
|
83
112
|
void performSearch(input);
|
|
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
|
|
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
|
|
85
114
|
? 'Type to search, Enter to start, Esc to clear'
|
|
86
|
-
: `Arrows to navigate, Enter to play, ]/[ more/fewer results (${navState.searchLimit}), H
|
|
115
|
+
: `Arrows to navigate, Enter to play, M mix, Shift+D download, ]/[ more/fewer results (${navState.searchLimit}), H history, Esc to type` })] }));
|
|
87
116
|
}
|
|
88
117
|
export default React.memo(SearchLayout);
|
|
@@ -10,20 +10,23 @@ 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);
|
|
22
25
|
useKeyboardBlocker(renamingPlaylistId !== null);
|
|
23
26
|
const handleCreate = useCallback(() => {
|
|
24
27
|
const name = `Playlist ${playlists.length + 1}`;
|
|
25
|
-
createPlaylist(name);
|
|
26
|
-
setLastCreated(name);
|
|
28
|
+
const playlist = createPlaylist(name);
|
|
29
|
+
setLastCreated(playlist.name);
|
|
27
30
|
setSelectedIndex(playlists.length);
|
|
28
31
|
}, [createPlaylist, playlists.length]);
|
|
29
32
|
const navigateUp = useCallback(() => {
|
|
@@ -67,16 +70,54 @@ export default function PlaylistList() {
|
|
|
67
70
|
}
|
|
68
71
|
dispatch({ category: 'GO_BACK' });
|
|
69
72
|
}, [dispatch, renamingPlaylistId]);
|
|
73
|
+
const handleDelete = useCallback(() => {
|
|
74
|
+
if (renamingPlaylistId)
|
|
75
|
+
return;
|
|
76
|
+
const playlist = playlists[selectedIndex];
|
|
77
|
+
if (!playlist)
|
|
78
|
+
return;
|
|
79
|
+
deletePlaylist(playlist.playlistId);
|
|
80
|
+
setSelectedIndex(prev => Math.max(0, prev - 1));
|
|
81
|
+
}, [deletePlaylist, playlists, renamingPlaylistId, selectedIndex]);
|
|
82
|
+
const handleDownload = useCallback(async () => {
|
|
83
|
+
if (renamingPlaylistId)
|
|
84
|
+
return;
|
|
85
|
+
const playlist = playlists[selectedIndex];
|
|
86
|
+
if (!playlist)
|
|
87
|
+
return;
|
|
88
|
+
const config = downloadService.getConfig();
|
|
89
|
+
if (!config.enabled) {
|
|
90
|
+
setDownloadStatus('Downloads are disabled. Enable Download Feature in Settings.');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const target = downloadService.resolvePlaylistTarget(playlist);
|
|
94
|
+
if (target.tracks.length === 0) {
|
|
95
|
+
setDownloadStatus(`No tracks to download in "${playlist.name}".`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
setDownloadStatus(`Downloading ${target.tracks.length} track(s) from "${playlist.name}"...`);
|
|
99
|
+
try {
|
|
100
|
+
const summary = await downloadService.downloadTracks(target.tracks);
|
|
101
|
+
setDownloadStatus(`Downloaded ${summary.downloaded}, skipped ${summary.skipped}, failed ${summary.failed}.`);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
setDownloadStatus(error instanceof Error ? error.message : 'Failed to download playlist.');
|
|
105
|
+
}
|
|
106
|
+
}, [downloadService, playlists, renamingPlaylistId, selectedIndex]);
|
|
70
107
|
useKeyBinding(KEYBINDINGS.UP, navigateUp);
|
|
71
108
|
useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
|
|
72
109
|
useKeyBinding(KEYBINDINGS.SELECT, startPlaylist);
|
|
73
110
|
useKeyBinding(['r'], handleRename);
|
|
74
111
|
useKeyBinding(KEYBINDINGS.CREATE_PLAYLIST, handleCreate);
|
|
112
|
+
useKeyBinding(KEYBINDINGS.DELETE_PLAYLIST, handleDelete);
|
|
75
113
|
useKeyBinding(KEYBINDINGS.BACK, handleBack);
|
|
114
|
+
useKeyBinding(KEYBINDINGS.DOWNLOAD, () => {
|
|
115
|
+
void handleDownload();
|
|
116
|
+
});
|
|
76
117
|
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
118
|
const isSelected = index === selectedIndex;
|
|
78
119
|
const isRenaming = renamingPlaylistId === playlist.playlistId && isSelected;
|
|
79
120
|
const rowBackground = isSelected ? theme.colors.secondary : undefined;
|
|
80
121
|
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
|
|
122
|
+
})), _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
123
|
}
|
|
@@ -4,7 +4,9 @@ type Props = {
|
|
|
4
4
|
results: SearchResult[];
|
|
5
5
|
selectedIndex: number;
|
|
6
6
|
isActive?: boolean;
|
|
7
|
+
onMixCreated?: (message: string) => void;
|
|
8
|
+
onDownloadStatus?: (message: string) => void;
|
|
7
9
|
};
|
|
8
|
-
declare function SearchResults({ results, selectedIndex, isActive }: 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;
|
|
9
11
|
declare const _default: React.MemoExoticComponent<typeof SearchResults>;
|
|
10
12
|
export default _default;
|
|
@@ -6,20 +6,28 @@ import { useTheme } from "../../hooks/useTheme.js";
|
|
|
6
6
|
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
7
7
|
import { useKeyBinding } from "../../hooks/useKeyboard.js";
|
|
8
8
|
import { usePlayer } from "../../hooks/usePlayer.js";
|
|
9
|
-
import {
|
|
9
|
+
import { usePlaylist } from "../../hooks/usePlaylist.js";
|
|
10
|
+
import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
|
|
10
11
|
import { truncate } from "../../utils/format.js";
|
|
11
12
|
import { useCallback, useRef, useEffect } from 'react';
|
|
12
13
|
import { logger } from "../../services/logger/logger.service.js";
|
|
13
14
|
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
14
15
|
import { getMusicService } from "../../services/youtube-music/api.js";
|
|
16
|
+
import { getDownloadService } from "../../services/download/download.service.js";
|
|
15
17
|
// Generate unique component instance ID
|
|
16
18
|
let instanceCounter = 0;
|
|
17
|
-
function SearchResults({ results, selectedIndex, isActive = true }) {
|
|
19
|
+
function SearchResults({ results, selectedIndex, isActive = true, onMixCreated, onDownloadStatus, }) {
|
|
18
20
|
const { theme } = useTheme();
|
|
19
21
|
const { dispatch } = useNavigation();
|
|
20
22
|
const { play, dispatch: playerDispatch } = usePlayer();
|
|
21
23
|
const { columns } = useTerminalSize();
|
|
22
24
|
const musicService = getMusicService();
|
|
25
|
+
const downloadService = getDownloadService();
|
|
26
|
+
const { createPlaylist } = usePlaylist();
|
|
27
|
+
const mixCreatedRef = useRef(onMixCreated);
|
|
28
|
+
mixCreatedRef.current = onMixCreated;
|
|
29
|
+
const downloadStatusRef = useRef(onDownloadStatus);
|
|
30
|
+
downloadStatusRef.current = onDownloadStatus;
|
|
23
31
|
// Track component instance and last action time for debouncing
|
|
24
32
|
const instanceIdRef = useRef(++instanceCounter);
|
|
25
33
|
const lastSelectTime = useRef(0);
|
|
@@ -123,9 +131,131 @@ function SearchResults({ results, selectedIndex, isActive = true }) {
|
|
|
123
131
|
logger.debug('SearchResults', 'SELECT key pressed', { isActive, instanceId });
|
|
124
132
|
playSelected();
|
|
125
133
|
}, [isActive, playSelected]);
|
|
134
|
+
const createMixPlaylist = useCallback(async () => {
|
|
135
|
+
if (!isActive)
|
|
136
|
+
return;
|
|
137
|
+
const selected = results[selectedIndex];
|
|
138
|
+
if (!selected) {
|
|
139
|
+
logger.warn('SearchResults', 'No result selected for mix');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
let playlistName = 'Dynamic mix';
|
|
143
|
+
const collectedTracks = [];
|
|
144
|
+
if (selected.type === 'song') {
|
|
145
|
+
const selectedTrack = selected.data;
|
|
146
|
+
const title = selectedTrack.title || 'selected track';
|
|
147
|
+
playlistName = `Mix for ${title}`;
|
|
148
|
+
collectedTracks.push(selectedTrack);
|
|
149
|
+
try {
|
|
150
|
+
const suggestions = await musicService.getSuggestions(selectedTrack.videoId);
|
|
151
|
+
collectedTracks.push(...suggestions);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
logger.error('SearchResults', 'Failed to fetch song suggestions', {
|
|
155
|
+
error,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
else if (selected.type === 'artist') {
|
|
160
|
+
const artistName = 'name' in selected.data ? selected.data.name : '';
|
|
161
|
+
if (!artistName) {
|
|
162
|
+
logger.warn('SearchResults', 'Artist name missing for mix');
|
|
163
|
+
mixCreatedRef.current?.('Artist information is missing, cannot create mix.');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
playlistName = `${artistName} mix`;
|
|
167
|
+
try {
|
|
168
|
+
const response = await musicService.search(artistName, {
|
|
169
|
+
type: 'songs',
|
|
170
|
+
limit: 25,
|
|
171
|
+
});
|
|
172
|
+
const artistTracks = response.results
|
|
173
|
+
.filter(result => result.type === 'song')
|
|
174
|
+
.map(result => result.data);
|
|
175
|
+
collectedTracks.push(...artistTracks);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
logger.error('SearchResults', 'Failed to fetch artist songs for mix', {
|
|
179
|
+
error,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
logger.warn('SearchResults', 'Mix creation unsupported result type', {
|
|
185
|
+
type: selected.type,
|
|
186
|
+
});
|
|
187
|
+
mixCreatedRef.current?.('Mix creation is only supported for songs and artists.');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const uniqueTracks = [];
|
|
191
|
+
const seenVideoIds = new Set();
|
|
192
|
+
for (const track of collectedTracks) {
|
|
193
|
+
if (!track?.videoId || seenVideoIds.has(track.videoId))
|
|
194
|
+
continue;
|
|
195
|
+
seenVideoIds.add(track.videoId);
|
|
196
|
+
uniqueTracks.push(track);
|
|
197
|
+
}
|
|
198
|
+
if (uniqueTracks.length === 0) {
|
|
199
|
+
mixCreatedRef.current?.('No similar tracks were found to create a mix.');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const playlist = createPlaylist(playlistName, uniqueTracks);
|
|
203
|
+
logger.info('SearchResults', 'Mix playlist created', {
|
|
204
|
+
name: playlist.name,
|
|
205
|
+
trackCount: uniqueTracks.length,
|
|
206
|
+
});
|
|
207
|
+
// Queue the mix tracks and start playing the first one
|
|
208
|
+
playerDispatch({ category: 'SET_QUEUE', queue: uniqueTracks });
|
|
209
|
+
const firstTrack = uniqueTracks[0];
|
|
210
|
+
if (firstTrack) {
|
|
211
|
+
playerDispatch({ category: 'PLAY', track: firstTrack });
|
|
212
|
+
}
|
|
213
|
+
// Navigate to player view so the user lands on the queue/player
|
|
214
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.PLAYER });
|
|
215
|
+
mixCreatedRef.current?.(`Created mix "${playlist.name}" with ${uniqueTracks.length} tracks — playing now.`);
|
|
216
|
+
}, [
|
|
217
|
+
createPlaylist,
|
|
218
|
+
dispatch,
|
|
219
|
+
isActive,
|
|
220
|
+
musicService,
|
|
221
|
+
playerDispatch,
|
|
222
|
+
results,
|
|
223
|
+
selectedIndex,
|
|
224
|
+
]);
|
|
225
|
+
const downloadSelected = useCallback(async () => {
|
|
226
|
+
if (!isActive)
|
|
227
|
+
return;
|
|
228
|
+
const selected = results[selectedIndex];
|
|
229
|
+
if (!selected)
|
|
230
|
+
return;
|
|
231
|
+
const config = downloadService.getConfig();
|
|
232
|
+
if (!config.enabled) {
|
|
233
|
+
downloadStatusRef.current?.('Downloads are disabled. Enable Download Feature in Settings.');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const target = await downloadService.resolveSearchTarget(selected);
|
|
238
|
+
if (target.tracks.length === 0) {
|
|
239
|
+
downloadStatusRef.current?.(`No tracks found for "${target.name}".`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
downloadStatusRef.current?.(`Downloading ${target.tracks.length} track(s) from "${target.name}"...`);
|
|
243
|
+
const summary = await downloadService.downloadTracks(target.tracks);
|
|
244
|
+
downloadStatusRef.current?.(`Downloaded ${summary.downloaded}, skipped ${summary.skipped}, failed ${summary.failed}.`);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
downloadStatusRef.current?.(error instanceof Error ? error.message : 'Download failed.');
|
|
248
|
+
}
|
|
249
|
+
}, [downloadService, isActive, results, selectedIndex]);
|
|
126
250
|
useKeyBinding(KEYBINDINGS.UP, navigateUp);
|
|
127
251
|
useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
|
|
128
252
|
useKeyBinding(KEYBINDINGS.SELECT, handleSelect);
|
|
253
|
+
useKeyBinding(KEYBINDINGS.CREATE_MIX, () => {
|
|
254
|
+
void createMixPlaylist();
|
|
255
|
+
});
|
|
256
|
+
useKeyBinding(KEYBINDINGS.DOWNLOAD, () => {
|
|
257
|
+
void downloadSelected();
|
|
258
|
+
});
|
|
129
259
|
// Note: Removed redundant useEffect that was syncing selectedIndex to dispatch
|
|
130
260
|
// This was causing unnecessary re-renders. The selectedIndex is already managed
|
|
131
261
|
// 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,7 +2,7 @@ import type { Playlist, Track } from '../types/youtube-music.types.ts';
|
|
|
2
2
|
export type AddTrackResult = 'added' | 'duplicate';
|
|
3
3
|
export declare function usePlaylist(): {
|
|
4
4
|
playlists: Playlist[];
|
|
5
|
-
createPlaylist: (name: string) =>
|
|
5
|
+
createPlaylist: (name: string, tracks?: Track[]) => Playlist;
|
|
6
6
|
deletePlaylist: (playlistId: string) => void;
|
|
7
7
|
renamePlaylist: (playlistId: string, newName: string) => void;
|
|
8
8
|
addTrackToPlaylist: (playlistId: string, track: Track, force?: boolean) => AddTrackResult;
|
|
@@ -7,15 +7,16 @@ export function usePlaylist() {
|
|
|
7
7
|
useEffect(() => {
|
|
8
8
|
setPlaylists(configService.get('playlists'));
|
|
9
9
|
}, []);
|
|
10
|
-
const createPlaylist = useCallback((name) => {
|
|
10
|
+
const createPlaylist = useCallback((name, tracks = []) => {
|
|
11
11
|
const newPlaylist = {
|
|
12
12
|
playlistId: Date.now().toString(),
|
|
13
13
|
name,
|
|
14
|
-
tracks:
|
|
14
|
+
tracks: tracks.map(track => ({ ...track })),
|
|
15
15
|
};
|
|
16
16
|
const updatedPlaylists = [...playlists, newPlaylist];
|
|
17
17
|
setPlaylists(updatedPlaylists);
|
|
18
18
|
configService.set('playlists', updatedPlaylists);
|
|
19
|
+
return newPlaylist;
|
|
19
20
|
}, [playlists, configService]);
|
|
20
21
|
const deletePlaylist = useCallback((playlistId) => {
|
|
21
22
|
const updatedPlaylists = playlists.filter(p => p.playlistId !== playlistId);
|
|
@@ -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,35 @@
|
|
|
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 readonly config;
|
|
17
|
+
private readonly musicService;
|
|
18
|
+
getConfig(): {
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
directory: string;
|
|
21
|
+
format: DownloadFormat;
|
|
22
|
+
};
|
|
23
|
+
resolveSearchTarget(result: SearchResult): Promise<DownloadTarget>;
|
|
24
|
+
resolvePlaylistTarget(playlist: Playlist): DownloadTarget;
|
|
25
|
+
downloadTracks(tracks: Track[]): Promise<DownloadResult>;
|
|
26
|
+
private uniqueTracks;
|
|
27
|
+
private getDestinationPath;
|
|
28
|
+
private sanitizeFilename;
|
|
29
|
+
private fetchAudio;
|
|
30
|
+
private ensureFfmpeg;
|
|
31
|
+
private convertAudio;
|
|
32
|
+
private runFfmpeg;
|
|
33
|
+
}
|
|
34
|
+
export declare function getDownloadService(): DownloadService;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { getConfigService } from "../config/config.service.js";
|
|
5
|
+
import { logger } from "../logger/logger.service.js";
|
|
6
|
+
import { getMusicService } from "../youtube-music/api.js";
|
|
7
|
+
class DownloadService {
|
|
8
|
+
ffmpegChecked = false;
|
|
9
|
+
ffmpegAvailable = false;
|
|
10
|
+
config = getConfigService();
|
|
11
|
+
musicService = getMusicService();
|
|
12
|
+
getConfig() {
|
|
13
|
+
return {
|
|
14
|
+
enabled: this.config.get('downloadsEnabled') ?? false,
|
|
15
|
+
directory: this.config.get('downloadDirectory') ?? '',
|
|
16
|
+
format: (this.config.get('downloadFormat') ?? 'mp3'),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
async resolveSearchTarget(result) {
|
|
20
|
+
if (result.type === 'song') {
|
|
21
|
+
const track = result.data;
|
|
22
|
+
return { name: track.title, tracks: [track] };
|
|
23
|
+
}
|
|
24
|
+
if (result.type === 'artist') {
|
|
25
|
+
const artistName = 'name' in result.data ? result.data.name : '';
|
|
26
|
+
if (!artistName) {
|
|
27
|
+
throw new Error('Artist name is missing.');
|
|
28
|
+
}
|
|
29
|
+
const response = await this.musicService.search(artistName, {
|
|
30
|
+
type: 'songs',
|
|
31
|
+
limit: 25,
|
|
32
|
+
});
|
|
33
|
+
const tracks = response.results
|
|
34
|
+
.filter(row => row.type === 'song')
|
|
35
|
+
.map(row => row.data);
|
|
36
|
+
return { name: artistName, tracks: this.uniqueTracks(tracks) };
|
|
37
|
+
}
|
|
38
|
+
if (result.type === 'playlist') {
|
|
39
|
+
const playlistInfo = result.data;
|
|
40
|
+
if (!playlistInfo.playlistId) {
|
|
41
|
+
throw new Error('Playlist id is missing.');
|
|
42
|
+
}
|
|
43
|
+
const playlist = await this.musicService.getPlaylist(playlistInfo.playlistId);
|
|
44
|
+
return {
|
|
45
|
+
name: playlist.name || playlistInfo.name || 'playlist',
|
|
46
|
+
tracks: this.uniqueTracks(playlist.tracks),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
throw new Error('Downloads are supported for songs, artists, and playlists.');
|
|
50
|
+
}
|
|
51
|
+
resolvePlaylistTarget(playlist) {
|
|
52
|
+
return {
|
|
53
|
+
name: playlist.name,
|
|
54
|
+
tracks: this.uniqueTracks(playlist.tracks),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async downloadTracks(tracks) {
|
|
58
|
+
const { directory, format } = this.getConfig();
|
|
59
|
+
if (!directory) {
|
|
60
|
+
throw new Error('No download directory configured.');
|
|
61
|
+
}
|
|
62
|
+
mkdirSync(directory, { recursive: true });
|
|
63
|
+
await this.ensureFfmpeg();
|
|
64
|
+
const result = {
|
|
65
|
+
downloaded: 0,
|
|
66
|
+
skipped: 0,
|
|
67
|
+
failed: 0,
|
|
68
|
+
errors: [],
|
|
69
|
+
};
|
|
70
|
+
for (const track of tracks) {
|
|
71
|
+
const destination = this.getDestinationPath(track, directory, format);
|
|
72
|
+
const tempSource = `${destination}.source`;
|
|
73
|
+
try {
|
|
74
|
+
if (existsSync(destination)) {
|
|
75
|
+
result.skipped++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const streamUrl = await this.musicService.getStreamUrl(track.videoId);
|
|
79
|
+
const audioBuffer = await this.fetchAudio(streamUrl);
|
|
80
|
+
writeFileSync(tempSource, audioBuffer);
|
|
81
|
+
await this.convertAudio(tempSource, destination, format);
|
|
82
|
+
result.downloaded++;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
result.failed++;
|
|
86
|
+
const message = error instanceof Error ? error.message : 'Unknown download failure';
|
|
87
|
+
result.errors.push(message);
|
|
88
|
+
logger.error('DownloadService', 'Track download failed', {
|
|
89
|
+
videoId: track.videoId,
|
|
90
|
+
title: track.title,
|
|
91
|
+
error: message,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
if (existsSync(tempSource)) {
|
|
96
|
+
unlinkSync(tempSource);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
uniqueTracks(tracks) {
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
const unique = [];
|
|
105
|
+
for (const track of tracks) {
|
|
106
|
+
if (!track?.videoId || seen.has(track.videoId))
|
|
107
|
+
continue;
|
|
108
|
+
seen.add(track.videoId);
|
|
109
|
+
unique.push(track);
|
|
110
|
+
}
|
|
111
|
+
return unique;
|
|
112
|
+
}
|
|
113
|
+
getDestinationPath(track, directory, format) {
|
|
114
|
+
const artist = track.artists[0]?.name ?? 'Unknown Artist';
|
|
115
|
+
const baseName = this.sanitizeFilename(`${artist} - ${track.title}`);
|
|
116
|
+
return path.join(directory, `${baseName}.${format}`);
|
|
117
|
+
}
|
|
118
|
+
sanitizeFilename(value) {
|
|
119
|
+
return value.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_').trim();
|
|
120
|
+
}
|
|
121
|
+
async fetchAudio(url) {
|
|
122
|
+
const response = await fetch(url);
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new Error(`Failed to fetch audio stream (${response.status}).`);
|
|
125
|
+
}
|
|
126
|
+
const audio = await response.arrayBuffer();
|
|
127
|
+
return Buffer.from(audio);
|
|
128
|
+
}
|
|
129
|
+
async ensureFfmpeg() {
|
|
130
|
+
if (this.ffmpegChecked) {
|
|
131
|
+
if (!this.ffmpegAvailable) {
|
|
132
|
+
throw new Error('ffmpeg is required for downloads. Install ffmpeg and ensure it is available in PATH.');
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.ffmpegChecked = true;
|
|
137
|
+
try {
|
|
138
|
+
await this.runFfmpeg(['-version']);
|
|
139
|
+
this.ffmpegAvailable = true;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
this.ffmpegAvailable = false;
|
|
143
|
+
throw new Error('ffmpeg is required for downloads. Install ffmpeg and ensure it is available in PATH.');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async convertAudio(sourcePath, destinationPath, format) {
|
|
147
|
+
if (format === 'mp3') {
|
|
148
|
+
await this.runFfmpeg([
|
|
149
|
+
'-y',
|
|
150
|
+
'-i',
|
|
151
|
+
sourcePath,
|
|
152
|
+
'-vn',
|
|
153
|
+
'-codec:a',
|
|
154
|
+
'libmp3lame',
|
|
155
|
+
'-q:a',
|
|
156
|
+
'2',
|
|
157
|
+
destinationPath,
|
|
158
|
+
]);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
await this.runFfmpeg([
|
|
162
|
+
'-y',
|
|
163
|
+
'-i',
|
|
164
|
+
sourcePath,
|
|
165
|
+
'-vn',
|
|
166
|
+
'-codec:a',
|
|
167
|
+
'aac',
|
|
168
|
+
'-b:a',
|
|
169
|
+
'192k',
|
|
170
|
+
destinationPath,
|
|
171
|
+
]);
|
|
172
|
+
}
|
|
173
|
+
async runFfmpeg(args) {
|
|
174
|
+
await new Promise((resolve, reject) => {
|
|
175
|
+
const process = spawn('ffmpeg', args, { windowsHide: true });
|
|
176
|
+
let stderr = '';
|
|
177
|
+
process.stderr.on('data', chunk => {
|
|
178
|
+
stderr += String(chunk);
|
|
179
|
+
});
|
|
180
|
+
process.on('error', reject);
|
|
181
|
+
process.on('exit', code => {
|
|
182
|
+
if (code === 0) {
|
|
183
|
+
resolve();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
reject(new Error(stderr.trim() || `ffmpeg exited with code ${code}`));
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
let downloadServiceInstance = null;
|
|
192
|
+
export function getDownloadService() {
|
|
193
|
+
if (!downloadServiceInstance) {
|
|
194
|
+
downloadServiceInstance = new DownloadService();
|
|
195
|
+
}
|
|
196
|
+
return downloadServiceInstance;
|
|
197
|
+
}
|
|
@@ -21,6 +21,7 @@ declare class PlayerService {
|
|
|
21
21
|
private ipcConnectRetries;
|
|
22
22
|
private readonly maxIpcRetries;
|
|
23
23
|
private currentTrackId;
|
|
24
|
+
private playSessionId;
|
|
24
25
|
private constructor();
|
|
25
26
|
static getInstance(): PlayerService;
|
|
26
27
|
getCurrentTrackId(): string | null;
|
|
@@ -29,7 +30,7 @@ declare class PlayerService {
|
|
|
29
30
|
*/
|
|
30
31
|
onEvent(callback: PlayerEventCallback): void;
|
|
31
32
|
/**
|
|
32
|
-
* Generate IPC socket path based on platform
|
|
33
|
+
* Generate IPC socket path based on platform, unique per play session
|
|
33
34
|
*/
|
|
34
35
|
private getIpcPath;
|
|
35
36
|
/**
|
|
@@ -14,6 +14,7 @@ class PlayerService {
|
|
|
14
14
|
ipcConnectRetries = 0;
|
|
15
15
|
maxIpcRetries = 10;
|
|
16
16
|
currentTrackId = null; // Track currently playing
|
|
17
|
+
playSessionId = 0; // Incremented per play() call for unique IPC paths
|
|
17
18
|
constructor() { }
|
|
18
19
|
static getInstance() {
|
|
19
20
|
if (!PlayerService.instance) {
|
|
@@ -31,16 +32,16 @@ class PlayerService {
|
|
|
31
32
|
this.eventCallback = callback;
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
34
|
-
* Generate IPC socket path based on platform
|
|
35
|
+
* Generate IPC socket path based on platform, unique per play session
|
|
35
36
|
*/
|
|
36
37
|
getIpcPath() {
|
|
37
38
|
if (process.platform === 'win32') {
|
|
38
39
|
// Windows named pipe
|
|
39
|
-
return `\\\\.\\pipe\\mpvsocket-${process.pid}`;
|
|
40
|
+
return `\\\\.\\pipe\\mpvsocket-${process.pid}-${this.playSessionId}`;
|
|
40
41
|
}
|
|
41
42
|
else {
|
|
42
43
|
// Unix domain socket
|
|
43
|
-
return `/tmp/mpvsocket-${process.pid}`;
|
|
44
|
+
return `/tmp/mpvsocket-${process.pid}-${this.playSessionId}`;
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
/**
|
|
@@ -193,6 +194,8 @@ class PlayerService {
|
|
|
193
194
|
if (!url.startsWith('http')) {
|
|
194
195
|
playUrl = `https://www.youtube.com/watch?v=${url}`;
|
|
195
196
|
}
|
|
197
|
+
// Increment session ID for a unique IPC socket path per play call
|
|
198
|
+
this.playSessionId++;
|
|
196
199
|
// Generate IPC socket path
|
|
197
200
|
this.ipcPath = this.getIpcPath();
|
|
198
201
|
return new Promise((resolve, reject) => {
|
|
@@ -223,8 +226,11 @@ class PlayerService {
|
|
|
223
226
|
mpvArgs.push(`--http-proxy=${options.proxy}`);
|
|
224
227
|
}
|
|
225
228
|
mpvArgs.push(playUrl);
|
|
226
|
-
|
|
227
|
-
|
|
229
|
+
// Capture process in local var so stale exit handlers from a killed
|
|
230
|
+
// process don't overwrite state belonging to a newly-spawned process.
|
|
231
|
+
const spawnedProcess = spawn('mpv', mpvArgs);
|
|
232
|
+
this.mpvProcess = spawnedProcess;
|
|
233
|
+
if (!spawnedProcess.stdout || !spawnedProcess.stderr) {
|
|
228
234
|
throw new Error('Failed to create mpv process streams');
|
|
229
235
|
}
|
|
230
236
|
this.isPlaying = true;
|
|
@@ -238,27 +244,30 @@ class PlayerService {
|
|
|
238
244
|
});
|
|
239
245
|
}, 200);
|
|
240
246
|
// Handle stdout (should be minimal with --really-quiet)
|
|
241
|
-
|
|
247
|
+
spawnedProcess.stdout.on('data', (data) => {
|
|
242
248
|
logger.debug('PlayerService', 'mpv stdout', {
|
|
243
249
|
output: data.toString().trim(),
|
|
244
250
|
});
|
|
245
251
|
});
|
|
246
252
|
// Handle stderr (errors)
|
|
247
|
-
|
|
253
|
+
spawnedProcess.stderr.on('data', (data) => {
|
|
248
254
|
const error = data.toString().trim();
|
|
249
255
|
if (error) {
|
|
250
256
|
logger.error('PlayerService', 'mpv stderr', { error });
|
|
251
257
|
}
|
|
252
258
|
});
|
|
253
|
-
// Handle process exit
|
|
254
|
-
|
|
259
|
+
// Handle process exit — guard against stale handlers from killed processes
|
|
260
|
+
spawnedProcess.on('exit', (code, signal) => {
|
|
255
261
|
logger.info('PlayerService', 'mpv process exited', {
|
|
256
262
|
code,
|
|
257
263
|
signal,
|
|
258
264
|
wasPlaying: this.isPlaying,
|
|
259
265
|
});
|
|
260
|
-
this
|
|
261
|
-
this.mpvProcess
|
|
266
|
+
// Only update shared state if this is still the active process
|
|
267
|
+
if (this.mpvProcess === spawnedProcess) {
|
|
268
|
+
this.isPlaying = false;
|
|
269
|
+
this.mpvProcess = null;
|
|
270
|
+
}
|
|
262
271
|
if (code === 0) {
|
|
263
272
|
// Normal exit (track finished)
|
|
264
273
|
resolve();
|
|
@@ -269,14 +278,16 @@ class PlayerService {
|
|
|
269
278
|
}
|
|
270
279
|
// If killed by signal, don't reject (user stopped it)
|
|
271
280
|
});
|
|
272
|
-
// Handle errors
|
|
273
|
-
|
|
281
|
+
// Handle errors — same guard
|
|
282
|
+
spawnedProcess.on('error', (error) => {
|
|
274
283
|
logger.error('PlayerService', 'mpv process error', {
|
|
275
284
|
error: error.message,
|
|
276
285
|
stack: error.stack,
|
|
277
286
|
});
|
|
278
|
-
this.
|
|
279
|
-
|
|
287
|
+
if (this.mpvProcess === spawnedProcess) {
|
|
288
|
+
this.isPlaying = false;
|
|
289
|
+
this.mpvProcess = null;
|
|
290
|
+
}
|
|
280
291
|
reject(error);
|
|
281
292
|
});
|
|
282
293
|
logger.info('PlayerService', 'mpv process started successfully');
|
|
@@ -124,11 +124,50 @@ class MusicService {
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
async getPlaylist(playlistId) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
try {
|
|
128
|
+
const yt = await getClient();
|
|
129
|
+
const playlistData = (await yt.music.getPlaylist(playlistId));
|
|
130
|
+
const rows = [
|
|
131
|
+
...(playlistData.contents ?? []),
|
|
132
|
+
...(playlistData.tracks ?? []),
|
|
133
|
+
];
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
const tracks = [];
|
|
136
|
+
for (const row of rows) {
|
|
137
|
+
const videoId = row.id || row.video_id;
|
|
138
|
+
if (!videoId || seen.has(videoId))
|
|
139
|
+
continue;
|
|
140
|
+
seen.add(videoId);
|
|
141
|
+
tracks.push({
|
|
142
|
+
videoId,
|
|
143
|
+
title: (typeof row.title === 'string' ? row.title : row.title?.text) ??
|
|
144
|
+
'Unknown',
|
|
145
|
+
artists: (row.artists ?? []).map(artist => ({
|
|
146
|
+
artistId: artist.channel_id || artist.id || '',
|
|
147
|
+
name: artist.name ?? 'Unknown',
|
|
148
|
+
})),
|
|
149
|
+
duration: typeof row.duration === 'number'
|
|
150
|
+
? row.duration
|
|
151
|
+
: (row.duration?.seconds ?? 0),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
playlistId,
|
|
156
|
+
name: playlistData.title || playlistData.name || 'Unknown Playlist',
|
|
157
|
+
tracks,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
logger.error('MusicService', 'getPlaylist failed', {
|
|
162
|
+
playlistId,
|
|
163
|
+
error: error instanceof Error ? error.message : String(error),
|
|
164
|
+
});
|
|
165
|
+
return {
|
|
166
|
+
playlistId,
|
|
167
|
+
name: 'Unknown Playlist',
|
|
168
|
+
tracks: [],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
132
171
|
}
|
|
133
172
|
async getTrending() {
|
|
134
173
|
try {
|
|
@@ -218,37 +257,39 @@ class MusicService {
|
|
|
218
257
|
async getSuggestions(trackId) {
|
|
219
258
|
try {
|
|
220
259
|
const yt = await getClient();
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
catch (error) {
|
|
226
|
-
logger.warn('MusicService', 'getSuggestions getInfo failed', {
|
|
227
|
-
error: error instanceof Error ? error.message : String(error),
|
|
228
|
-
});
|
|
229
|
-
return [];
|
|
230
|
-
}
|
|
231
|
-
const suggestions = video?.related?.contents ?? [];
|
|
260
|
+
// Use music.getUpNext with automix — avoids the yt.getInfo() ParsingError
|
|
261
|
+
// caused by YouTube "Remove ads" menu items that youtubei.js can't parse.
|
|
262
|
+
const panel = await yt.music.getUpNext(trackId, true);
|
|
232
263
|
const tracks = [];
|
|
233
|
-
for (const item of
|
|
234
|
-
const
|
|
235
|
-
|
|
264
|
+
for (const item of panel.contents) {
|
|
265
|
+
const video = item;
|
|
266
|
+
const videoId = video.video_id;
|
|
267
|
+
if (!videoId || videoId === trackId)
|
|
236
268
|
continue;
|
|
237
|
-
const title = typeof
|
|
269
|
+
const title = typeof video.title === 'string'
|
|
270
|
+
? video.title
|
|
271
|
+
: (video.title?.text ?? '');
|
|
238
272
|
if (!title)
|
|
239
273
|
continue;
|
|
240
274
|
tracks.push({
|
|
241
275
|
videoId,
|
|
242
276
|
title,
|
|
243
|
-
artists: []
|
|
277
|
+
artists: (video.artists ?? []).map(a => ({
|
|
278
|
+
artistId: a.channel_id ?? '',
|
|
279
|
+
name: a.name ?? 'Unknown',
|
|
280
|
+
})),
|
|
281
|
+
duration: video.duration?.seconds ?? 0,
|
|
244
282
|
});
|
|
245
283
|
}
|
|
246
|
-
|
|
284
|
+
logger.debug('MusicService', 'getSuggestions success', {
|
|
285
|
+
trackId,
|
|
286
|
+
count: tracks.length,
|
|
287
|
+
});
|
|
288
|
+
return tracks.slice(0, 15);
|
|
247
289
|
}
|
|
248
290
|
catch (error) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
});
|
|
291
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
292
|
+
logger.warn('MusicService', 'getSuggestions failed', { error: message });
|
|
252
293
|
return [];
|
|
253
294
|
}
|
|
254
295
|
}
|
|
@@ -407,10 +407,18 @@ function PlayerManager() {
|
|
|
407
407
|
}, [state.progress, state.duration, state.currentTrack]);
|
|
408
408
|
// Handle play/pause state
|
|
409
409
|
useEffect(() => {
|
|
410
|
-
if (
|
|
410
|
+
if (state.isPlaying) {
|
|
411
|
+
// Resume only if the same track is already loaded in the player service.
|
|
412
|
+
// If the track changed, the "handle track changes" effect will call play().
|
|
413
|
+
const currentTrackId = playerService.getCurrentTrackId?.() ?? '';
|
|
414
|
+
if (currentTrackId && state.currentTrack?.videoId === currentTrackId) {
|
|
415
|
+
playerService.resume();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
411
419
|
playerService.pause();
|
|
412
420
|
}
|
|
413
|
-
}, [state.isPlaying, playerService]);
|
|
421
|
+
}, [state.isPlaying, state.currentTrack, playerService]);
|
|
414
422
|
// Handle volume changes
|
|
415
423
|
useEffect(() => {
|
|
416
424
|
const config = getConfigService();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Playlist } from './youtube-music.types.ts';
|
|
2
2
|
import type { Theme } from './theme.types.ts';
|
|
3
3
|
export type RepeatMode = 'off' | 'all' | 'one';
|
|
4
|
+
export type DownloadFormat = 'mp3' | 'm4a';
|
|
4
5
|
export interface KeybindingConfig {
|
|
5
6
|
keys: string[];
|
|
6
7
|
description: string;
|
|
@@ -30,4 +31,7 @@ export interface Config {
|
|
|
30
31
|
};
|
|
31
32
|
discordRichPresence?: boolean;
|
|
32
33
|
proxy?: string;
|
|
34
|
+
downloadsEnabled?: boolean;
|
|
35
|
+
downloadDirectory?: string;
|
|
36
|
+
downloadFormat?: DownloadFormat;
|
|
33
37
|
}
|
|
@@ -59,7 +59,9 @@ export declare const KEYBINDINGS: {
|
|
|
59
59
|
readonly ADD_TO_PLAYLIST: readonly ["a"];
|
|
60
60
|
readonly REMOVE_FROM_PLAYLIST: readonly ["d"];
|
|
61
61
|
readonly CREATE_PLAYLIST: readonly ["c"];
|
|
62
|
+
readonly CREATE_MIX: readonly ["m"];
|
|
62
63
|
readonly DELETE_PLAYLIST: readonly ["D"];
|
|
64
|
+
readonly DOWNLOAD: readonly ["shift+d"];
|
|
63
65
|
};
|
|
64
66
|
export declare const DEFAULT_VOLUME = 70;
|
|
65
67
|
export declare const MAX_QUEUE_SIZE = 1000;
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@involvex/youtube-music-cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"description": "- A Commandline music player for youtube-music",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
|
-
"url": "https://github.com/involvex/youtube-music-cli"
|
|
7
|
+
"url": "git+https://github.com/involvex/youtube-music-cli.git"
|
|
8
8
|
},
|
|
9
9
|
"funding": "https://github.com/sponsors/involvex",
|
|
10
10
|
"license": "MIT",
|
|
@@ -21,6 +21,20 @@
|
|
|
21
21
|
"LICENSE",
|
|
22
22
|
".github/FUNDING.yml"
|
|
23
23
|
],
|
|
24
|
+
"icon": "assets/icon2.PNG",
|
|
25
|
+
"sponsor": {
|
|
26
|
+
"url": "https://github.com/sponsors/involvex"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://involvex.github.io/youtube-music-cli/",
|
|
29
|
+
"keywords": [
|
|
30
|
+
"cli",
|
|
31
|
+
"youtube",
|
|
32
|
+
"youtube-music",
|
|
33
|
+
"youtube-cli",
|
|
34
|
+
"youtube-music-cli",
|
|
35
|
+
"ink",
|
|
36
|
+
"cli-music"
|
|
37
|
+
],
|
|
24
38
|
"scripts": {
|
|
25
39
|
"prebuild": "bun run format && bun run lint:fix && bun run typecheck",
|
|
26
40
|
"build": "tsc",
|