@involvex/youtube-music-cli 0.0.6 → 0.0.9

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 CHANGED
@@ -1,3 +1,26 @@
1
+ ## [0.0.9](https://github.com/involvex/youtube-music-cli/compare/v0.0.8...v0.0.9) (2026-02-18)
2
+
3
+ ### Features
4
+
5
+ - **search:** add dynamic mix creation from search results ([0d50231](https://github.com/involvex/youtube-music-cli/commit/0d5023168c73cec9d22dab9808e0fb2f23b5c1cc))
6
+
7
+ ## [0.0.8](https://github.com/involvex/youtube-music-cli/compare/v0.0.7...v0.0.8) (2026-02-18)
8
+
9
+ ### Features
10
+
11
+ - **ui:** add keyboard blocker to search bar ([ee533fe](https://github.com/involvex/youtube-music-cli/commit/ee533fe716a203d432170e36149389f5bd48d687))
12
+
13
+ ## [0.0.7](https://github.com/involvex/youtube-music-cli/compare/v0.0.6...v0.0.7) (2026-02-18)
14
+
15
+ ### Features
16
+
17
+ - **ui:** add plugins and enhance playlist management ([83a043c](https://github.com/involvex/youtube-music-cli/commit/83a043c0956da7023ea198468db8bbc2ce3acb0d))
18
+
19
+ ### BREAKING CHANGES
20
+
21
+ - **ui:** The keybinding for playlists has changed from 'p' to
22
+ 'shift+p' to accommodate the new plugins feature.
23
+
1
24
  ## [0.0.6](https://github.com/involvex/youtube-music-cli/compare/v0.0.5...v0.0.6) (2026-02-18)
2
25
 
3
26
  ### 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: "p" }), " - Playlists", _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: "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"] })] })] }));
10
10
  }
@@ -31,5 +31,5 @@ export default function ShortcutsBar() {
31
31
  useKeyBinding(KEYBINDINGS.VOLUME_FINE_UP, volumeFineUp);
32
32
  useKeyBinding(KEYBINDINGS.VOLUME_FINE_DOWN, volumeFineDown);
33
33
  useKeyBinding(KEYBINDINGS.SETTINGS, goConfig);
34
- return (_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.dim, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.colors.dim, children: ["Shortcuts: ", _jsx(Text, { color: theme.colors.text, children: "Space" }), " Play/Pause |", ' ', _jsx(Text, { color: theme.colors.text, children: "n" }), " Next |", ' ', _jsx(Text, { color: theme.colors.text, children: "p" }), " Previous |", ' ', _jsx(Text, { color: theme.colors.text, children: "/" }), " Search |", ' ', _jsx(Text, { color: theme.colors.text, children: "," }), " Settings |", ' ', _jsx(Text, { color: theme.colors.text, children: "?" }), " Help |", ' ', _jsx(Text, { color: theme.colors.text, children: "q" }), " Quit"] }), _jsxs(Text, { color: theme.colors.text, children: [_jsx(Text, { color: theme.colors.dim, children: "[=/" }), "-", _jsx(Text, { color: theme.colors.dim, children: "]" }), " Vol:", ' ', _jsxs(Text, { color: theme.colors.primary, children: [playerState.volume, "%"] })] })] }));
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" }), " Previous |", ' ', _jsx(Text, { color: theme.colors.text, children: "Shift+P" }), " Playlists |", ' ', _jsx(Text, { color: theme.colors.text, children: "p" }), " Plugins |", ' ', _jsx(Text, { color: theme.colors.text, children: "/" }), " Search |", ' ', _jsx(Text, { color: theme.colors.text, children: "," }), " Settings |", ' ', _jsx(Text, { color: theme.colors.text, children: "?" }), " Help |", ' ', _jsx(Text, { color: theme.colors.text, children: "q" }), " Quit"] }), _jsxs(Text, { color: theme.colors.text, children: [_jsx(Text, { color: theme.colors.dim, children: "[=/" }), "-", _jsx(Text, { color: theme.colors.dim, children: "]" }), " Vol:", ' ', _jsxs(Text, { color: theme.colors.primary, children: [playerState.volume, "%"] })] })] }));
35
35
  }
