Skip to content

Commit 83a043c

Browse files
committed
feat(ui): add plugins and enhance playlist management
This commit introduces a new plugins feature accessible via 'p' key, reorganizes playlist shortcut to 'shift+p', and significantly improves playlist management with rename functionality, keyboard navigation, and direct playback. It also adds the discord-rpc dependency to support plugin functionality. The changes include visual selection feedback and updated help text to reflect the new shortcuts. BREAKING CHANGE: The keybinding for playlists has changed from 'p' to 'shift+p' to accommodate the new plugins feature.
1 parent cca2af1 commit 83a043c

11 files changed

Lines changed: 260 additions & 34 deletions

File tree

bun.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"jiti": "^2.6.1",
6363
"meow": "^14.0.0",
6464
"node-notifier": "^10.0.1",
65+
"discord-rpc": "^4.0.1",
6566
"node-youtube-music": "^0.10.3",
6667
"play-sound": "^1.1.6",
6768
"react": "^19.2.4",

source/components/common/Help.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ export default function Help() {
3232
<Text> | </Text>
3333
<Text color={theme.colors.text}>/</Text> - Search
3434
<Text> | </Text>
35-
<Text color={theme.colors.text}>p</Text> - Playlists
35+
<Text color={theme.colors.text}>Shift+P</Text> - Playlists
36+
<Text> | </Text>
37+
<Text color={theme.colors.text}>p</Text> - Plugins
3638
<Text> | </Text>
3739
<Text color={theme.colors.text}>g</Text> - Suggestions
3840
<Text> | </Text>

source/components/common/ShortcutsBar.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,10 @@ export default function ShortcutsBar() {
5454
{/* Left: Navigation shortcuts */}
5555
<Text color={theme.colors.dim}>
5656
Shortcuts: <Text color={theme.colors.text}>Space</Text> Play/Pause |{' '}
57-
<Text color={theme.colors.text}>n</Text> Next |{' '}
58-
<Text color={theme.colors.text}>p</Text> Previous |{' '}
57+
<Text color={theme.colors.text}></Text> Next |{' '}
58+
<Text color={theme.colors.text}></Text> Previous |{' '}
59+
<Text color={theme.colors.text}>Shift+P</Text> Playlists |{' '}
60+
<Text color={theme.colors.text}>p</Text> Plugins |{' '}
5961
<Text color={theme.colors.text}>/</Text> Search |{' '}
6062
<Text color={theme.colors.text}>,</Text> Settings |{' '}
6163
<Text color={theme.colors.text}>?</Text> Help |{' '}

source/components/layouts/MainLayout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ function MainLayout() {
4444
dispatch({category: 'NAVIGATE', view: VIEW.SUGGESTIONS});
4545
}, [dispatch]);
4646

47+
const goToPlugins = useCallback(() => {
48+
dispatch({category: 'NAVIGATE', view: VIEW.PLUGINS});
49+
}, [dispatch]);
50+
4751
const goToSettings = useCallback(() => {
4852
dispatch({category: 'NAVIGATE', view: VIEW.SETTINGS});
4953
}, [dispatch]);
@@ -81,6 +85,7 @@ function MainLayout() {
8185
useKeyBinding(KEYBINDINGS.QUIT, handleQuit);
8286
useKeyBinding(KEYBINDINGS.SEARCH, goToSearch);
8387
useKeyBinding(KEYBINDINGS.PLAYLISTS, goToPlaylists);
88+
useKeyBinding(KEYBINDINGS.PLUGINS, goToPlugins);
8489
useKeyBinding(KEYBINDINGS.SUGGESTIONS, goToSuggestions);
8590
useKeyBinding(KEYBINDINGS.SETTINGS, goToSettings);
8691
useKeyBinding(KEYBINDINGS.HELP, goToHelp);

source/components/playlist/PlaylistList.tsx

Lines changed: 121 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,88 @@
11
// Playlist list component
22
import {Box, Text} from 'ink';
3-
import {useTheme} from '../../hooks/useTheme.ts';
4-
import {usePlaylist} from '../../hooks/usePlaylist.ts';
3+
import TextInput from 'ink-text-input';
4+
import {useCallback, useState} from 'react';
5+
import {useNavigation} from '../../hooks/useNavigation.ts';
56
import {useKeyBinding} from '../../hooks/useKeyboard.ts';
7+
import {usePlayer} from '../../hooks/usePlayer.ts';
8+
import {usePlaylist} from '../../hooks/usePlaylist.ts';
9+
import {useTheme} from '../../hooks/useTheme.ts';
10+
import {useKeyboardBlocker} from '../../hooks/useKeyboardBlocker.tsx';
611
import {KEYBINDINGS} from '../../utils/constants.ts';
7-
import {useState, useCallback} from 'react';
8-
import type {Playlist} from '../../types/youtube-music.types.ts';
912

1013
export default function PlaylistList() {
1114
const {theme} = useTheme();
12-
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);
1319
const [lastCreated, setLastCreated] = useState<string | null>(null);
20+
const [renamingPlaylistId, setRenamingPlaylistId] = useState<string | null>(
21+
null,
22+
);
23+
const [renameValue, setRenameValue] = useState('');
24+
useKeyboardBlocker(renamingPlaylistId !== null);
1425

1526
const handleCreate = useCallback(() => {
1627
const name = `Playlist ${playlists.length + 1}`;
1728
createPlaylist(name);
1829
setLastCreated(name);
19-
}, [playlists.length, createPlaylist]);
30+
setSelectedIndex(playlists.length);
31+
}, [createPlaylist, playlists.length]);
32+
33+
const navigateUp = useCallback(() => {
34+
setSelectedIndex(prev => Math.max(0, prev - 1));
35+
}, []);
36+
37+
const navigateDown = useCallback(() => {
38+
setSelectedIndex(prev =>
39+
Math.min(playlists.length === 0 ? 0 : playlists.length - 1, prev + 1),
40+
);
41+
}, [playlists.length]);
2042

