@involvex/youtube-music-cli 0.0.37 → 0.0.39

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,4 +1,14 @@
1
- ## [0.0.37](https://github.com/involvex/youtube-music-cli/compare/v0.0.36...v0.0.37) (2026-02-22)
1
+ ## [0.0.39](https://github.com/involvex/youtube-music-cli/compare/v0.0.38...v0.0.39) (2026-02-23)
2
+
3
+ ### Features
4
+
5
+ - add visual flash feedback when shortcuts are pressed ([dc3efa8](https://github.com/involvex/youtube-music-cli/commit/dc3efa8f642619ad2e9ce937528e76f4d388e4dc))
6
+
7
+ ## [0.0.38](https://github.com/involvex/youtube-music-cli/compare/v0.0.36...v0.0.38) (2026-02-22)
8
+
9
+ ### Bug Fixes
10
+
11
+ - **search:** prevent 'q' key from triggering quit when typing in search bar ([32cd888](https://github.com/involvex/youtube-music-cli/commit/32cd888afcabc30d10988491da12c653cb5175d5))
2
12
 
3
13
  ## [0.0.36](https://github.com/involvex/youtube-music-cli/compare/v0.0.35...v0.0.36) (2026-02-22)
4
14
 
@@ -1,16 +1,27 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  // Shortcuts bar component
3
+ import { useState } from 'react';
3
4
  import { Box, Text } from 'ink';
4
5
  import { usePlayer } from "../../hooks/usePlayer.js";
5
6
  import { useTheme } from "../../hooks/useTheme.js";
6
7
  import { useKeyBinding } from "../../hooks/useKeyboard.js";
7
8
  import { KEYBINDINGS } from "../../utils/constants.js";
8
9
  import { ICONS } from "../../utils/icons.js";
10
+ const FLASH_DURATION_MS = 300;
9
11
  export default function ShortcutsBar() {
10
12
  const { theme } = useTheme();
11
13
  const { state: playerState, pause, resume, next, previous, volumeUp, volumeDown, volumeFineUp, volumeFineDown, toggleShuffle, toggleRepeat, } = usePlayer();
14
+ const [flashState, setFlashState] = useState({});
15
+ const flash = (key) => {
16
+ setFlashState(prev => ({ ...prev, [key]: true }));
17
+ setTimeout(() => {
18
+ setFlashState(prev => ({ ...prev, [key]: false }));
19
+ }, FLASH_DURATION_MS);
20
+ };
21
+ const shortcutColor = (key) => flashState[key] ? theme.colors.success : theme.colors.text;
12
22
  // Register key bindings globally
13
23
  const handlePlayPause = () => {
24
+ flash('playPause');
14
25
  if (playerState.isPlaying) {
15
26
  pause();
16
27
  }
@@ -19,16 +30,51 @@ export default function ShortcutsBar() {
19
30
  }
20
31
  };
21
32
  useKeyBinding(KEYBINDINGS.PLAY_PAUSE, handlePlayPause);
22
- useKeyBinding(KEYBINDINGS.NEXT, next);
23
- useKeyBinding(KEYBINDINGS.PREVIOUS, previous);
24
- useKeyBinding(KEYBINDINGS.VOLUME_UP, volumeUp);
25
- useKeyBinding(KEYBINDINGS.VOLUME_DOWN, volumeDown);
26
- useKeyBinding(KEYBINDINGS.VOLUME_FINE_UP, volumeFineUp);
27
- useKeyBinding(KEYBINDINGS.VOLUME_FINE_DOWN, volumeFineDown);
28
- useKeyBinding(KEYBINDINGS.SHUFFLE, toggleShuffle);
29
- useKeyBinding(KEYBINDINGS.REPEAT, toggleRepeat);
33
+ useKeyBinding(KEYBINDINGS.NEXT, () => {
34
+ flash('next');
35
+ next();
36
+ });
37
+ useKeyBinding(KEYBINDINGS.PREVIOUS, () => {
38
+ flash('prev');
39
+ previous();
40
+ });
41
+ useKeyBinding(KEYBINDINGS.VOLUME_UP, () => {
42
+ flash('volume');
43
+ volumeUp();
44
+ });
45
+ useKeyBinding(KEYBINDINGS.VOLUME_DOWN, () => {
46
+ flash('volume');
47
+ volumeDown();
48
+ });
49
+ useKeyBinding(KEYBINDINGS.VOLUME_FINE_UP, () => {
50
+ flash('volume');
51
+ volumeFineUp();
52
+ });
53
+ useKeyBinding(KEYBINDINGS.VOLUME_FINE_DOWN, () => {
54
+ flash('volume');
55
+ volumeFineDown();
56
+ });
57
+ useKeyBinding(KEYBINDINGS.SHUFFLE, () => {
58
+ flash('shuffle');
59
+ toggleShuffle();
60
+ });
61
+ useKeyBinding(KEYBINDINGS.REPEAT, () => {
62
+ flash('repeat');
63
+ toggleRepeat();
64
+ });
30
65
  // Note: SETTINGS keybinding handled by MainLayout to avoid double-dispatch
31
- return (_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.dim, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.colors.dim, children: [_jsxs(Text, { color: theme.colors.text, children: [ICONS.PLAY_PAUSE_ON, "/", ICONS.PAUSE, " [Space]"] }), ' ', "| ", _jsxs(Text, { color: theme.colors.text, children: [ICONS.PREV, " [B/\u2190]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.NEXT, " [N/\u2192]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.SHUFFLE, " [Shift+S]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.REPEAT_ALL, " [R]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.PLAYLIST, " [Shift+P]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.DOWNLOAD, " [Shift+D]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.SEARCH, " [/]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.HELP, " [?]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.BG_PLAY, " [Shift+Q]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.RESUME, " [Shift+R]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.QUIT, " [Q]"] })] }), _jsxs(Text, { color: theme.colors.text, children: [_jsx(Text, { color: playerState.shuffle ? theme.colors.primary : theme.colors.dim, children: ICONS.SHUFFLE }), ' ', _jsx(Text, { color: playerState.repeat === 'off'
32
- ? theme.colors.dim
33
- : theme.colors.secondary, children: playerState.repeat === 'one' ? ICONS.REPEAT_ONE : ICONS.REPEAT_ALL }), ' ', _jsxs(Text, { color: theme.colors.dim, children: [ICONS.VOLUME, " [+/-]"] }), ' ', _jsxs(Text, { color: theme.colors.primary, children: [playerState.volume, "%"] })] })] }));
66
+ const shuffleColor = flashState['shuffle']
67
+ ? theme.colors.success
68
+ : playerState.shuffle
69
+ ? theme.colors.primary
70
+ : theme.colors.dim;
71
+ const repeatColor = flashState['repeat']
72
+ ? theme.colors.success
73
+ : playerState.repeat !== 'off'
74
+ ? theme.colors.secondary
75
+ : theme.colors.dim;
76
+ const volumeColor = flashState['volume']
77
+ ? theme.colors.success
78
+ : theme.colors.primary;
79
+ return (_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.dim, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.colors.dim, children: [_jsxs(Text, { color: shortcutColor('playPause'), children: [playerState.isPlaying ? ICONS.PAUSE : ICONS.PLAY_PAUSE_ON, " [Space]"] }), ' ', "| ", _jsxs(Text, { color: shortcutColor('prev'), children: [ICONS.PREV, " [B/\u2190]"] }), " |", ' ', _jsxs(Text, { color: shortcutColor('next'), children: [ICONS.NEXT, " [N/\u2192]"] }), " |", ' ', _jsxs(Text, { color: shuffleColor, children: [ICONS.SHUFFLE, " [Shift+S]"] }), " |", ' ', _jsxs(Text, { color: repeatColor, children: [playerState.repeat === 'one' ? ICONS.REPEAT_ONE : ICONS.REPEAT_ALL, ' ', "[R]"] }), ' ', "| ", _jsxs(Text, { color: theme.colors.text, children: [ICONS.PLAYLIST, " [Shift+P]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.DOWNLOAD, " [Shift+D]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.SEARCH, " [/]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.HELP, " [?]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.BG_PLAY, " [Shift+Q]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.RESUME, " [Shift+R]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.QUIT, " [Q]"] })] }), _jsxs(Text, { color: theme.colors.text, children: [_jsx(Text, { color: shuffleColor, children: ICONS.SHUFFLE }), ' ', _jsx(Text, { color: repeatColor, children: playerState.repeat === 'one' ? ICONS.REPEAT_ONE : ICONS.REPEAT_ALL }), ' ', _jsxs(Text, { color: theme.colors.dim, children: [ICONS.VOLUME, " [+/-]"] }), ' ', _jsxs(Text, { color: volumeColor, children: [playerState.volume, "%"] })] })] }));
34
80
  }