@@ -39,6 +39,9 @@ function MainLayout() {
39
39
  const goToSuggestions = useCallback(() => {
40
40
  dispatch({ category: 'NAVIGATE', view: VIEW.SUGGESTIONS });
41
41
  }, [dispatch]);
42
+ const goToPlugins = useCallback(() => {
43
+ dispatch({ category: 'NAVIGATE', view: VIEW.PLUGINS });
44
+ }, [dispatch]);
42
45
  const goToSettings = useCallback(() => {
43
46
  dispatch({ category: 'NAVIGATE', view: VIEW.SETTINGS });
44
47
  }, [dispatch]);
@@ -69,6 +72,7 @@ function MainLayout() {
69
72
  useKeyBinding(KEYBINDINGS.QUIT, handleQuit);
70
73
  useKeyBinding(KEYBINDINGS.SEARCH, goToSearch);
71
74
  useKeyBinding(KEYBINDINGS.PLAYLISTS, goToPlaylists);
75
+ useKeyBinding(KEYBINDINGS.PLUGINS, goToPlugins);
72
76
  useKeyBinding(KEYBINDINGS.SUGGESTIONS, goToSuggestions);
73
77
  useKeyBinding(KEYBINDINGS.SETTINGS, goToSettings);
74
78
  useKeyBinding(KEYBINDINGS.HELP, goToHelp);
@@ -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 [mixMessage, setMixMessage] = useState(null);
21
+ const mixTimeoutRef = useRef(null);
20
22
  // Handle search action
21
23
  const performSearch = useCallback(async (query) => {
22
24
  if (!query || isSearching)
@@ -71,6 +73,23 @@ function SearchLayout() {
71
73
  }
72
74
  }, [isTyping, dispatch]);
73
75
  useKeyBinding(KEYBINDINGS.BACK, goBack);
76
+ const handleMixCreated = useCallback((message) => {
77
+ setMixMessage(message);
78
+ if (mixTimeoutRef.current) {
79
+ clearTimeout(mixTimeoutRef.current);
80
+ }
81
+ mixTimeoutRef.current = setTimeout(() => {
82
+ setMixMessage(null);
83
+ mixTimeoutRef.current = null;
84
+ }, 4000);
85
+ }, []);
86
+ useEffect(() => {
87
+ return () => {
88
+ if (mixTimeoutRef.current) {
89
+ clearTimeout(mixTimeoutRef.current);
90
+ }
91
+ };
92
+ }, []);
74
93
  // Reset search state when leaving view
75
94
  useEffect(() => {
76
95
  return () => {
@@ -81,8 +100,8 @@ function SearchLayout() {
81
100
  }, [dispatch]);
82
101
  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
102
  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
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" })), mixMessage && _jsx(Text, { color: theme.colors.accent, children: mixMessage }), _jsx(Text, { color: theme.colors.dim, children: isTyping
85
104
  ? 'Type to search, Enter to start, Esc to clear'
86
- : `Arrows to navigate, Enter to play, ]/[ more/fewer results (${navState.searchLimit}), H for history, Esc to type` })] }));
105
+ : `Arrows to navigate, Enter to play, M to create mix, ]/[ more/fewer results (${navState.searchLimit}), H for history, Esc to type` })] }));
87
106
  }
88
107
  export default React.memo(SearchLayout);
@@ -1,20 +1,82 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Playlist list component
3
3
  import { Box, Text } from 'ink';
4
- import { useTheme } from "../../hooks/useTheme.js";
5
- import { usePlaylist } from "../../hooks/usePlaylist.js";
4
+ import TextInput from 'ink-text-input';
5
+ import { useCallback, useState } from 'react';
6
+ import { useNavigation } from "../../hooks/useNavigation.js";
6
7
  import { useKeyBinding } from "../../hooks/useKeyboard.js";
8
+ import { usePlayer } from "../../hooks/usePlayer.js";
9
+ import { usePlaylist } from "../../hooks/usePlaylist.js";
10
+ import { useTheme } from "../../hooks/useTheme.js";
11
+ import { useKeyboardBlocker } from "../../hooks/useKeyboardBlocker.js";
7
12
  import { KEYBINDINGS } from "../../utils/constants.js";
