@involvex/youtube-music-cli 0.0.32 → 0.0.33

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,9 @@
1
+ ## [0.0.33](https://github.com/involvex/youtube-music-cli/compare/v0.0.32...v0.0.33) (2026-02-22)
2
+
3
+ ### Features
4
+
5
+ - add gapless playback, crossfade, and equalizer settings ([0fe10f4](https://github.com/involvex/youtube-music-cli/commit/0fe10f42700bb9031e4e4c1eacca1bc3aba5ec4d))
6
+
1
7
  ## [0.0.32](https://github.com/involvex/youtube-music-cli/compare/v0.0.31...v0.0.32) (2026-02-22)
2
8
 
3
9
  ## [0.0.31](https://github.com/involvex/youtube-music-cli/compare/v0.0.30...v0.0.31) (2026-02-22)
@@ -5,10 +5,23 @@ import { KEYBINDINGS } from "../../utils/constants.js";
5
5
  import { usePlayer } from "../../hooks/usePlayer.js";
6
6
  import { useTheme } from "../../hooks/useTheme.js";
7
7
  import { Box, Text } from 'ink';
8
- import { useEffect } from 'react';
8
+ import { useEffect, useState } from 'react';
9
9
  import { logger } from "../../services/logger/logger.service.js";
10
10
  import { ICONS } from "../../utils/icons.js";
11
+ import { getConfigService } from "../../services/config/config.service.js";
11
12
  let mountCount = 0;
13
+ const CROSSFADE_PRESETS = [0, 1, 2, 3, 5];
14
+ const EQUALIZER_PRESETS = [
15
+ 'flat',
16
+ 'bass_boost',
17
+ 'vocal',
18
+ 'bright',
19
+ 'warm',
20
+ ];
21
+ const formatEqualizerLabel = (preset) => preset
22
+ .split('_')
23
+ .map(segment => `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`)
24
+ .join(' ');
12
25
  export default function PlayerControls() {
13
26
  const instanceId = ++mountCount;
14
27
  useEffect(() => {
@@ -19,6 +32,10 @@ export default function PlayerControls() {
19
32
  }, [instanceId]);
20
33
  const { theme } = useTheme();
21
34
  const { state: playerState, pause, resume, next, previous, volumeUp, volumeDown, speedUp, speedDown, toggleShuffle, } = usePlayer();
35
+ const config = getConfigService();
36
+ const [gaplessPlayback, setGaplessPlayback] = useState(config.get('gaplessPlayback') ?? true);
37
+ const [crossfadeDuration, setCrossfadeDuration] = useState(config.get('crossfadeDuration') ?? 0);
38
+ const [equalizerPreset, setEqualizerPreset] = useState(config.get('equalizerPreset') ?? 'flat');
22
39
  // DEBUG: Log when callbacks change (detect instability)
23
40
  useEffect(() => {
24
41
  // Temporarily output to stderr to debug without triggering Ink re-render
@@ -32,6 +49,24 @@ export default function PlayerControls() {
32
49
  resume();
33
50
  }
34
51
  };
52
+ const toggleGaplessPlayback = () => {
53
+ const next = !gaplessPlayback;
54
+ setGaplessPlayback(next);
55
+ config.set('gaplessPlayback', next);
56
+ };
57
+ const cycleCrossfadeDuration = () => {
58
+ const currentIndex = CROSSFADE_PRESETS.indexOf(crossfadeDuration);
59
+ const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % CROSSFADE_PRESETS.length;
60
+ const next = CROSSFADE_PRESETS[nextIndex] ?? 0;
61
+ setCrossfadeDuration(next);
62
+ config.set('crossfadeDuration', next);
63
+ };
64
+ const cycleEqualizerPreset = () => {
65
+ const currentIndex = EQUALIZER_PRESETS.indexOf(equalizerPreset);
66
+ const next = EQUALIZER_PRESETS[(currentIndex + 1) % EQUALIZER_PRESETS.length];
67
+ setEqualizerPreset(next);
68
+ config.set('equalizerPreset', next);
69
+ };
35
70
  // Keyboard bindings
36
71
  useKeyBinding(KEYBINDINGS.PLAY_PAUSE, handlePlayPause);
37
72
  useKeyBinding(KEYBINDINGS.NEXT, next);
@@ -41,5 +76,8 @@ export default function PlayerControls() {
41
76
  useKeyBinding(KEYBINDINGS.SPEED_UP, speedUp);
42
77
  useKeyBinding(KEYBINDINGS.SPEED_DOWN, speedDown);
43
78
  useKeyBinding(KEYBINDINGS.SHUFFLE, toggleShuffle);
44
- return (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", paddingX: 2, borderStyle: "classic", borderColor: theme.colors.dim, children: [_jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "\u2190 / b" }), "] Prev"] }), _jsx(Text, { color: theme.colors.primary, children: playerState.isPlaying ? (_jsxs(Text, { children: ["[", _jsx(Text, { color: theme.colors.dim, children: "Space" }), "] Pause"] })) : (_jsxs(Text, { children: ["[", _jsx(Text, { color: theme.colors.dim, children: "Space" }), "] Play"] })) }), _jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "\u2192 / n" }), "] Next"] }), _jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "+/-" }), "] Vol: ", playerState.volume, "%"] }), _jsxs(Text, { color: playerState.shuffle ? theme.colors.primary : theme.colors.dim, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "Shift+S" }), "]", ' ', playerState.shuffle ? `${ICONS.SHUFFLE} ON` : `${ICONS.SHUFFLE} OFF`] }), (playerState.speed ?? 1.0) !== 1.0 && (_jsxs(Text, { color: theme.colors.accent, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "<>" }), "]", ' ', (playerState.speed ?? 1.0).toFixed(2), "x"] }))] }));
79
+ useKeyBinding(KEYBINDINGS.GAPLESS_TOGGLE, toggleGaplessPlayback);
80
+ useKeyBinding(KEYBINDINGS.CROSSFADE_CYCLE, cycleCrossfadeDuration);
81
+ useKeyBinding(KEYBINDINGS.EQUALIZER_CYCLE, cycleEqualizerPreset);
82
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", paddingX: 2, borderStyle: "classic", borderColor: theme.colors.dim, children: [_jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "\u2190 / b" }), "] Prev"] }), _jsx(Text, { color: theme.colors.primary, children: playerState.isPlaying ? (_jsxs(Text, { children: ["[", _jsx(Text, { color: theme.colors.dim, children: "Space" }), "] Pause"] })) : (_jsxs(Text, { children: ["[", _jsx(Text, { color: theme.colors.dim, children: "Space" }), "] Play"] })) }), _jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "\u2192 / n" }), "] Next"] }), _jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "+/-" }), "] Vol: ", playerState.volume, "%"] }), _jsxs(Text, { color: playerState.shuffle ? theme.colors.primary : theme.colors.dim, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "Shift+S" }), "]", ' ', playerState.shuffle ? `${ICONS.SHUFFLE} ON` : `${ICONS.SHUFFLE} OFF`] }), (playerState.speed ?? 1.0) !== 1.0 && (_jsxs(Text, { color: theme.colors.accent, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "<>" }), "]", ' ', (playerState.speed ?? 1.0).toFixed(2), "x"] }))] }), _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", paddingX: 2, gap: 2, children: [_jsxs(Text, { color: gaplessPlayback ? theme.colors.primary : theme.colors.dim, children: ["Gapless: ", gaplessPlayback ? 'ON' : 'OFF'] }), _jsxs(Text, { color: theme.colors.text, children: ["Crossfade: ", crossfadeDuration === 0 ? 'Off' : `${crossfadeDuration}s`] }), _jsxs(Text, { color: theme.colors.text, children: ["Equalizer: ", formatEqualizerLabel(equalizerPreset)] })] })] }));
45
83
  }