@@ -124,7 +124,7 @@ function MainLayout() {
124
124
  dispatch({ category: 'TOGGLE_PLAYER_MODE' });
125
125
  }, [dispatch]);
126
126
  // Global keyboard bindings
127
- useKeyBinding(KEYBINDINGS.QUIT, handleQuit, { bypassBlock: true });
127
+ useKeyBinding(KEYBINDINGS.QUIT, handleQuit);
128
128
  useKeyBinding(KEYBINDINGS.SEARCH, goToSearch);
129
129
  useKeyBinding(KEYBINDINGS.PLAYLISTS, goToPlaylists);
130
130
  useKeyBinding(KEYBINDINGS.PLUGINS, goToPlugins);
@@ -218,6 +218,7 @@ class PlayerService {
218
218
  case 'eof-reached':
219
219
  event.eof = message.data;
220
220
  if (event.eof) {
221
+ this.isPlaying = false;
221
222
  logger.info('PlayerService', 'End of file reached');
222
223
  }
223
224
  break;
@@ -23,6 +23,7 @@ class WebServerManager {
23
23
  shuffle: false,
24
24
  isLoading: false,
25
25
  error: null,
26
+ playRequestId: 0,
26
27
  };
27
28
  constructor() {
28
29
  // Load config or use defaults
@@ -23,6 +23,7 @@ const initialState = {
23
23
  shuffle: false,
24
24
  isLoading: false,
25
25
  error: null,
26
+ playRequestId: 0,
26
27
  };
27
28
  // Get player service instance
28
29
  const playerService = getPlayerService();
@@ -35,6 +36,7 @@ export function playerReducer(state, action) {
35
36
  isPlaying: true,
36
37
  progress: 0,
37
38
  error: null,
39
+ playRequestId: state.playRequestId + 1,
38
40
  };
39
41
  case 'PAUSE':
40
42
  return { ...state, isPlaying: false };
@@ -62,6 +64,7 @@ export function playerReducer(state, action) {
62
64
  currentTrack: state.queue[randomIndex] ?? null,
63
65
  isPlaying: true,
64
66
  progress: 0,
67
+ playRequestId: state.playRequestId + 1,
65
68
  };
66
69
  }
67
70
  // Sequential mode
@@ -74,6 +77,7 @@ export function playerReducer(state, action) {
74
77
  currentTrack: state.queue[0] ?? null,
75
78
  isPlaying: true,
76
79
  progress: 0,
80
+ playRequestId: state.playRequestId + 1,
77
81
  };
78
82
  }