8
- import { useState, useCallback } from 'react';
9
13
  export default function PlaylistList() {
10
14
  const { theme } = useTheme();
11
- const { playlists, createPlaylist } = usePlaylist();
15
+ const { play, setQueue } = usePlayer();
16
+ const { dispatch } = useNavigation();
17
+ const { playlists, createPlaylist, renamePlaylist } = usePlaylist();
18
+ const [selectedIndex, setSelectedIndex] = useState(0);
12
19
  const [lastCreated, setLastCreated] = useState(null);
20
+ const [renamingPlaylistId, setRenamingPlaylistId] = useState(null);
21
+ const [renameValue, setRenameValue] = useState('');
22
+ useKeyboardBlocker(renamingPlaylistId !== null);
13
23
  const handleCreate = useCallback(() => {
14
24
  const name = `Playlist ${playlists.length + 1}`;
15
- createPlaylist(name);
16
- setLastCreated(name);
17
- }, [playlists.length, createPlaylist]);
25
+ const playlist = createPlaylist(name);
26
+ setLastCreated(playlist.name);
27
+ setSelectedIndex(playlists.length);
28
+ }, [createPlaylist, playlists.length]);
29
+ const navigateUp = useCallback(() => {
30
+ setSelectedIndex(prev => Math.max(0, prev - 1));
31
+ }, []);
32
+ const navigateDown = useCallback(() => {
33
+ setSelectedIndex(prev => Math.min(playlists.length === 0 ? 0 : playlists.length - 1, prev + 1));
34
+ }, [playlists.length]);
35
+ const startPlaylist = useCallback(() => {
36
+ if (renamingPlaylistId)
37
+ return;
38
+ const playlist = playlists[selectedIndex];
39
+ if (!playlist || playlist.tracks.length === 0)
40
+ return;
41
+ setQueue([...playlist.tracks]);
42
+ const firstTrack = playlist.tracks[0];
43
+ if (!firstTrack)
44
+ return;
45
+ play(firstTrack);
46
+ }, [play, playlists, selectedIndex, renamingPlaylistId, setQueue]);
47
+ const handleRename = useCallback(() => {
48
+ const playlist = playlists[selectedIndex];
49
+ if (!playlist)
50
+ return;
51
+ setRenamingPlaylistId(playlist.playlistId);
52
+ setRenameValue(playlist.name);
53
+ }, [playlists, selectedIndex]);
54
+ const handleRenameSubmit = useCallback((value) => {
55
+ if (!renamingPlaylistId)
56
+ return;
57
+ const trimmedValue = value.trim() || `Playlist ${selectedIndex + 1}`;
58
+ renamePlaylist(renamingPlaylistId, trimmedValue);
59
+ setRenamingPlaylistId(null);
60
+ setRenameValue('');
61
+ }, [renamePlaylist, renamingPlaylistId, selectedIndex]);
62
+ const handleBack = useCallback(() => {
63
+ if (renamingPlaylistId) {
64
+ setRenamingPlaylistId(null);
65
+ setRenameValue('');
66
+ return;
67
+ }
68
+ dispatch({ category: 'GO_BACK' });
69
+ }, [dispatch, renamingPlaylistId]);
70
+ useKeyBinding(KEYBINDINGS.UP, navigateUp);
71
+ useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
72
+ useKeyBinding(KEYBINDINGS.SELECT, startPlaylist);
73
+ useKeyBinding(['r'], handleRename);
18
74
  useKeyBinding(KEYBINDINGS.CREATE_PLAYLIST, handleCreate);
19
- 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) => (_jsxs(Box, { paddingX: 1, children: [_jsxs(Text, { color: theme.colors.primary, children: [index + 1, "."] }), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.colors.text, children: playlist.name }), _jsxs(Text, { color: theme.colors.dim, children: [_jsx(Text, { children: " " }), "(", playlist.tracks?.length || 0, " tracks)"] })] }, playlist.playlistId || index)))), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: theme.colors.dim, children: ["Press ", _jsx(Text, { color: theme.colors.text, children: "c" }), " to create playlist", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Esc" }), " to go back"] }), lastCreated && (_jsxs(Text, { color: theme.colors.accent, children: [" Created ", lastCreated] }))] })] }));
75
+ useKeyBinding(KEYBINDINGS.BACK, handleBack);
76
+ 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
+ const isSelected = index === selectedIndex;
78
+ const isRenaming = renamingPlaylistId === playlist.playlistId && isSelected;
79
+ const rowBackground = isSelected ? theme.colors.secondary : undefined;
80
+ 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 playlist |", ' ', _jsx(Text, { color: theme.colors.text, children: "r" }), " to rename |", ' ', _jsx(Text, { color: theme.colors.text, children: "c" }), " to create |", ' ', _jsx(Text, { color: theme.colors.text, children: "Esc" }), " to go back"] }), lastCreated && (_jsxs(Text, { color: theme.colors.accent, children: [" Created ", lastCreated] }))] })] }));
20
82
  }