43+
const startPlaylist = useCallback(() => {
44+
if (renamingPlaylistId) return;
45+
const playlist = playlists[selectedIndex];
46+
if (!playlist || playlist.tracks.length === 0) return;
47+
setQueue([...playlist.tracks]);
48+
const firstTrack = playlist.tracks[0];
49+
if (!firstTrack) return;
50+
play(firstTrack);
51+
}, [play, playlists, selectedIndex, renamingPlaylistId, setQueue]);
52+
53+
const handleRename = useCallback(() => {
54+
const playlist = playlists[selectedIndex];
55+
if (!playlist) return;
56+
setRenamingPlaylistId(playlist.playlistId);
57+
setRenameValue(playlist.name);
58+
}, [playlists, selectedIndex]);
59+
60+
const handleRenameSubmit = useCallback(
61+
(value: string) => {
62+
if (!renamingPlaylistId) return;
63+
const trimmedValue = value.trim() || `Playlist ${selectedIndex + 1}`;
64+
renamePlaylist(renamingPlaylistId, trimmedValue);
65+
setRenamingPlaylistId(null);
66+
setRenameValue('');
67+
},
68+
[renamePlaylist, renamingPlaylistId, selectedIndex],
69+
);
70+
71+
const handleBack = useCallback(() => {
72+
if (renamingPlaylistId) {
73+
setRenamingPlaylistId(null);
74+
setRenameValue('');
75+
return;
76+
}
77+
dispatch({category: 'GO_BACK'});
78+
}, [dispatch, renamingPlaylistId]);
79+
80+
useKeyBinding(KEYBINDINGS.UP, navigateUp);
81+
useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
82+
useKeyBinding(KEYBINDINGS.SELECT, startPlaylist);
83+
useKeyBinding(['r'], handleRename);
2184
useKeyBinding(KEYBINDINGS.CREATE_PLAYLIST, handleCreate);
85+
useKeyBinding(KEYBINDINGS.BACK, handleBack);
2286