79
83
  return state;
@@ -84,6 +88,7 @@ export function playerReducer(state, action) {
84
88
  currentTrack: state.queue[nextPosition] ?? null,
85
89
  isPlaying: true,
86
90
  progress: 0,
91
+ playRequestId: state.playRequestId + 1,
87
92
  };
88
93
  }
89
94
  case 'PREVIOUS':
@@ -95,6 +100,7 @@ export function playerReducer(state, action) {
95
100
  return {
96
101
  ...state,
97
102
  progress: 0,
103
+ playRequestId: state.playRequestId + 1,
98
104
  };
99
105
  }
100
106
  return {
@@ -102,6 +108,7 @@ export function playerReducer(state, action) {
102
108
  queuePosition: prevPosition,
103
109
  currentTrack: state.queue[prevPosition] ?? null,
104
110
  progress: 0,
111
+ playRequestId: state.playRequestId + 1,
105
112
  };
106
113
  case 'SEEK':
107
114
  return {
@@ -174,6 +181,7 @@ export function playerReducer(state, action) {
174
181
  queuePosition: action.position,
175
182
  currentTrack: state.queue[action.position] ?? null,
176
183
  progress: 0,
184
+ playRequestId: state.playRequestId + 1,
177
185
  };
178
186
  }
179
187
  return state;
@@ -207,6 +215,7 @@ export function playerReducer(state, action) {
207
215
  hasTrack: !!action.currentTrack,
208
216
  queueLength: action.queue.length,
209
217
  });