@@ -6,6 +6,7 @@ import React from 'react';
6
6
  import { SEARCH_TYPE } from "../../utils/constants.js";
7
7
  import { useTheme } from "../../hooks/useTheme.js";
8
8
  import { useKeyBinding } from "../../hooks/useKeyboard.js";
9
+ import { useKeyboardBlocker } from "../../hooks/useKeyboardBlocker.js";
9
10
  import { Box, Text } from 'ink';
10
11
  import TextInput from 'ink-text-input';
11
12
  import { getConfigService } from "../../services/config/config.service.js";
@@ -46,6 +47,7 @@ function SearchBar({ onInput, isActive = true }) {
46
47
  }, [isActive, onInput]);
47
48
  useKeyBinding(['tab'], cycleType);
48
49
  useKeyBinding(['escape'], clearSearch);
50
+ useKeyboardBlocker(isActive);
49
51
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.secondary, padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: theme.colors.dim, children: "Type: " }), searchTypes.map((type, index) => (_jsxs(Text, { color: navState.searchType === type
50
52
  ? theme.colors.primary
51
53
  : theme.colors.dim, bold: navState.searchType === type, children: [type, index < searchTypes.length - 1 && ' '] }, type))), _jsx(Text, { color: theme.colors.dim, children: " (Tab to switch)" })] }), isActive && (_jsxs(Box, { children: [_jsx(Text, { color: theme.colors.primary, children: "Search: " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: handleSubmit, placeholder: "Type to search...", focus: isActive })] })), !isActive && (_jsxs(Box, { children: [_jsx(Text, { color: theme.colors.primary, children: "Search: " }), _jsx(Text, { color: theme.colors.dim, children: input || 'Type to search...' })] })), _jsx(Text, { color: theme.colors.dim, children: "Type to search, Enter to search, Tab to change type, Esc to clear" })] }));
@@ -4,7 +4,8 @@ type Props = {
4
4
  results: SearchResult[];
5
5
  selectedIndex: number;
6
6
  isActive?: boolean;
7
+ onMixCreated?: (message: string) => void;
7
8
  };
8
- declare function SearchResults({ results, selectedIndex, isActive }: Props): import("react/jsx-runtime").JSX.Element | null;
9
+ declare function SearchResults({ results, selectedIndex, isActive, onMixCreated, }: Props): import("react/jsx-runtime").JSX.Element | null;
9
10
  declare const _default: React.MemoExoticComponent<typeof SearchResults>;
10
11
  export default _default;
@@ -6,6 +6,7 @@ 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 { usePlaylist } from "../../hooks/usePlaylist.js";
9
10
  import { KEYBINDINGS } from "../../utils/constants.js";
10
11
  import { truncate } from "../../utils/format.js";
11
12
  import { useCallback, useRef, useEffect } from 'react';
@@ -14,12 +15,15 @@ import { useTerminalSize } from "../../hooks/useTerminalSize.js";
14
15
  import { getMusicService } from "../../services/youtube-music/api.js";
15
16
  // Generate unique component instance ID
16
17
  let instanceCounter = 0;
