agentvibes 5.0.0 → 5.1.0
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/.claude/audio/tracks/Drifting Down the Hall.mp3 +0 -0
- package/.claude/audio/tracks/Late Night Hip Hop Groove.mp3 +0 -0
- package/.claude/audio/tracks/Midnight Charleston Stomp.mp3 +0 -0
- package/.claude/config/audio-effects.cfg +1 -1
- package/.claude/hooks/play-tts.sh +10 -3
- package/.claude/hooks-windows/play-tts.ps1 +37 -107
- package/README.md +16 -2
- package/RELEASE_NOTES.md +48 -0
- package/package.json +1 -1
- package/src/console/tabs/agents-tab.js +65 -62
- package/src/console/tabs/music-tab.js +49 -19
- package/src/console/tabs/settings-tab.js +39 -37
- package/src/console/tabs/setup-tab.js +346 -86
- package/src/console/tabs/voices-tab.js +152 -29
- package/src/console/widgets/format-utils.js +92 -89
- package/src/console/widgets/track-picker.js +325 -322
- package/src/installer.js +8 -8
- package/src/services/llm-provider-service.js +79 -0
|
@@ -1,322 +1,325 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AgentVibes TUI — Shared Widget: Background Music Track Picker
|
|
3
|
-
*
|
|
4
|
-
* Inline modal list for selecting background music tracks.
|
|
5
|
-
* Extracted from settings-tab.js for reuse across tabs.
|
|
6
|
-
* Space previews track, Enter selects.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import fs from 'node:fs';
|
|
10
|
-
import path from 'node:path';
|
|
11
|
-
import { spawn } from 'node:child_process';
|
|
12
|
-
import { destroyList } from './destroy-list.js';
|
|
13
|
-
import { BRAND_PINK } from '../brand-colors.js';
|
|
14
|
-
import { formatTrackName } from './format-utils.js';
|
|
15
|
-
import { buildAudioEnv, detectMp3Player } from '../audio-env.js';
|
|
16
|
-
|
|
17
|
-
const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
|
|
18
|
-
let blessed;
|
|
19
|
-
if (!IS_TEST) {
|
|
20
|
-
const { default: b } = await import('blessed');
|
|
21
|
-
blessed = b;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
|
|
25
|
-
const _hintLabel = '{#455a64-fg}[Space] Preview [Enter] Select [Esc] Cancel{/#455a64-fg}';
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Open a small volume input modal (0–100).
|
|
29
|
-
* Left/Right arrows adjust by 5; type a number directly; Enter confirms.
|
|
30
|
-
*
|
|
31
|
-
* @param {object} screen - blessed screen
|
|
32
|
-
* @param {number} currentVol - current volume (0-100)
|
|
33
|
-
* @param {Function} onConfirm - called with volume (number) on Enter
|
|
34
|
-
* @param {Function} [onClose] - called when modal closes (confirm or cancel)
|
|
35
|
-
*/
|
|
36
|
-
export function openVolumeInput(screen, currentVol, onConfirm, onClose) {
|
|
37
|
-
if (IS_TEST) { onConfirm(currentVol ?? 70); return; }
|
|
38
|
-
let vol = (Number.isFinite(currentVol) && currentVol >= 0 && currentVol <= 100)
|
|
39
|
-
? currentVol : 70;
|
|
40
|
-
|
|
41
|
-
const box = blessed.box({
|
|
42
|
-
parent: screen,
|
|
43
|
-
top: 'center',
|
|
44
|
-
left: 'center',
|
|
45
|
-
width: 44,
|
|
46
|
-
height: 11,
|
|
47
|
-
border: { type: 'line' },
|
|
48
|
-
tags: true,
|
|
49
|
-
label: _modalTitle('Music Volume'),
|
|
50
|
-
style: { border: { fg: 'bright-cyan' } },
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
blessed.text({
|
|
54
|
-
parent: box,
|
|
55
|
-
top: 1,
|
|
56
|
-
left: 2,
|
|
57
|
-
width: 38,
|
|
58
|
-
tags: true,
|
|
59
|
-
content: '{cyan-fg}Use ← → arrow keys to adjust volume{/cyan-fg}',
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
const barText = blessed.text({
|
|
63
|
-
parent: box,
|
|
64
|
-
top: 3,
|
|
65
|
-
left: 2,
|
|
66
|
-
width: 38,
|
|
67
|
-
tags: true,
|
|
68
|
-
content: '',
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
blessed.text({
|
|
72
|
-
parent: box,
|
|
73
|
-
top: 5,
|
|
74
|
-
left: 2,
|
|
75
|
-
width: 38,
|
|
76
|
-
tags: true,
|
|
77
|
-
content: '{white-fg}[← →] ±5 [0-9] number [Esc] Cancel{/white-fg}',
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
blessed.text({
|
|
81
|
-
parent: box,
|
|
82
|
-
top: 7,
|
|
83
|
-
left: 2,
|
|
84
|
-
width: 38,
|
|
85
|
-
tags: true,
|
|
86
|
-
content: '{white-fg}[Enter] Confirm then {bold}{cyan-fg}[Tab]{/cyan-fg}{/bold} → Save{/white-fg}',
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
function _renderBar() {
|
|
90
|
-
const filled = Math.round(vol / 5);
|
|
91
|
-
const empty = 20 - filled;
|
|
92
|
-
const bar = '{bright-cyan-fg}' + '█'.repeat(filled) + '{/bright-cyan-fg}' +
|
|
93
|
-
'{#263238-fg}' + '░'.repeat(empty) + '{/#263238-fg}';
|
|
94
|
-
barText.setContent(`{white-fg}Volume:{/white-fg} ${bar} {bold}${vol}%{/bold}`);
|
|
95
|
-
screen.render();
|
|
96
|
-
}
|
|
97
|
-
_renderBar();
|
|
98
|
-
// Take focus so fieldList's key handlers don't fire while this dialog is open
|
|
99
|
-
box.focus();
|
|
100
|
-
screen.render();
|
|
101
|
-
|
|
102
|
-
// Capture keypress directly on screen to avoid input mode issues
|
|
103
|
-
let _digits = '';
|
|
104
|
-
function _onKey(ch, key) {
|
|
105
|
-
const name = key?.name ?? '';
|
|
106
|
-
if (name === 'enter') { _close(true); return; }
|
|
107
|
-
if (name === 'escape') { _close(false); return; }
|
|
108
|
-
if (name === 'left') { vol = Math.max(0, vol - 5); _digits = ''; _renderBar(); return; }
|
|
109
|
-
if (name === 'right') { vol = Math.min(100, vol + 5); _digits = ''; _renderBar(); return; }
|
|
110
|
-
if (ch && /^[0-9]$/.test(ch)) {
|
|
111
|
-
_digits += ch;
|
|
112
|
-
const n = parseInt(_digits, 10);
|
|
113
|
-
if (n >= 0 && n <= 100) { vol = n; _renderBar(); }
|
|
114
|
-
if (_digits.length >= 3) _digits = '';
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
screen.on('keypress', _onKey);
|
|
118
|
-
|
|
119
|
-
function _close(confirm) {
|
|
120
|
-
screen.removeListener('keypress', _onKey);
|
|
121
|
-
box.destroy();
|
|
122
|
-
screen.render();
|
|
123
|
-
// Defer callbacks so the Enter keypress finishes propagating before fieldList
|
|
124
|
-
// regains focus — otherwise the same Enter event re-opens the track picker.
|
|
125
|
-
setTimeout(() => {
|
|
126
|
-
if (confirm && onConfirm) onConfirm(vol);
|
|
127
|
-
if (onClose) onClose();
|
|
128
|
-
}, 0);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const BUILT_IN_TRACKS = [
|
|
133
|
-
{ label: '🎻 Soft Flamenco', file: 'agentvibes_soft_flamenco_loop.mp3' },
|
|
134
|
-
{ label: '🌸 Bossa Nova', file: 'agent_vibes_bossa_nova_v2_loop.mp3' },
|
|
135
|
-
{ label: '🌊 Chillwave', file: 'agent_vibes_chillwave_v2_loop.mp3' },
|
|
136
|
-
{ label: '🪘 Gnawa Ambient', file: 'agent_vibes_ganawa_ambient_v2_loop.mp3' },
|
|
137
|
-
];
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Open the background music track picker modal.
|
|
141
|
-
* After selecting a track, prompts for volume (0-100) via openVolumeInput.
|
|
142
|
-
*
|
|
143
|
-
* @param {object} screen - blessed screen
|
|
144
|
-
* @param {string} currentTrack - currently selected track filename
|
|
145
|
-
* @param {number} currentVolume - currently set volume (0-100, default 70)
|
|
146
|
-
* @param {Function} onSelect - called with (trackFile, volume)
|
|
147
|
-
* @param {Function} [onClose] - called after modal fully closes
|
|
148
|
-
*/
|
|
149
|
-
export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, onClose, options = {}) {
|
|
150
|
-
const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
|
|
151
|
-
let tracks;
|
|
152
|
-
try {
|
|
153
|
-
const files = fs.readdirSync(tracksDir);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
list.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
1
|
+
/**
|
|
2
|
+
* AgentVibes TUI — Shared Widget: Background Music Track Picker
|
|
3
|
+
*
|
|
4
|
+
* Inline modal list for selecting background music tracks.
|
|
5
|
+
* Extracted from settings-tab.js for reuse across tabs.
|
|
6
|
+
* Space previews track, Enter selects.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { destroyList } from './destroy-list.js';
|
|
13
|
+
import { BRAND_PINK } from '../brand-colors.js';
|
|
14
|
+
import { formatTrackName } from './format-utils.js';
|
|
15
|
+
import { buildAudioEnv, detectMp3Player } from '../audio-env.js';
|
|
16
|
+
|
|
17
|
+
const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
|
|
18
|
+
let blessed;
|
|
19
|
+
if (!IS_TEST) {
|
|
20
|
+
const { default: b } = await import('blessed');
|
|
21
|
+
blessed = b;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
|
|
25
|
+
const _hintLabel = '{#455a64-fg}[Space] Preview [Enter] Select [Esc] Cancel{/#455a64-fg}';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Open a small volume input modal (0–100).
|
|
29
|
+
* Left/Right arrows adjust by 5; type a number directly; Enter confirms.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} screen - blessed screen
|
|
32
|
+
* @param {number} currentVol - current volume (0-100)
|
|
33
|
+
* @param {Function} onConfirm - called with volume (number) on Enter
|
|
34
|
+
* @param {Function} [onClose] - called when modal closes (confirm or cancel)
|
|
35
|
+
*/
|
|
36
|
+
export function openVolumeInput(screen, currentVol, onConfirm, onClose) {
|
|
37
|
+
if (IS_TEST) { onConfirm(currentVol ?? 70); return; }
|
|
38
|
+
let vol = (Number.isFinite(currentVol) && currentVol >= 0 && currentVol <= 100)
|
|
39
|
+
? currentVol : 70;
|
|
40
|
+
|
|
41
|
+
const box = blessed.box({
|
|
42
|
+
parent: screen,
|
|
43
|
+
top: 'center',
|
|
44
|
+
left: 'center',
|
|
45
|
+
width: 44,
|
|
46
|
+
height: 11,
|
|
47
|
+
border: { type: 'line' },
|
|
48
|
+
tags: true,
|
|
49
|
+
label: _modalTitle('Music Volume'),
|
|
50
|
+
style: { border: { fg: 'bright-cyan' } },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
blessed.text({
|
|
54
|
+
parent: box,
|
|
55
|
+
top: 1,
|
|
56
|
+
left: 2,
|
|
57
|
+
width: 38,
|
|
58
|
+
tags: true,
|
|
59
|
+
content: '{cyan-fg}Use ← → arrow keys to adjust volume{/cyan-fg}',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const barText = blessed.text({
|
|
63
|
+
parent: box,
|
|
64
|
+
top: 3,
|
|
65
|
+
left: 2,
|
|
66
|
+
width: 38,
|
|
67
|
+
tags: true,
|
|
68
|
+
content: '',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
blessed.text({
|
|
72
|
+
parent: box,
|
|
73
|
+
top: 5,
|
|
74
|
+
left: 2,
|
|
75
|
+
width: 38,
|
|
76
|
+
tags: true,
|
|
77
|
+
content: '{white-fg}[← →] ±5 [0-9] number [Esc] Cancel{/white-fg}',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
blessed.text({
|
|
81
|
+
parent: box,
|
|
82
|
+
top: 7,
|
|
83
|
+
left: 2,
|
|
84
|
+
width: 38,
|
|
85
|
+
tags: true,
|
|
86
|
+
content: '{white-fg}[Enter] Confirm then {bold}{cyan-fg}[Tab]{/cyan-fg}{/bold} → Save{/white-fg}',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function _renderBar() {
|
|
90
|
+
const filled = Math.round(vol / 5);
|
|
91
|
+
const empty = 20 - filled;
|
|
92
|
+
const bar = '{bright-cyan-fg}' + '█'.repeat(filled) + '{/bright-cyan-fg}' +
|
|
93
|
+
'{#263238-fg}' + '░'.repeat(empty) + '{/#263238-fg}';
|
|
94
|
+
barText.setContent(`{white-fg}Volume:{/white-fg} ${bar} {bold}${vol}%{/bold}`);
|
|
95
|
+
screen.render();
|
|
96
|
+
}
|
|
97
|
+
_renderBar();
|
|
98
|
+
// Take focus so fieldList's key handlers don't fire while this dialog is open
|
|
99
|
+
box.focus();
|
|
100
|
+
screen.render();
|
|
101
|
+
|
|
102
|
+
// Capture keypress directly on screen to avoid input mode issues
|
|
103
|
+
let _digits = '';
|
|
104
|
+
function _onKey(ch, key) {
|
|
105
|
+
const name = key?.name ?? '';
|
|
106
|
+
if (name === 'enter') { _close(true); return; }
|
|
107
|
+
if (name === 'escape') { _close(false); return; }
|
|
108
|
+
if (name === 'left') { vol = Math.max(0, vol - 5); _digits = ''; _renderBar(); return; }
|
|
109
|
+
if (name === 'right') { vol = Math.min(100, vol + 5); _digits = ''; _renderBar(); return; }
|
|
110
|
+
if (ch && /^[0-9]$/.test(ch)) {
|
|
111
|
+
_digits += ch;
|
|
112
|
+
const n = parseInt(_digits, 10);
|
|
113
|
+
if (n >= 0 && n <= 100) { vol = n; _renderBar(); }
|
|
114
|
+
if (_digits.length >= 3) _digits = '';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
screen.on('keypress', _onKey);
|
|
118
|
+
|
|
119
|
+
function _close(confirm) {
|
|
120
|
+
screen.removeListener('keypress', _onKey);
|
|
121
|
+
box.destroy();
|
|
122
|
+
screen.render();
|
|
123
|
+
// Defer callbacks so the Enter keypress finishes propagating before fieldList
|
|
124
|
+
// regains focus — otherwise the same Enter event re-opens the track picker.
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
if (confirm && onConfirm) onConfirm(vol);
|
|
127
|
+
if (onClose) onClose();
|
|
128
|
+
}, 0);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const BUILT_IN_TRACKS = [
|
|
133
|
+
{ label: '🎻 Soft Flamenco', file: 'agentvibes_soft_flamenco_loop.mp3' },
|
|
134
|
+
{ label: '🌸 Bossa Nova', file: 'agent_vibes_bossa_nova_v2_loop.mp3' },
|
|
135
|
+
{ label: '🌊 Chillwave', file: 'agent_vibes_chillwave_v2_loop.mp3' },
|
|
136
|
+
{ label: '🪘 Gnawa Ambient', file: 'agent_vibes_ganawa_ambient_v2_loop.mp3' },
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Open the background music track picker modal.
|
|
141
|
+
* After selecting a track, prompts for volume (0-100) via openVolumeInput.
|
|
142
|
+
*
|
|
143
|
+
* @param {object} screen - blessed screen
|
|
144
|
+
* @param {string} currentTrack - currently selected track filename
|
|
145
|
+
* @param {number} currentVolume - currently set volume (0-100, default 70)
|
|
146
|
+
* @param {Function} onSelect - called with (trackFile, volume)
|
|
147
|
+
* @param {Function} [onClose] - called after modal fully closes
|
|
148
|
+
*/
|
|
149
|
+
export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, onClose, options = {}) {
|
|
150
|
+
const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
|
|
151
|
+
let tracks;
|
|
152
|
+
try {
|
|
153
|
+
const files = fs.readdirSync(tracksDir);
|
|
154
|
+
// Sort by the alphabetic part of the label (skip leading emoji/symbols)
|
|
155
|
+
// so the order reflects the track NAME, not the emoji codepoint.
|
|
156
|
+
const _sortKey = (s) => s.replace(/^[^a-zA-Z]+/, '');
|
|
157
|
+
tracks = files
|
|
158
|
+
.filter(f => /\.mp3$/i.test(f))
|
|
159
|
+
.map(f => ({ file: f, label: formatTrackName(f) }))
|
|
160
|
+
.sort((a, b) => _sortKey(a.label).localeCompare(_sortKey(b.label), undefined, { sensitivity: 'base' }));
|
|
161
|
+
} catch {
|
|
162
|
+
tracks = BUILT_IN_TRACKS;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const COLORS = {
|
|
166
|
+
btnFocus: '#2e7d32',
|
|
167
|
+
btnFocusFg: '#ffffff',
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const items = tracks.map(t =>
|
|
171
|
+
t.file === currentTrack ? `● ${t.label}` : ` ${t.label}`
|
|
172
|
+
);
|
|
173
|
+
const currentIdx = tracks.findIndex(t => t.file === currentTrack);
|
|
174
|
+
|
|
175
|
+
const listHeight = Math.min(tracks.length + 6, Math.floor(screen.rows * 0.7));
|
|
176
|
+
const list = blessed.list({
|
|
177
|
+
parent: screen,
|
|
178
|
+
top: 'center',
|
|
179
|
+
left: 'center',
|
|
180
|
+
width: 54,
|
|
181
|
+
height: listHeight,
|
|
182
|
+
border: { type: 'line' },
|
|
183
|
+
tags: true,
|
|
184
|
+
label: _modalTitle('Select Track'),
|
|
185
|
+
items,
|
|
186
|
+
keys: true,
|
|
187
|
+
vi: false,
|
|
188
|
+
mouse: true,
|
|
189
|
+
scrollable: true,
|
|
190
|
+
scrollbar: { ch: '│', track: { bg: '#1e2a3a' }, style: { fg: COLORS.btnFocus } },
|
|
191
|
+
style: {
|
|
192
|
+
border: { fg: COLORS.btnFocus },
|
|
193
|
+
selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
194
|
+
item: { fg: '#e3f2fd' },
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Helper: update hint text in the bottom border label
|
|
199
|
+
function _setHint(text) {
|
|
200
|
+
list.setLabel({ text: _modalTitle('Select Track'), side: 'left' });
|
|
201
|
+
// Use _ prefix convention for bottom border content (blessed doesn't have setFooter)
|
|
202
|
+
list._label2 && list._label2.destroy();
|
|
203
|
+
list._label2 = blessed.text({
|
|
204
|
+
parent: list,
|
|
205
|
+
bottom: -1,
|
|
206
|
+
left: 1,
|
|
207
|
+
width: 50,
|
|
208
|
+
height: 1,
|
|
209
|
+
tags: true,
|
|
210
|
+
content: text,
|
|
211
|
+
style: { fg: '#e3f2fd' },
|
|
212
|
+
});
|
|
213
|
+
screen.render();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
_setHint(_hintLabel);
|
|
217
|
+
|
|
218
|
+
if (currentIdx >= 0) list.select(currentIdx);
|
|
219
|
+
list.focus();
|
|
220
|
+
screen.render();
|
|
221
|
+
|
|
222
|
+
// Preview playback state
|
|
223
|
+
const _spawnEnv = buildAudioEnv();
|
|
224
|
+
const _mp3Player = detectMp3Player(_spawnEnv);
|
|
225
|
+
let _previewProc = null;
|
|
226
|
+
let _previewTrackId = null;
|
|
227
|
+
|
|
228
|
+
function _killPreview() {
|
|
229
|
+
if (_previewProc) {
|
|
230
|
+
const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
|
|
231
|
+
if (_isWin) {
|
|
232
|
+
try { _previewProc.kill(); } catch {}
|
|
233
|
+
} else {
|
|
234
|
+
try { process.kill(-_previewProc.pid, 'SIGTERM'); } catch {}
|
|
235
|
+
}
|
|
236
|
+
_previewProc = null;
|
|
237
|
+
}
|
|
238
|
+
_previewTrackId = null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function _previewTrack(trackFile) {
|
|
242
|
+
// Toggle off if same track
|
|
243
|
+
if (_previewTrackId === trackFile) {
|
|
244
|
+
_killPreview();
|
|
245
|
+
_setHint(_hintLabel);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
_killPreview();
|
|
250
|
+
|
|
251
|
+
const trackPath = path.resolve(tracksDir, trackFile);
|
|
252
|
+
const safeBase = path.resolve(tracksDir);
|
|
253
|
+
if (!trackPath.startsWith(safeBase + path.sep) && trackPath !== safeBase) return;
|
|
254
|
+
|
|
255
|
+
if (!_mp3Player || !fs.existsSync(trackPath)) {
|
|
256
|
+
_setHint('{red-fg}No MP3 player found or track missing{/red-fg}');
|
|
257
|
+
setTimeout(() => {
|
|
258
|
+
_setHint(_hintLabel);
|
|
259
|
+
}, 3000);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_previewProc = spawn(_mp3Player.bin, _mp3Player.args(trackPath), {
|
|
264
|
+
stdio: 'ignore', detached: true, env: _spawnEnv,
|
|
265
|
+
});
|
|
266
|
+
_previewTrackId = trackFile;
|
|
267
|
+
|
|
268
|
+
const label = tracks.find(t => t.file === trackFile)?.label ?? trackFile;
|
|
269
|
+
_setHint(`{bright-cyan-fg}♪ Previewing: ${label} (Space to stop){/bright-cyan-fg}`);
|
|
270
|
+
|
|
271
|
+
_previewProc.on('exit', () => {
|
|
272
|
+
if (_previewTrackId === trackFile) {
|
|
273
|
+
_previewTrackId = null;
|
|
274
|
+
_previewProc = null;
|
|
275
|
+
_setHint(_hintLabel);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
_previewProc.on('error', () => {
|
|
280
|
+
_previewTrackId = null;
|
|
281
|
+
_previewProc = null;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function _close(callback) {
|
|
286
|
+
_killPreview();
|
|
287
|
+
if (list._label2) list._label2.destroy();
|
|
288
|
+
if (callback) {
|
|
289
|
+
callback();
|
|
290
|
+
destroyList(list, screen, onClose);
|
|
291
|
+
} else {
|
|
292
|
+
destroyList(list, screen, onClose);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Space = preview
|
|
297
|
+
list.key(['space'], () => {
|
|
298
|
+
const selected = tracks[list.selected];
|
|
299
|
+
if (selected) _previewTrack(selected.file);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Enter = select track; if skipVolume, return track only, otherwise prompt for volume
|
|
303
|
+
list.key(['enter'], () => {
|
|
304
|
+
const selected = tracks[list.selected];
|
|
305
|
+
if (!selected) return;
|
|
306
|
+
_killPreview();
|
|
307
|
+
if (list._label2) list._label2.destroy();
|
|
308
|
+
if (options.skipVolume) {
|
|
309
|
+
destroyList(list, screen, null);
|
|
310
|
+
setTimeout(() => {
|
|
311
|
+
onSelect(selected.file);
|
|
312
|
+
if (onClose) onClose();
|
|
313
|
+
}, 0);
|
|
314
|
+
} else {
|
|
315
|
+
destroyList(list, screen, null);
|
|
316
|
+
openVolumeInput(screen, currentVolume ?? 20, (volume) => {
|
|
317
|
+
onSelect(selected.file, volume);
|
|
318
|
+
}, onClose);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
list.key(['escape', 'q'], () => {
|
|
323
|
+
_close();
|
|
324
|
+
});
|
|
325
|
+
}
|