218
+ playerService.setVolume(action.volume);
210
219
  return {
211
220
  ...state,
212
221
  currentTrack: action.currentTrack,
@@ -292,6 +301,7 @@ function PlayerManager() {
292
301
  };
293
302
  }, [dispatch, playerService]);
294
303
  // Handle track changes
304
+ const lastPlayedRequestId = useRef(-1);
295
305
  useEffect(() => {
296
306
  const track = state.currentTrack;
297
307
  if (!track) {
@@ -306,14 +316,17 @@ function PlayerManager() {
306
316
  });
307
317
  return;
308
318
  }
309
- // Guard: Only play if track actually changed
319
+ // Guard: Don't replay same track unless a new play request was explicitly dispatched
310
320
  const currentTrackId = playerService.getCurrentTrackId?.() || '';
311
- if (currentTrackId === track.videoId) {
321
+ const isSameTrack = currentTrackId === track.videoId;
322
+ const isNewPlayRequest = state.playRequestId !== lastPlayedRequestId.current;
323
+ if (isSameTrack && !isNewPlayRequest) {
312
324
  logger.debug('PlayerManager', 'Track already playing, skipping', {
313
325
  videoId: track.videoId,
314
326
  });
315
327
  return;
316
328
  }
329
+ lastPlayedRequestId.current = state.playRequestId;
317
330
  logger.info('PlayerManager', 'Loading track', {
318
331
  title: track.title,
319
332
  videoId: track.videoId,
@@ -424,7 +437,13 @@ function PlayerManager() {
424
437
  void loadAndPlayTrack();
425
438
  // Note: state.volume intentionally excluded - volume changes should not restart playback
426
439
  // eslint-disable-next-line react-hooks/exhaustive-deps
427
- }, [state.currentTrack, state.isPlaying, dispatch, musicService]);
440
+ }, [
441
+ state.currentTrack,
442
+ state.isPlaying,
443
+ state.playRequestId,
444
+ dispatch,
445
+ musicService,
446
+ ]);
428
447
  // Handle progress tracking
429
448
  useEffect(() => {
430
449
  if (state.isPlaying && state.currentTrack) {
@@ -13,5 +13,6 @@ export interface PlayerState {
13
13
  shuffle: boolean;
14
14
  isLoading: boolean;
15
15
  error: string | null;
16
+ playRequestId: number;
16
17
  }
17
18
  export type PlayerAction = PlayAction | PauseAction | ResumeAction | StopAction | NextAction | PreviousAction | SeekAction | SetVolumeAction | VolumeUpAction | VolumeDownAction | VolumeFineUpAction | VolumeFineDownAction | ToggleShuffleAction | ToggleRepeatAction | SetQueueAction | AddToQueueAction | RemoveFromQueueAction | ClearQueueAction | SetQueuePositionAction | UpdateProgressAction | SetDurationAction | TickAction | SetLoadingAction | SetErrorAction | RestoreStateAction | SetSpeedAction;
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@involvex/youtube-music-cli",
3
- "version": "0.0.37",
3
+ "version": "0.0.39",
4
4
  "description": "- A Commandline music player for youtube-music",
5
5
  "repository": {
6
6
  "type": "git",