@@ -13,9 +13,20 @@ import { formatTime } from "../../utils/format.js";
13
13
  import { useKeyboardBlocker } from "../../hooks/useKeyboardBlocker.js";
14
14
  const QUALITIES = ['low', 'medium', 'high'];
15
15
  const DOWNLOAD_FORMATS = ['mp3', 'm4a'];
16
+ const CROSSFADE_PRESETS = [0, 1, 2, 3, 5];
17
+ const EQUALIZER_PRESETS = [
18
+ 'flat',
19
+ 'bass_boost',
20
+ 'vocal',
21
+ 'bright',
22
+ 'warm',
23
+ ];
16
24
  const SETTINGS_ITEMS = [
17
25
  'Stream Quality',
18
26
  'Audio Normalization',
27
+ 'Gapless Playback',
28
+ 'Crossfade Duration',
29
+ 'Equalizer Preset',
19
30
  'Notifications',
20
31
  'Discord Rich Presence',
21
32
  'Downloads Enabled',
@@ -34,6 +45,9 @@ export default function Settings() {
34
45
  const [selectedIndex, setSelectedIndex] = useState(0);
35
46
  const [quality, setQuality] = useState(config.get('streamQuality') || 'high');
36
47
  const [audioNormalization, setAudioNormalization] = useState(config.get('audioNormalization') ?? false);
48
+ const [gaplessPlayback, setGaplessPlayback] = useState(config.get('gaplessPlayback') ?? true);
49
+ const [crossfadeDuration, setCrossfadeDuration] = useState(config.get('crossfadeDuration') ?? 0);
50
+ const [equalizerPreset, setEqualizerPreset] = useState(config.get('equalizerPreset') ?? 'flat');
37
51
  const [notifications, setNotifications] = useState(config.get('notifications') ?? false);
38
52
  const [discordRpc, setDiscordRpc] = useState(config.get('discordRichPresence') ?? false);
39
53
  const [downloadsEnabled, setDownloadsEnabled] = useState(config.get('downloadsEnabled') ?? false);
@@ -59,6 +73,28 @@ export default function Settings() {
59
73
  setAudioNormalization(next);
60
74
  config.set('audioNormalization', next);
61
75
  };
76
+ const toggleGaplessPlayback = () => {
77
+ const next = !gaplessPlayback;
78
+ setGaplessPlayback(next);
79
+ config.set('gaplessPlayback', next);
80
+ };
81
+ const cycleCrossfadeDuration = () => {
82
+ const currentIndex = CROSSFADE_PRESETS.indexOf(crossfadeDuration);
83
+ const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % CROSSFADE_PRESETS.length;
84
+ const next = CROSSFADE_PRESETS[nextIndex] ?? 0;
85
+ setCrossfadeDuration(next);
86
+ config.set('crossfadeDuration', next);
87
+ };
88
+ const cycleEqualizerPreset = () => {
89
+ const currentIndex = EQUALIZER_PRESETS.indexOf(equalizerPreset);
90
+ const nextPreset = EQUALIZER_PRESETS[(currentIndex + 1) % EQUALIZER_PRESETS.length];
91
+ setEqualizerPreset(nextPreset);
92
+ config.set('equalizerPreset', nextPreset);
93
+ };
94
+ const formatEqualizerLabel = (preset) => preset
95
+ .split('_')
96
+ .map(segment => `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`)
97
+ .join(' ');
62
98
  const toggleNotifications = () => {
63
99
  const next = !notifications;
64
100
  setNotifications(next);
@@ -100,33 +136,42 @@ export default function Settings() {
100
136
  toggleNormalization();
101
137
  }
102
138
  else if (selectedIndex === 2) {
103
- toggleNotifications();
139
+ toggleGaplessPlayback();
104
140
  }
105
141
  else if (selectedIndex === 3) {
106
- toggleDiscordRpc();
142
+ cycleCrossfadeDuration();
107
143
  }
108
144
  else if (selectedIndex === 4) {
109
- toggleDownloadsEnabled();
145
+ cycleEqualizerPreset();
110
146
  }
111
147
  else if (selectedIndex === 5) {
112
- setIsEditingDownloadDirectory(true);
148
+ toggleNotifications();
113
149
  }
114
150
  else if (selectedIndex === 6) {
115
- cycleDownloadFormat();
151
+ toggleDiscordRpc();
116
152
  }
117
153
  else if (selectedIndex === 7) {
118
- cycleSleepTimer();
154
+ toggleDownloadsEnabled();
119
155
  }
120
156
  else if (selectedIndex === 8) {
121
- dispatch({ category: 'NAVIGATE', view: VIEW.IMPORT });
157
+ setIsEditingDownloadDirectory(true);
122
158
  }
123
159
  else if (selectedIndex === 9) {
124
- dispatch({ category: 'NAVIGATE', view: VIEW.EXPORT_PLAYLISTS });
160
+ cycleDownloadFormat();
125
161
  }
126
162
  else if (selectedIndex === 10) {
127
- dispatch({ category: 'NAVIGATE', view: VIEW.KEYBINDINGS });
163
+ cycleSleepTimer();
128
164
  }
129
165
  else if (selectedIndex === 11) {
166
+ dispatch({ category: 'NAVIGATE', view: VIEW.IMPORT });
167
+ }
168
+ else if (selectedIndex === 12) {
169
+ dispatch({ category: 'NAVIGATE', view: VIEW.EXPORT_PLAYLISTS });
170
+ }
171
+ else if (selectedIndex === 13) {
172
+ dispatch({ category: 'NAVIGATE', view: VIEW.KEYBINDINGS });
173
+ }
174
+ else if (selectedIndex === 14) {
130
175
  dispatch({ category: 'NAVIGATE', view: VIEW.PLUGINS });
131
176
  }
132
177
  };
@@ -136,19 +181,18 @@ export default function Settings() {
136
181
  const sleepTimerLabel = isActive && remainingSeconds !== null
137
182
  ? `Sleep Timer: ${formatTime(remainingSeconds)} remaining (Enter to cancel)`
138
183
  : 'Sleep Timer: Off (Enter to set)';
139
- 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 => {
184
+ 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: ["Gapless Playback: ", gaplessPlayback ? '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: ["Crossfade: ", crossfadeDuration === 0 ? 'Off' : `${crossfadeDuration}s`] }) }), _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: ["Equalizer: ", formatEqualizerLabel(equalizerPreset)] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 5 ? theme.colors.primary : undefined, color: selectedIndex === 5 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 5, children: ["Desktop Notifications: ", notifications ? 'ON' : 'OFF'] }) }), _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: ["Discord Rich Presence: ", discordRpc ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 7 ? theme.colors.primary : undefined, color: selectedIndex === 7 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 7, children: ["Download Feature: ", downloadsEnabled ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: isEditingDownloadDirectory && selectedIndex === 8 ? (_jsx(TextInput, { value: downloadDirectory, onChange: setDownloadDirectory, onSubmit: value => {
140
185
  const normalized = value.trim();
141
186
  if (!normalized) {
142
- setDownloadDirectory(config.get('downloadDirectory') ?? '');
143
187
  setIsEditingDownloadDirectory(false);
144
188
  return;
145
189
  }
146
190
  setDownloadDirectory(normalized);
147
191
  config.set('downloadDirectory', normalized);
148
192
  setIsEditingDownloadDirectory(false);
149
- }, 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
193
+ }, placeholder: "Download directory", focus: true })) : (_jsxs(Text, { backgroundColor: selectedIndex === 8 ? theme.colors.primary : undefined, color: selectedIndex === 8 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 8, children: ["Download Folder: ", downloadDirectory] })) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 9 ? theme.colors.primary : undefined, color: selectedIndex === 9 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 9, children: ["Download Format: ", downloadFormat.toUpperCase()] }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 10 ? theme.colors.primary : undefined, color: selectedIndex === 10
150
194
  ? theme.colors.background