17
- function SearchResults({ results, selectedIndex, isActive = true }) {
18
+ function SearchResults({ results, selectedIndex, isActive = true, onMixCreated, }) {
18
19
  const { theme } = useTheme();
19
20
  const { dispatch } = useNavigation();
20
21
  const { play, dispatch: playerDispatch } = usePlayer();
21
22
  const { columns } = useTerminalSize();
22
23
  const musicService = getMusicService();
24
+ const { createPlaylist } = usePlaylist();
25
+ const mixCreatedRef = useRef(onMixCreated);
26
+ mixCreatedRef.current = onMixCreated;
23
27
  // Track component instance and last action time for debouncing
24
28
  const instanceIdRef = useRef(++instanceCounter);
25
29
  const lastSelectTime = useRef(0);
@@ -123,9 +127,100 @@ function SearchResults({ results, selectedIndex, isActive = true }) {
123
127
  logger.debug('SearchResults', 'SELECT key pressed', { isActive, instanceId });
124
128
  playSelected();
125
129
  }, [isActive, playSelected]);
130
+ const createMixPlaylist = useCallback(async () => {
131
+ if (!isActive)
132
+ return;
133
+ const selected = results[selectedIndex];
134
+ if (!selected) {
135
+ logger.warn('SearchResults', 'No result selected for mix');
136
+ return;
137
+ }
138
+ let playlistName = 'Dynamic mix';
139
+ const collectedTracks = [];
140
+ if (selected.type === 'song') {
141
+ const selectedTrack = selected.data;
142
+ const title = selectedTrack.title || 'selected track';
143
+ playlistName = `Mix for ${title}`;
144
+ collectedTracks.push(selectedTrack);
145
+ try {
146
+ const suggestions = await musicService.getSuggestions(selectedTrack.videoId);
147
+ collectedTracks.push(...suggestions);
148
+ }
149
+ catch (error) {
150
+ logger.error('SearchResults', 'Failed to fetch song suggestions', {
151
+ error,
152
+ });
153
+ }
154
+ }
155
+ else if (selected.type === 'artist') {
156
+ const artistName = 'name' in selected.data ? selected.data.name : '';
157
+ if (!artistName) {
158
+ logger.warn('SearchResults', 'Artist name missing for mix');
159
+ mixCreatedRef.current?.('Artist information is missing, cannot create mix.');
160
+ return;
161
+ }
162
+ playlistName = `${artistName} mix`;
163
+ try {
164
+ const response = await musicService.search(artistName, {
165
+ type: 'songs',
166
+ limit: 25,
167
+ });
168
+ const artistTracks = response.results
169
+ .filter(result => result.type === 'song')
170
+ .map(result => result.data);
171
+ collectedTracks.push(...artistTracks);
172
+ }
173
+ catch (error) {
174
+ logger.error('SearchResults', 'Failed to fetch artist songs for mix', {
175
+ error,
176
+ });
177
+ }
178
+ }
179
+ else {
180
+ logger.warn('SearchResults', 'Mix creation unsupported result type', {
181
+ type: selected.type,
182
+ });
183
+ mixCreatedRef.current?.('Mix creation is only supported for songs and artists.');
184
+ return;
185
+ }
186
+ const uniqueTracks = [];
187
+ const seenVideoIds = new Set();
188
+ for (const track of collectedTracks) {
189
+ if (!track?.videoId || seenVideoIds.has(track.videoId))
190
+ continue;
191
+ seenVideoIds.add(track.videoId);
192
+ uniqueTracks.push(track);
193
+ }
194
+ if (uniqueTracks.length === 0) {
195
+ mixCreatedRef.current?.('No similar tracks were found to create a mix.');
196
+ return;
197
+ }
198
+ const playlist = createPlaylist(playlistName, uniqueTracks);
199
+ logger.info('SearchResults', 'Mix playlist created', {
200
+ name: playlist.name,
201
+ trackCount: uniqueTracks.length,
202
+ });
203
+ // Queue the mix tracks and start playing the first one
204
+ playerDispatch({ category: 'SET_QUEUE', queue: uniqueTracks });
205
+ const firstTrack = uniqueTracks[0];
206
+ if (firstTrack) {
207
+ playerDispatch({ category: 'PLAY', track: firstTrack });
208
+ }
209
+ mixCreatedRef.current?.(`Created mix "${playlist.name}" with ${uniqueTracks.length} tracks — playing now.`);
210
+ }, [
211
+ createPlaylist,
212
+ isActive,
213
+ musicService,
214
+ playerDispatch,
215
+ results,
216
+ selectedIndex,
217
+ ]);
126
218
  useKeyBinding(KEYBINDINGS.UP, navigateUp);
127
219
  useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
128
220
  useKeyBinding(KEYBINDINGS.SELECT, handleSelect);
221
+ useKeyBinding(KEYBINDINGS.CREATE_MIX, () => {
222
+ void createMixPlaylist();
223
+ });
129
224
  // Note: Removed redundant useEffect that was syncing selectedIndex to dispatch
130
225
  // This was causing unnecessary re-renders. The selectedIndex is already managed
131
226
  // by the parent component (SearchLayout) and passed down as a prop.
@@ -2,6 +2,7 @@
2
2
  import { useCallback, useEffect } from 'react';
3
3
  import { useInput } from 'ink';
4
4
  import { logger } from "../services/logger/logger.service.js";
5
+ import { useKeyboardBlockContext } from "./useKeyboardBlocker.js";
5
6
  // Global registry for key handlers
6
7
  const registry = new Set();
7
8
  /**
@@ -23,7 +24,13 @@ export function useKeyBinding(keys, handler) {
23
24
  * This should be rendered once at the root of the app.
24
25
  */
25
26
  export function KeyboardManager() {
27
+ const { blockCount } = useKeyboardBlockContext();
26
28
  useInput((input, key) => {
29
+ if (blockCount > 0) {
30
+ // When keyboard input is blocked (e.g., within a focused text input),
31
+ // we deliberately skip executing global shortcuts.
32
+ return;
33
+ }
27
34
  // Debug logging for key presses (helps diagnose binding issues)
28
35
  if (input || key.ctrl || key.meta || key.shift) {
29
36
  logger.debug('KeyboardManager', 'Key pressed', {
@@ -74,6 +81,8 @@ export function KeyboardManager() {
74
81
  return false;
75
82
  if (hasShift && !key.shift)
76
83
  return false;
84
+ if (!hasShift && key.shift)
85
+ return false;
77
86
  // Check the actual key
78
87
  if (mainKey === 'up' && key.upArrow)
79
88
  return true;
@@ -0,0 +1,13 @@
1
+ import { type ReactNode } from 'react';
2
+ type KeyboardBlockContextValue = {
3
+ blockCount: number;
4
+ increment: () => void;
5
+ decrement: () => void;
6
+ };
7
+ export declare function KeyboardBlockProvider({ children }: {
8
+ children: ReactNode;
9
+ }): import("react/jsx-runtime").JSX.Element;
10
+ export declare function useKeyboardBlockContext(): KeyboardBlockContextValue;
11
+ export declare function useKeyboardBlocker(shouldBlock: boolean): void;
12
+ export declare function useIsKeyboardBlocked(): boolean;
13
+ export {};
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
3
+ const KeyboardBlockContext = createContext(null);
4
+ export function KeyboardBlockProvider({ children }) {
5
+ const [blockCount, setBlockCount] = useState(0);
6
+ const increment = useCallback(() => {
7
+ setBlockCount(prev => prev + 1);
8
+ }, []);
9
+ const decrement = useCallback(() => {
10
+ setBlockCount(prev => Math.max(0, prev - 1));
11
+ }, []);
12
+ const value = useMemo(() => ({ blockCount, increment, decrement }), [blockCount, increment, decrement]);
13
+ return (_jsx(KeyboardBlockContext.Provider, { value: value, children: children }));
14
+ }
15
+ export function useKeyboardBlockContext() {
16
+ const context = useContext(KeyboardBlockContext);
17
+ if (!context) {
18
+ throw new Error('useKeyboardBlockContext must be used within KeyboardBlockProvider');
19
+ }
20
+ return context;
21
+ }
22
+ export function useKeyboardBlocker(shouldBlock) {
23
+ const { increment, decrement } = useKeyboardBlockContext();
24
+ const blockedRef = useRef(false);
25
+ useEffect(() => {
26
+ if (shouldBlock && !blockedRef.current) {
27
+ increment();
28
+ blockedRef.current = true;
29
+ }
30
+ else if (!shouldBlock && blockedRef.current) {
31
+ decrement();
32
+ blockedRef.current = false;
33
+ }
34
+ return () => {
35
+ if (blockedRef.current) {
36
+ decrement();
37
+ blockedRef.current = false;
38
+ }
39
+ };
40
+ }, [shouldBlock, increment, decrement]);
41
+ }
42
+ export function useIsKeyboardBlocked() {
43
+ const { blockCount } = useKeyboardBlockContext();
44
+ return blockCount > 0;
45
+ }
@@ -2,8 +2,9 @@ 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) => void;
5
+ createPlaylist: (name: string, tracks?: Track[]) => Playlist;
6
6
  deletePlaylist: (playlistId: string) => void;
7
+ renamePlaylist: (playlistId: string, newName: string) => void;
7
8
  addTrackToPlaylist: (playlistId: string, track: Track, force?: boolean) => AddTrackResult;
8
9
  removeTrackFromPlaylist: (playlistId: string, trackIndex: number) => void;
9
10
  };
@@ -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);
@@ -37,6 +38,13 @@ export function usePlaylist() {
37
38
  configService.set('playlists', updatedPlaylists);
38
39
  return 'added';
39
40
  }, [playlists, configService]);
41
+ const renamePlaylist = useCallback((playlistId, newName) => {
42
+ const updatedPlaylists = playlists.map(playlist => playlist.playlistId === playlistId
43
+ ? { ...playlist, name: newName }
44
+ : playlist);
45
+ setPlaylists(updatedPlaylists);
46
+ configService.set('playlists', updatedPlaylists);
47
+ }, [playlists, configService]);
40
48
  const removeTrackFromPlaylist = useCallback((playlistId, trackIndex) => {
41
49
  const playlistIndex = playlists.findIndex(p => p.playlistId === playlistId);
42
50
  if (playlistIndex === -1)
@@ -50,6 +58,7 @@ export function usePlaylist() {
50
58
  playlists,
51
59
  createPlaylist,
52
60
  deletePlaylist,
61
+ renamePlaylist,
53
62
  addTrackToPlaylist,
54
63
  removeTrackFromPlaylist,
55
64
  };
@@ -7,6 +7,7 @@ import { ThemeProvider } from "./contexts/theme.context.js";
7
7
  import { PlayerProvider } from "./stores/player.store.js";
8
8
  import { ErrorBoundary } from "./components/common/ErrorBoundary.js";
9
9
  import { KeyboardManager } from "./hooks/useKeyboard.js";
10
+ import { KeyboardBlockProvider } from "./hooks/useKeyboardBlocker.js";
10
11
  import { Box, Text } from 'ink';
11
12
  import { useEffect } from 'react';
12
13
  import { useNavigation } from "./hooks/useNavigation.js";
@@ -65,5 +66,5 @@ function HeadlessLayout({ flags }) {
65
66
  return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "green", children: "Headless mode active." }) }));
66
67
  }
67
68
  export default function Main({ flags }) {
68
- return (_jsx(ErrorBoundary, { children: _jsx(ThemeProvider, { children: _jsx(PlayerProvider, { children: _jsx(NavigationProvider, { children: _jsx(PluginsProvider, { children: _jsxs(Box, { flexDirection: "column", children: [_jsx(KeyboardManager, {}), flags?.headless ? (_jsx(HeadlessLayout, { flags: flags })) : (_jsxs(_Fragment, { children: [_jsx(Initializer, { flags: flags }), _jsx(MainLayout, {})] }))] }) }) }) }) }) }));
69
+ return (_jsx(ErrorBoundary, { children: _jsx(ThemeProvider, { children: _jsx(PlayerProvider, { children: _jsx(NavigationProvider, { children: _jsx(PluginsProvider, { children: _jsx(KeyboardBlockProvider, { children: _jsxs(Box, { flexDirection: "column", children: [_jsx(KeyboardManager, {}), flags?.headless ? (_jsx(HeadlessLayout, { flags: flags })) : (_jsxs(_Fragment, { children: [_jsx(Initializer, { flags: flags }), _jsx(MainLayout, {})] }))] }) }) }) }) }) }) }));
69
70
  }
@@ -218,37 +218,39 @@ class MusicService {
218
218
  async getSuggestions(trackId) {
219
219
  try {
220
220
  const yt = await getClient();
221
- let video = null;
222
- try {
223
- video = (await yt.getInfo(trackId));
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 ?? [];
221
+ // Use music.getUpNext with automix — avoids the yt.getInfo() ParsingError
222
+ // caused by YouTube "Remove ads" menu items that youtubei.js can't parse.
223
+ const panel = await yt.music.getUpNext(trackId, true);
232
224
  const tracks = [];
233
- for (const item of suggestions) {
234
- const videoId = item?.id || '';
235
- if (!videoId)
225
+ for (const item of panel.contents) {
226
+ const video = item;
227
+ const videoId = video.video_id;
228
+ if (!videoId || videoId === trackId)
236
229
  continue;
237
- const title = typeof item.title === 'string' ? item.title : item.title?.text;
230
+ const title = typeof video.title === 'string'
231
+ ? video.title
232
+ : (video.title?.text ?? '');
238
233
  if (!title)
239
234
  continue;
240
235
  tracks.push({
241
236
  videoId,
242
237
  title,
243
- artists: [],
238
+ artists: (video.artists ?? []).map(a => ({
239
+ artistId: a.channel_id ?? '',
240
+ name: a.name ?? 'Unknown',
241
+ })),
242
+ duration: video.duration?.seconds ?? 0,
244
243
  });
245
244
  }
246
- return tracks.slice(0, 10);
245
+ logger.debug('MusicService', 'getSuggestions success', {
246
+ trackId,
247
+ count: tracks.length,
248
+ });
249
+ return tracks.slice(0, 15);
247
250
  }
248
251
  catch (error) {
249
- logger.error('MusicService', 'getSuggestions failed', {
250
- error: error instanceof Error ? error.message : String(error),
251
- });
252
+ const message = error instanceof Error ? error.message : String(error);
253
+ logger.warn('MusicService', 'getSuggestions failed', { error: message });
252
254
  return [];
253
255
  }
254
256
  }
@@ -30,9 +30,10 @@ export declare const KEYBINDINGS: {
30
30
  readonly QUIT: readonly ["q", "escape"];
31
31
  readonly HELP: readonly ["?"];
32
32
  readonly SEARCH: readonly ["/"];
33
- readonly PLAYLISTS: readonly ["p"];
33
+ readonly PLAYLISTS: readonly ["shift+p"];
34
34
  readonly SUGGESTIONS: readonly ["g"];
35
35
  readonly SETTINGS: readonly [","];
36
+ readonly PLUGINS: readonly ["p"];
36
37
  readonly PLAY_PAUSE: readonly [" "];
37
38
  readonly NEXT: readonly ["n", "right"];
38
39
  readonly PREVIOUS: readonly ["b", "left"];
@@ -58,6 +59,7 @@ export declare const KEYBINDINGS: {
58
59
  readonly ADD_TO_PLAYLIST: readonly ["a"];
59
60
  readonly REMOVE_FROM_PLAYLIST: readonly ["d"];
60
61
  readonly CREATE_PLAYLIST: readonly ["c"];
62
+ readonly CREATE_MIX: readonly ["m"];
61
63
  readonly DELETE_PLAYLIST: readonly ["D"];
62
64
  };
63
65
  export declare const DEFAULT_VOLUME = 70;
@@ -38,9 +38,10 @@ export const KEYBINDINGS = {
38
38
  QUIT: ['q', 'escape'],
39
39
  HELP: ['?'],
40
40
  SEARCH: ['/'],
41
- PLAYLISTS: ['p'],
41
+ PLAYLISTS: ['shift+p'],
42
42
  SUGGESTIONS: ['g'],
43
43
  SETTINGS: [','],
44
+ PLUGINS: ['p'],
44
45
  // Player
45
46
  PLAY_PAUSE: [' '],
46
47
  NEXT: ['n', 'right'],
@@ -70,6 +71,7 @@ export const KEYBINDINGS = {
70
71
  ADD_TO_PLAYLIST: ['a'],
71
72
  REMOVE_FROM_PLAYLIST: ['d'],
72
73
  CREATE_PLAYLIST: ['c'],
74
+ CREATE_MIX: ['m'],
73
75
  DELETE_PLAYLIST: ['D'],
74
76
  };
75
77
  // Default volume
Binary file
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@involvex/youtube-music-cli",
3
- "version": "0.0.6",
3
+ "version": "0.0.9",
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",
@@ -62,6 +62,7 @@
62
62
  "jiti": "^2.6.1",
63
63
  "meow": "^14.0.0",
64
64
  "node-notifier": "^10.0.1",
65
+ "discord-rpc": "^4.0.1",
65
66
  "node-youtube-music": "^0.10.3",
66
67
  "play-sound": "^1.1.6",
67
68
  "react": "^19.2.4",