2387
return (
2488
<Box flexDirection="column" gap={1}>
@@ -34,27 +98,65 @@ export default function PlaylistList() {
3498
</Text>
3599
</Box>
36100

37-
{/* Playlist List */}
101+
{/* Playlist entries */}
38102
{playlists.length === 0 ? (
39103
<Text color={theme.colors.dim}>No playlists yet</Text>
40104
) : (
41-
playlists.map((playlist: Playlist, index: number) => (
42-
<Box key={playlist.playlistId || index} paddingX={1}>
43-
<Text color={theme.colors.primary}>{index + 1}.</Text>
44-
<Text> </Text>
45-
<Text color={theme.colors.text}>{playlist.name}</Text>
46-
<Text color={theme.colors.dim}>
47-
<Text> </Text>({playlist.tracks?.length || 0} tracks)
48-
</Text>
49-
</Box>
50-
))
105+
playlists.map((playlist, index) => {
106+
const isSelected = index === selectedIndex;
107+
const isRenaming =
108+
renamingPlaylistId === playlist.playlistId && isSelected;
109+
const rowBackground = isSelected ? theme.colors.secondary : undefined;
110+
111+
return (
112+
<Box
113+
key={playlist.playlistId}
114+
paddingX={1}
115+
backgroundColor={rowBackground}
116+
>
117+
<Text
118+
color={
119+
isSelected ? theme.colors.background : theme.colors.primary
120+
}
121+
bold={isSelected}
122+
>
123+
{index + 1}.
124+
</Text>
125+
<Text> </Text>
126+
<Box flexDirection="column">
127+
{isRenaming ? (
128+
<TextInput
129+
value={renameValue}
130+
onChange={setRenameValue}
131+
onSubmit={handleRenameSubmit}
132+
placeholder="Playlist name"
133+
focus
134+
/>
135+
) : (
136+
<Text
137+
color={
138+
isSelected ? theme.colors.background : theme.colors.text
139+
}
140+
bold={isSelected}
141+
>
142+
{playlist.name}
143+
</Text>
144+
)}
145+
<Text color={theme.colors.dim}>
146+
{` (${playlist.tracks.length} tracks)`}
147+
</Text>
148+
</Box>
149+
</Box>
150+
);
151+
})
51152
)}
52153

53154
{/* Instructions */}
54155
<Box marginTop={1}>
55156
<Text color={theme.colors.dim}>
56-
Press <Text color={theme.colors.text}>c</Text> to create playlist
57-
<Text> | </Text>
157+
<Text color={theme.colors.text}>Enter</Text> to play playlist |{' '}
158+
<Text color={theme.colors.text}>r</Text> to rename |{' '}
159+
<Text color={theme.colors.text}>c</Text> to create |{' '}
58160
<Text color={theme.colors.text}>Esc</Text> to go back
59161
</Text>
60162
{lastCreated && (

source/hooks/useKeyboard.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import {useCallback, useEffect} from 'react';
33
import {useInput} from 'ink';
44
import {logger} from '../services/logger/logger.service.ts';
5+
import {useKeyboardBlockContext} from './useKeyboardBlocker.tsx';
56

67
type KeyHandler = () => void;
78
type RegistryEntry = {
@@ -37,7 +38,14 @@ export function useKeyBinding(
3738
* This should be rendered once at the root of the app.
3839
*/
3940
export function KeyboardManager() {
41+
const {blockCount} = useKeyboardBlockContext();
4042
useInput((input, key) => {
43+
if (blockCount > 0) {
44+
// When keyboard input is blocked (e.g., within a focused text input),
45+
// we deliberately skip executing global shortcuts.
46+
return;
47+
}
48+
4149
// Debug logging for key presses (helps diagnose binding issues)
4250
if (input || key.ctrl || key.meta || key.shift) {
4351
logger.debug('KeyboardManager', 'Key pressed', {
@@ -92,6 +100,7 @@ export function KeyboardManager() {
92100
if (hasCtrl && !key.ctrl) return false;
93101
if (hasMeta && !key.meta) return false;
94102
if (hasShift && !key.shift) return false;
103+
if (!hasShift && key.shift) return false;
95104

96105
// Check the actual key
97106
if (mainKey === 'up' && key.upArrow) return true;
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {
2+
createContext,
3+
useCallback,
4+
useContext,
5+
useEffect,
6+
useMemo,
7+
useRef,
8+
useState,
9+
type ReactNode,
10+
} from 'react';
11+
12+
type KeyboardBlockContextValue = {
13+
blockCount: number;
14+
increment: () => void;
15+
decrement: () => void;
16+
};
17+
18+
const KeyboardBlockContext = createContext<KeyboardBlockContextValue | null>(
19+
null,
20+
);
21+
22+
export function KeyboardBlockProvider({children}: {children: ReactNode}) {
23+
const [blockCount, setBlockCount] = useState(0);
24+
25+
const increment = useCallback(() => {
26+
setBlockCount(prev => prev + 1);
27+
}, []);
28+
29+
const decrement = useCallback(() => {
30+
setBlockCount(prev => Math.max(0, prev - 1));
31+
}, []);
32+
33+
const value = useMemo(
34+
() => ({blockCount, increment, decrement}),
35+
[blockCount, increment, decrement],
36+
);
37+
38+
return (
39+
<KeyboardBlockContext.Provider value={value}>
40+
{children}
41+
</KeyboardBlockContext.Provider>
42+
);
43+
}
44+
45+
export function useKeyboardBlockContext() {
46+
const context = useContext(KeyboardBlockContext);
47+
if (!context) {
48+
throw new Error(
49+
'useKeyboardBlockContext must be used within KeyboardBlockProvider',
50+
);
51+
}
52+
return context;
53+
}
54+
55+
export function useKeyboardBlocker(shouldBlock: boolean) {
56+
const {increment, decrement} = useKeyboardBlockContext();
57+
const blockedRef = useRef(false);
58+
59+
useEffect(() => {
60+
if (shouldBlock && !blockedRef.current) {
61+
increment();
62+
blockedRef.current = true;
63+
} else if (!shouldBlock && blockedRef.current) {
64+
decrement();
65+
blockedRef.current = false;
66+
}
67+
68+
return () => {
69+
if (blockedRef.current) {
70+
decrement();
71+
blockedRef.current = false;
72+
}
73+
};
74+
}, [shouldBlock, increment, decrement]);
75+
}
76+
77+
export function useIsKeyboardBlocked() {
78+
const {blockCount} = useKeyboardBlockContext();
79+
return blockCount > 0;
80+
}

source/hooks/usePlaylist.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@ export function usePlaylist() {
6565
[playlists, configService],
6666
);
6767

68+
const renamePlaylist = useCallback(
69+
(playlistId: string, newName: string) => {
70+
const updatedPlaylists = playlists.map(playlist =>
71+
playlist.playlistId === playlistId
72+
? {...playlist, name: newName}
73+
: playlist,
74+
);
75+
setPlaylists(updatedPlaylists);
76+
configService.set('playlists', updatedPlaylists);
77+
},
78+
[playlists, configService],
79+
);
80+
6881
const removeTrackFromPlaylist = useCallback(
6982
(playlistId: string, trackIndex: number) => {
7083
const playlistIndex = playlists.findIndex(
@@ -85,6 +98,7 @@ export function usePlaylist() {
8598
playlists,
8699
createPlaylist,
87100
deletePlaylist,
101+
renamePlaylist,
88102
addTrackToPlaylist,
89103
removeTrackFromPlaylist,
90104
};

0 commit comments

Comments
 (0)