151
195
  : isActive
152
196
  ? theme.colors.accent
153
- : 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: "Import Playlists \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: "Export Playlists \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 10 ? theme.colors.primary : undefined, color: selectedIndex === 10 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 10, children: "Custom Keybindings \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 11 ? theme.colors.primary : undefined, color: selectedIndex === 11 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 11, 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" }) })] }));
197
+ : theme.colors.text, bold: selectedIndex === 10, children: sleepTimerLabel }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 11 ? theme.colors.primary : undefined, color: selectedIndex === 11 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 11, children: "Import Playlists \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 12 ? theme.colors.primary : undefined, color: selectedIndex === 12 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 12, children: "Export Playlists \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 13 ? theme.colors.primary : undefined, color: selectedIndex === 13 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 13, children: "Custom Keybindings \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 14 ? theme.colors.primary : undefined, color: selectedIndex === 14 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 14, 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" }) })] }));
154
198
  }
@@ -41,6 +41,9 @@ class ConfigService {
41
41
  enabled: false,
42
42
  },
43
43
  },
44
+ gaplessPlayback: true,
45
+ crossfadeDuration: 0,
46
+ equalizerPreset: 'flat',
44
47
  };
45
48
  }
46
49
  load() {
@@ -1,7 +1,11 @@
1
+ import type { EqualizerPreset } from '../../types/config.types.ts';
1
2
  export type PlayOptions = {
2
3
  volume?: number;
3
4
  audioNormalization?: boolean;
4
5
  proxy?: string;
6
+ gaplessPlayback?: boolean;
7
+ crossfadeDuration?: number;
8
+ equalizerPreset?: EqualizerPreset;
5
9
  };
6
10
  export type PlayerEventCallback = (event: {
7
11
  timePos?: number;
@@ -2,6 +2,19 @@
2
2
  import { spawn } from 'node:child_process';
3
3
  import { connect } from 'node:net';
4
4
  import { logger } from "../logger/logger.service.js";
5
+ const EQUALIZER_PRESET_FILTERS = {
6
+ flat: [],
7
+ bass_boost: ['equalizer=f=60:width_type=o:width=2:g=5'],
8
+ vocal: ['equalizer=f=2500:width_type=o:width=2:g=3'],
9
+ bright: [
10
+ 'equalizer=f=4000:width_type=o:width=2:g=3',
11
+ 'equalizer=f=8000:width_type=o:width=2:g=2',
12
+ ],
13
+ warm: [
14
+ 'equalizer=f=100:width_type=o:width=2:g=4',
15
+ 'equalizer=f=250:width_type=o:width=2:g=2',
16
+ ],
17
+ };
5
18
  class PlayerService {
6
19
  static instance;
7
20
  mpvProcess = null;
@@ -212,6 +225,20 @@ class PlayerService {
212
225
  volume: this.currentVolume,
213
226
  ipcPath: this.ipcPath,
214
227
  });
228
+ const gapless = options?.gaplessPlayback ?? true;
229
+ const crossfadeDuration = Math.max(0, options?.crossfadeDuration ?? 0);
230
+ const eqPreset = options?.equalizerPreset ?? 'flat';
231
+ const audioFilters = [];
232
+ if (options?.audioNormalization) {
233
+ audioFilters.push('dynaudnorm');
234
+ }
235
+ if (crossfadeDuration > 0) {
236
+ audioFilters.push(`acrossfade=d=${crossfadeDuration}`);
237
+ }
238
+ const presetFilters = EQUALIZER_PRESET_FILTERS[eqPreset] ?? [];
239
+ if (presetFilters.length > 0) {
240
+ audioFilters.push(...presetFilters);
241
+ }
215
242
  // Spawn mpv with JSON IPC for better control
216
243
  const mpvArgs = [
217
244
  '--no-video', // Audio only
@@ -225,9 +252,10 @@ class PlayerService {
225
252
  '--cache=yes', // Enable cache for network streams
226
253
  '--cache-secs=30', // Buffer 30 seconds ahead
227
254
  '--network-timeout=10', // 10s network timeout
255
+ `--gapless-audio=${gapless ? 'yes' : 'no'}`,
228
256
  ];
229
- if (options?.audioNormalization) {
230
- mpvArgs.push('--af=dynaudnorm');
257
+ if (audioFilters.length > 0) {
258
+ mpvArgs.push(`--af=${audioFilters.join(',')}`);
231
259
  }
232
260
  if (options?.proxy) {
233
261
  mpvArgs.push(`--http-proxy=${options.proxy}`);
@@ -386,6 +386,9 @@ function PlayerManager() {
386
386
  volume: state.volume,
387
387
  audioNormalization: config.get('audioNormalization') ?? false,
388
388
  proxy: config.get('proxy'),
389
+ gaplessPlayback: config.get('gaplessPlayback') ?? true,
390
+ crossfadeDuration: config.get('crossfadeDuration') ?? 0,
391
+ equalizerPreset: config.get('equalizerPreset') ?? 'flat',
389
392
  });
390
393
  logger.info('PlayerManager', 'Playback started successfully', {
391
394
  attempt,
@@ -3,6 +3,7 @@ import type { Theme } from './theme.types.ts';
3
3
  import type { WebServerConfig } from './web.types.ts';
4
4
  export type RepeatMode = 'off' | 'all' | 'one';
5
5
  export type DownloadFormat = 'mp3' | 'm4a';
6
+ export type EqualizerPreset = 'flat' | 'bass_boost' | 'vocal' | 'bright' | 'warm';
6
7
  export interface KeybindingConfig {
7
8
  keys: string[];
8
9
  description: string;
@@ -20,6 +21,9 @@ export interface Config {
20
21
  customTheme?: Theme;
21
22
  streamQuality?: 'low' | 'medium' | 'high';
22
23
  audioNormalization?: boolean;
24
+ gaplessPlayback?: boolean;
25
+ crossfadeDuration?: number;
26
+ equalizerPreset?: EqualizerPreset;
23
27
  notifications?: boolean;
24
28
  scrobbling?: {
25
29
  lastfm?: {
@@ -47,6 +47,9 @@ export declare const KEYBINDINGS: {
47
47
  readonly VOLUME_FINE_DOWN: readonly ["shift+-"];
48
48
  readonly SHUFFLE: readonly ["shift+s"];
49
49
  readonly REPEAT: readonly ["r"];
50
+ readonly GAPLESS_TOGGLE: readonly ["shift+g"];
51
+ readonly CROSSFADE_CYCLE: readonly ["shift+c"];
52
+ readonly EQUALIZER_CYCLE: readonly ["shift+e"];
50
53
  readonly SEEK_FORWARD: readonly ["shift+right"];
51
54
  readonly SEEK_BACKWARD: readonly ["shift+left"];
52
55
  readonly SPEED_UP: readonly [">"];
@@ -56,6 +56,9 @@ export const KEYBINDINGS = {
56
56
  VOLUME_FINE_DOWN: ['shift+-'], // Fine-grained -1 step
57
57
  SHUFFLE: ['shift+s'],
58
58
  REPEAT: ['r'],
59
+ GAPLESS_TOGGLE: ['shift+g'],
60
+ CROSSFADE_CYCLE: ['shift+c'],
61
+ EQUALIZER_CYCLE: ['shift+e'],
59
62
  SEEK_FORWARD: ['shift+right'],
60
63
  SEEK_BACKWARD: ['shift+left'],
61
64
  SPEED_UP: ['>'],
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@involvex/youtube-music-cli",
3
- "version": "0.0.32",
3
+ "version": "0.0.33",
4
4
  "description": "- A Commandline music player for youtube-music",
5
5
  "repository": {
6
6
  "type": "git",