agentvibes 4.6.8 → 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/.agentvibes/bmad-voice-map.json +104 -0
- package/.agentvibes/config.json +13 -12
- package/.agentvibes/copilot-sessions.log +4 -0
- 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/audio/tracks/README.md +51 -52
- package/.claude/config/audio-effects-bmad.cfg +50 -0
- package/.claude/config/audio-effects.cfg +4 -4
- package/.claude/config/background-music-enabled.txt +1 -0
- package/.claude/config/personality.txt +1 -0
- package/.claude/hooks/play-tts-piper.sh +3 -1
- package/.claude/hooks/play-tts.sh +380 -301
- package/.claude/hooks/session-start-tts.sh +81 -81
- package/.claude/hooks-windows/audio-processor.ps1 +181 -0
- package/.claude/hooks-windows/play-tts-piper.ps1 +259 -245
- package/.claude/hooks-windows/play-tts.ps1 +28 -6
- package/.claude/hooks-windows/session-start-tts.ps1 +114 -114
- package/README.md +112 -6
- package/RELEASE_NOTES.md +83 -0
- package/bin/bmad-speak.js +16 -8
- package/mcp-server/server.py +15 -8
- package/package.json +1 -1
- package/src/console/app.js +899 -897
- package/src/console/footer-config.js +50 -50
- package/src/console/navigation.js +65 -65
- package/src/console/tabs/agents-tab.js +1899 -1886
- package/src/console/tabs/music-tab.js +1076 -1039
- package/src/console/tabs/placeholder-tab.js +81 -80
- package/src/console/tabs/settings-tab.js +941 -3988
- package/src/console/tabs/setup-tab.js +2071 -0
- package/src/console/tabs/voices-tab.js +1843 -1714
- package/src/console/widgets/format-utils.js +92 -89
- package/src/console/widgets/track-picker.js +325 -322
- package/src/installer.js +6147 -6092
- package/src/services/llm-provider-service.js +486 -0
- package/src/services/navigation-service.js +123 -123
- package/src/services/tts-engine-service.js +69 -0
- package/.claude/audio/tracks/dreamy_house_loop.mp3 +0 -0
- package/src/console/tabs/install-tab.js +0 -1081
|
@@ -1,1039 +1,1076 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AgentVibes TUI Console — Music Tab
|
|
3
|
-
* Epic 9: Stories 9.1-9.3
|
|
4
|
-
*
|
|
5
|
-
* Implements the Tab Component Contract:
|
|
6
|
-
* createMusicTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
|
|
7
|
-
*
|
|
8
|
-
* Features: dynamic track library from .claude/audio/tracks/, favorites (★), active track (▶),
|
|
9
|
-
* toggle music on/off, favorites filter, preview playback on Enter/Space (toggle).
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import fs from 'node:fs';
|
|
13
|
-
import path from 'node:path';
|
|
14
|
-
import os from 'node:os';
|
|
15
|
-
import { fileURLToPath } from 'node:url';
|
|
16
|
-
import { spawn } from 'node:child_process';
|
|
17
|
-
import { buildAudioEnv, detectMp3Player } from '../audio-env.js';
|
|
18
|
-
import { t } from '../../i18n/strings.js';
|
|
19
|
-
|
|
20
|
-
// Package-relative tracks dir — used as fallback when cwd has no .claude/audio/tracks/
|
|
21
|
-
const _PKG_TRACKS_DIR = path.resolve(
|
|
22
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
23
|
-
'..', '..', '..', '.claude', 'audio', 'tracks'
|
|
24
|
-
);
|
|
25
|
-
|
|
26
|
-
const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
|
|
27
|
-
|
|
28
|
-
let blessed;
|
|
29
|
-
if (!IS_TEST) {
|
|
30
|
-
const { default: b } = await import('blessed');
|
|
31
|
-
blessed = b;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
const COLORS = {
|
|
37
|
-
contentBg: '#0a0e1a',
|
|
38
|
-
sectionHdr: '#f06292', // Light magenta — section headers for Music tab
|
|
39
|
-
labelFg: '#e3f2fd',
|
|
40
|
-
valueFg: '#f06292', // Light magenta — brand color
|
|
41
|
-
activeFg: '#69f0ae', // Green — active/playing track
|
|
42
|
-
favoriteFg: '#ffff00', // Yellow — favorite star
|
|
43
|
-
btnDefault: '#880e4f', // Dark magenta — Music tab buttons
|
|
44
|
-
btnFocus: '#2e7d32', // Green — focused/selected
|
|
45
|
-
btnFocusFg: '#ffffff',
|
|
46
|
-
btnPress: '#ff00ff',
|
|
47
|
-
borderFg: '#f06292', // Light magenta — border
|
|
48
|
-
footerBg: '#880e4f', // Dark magenta — Music tab footer
|
|
49
|
-
noticeFg: '#90a4ae',
|
|
50
|
-
dimFg: '#455a64',
|
|
51
|
-
playingFg: 'bright-cyan', // Cyan — currently previewing track indicator
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const FOOTER_TEXT = '[↑↓/jk] Navigate [Space] Preview [Enter] Select [M] Toggle [*] Favorite [F] Filter [Q] Quit';
|
|
55
|
-
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// Static catalog — correct real filenames; Soft Flamenco kept first for compat.
|
|
58
|
-
// At runtime the UI scans .claude/audio/tracks/ dynamically so new tracks appear.
|
|
59
|
-
|
|
60
|
-
// Full display names per track — emoji + label. Single-codepoint emoji only (no \uFE0F
|
|
61
|
-
// variation selectors) so blessed renders them cleanly in list widgets.
|
|
62
|
-
const TRACK_DISPLAY = Object.freeze({
|
|
63
|
-
'agentvibes_soft_flamenco_loop.mp3': '🎻 Soft Flamenco',
|
|
64
|
-
'agent_vibes_arabic_v2_loop.mp3': '🎵 Arabic Oud',
|
|
65
|
-
'agent_vibes_bachata_v1_loop.mp3': '🎺 Bachata',
|
|
66
|
-
'agent_vibes_bossa_nova_v2_loop.mp3': '🌸 Bossa Nova',
|
|
67
|
-
'agent_vibes_celtic_harp_v1_loop.mp3': '🎶 Celtic Harp',
|
|
68
|
-
'agent_vibes_chillwave_v2_loop.mp3': '🌊 Chillwave',
|
|
69
|
-
'agent_vibes_cumbia_v1_loop.mp3': '🎸 Cumbia',
|
|
70
|
-
'agent_vibes_dark_chill_step_loop.mp3': '🌙 Dark Chill Step',
|
|
71
|
-
'agent_vibes_ganawa_ambient_v2_loop.mp3': '🪘 Gnawa Ambient',
|
|
72
|
-
'agent_vibes_goa_trance_v2_loop.mp3': '🌀 Goa Trance',
|
|
73
|
-
'agent_vibes_harpsichord_v2_loop.mp3': '🎼 Harpsichord',
|
|
74
|
-
'agent_vibes_hawaiian_slack_key_guitar_v2_loop.mp3': '🌺 Hawaiian Slack Key Guitar',
|
|
75
|
-
'agent_vibes_japanese_city_pop_v1_loop.mp3': '🌆 Japanese City Pop',
|
|
76
|
-
'agent_vibes_salsa_v2_loop.mp3': '💃 Salsa',
|
|
77
|
-
'agent_vibes_tabla_dream_pop_v1_loop.mp3': '🥁 Tabla Dream Pop',
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
{ id: '
|
|
85
|
-
{ id: '
|
|
86
|
-
{ id: '
|
|
87
|
-
{ id: '
|
|
88
|
-
{ id: '
|
|
89
|
-
{ id: '
|
|
90
|
-
{ id: '
|
|
91
|
-
{ id: '
|
|
92
|
-
{ id: '
|
|
93
|
-
{ id: '
|
|
94
|
-
{ id: '
|
|
95
|
-
{ id: '
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
*
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
.replace(
|
|
128
|
-
.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
const
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
parent: box,
|
|
358
|
-
top: '
|
|
359
|
-
left: 2,
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
style: {
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
parent: box,
|
|
367
|
-
top: '
|
|
368
|
-
left: 2,
|
|
369
|
-
tags: true,
|
|
370
|
-
content: '',
|
|
371
|
-
style: { fg: COLORS.
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
btn.
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
});
|
|
415
|
-
btn.
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
function
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
const
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
);
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
const
|
|
709
|
-
parent:
|
|
710
|
-
top:
|
|
711
|
-
left:
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
function
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
});
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
if (
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
});
|
|
863
|
-
|
|
864
|
-
// [
|
|
865
|
-
trackList.key(['
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
if (
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
if (
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
if (items[sel]) {
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
if (
|
|
939
|
-
items[
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
if (!
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
const
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
});
|
|
982
|
-
|
|
983
|
-
//
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1
|
+
/**
|
|
2
|
+
* AgentVibes TUI Console — Music Tab
|
|
3
|
+
* Epic 9: Stories 9.1-9.3
|
|
4
|
+
*
|
|
5
|
+
* Implements the Tab Component Contract:
|
|
6
|
+
* createMusicTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
|
|
7
|
+
*
|
|
8
|
+
* Features: dynamic track library from .claude/audio/tracks/, favorites (★), active track (▶),
|
|
9
|
+
* toggle music on/off, favorites filter, preview playback on Enter/Space (toggle).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { buildAudioEnv, detectMp3Player } from '../audio-env.js';
|
|
18
|
+
import { t } from '../../i18n/strings.js';
|
|
19
|
+
|
|
20
|
+
// Package-relative tracks dir — used as fallback when cwd has no .claude/audio/tracks/
|
|
21
|
+
const _PKG_TRACKS_DIR = path.resolve(
|
|
22
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
23
|
+
'..', '..', '..', '.claude', 'audio', 'tracks'
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
|
|
27
|
+
|
|
28
|
+
let blessed;
|
|
29
|
+
if (!IS_TEST) {
|
|
30
|
+
const { default: b } = await import('blessed');
|
|
31
|
+
blessed = b;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const COLORS = {
|
|
37
|
+
contentBg: '#0a0e1a',
|
|
38
|
+
sectionHdr: '#f06292', // Light magenta — section headers for Music tab
|
|
39
|
+
labelFg: '#e3f2fd',
|
|
40
|
+
valueFg: '#f06292', // Light magenta — brand color
|
|
41
|
+
activeFg: '#69f0ae', // Green — active/playing track
|
|
42
|
+
favoriteFg: '#ffff00', // Yellow — favorite star
|
|
43
|
+
btnDefault: '#880e4f', // Dark magenta — Music tab buttons
|
|
44
|
+
btnFocus: '#2e7d32', // Green — focused/selected
|
|
45
|
+
btnFocusFg: '#ffffff',
|
|
46
|
+
btnPress: '#ff00ff',
|
|
47
|
+
borderFg: '#f06292', // Light magenta — border
|
|
48
|
+
footerBg: '#880e4f', // Dark magenta — Music tab footer
|
|
49
|
+
noticeFg: '#90a4ae',
|
|
50
|
+
dimFg: '#455a64',
|
|
51
|
+
playingFg: 'bright-cyan', // Cyan — currently previewing track indicator
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const FOOTER_TEXT = '[↑↓/jk] Navigate [Space] Preview [Enter] Select [M] Toggle [*] Favorite [F] Filter [Q] Quit';
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Static catalog — correct real filenames; Soft Flamenco kept first for compat.
|
|
58
|
+
// At runtime the UI scans .claude/audio/tracks/ dynamically so new tracks appear.
|
|
59
|
+
|
|
60
|
+
// Full display names per track — emoji + label. Single-codepoint emoji only (no \uFE0F
|
|
61
|
+
// variation selectors) so blessed renders them cleanly in list widgets.
|
|
62
|
+
const TRACK_DISPLAY = Object.freeze({
|
|
63
|
+
'agentvibes_soft_flamenco_loop.mp3': '🎻 Soft Flamenco',
|
|
64
|
+
'agent_vibes_arabic_v2_loop.mp3': '🎵 Arabic Oud',
|
|
65
|
+
'agent_vibes_bachata_v1_loop.mp3': '🎺 Bachata',
|
|
66
|
+
'agent_vibes_bossa_nova_v2_loop.mp3': '🌸 Bossa Nova',
|
|
67
|
+
'agent_vibes_celtic_harp_v1_loop.mp3': '🎶 Celtic Harp',
|
|
68
|
+
'agent_vibes_chillwave_v2_loop.mp3': '🌊 Chillwave',
|
|
69
|
+
'agent_vibes_cumbia_v1_loop.mp3': '🎸 Cumbia',
|
|
70
|
+
'agent_vibes_dark_chill_step_loop.mp3': '🌙 Dark Chill Step',
|
|
71
|
+
'agent_vibes_ganawa_ambient_v2_loop.mp3': '🪘 Gnawa Ambient',
|
|
72
|
+
'agent_vibes_goa_trance_v2_loop.mp3': '🌀 Goa Trance',
|
|
73
|
+
'agent_vibes_harpsichord_v2_loop.mp3': '🎼 Harpsichord',
|
|
74
|
+
'agent_vibes_hawaiian_slack_key_guitar_v2_loop.mp3': '🌺 Hawaiian Slack Key Guitar',
|
|
75
|
+
'agent_vibes_japanese_city_pop_v1_loop.mp3': '🌆 Japanese City Pop',
|
|
76
|
+
'agent_vibes_salsa_v2_loop.mp3': '💃 Salsa',
|
|
77
|
+
'agent_vibes_tabla_dream_pop_v1_loop.mp3': '🥁 Tabla Dream Pop',
|
|
78
|
+
'Late Night Hip Hop Groove.mp3': '🎤 Late Night Hip Hop Groove',
|
|
79
|
+
'Drifting Down the Hall.mp3': '🌃 Drifting Down the Hall',
|
|
80
|
+
'Midnight Charleston Stomp.mp3': '🎩 Midnight Charleston Stomp',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const BUILT_IN_TRACK_CATALOG = Object.freeze([
|
|
84
|
+
{ id: 'agentvibes_soft_flamenco_loop.mp3', label: '🎻 Soft Flamenco' },
|
|
85
|
+
{ id: 'agent_vibes_arabic_v2_loop.mp3', label: '🎵 Arabic Oud' },
|
|
86
|
+
{ id: 'agent_vibes_bachata_v1_loop.mp3', label: '🎺 Bachata' },
|
|
87
|
+
{ id: 'agent_vibes_bossa_nova_v2_loop.mp3', label: '🌸 Bossa Nova' },
|
|
88
|
+
{ id: 'agent_vibes_celtic_harp_v1_loop.mp3', label: '🎶 Celtic Harp' },
|
|
89
|
+
{ id: 'agent_vibes_chillwave_v2_loop.mp3', label: '🌊 Chillwave' },
|
|
90
|
+
{ id: 'agent_vibes_cumbia_v1_loop.mp3', label: '🎸 Cumbia' },
|
|
91
|
+
{ id: 'agent_vibes_dark_chill_step_loop.mp3', label: '🌙 Dark Chill Step' },
|
|
92
|
+
{ id: 'agent_vibes_ganawa_ambient_v2_loop.mp3', label: '🪘 Gnawa Ambient' },
|
|
93
|
+
{ id: 'agent_vibes_goa_trance_v2_loop.mp3', label: '🌀 Goa Trance' },
|
|
94
|
+
{ id: 'agent_vibes_harpsichord_v2_loop.mp3', label: '🎼 Harpsichord' },
|
|
95
|
+
{ id: 'agent_vibes_hawaiian_slack_key_guitar_v2_loop.mp3', label: '🌺 Hawaiian Slack Key Guitar' },
|
|
96
|
+
{ id: 'agent_vibes_japanese_city_pop_v1_loop.mp3', label: '🌆 Japanese City Pop' },
|
|
97
|
+
{ id: 'agent_vibes_salsa_v2_loop.mp3', label: '💃 Salsa' },
|
|
98
|
+
{ id: 'agent_vibes_tabla_dream_pop_v1_loop.mp3', label: '🥁 Tabla Dream Pop' },
|
|
99
|
+
{ id: 'Late Night Hip Hop Groove.mp3', label: '🎤 Late Night Hip Hop Groove' },
|
|
100
|
+
{ id: 'Drifting Down the Hall.mp3', label: '🌃 Drifting Down the Hall' },
|
|
101
|
+
{ id: 'Midnight Charleston Stomp.mp3', label: '🎩 Midnight Charleston Stomp' },
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Exported pure helpers (testable without blessed)
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Return the built-in track catalog (static, predictable for tests).
|
|
109
|
+
* @returns {{ id: string, label: string }[]}
|
|
110
|
+
*/
|
|
111
|
+
export function getBuiltInTracks() {
|
|
112
|
+
return [...BUILT_IN_TRACK_CATALOG];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Generate a pretty label from a track filename.
|
|
117
|
+
* Returns the canonical display name (with emoji) for known tracks.
|
|
118
|
+
* For unknown tracks, strips agent_vibes_/agentvibes_ prefix and _loop/_vN suffixes,
|
|
119
|
+
* then title-cases the result.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} filename
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
export function formatTrackLabel(filename) {
|
|
125
|
+
if (TRACK_DISPLAY[filename]) return TRACK_DISPLAY[filename];
|
|
126
|
+
const label = filename
|
|
127
|
+
.replace(/\.mp3$/i, '')
|
|
128
|
+
.replace(/^agent_vibes_/i, '')
|
|
129
|
+
.replace(/^agentvibes_/i, '')
|
|
130
|
+
.replace(/_loop$/i, '')
|
|
131
|
+
.replace(/_v\d+$/i, '')
|
|
132
|
+
.replace(/_/g, ' ')
|
|
133
|
+
.replace(/\b\w/g, c => c.toUpperCase())
|
|
134
|
+
.trim();
|
|
135
|
+
return label || filename;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Format music enabled state as readable string.
|
|
140
|
+
* @param {boolean|undefined} enabled
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
export function formatMusicStatus(enabled) {
|
|
144
|
+
return enabled ? 'Enabled' : 'Disabled';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Test stub
|
|
149
|
+
|
|
150
|
+
function createTestStub() {
|
|
151
|
+
return {
|
|
152
|
+
box: {},
|
|
153
|
+
show: () => {},
|
|
154
|
+
hide: () => {},
|
|
155
|
+
onFocus: () => {},
|
|
156
|
+
onBlur: () => {},
|
|
157
|
+
getFooterText: () => FOOTER_TEXT,
|
|
158
|
+
getFooterColor: () => COLORS.footerBg,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Helpers (used inside createMusicTab)
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve the tracks directory for the running project.
|
|
167
|
+
* Uses process.cwd() because the TUI always runs from the project root
|
|
168
|
+
* and this function is called both internally and from exported helpers
|
|
169
|
+
* that lack configService context.
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
function _getTracksDir() {
|
|
173
|
+
const cwdTracks = path.join(process.cwd(), '.claude', 'audio', 'tracks');
|
|
174
|
+
// Fall back to package-bundled tracks if cwd doesn't have any
|
|
175
|
+
return fs.existsSync(cwdTracks) ? cwdTracks : _PKG_TRACKS_DIR;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Scan .claude/audio/tracks/ for .mp3 files.
|
|
180
|
+
* Falls back to the static catalog if the directory is absent.
|
|
181
|
+
*
|
|
182
|
+
* @returns {{ id: string, label: string, isBuiltIn: boolean }[]}
|
|
183
|
+
*/
|
|
184
|
+
export function scanTracks() {
|
|
185
|
+
const tracksDir = _getTracksDir();
|
|
186
|
+
try {
|
|
187
|
+
const files = fs.readdirSync(tracksDir);
|
|
188
|
+
const builtInIds = new Set(BUILT_IN_TRACK_CATALOG.map(t => t.id));
|
|
189
|
+
// Sort by the alphabetic part of the label (skip leading emoji/symbols)
|
|
190
|
+
// so the order reflects the track NAME, not the emoji codepoint.
|
|
191
|
+
const _sortKey = (s) => s.replace(/^[^a-zA-Z]+/, '');
|
|
192
|
+
return files
|
|
193
|
+
.filter(f => /\.mp3$/i.test(f))
|
|
194
|
+
.map(f => ({ id: f, label: formatTrackLabel(f), isBuiltIn: builtInIds.has(f) }))
|
|
195
|
+
.sort((a, b) => _sortKey(a.label).localeCompare(_sortKey(b.label), undefined, { sensitivity: 'base' }));
|
|
196
|
+
} catch {
|
|
197
|
+
// Directory not found or unreadable — use the static catalog
|
|
198
|
+
return BUILT_IN_TRACK_CATALOG.map(t => ({ ...t, isBuiltIn: true }));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get music config from configService.
|
|
204
|
+
*/
|
|
205
|
+
function _getMusic(configService) {
|
|
206
|
+
const cfg = configService.getConfig();
|
|
207
|
+
// Use backgroundMusic (matches settings-tab); fall back to legacy 'music' key
|
|
208
|
+
const music = cfg.backgroundMusic ?? cfg.music ?? {};
|
|
209
|
+
return {
|
|
210
|
+
enabled: music.enabled ?? false,
|
|
211
|
+
track: music.track ?? BUILT_IN_TRACK_CATALOG[0].id,
|
|
212
|
+
volume: music.volume ?? 20,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Update music config (merge, never overwrite).
|
|
218
|
+
* Writes to 'backgroundMusic' key (shared with settings-tab).
|
|
219
|
+
*/
|
|
220
|
+
function _setMusic(configService, update) {
|
|
221
|
+
const current = _getMusic(configService);
|
|
222
|
+
configService.set('backgroundMusic', { ...current, ...update });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Patch the 'default' entry in audio-effects.cfg to use the given track.
|
|
227
|
+
* play-tts-piper.sh reads the track from audio-effects.cfg (not from config.json),
|
|
228
|
+
* so any track change must be reflected here to take effect at runtime.
|
|
229
|
+
* Safe to call with invalid/missing tracks — non-fatal on failure.
|
|
230
|
+
* @param {string} track - Filename like "agent_vibes_salsa_v2_loop.mp3"
|
|
231
|
+
*/
|
|
232
|
+
export function applyTrackToAudioEffects(track) {
|
|
233
|
+
if (!track || /[|/\\]/.test(track)) return;
|
|
234
|
+
const cfgFile = path.join(process.cwd(), '.claude', 'config', 'audio-effects.cfg');
|
|
235
|
+
try {
|
|
236
|
+
let content = fs.readFileSync(cfgFile, 'utf-8');
|
|
237
|
+
content = content.replace(
|
|
238
|
+
/^default\|([^|]*)\|([^|]*)\|(.*)$/m,
|
|
239
|
+
(match, g1, g2, g3) => `default|${g1}|${track}|${g3}`,
|
|
240
|
+
);
|
|
241
|
+
fs.writeFileSync(cfgFile, content, 'utf-8');
|
|
242
|
+
} catch { /* file may not exist — non-fatal */ }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get favorites array from config.musicFavorites.
|
|
247
|
+
*/
|
|
248
|
+
export function getMusicFavorites(configService) {
|
|
249
|
+
const favs = configService.getConfig().musicFavorites;
|
|
250
|
+
return Array.isArray(favs) ? favs : [];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Toggle a track in the favorites list.
|
|
255
|
+
*/
|
|
256
|
+
export function toggleMusicFavorite(configService, trackId) {
|
|
257
|
+
const favs = getMusicFavorites(configService);
|
|
258
|
+
const idx = favs.indexOf(trackId);
|
|
259
|
+
if (idx >= 0) {
|
|
260
|
+
favs.splice(idx, 1);
|
|
261
|
+
} else {
|
|
262
|
+
favs.push(trackId);
|
|
263
|
+
}
|
|
264
|
+
configService.set('musicFavorites', favs);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get custom tracks from config.
|
|
269
|
+
*/
|
|
270
|
+
function _getCustomTracks(configService) {
|
|
271
|
+
const custom = configService.getConfig().customTracks;
|
|
272
|
+
return Array.isArray(custom) ? custom : [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Create the Music tab component.
|
|
279
|
+
*
|
|
280
|
+
* @param {object} screen - Blessed screen instance (or test stub)
|
|
281
|
+
* @param {object} services
|
|
282
|
+
* @param {import('../../services/config-service.js').ConfigService} services.configService
|
|
283
|
+
* @returns {{ box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }}
|
|
284
|
+
*/
|
|
285
|
+
export function createMusicTab(screen, services) {
|
|
286
|
+
if (IS_TEST) return createTestStub();
|
|
287
|
+
|
|
288
|
+
const { configService, focusMainTabBar, updateHeaderStatus, languageService } = services;
|
|
289
|
+
const _tl = (key) => languageService ? languageService.t(key) : t('en', key);
|
|
290
|
+
|
|
291
|
+
// -------------------------------------------------------------------------
|
|
292
|
+
// Container
|
|
293
|
+
|
|
294
|
+
const box = blessed.box({
|
|
295
|
+
parent: screen,
|
|
296
|
+
top: 5,
|
|
297
|
+
left: 0,
|
|
298
|
+
width: '100%',
|
|
299
|
+
bottom: 2,
|
|
300
|
+
hidden: true,
|
|
301
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
302
|
+
border: { type: 'line' },
|
|
303
|
+
borderStyle: { fg: COLORS.borderFg },
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// -------------------------------------------------------------------------
|
|
307
|
+
// Section headers
|
|
308
|
+
|
|
309
|
+
const builtInHdr = blessed.text({
|
|
310
|
+
parent: box,
|
|
311
|
+
top: 1,
|
|
312
|
+
left: 2,
|
|
313
|
+
content: `{${COLORS.sectionHdr}-fg}${_tl('musicBuiltInHeader')}${'─'.repeat(48)}{/${COLORS.sectionHdr}-fg}`,
|
|
314
|
+
tags: true,
|
|
315
|
+
style: { bg: COLORS.contentBg },
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Currently selected track indicator (updated by refreshDisplay)
|
|
319
|
+
const activeTrackText = blessed.text({
|
|
320
|
+
parent: box,
|
|
321
|
+
top: 1,
|
|
322
|
+
right: 4,
|
|
323
|
+
shrink: true,
|
|
324
|
+
tags: true,
|
|
325
|
+
content: '',
|
|
326
|
+
style: { bg: COLORS.contentBg },
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// -------------------------------------------------------------------------
|
|
330
|
+
// Track list
|
|
331
|
+
|
|
332
|
+
const trackList = blessed.list({
|
|
333
|
+
parent: box,
|
|
334
|
+
top: 3,
|
|
335
|
+
left: 2,
|
|
336
|
+
width: '96%',
|
|
337
|
+
height: '55%',
|
|
338
|
+
keys: true,
|
|
339
|
+
vi: true,
|
|
340
|
+
mouse: true,
|
|
341
|
+
tags: true,
|
|
342
|
+
border: { type: 'line' },
|
|
343
|
+
scrollbar: { ch: '│', style: { fg: COLORS.sectionHdr } },
|
|
344
|
+
style: {
|
|
345
|
+
fg: COLORS.labelFg,
|
|
346
|
+
bg: COLORS.contentBg,
|
|
347
|
+
border: { fg: COLORS.borderFg },
|
|
348
|
+
selected: { bg: '#3e2000', fg: COLORS.activeFg, bold: true },
|
|
349
|
+
item: { fg: COLORS.labelFg },
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// -------------------------------------------------------------------------
|
|
354
|
+
// Status panel
|
|
355
|
+
|
|
356
|
+
const musicStatusHdr = blessed.text({
|
|
357
|
+
parent: box,
|
|
358
|
+
top: '64%',
|
|
359
|
+
left: 2,
|
|
360
|
+
content: `{${COLORS.sectionHdr}-fg}${_tl('musicStatusHeader')}${'─'.repeat(52)}{/${COLORS.sectionHdr}-fg}`,
|
|
361
|
+
tags: true,
|
|
362
|
+
style: { bg: COLORS.contentBg },
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const statusLine = blessed.text({
|
|
366
|
+
parent: box,
|
|
367
|
+
top: '69%',
|
|
368
|
+
left: 2,
|
|
369
|
+
tags: true,
|
|
370
|
+
content: '',
|
|
371
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const previewLine = blessed.text({
|
|
375
|
+
parent: box,
|
|
376
|
+
top: '74%',
|
|
377
|
+
left: 2,
|
|
378
|
+
tags: true,
|
|
379
|
+
content: '',
|
|
380
|
+
style: { fg: COLORS.playingFg, bg: COLORS.contentBg },
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// -------------------------------------------------------------------------
|
|
384
|
+
// Buttons
|
|
385
|
+
|
|
386
|
+
function _createBtn(label, onClick) {
|
|
387
|
+
const btn = blessed.button({
|
|
388
|
+
parent: box,
|
|
389
|
+
content: label,
|
|
390
|
+
mouse: true,
|
|
391
|
+
keys: true,
|
|
392
|
+
shrink: true,
|
|
393
|
+
padding: { left: 1, right: 1 },
|
|
394
|
+
style: {
|
|
395
|
+
bg: COLORS.btnDefault,
|
|
396
|
+
fg: 'white',
|
|
397
|
+
focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
398
|
+
hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
btn.on('focus', () => {
|
|
402
|
+
btn.style.bg = COLORS.btnFocus;
|
|
403
|
+
btn.style.fg = COLORS.btnFocusFg;
|
|
404
|
+
const raw = btn.content.replace(/[►◄]/g, '').trim();
|
|
405
|
+
btn.setContent(`►${raw}◄`);
|
|
406
|
+
screen.render();
|
|
407
|
+
});
|
|
408
|
+
btn.on('blur', () => {
|
|
409
|
+
btn.style.bg = COLORS.btnDefault;
|
|
410
|
+
btn.style.fg = 'white';
|
|
411
|
+
const raw = btn.content.replace(/[►◄]/g, '').trim();
|
|
412
|
+
btn.setContent(raw);
|
|
413
|
+
screen.render();
|
|
414
|
+
});
|
|
415
|
+
btn.key(['enter', 'space'], () => {
|
|
416
|
+
btn.style.bg = COLORS.btnPress;
|
|
417
|
+
screen.render();
|
|
418
|
+
setTimeout(() => {
|
|
419
|
+
btn.style.bg = COLORS.btnDefault;
|
|
420
|
+
screen.render();
|
|
421
|
+
onClick();
|
|
422
|
+
}, 150);
|
|
423
|
+
});
|
|
424
|
+
btn.on('click', () => btn.press());
|
|
425
|
+
btn.on('mouseover', () => btn.focus());
|
|
426
|
+
return btn;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const toggleBtn = _createBtn(_tl('musicToggleBtn'), () => {
|
|
430
|
+
const { enabled } = _getMusic(configService);
|
|
431
|
+
_setMusic(configService, { enabled: !enabled });
|
|
432
|
+
refreshDisplay();
|
|
433
|
+
});
|
|
434
|
+
toggleBtn.bottom = 4;
|
|
435
|
+
toggleBtn.left = 4;
|
|
436
|
+
|
|
437
|
+
const addCustomTrackBtn = _createBtn(_tl('musicAddCustomBtn'), () => {
|
|
438
|
+
const modal = blessed.box({
|
|
439
|
+
parent: screen,
|
|
440
|
+
top: 'center',
|
|
441
|
+
left: 'center',
|
|
442
|
+
width: 66,
|
|
443
|
+
height: 11,
|
|
444
|
+
border: { type: 'line' },
|
|
445
|
+
tags: true,
|
|
446
|
+
label: ` {${COLORS.activeFg}-fg}Add Custom Background Track{/${COLORS.activeFg}-fg} `,
|
|
447
|
+
style: { border: { fg: COLORS.borderFg }, bg: COLORS.contentBg },
|
|
448
|
+
content: [
|
|
449
|
+
'',
|
|
450
|
+
` {${COLORS.labelFg}-fg}To add a custom track:{/${COLORS.labelFg}-fg}`,
|
|
451
|
+
'',
|
|
452
|
+
` {${COLORS.valueFg}-fg}1.{/${COLORS.valueFg}-fg} Place an MP3/OGG/WAV file in:`,
|
|
453
|
+
` {${COLORS.noticeFg}-fg}.claude/audio/tracks/{/${COLORS.noticeFg}-fg}`,
|
|
454
|
+
'',
|
|
455
|
+
` {${COLORS.valueFg}-fg}2.{/${COLORS.valueFg}-fg} Or run: {${COLORS.noticeFg}-fg}/agent-vibes:background-music{/${COLORS.noticeFg}-fg}`,
|
|
456
|
+
'',
|
|
457
|
+
` {${COLORS.dimFg}-fg}[Esc / Enter] Close{/${COLORS.dimFg}-fg}`,
|
|
458
|
+
].join('\n'),
|
|
459
|
+
});
|
|
460
|
+
modal.key(['escape', 'enter', 'q'], () => { modal.destroy(); trackList.focus(); screen.render(); });
|
|
461
|
+
modal.setFront();
|
|
462
|
+
modal.focus();
|
|
463
|
+
screen.render();
|
|
464
|
+
});
|
|
465
|
+
addCustomTrackBtn.bottom = 4;
|
|
466
|
+
addCustomTrackBtn.left = 26;
|
|
467
|
+
|
|
468
|
+
// -------------------------------------------------------------------------
|
|
469
|
+
// Hint text shown in previewLine when the list has focus and nothing is playing.
|
|
470
|
+
// Getter functions so they re-translate when language changes.
|
|
471
|
+
const _hintText = () => `{${COLORS.dimFg}-fg}${_tl('musicHintText')}{/${COLORS.dimFg}-fg}`;
|
|
472
|
+
const _rowHint = () => ` {bright-black-fg}${_tl('musicRowHint')}{/bright-black-fg}`;
|
|
473
|
+
let _listFocused = false;
|
|
474
|
+
|
|
475
|
+
// Inline selection hint appended to the currently highlighted track row.
|
|
476
|
+
// _hintBase stores the item's clean content (no hint, no █) so we never need
|
|
477
|
+
// a sentinel character — PUA chars like U+E000 render as Nerd Font icons.
|
|
478
|
+
let _hintIdx = -1;
|
|
479
|
+
let _hintBase = ''; // content of items[_hintIdx] before hint was appended
|
|
480
|
+
let _refreshing = false;
|
|
481
|
+
|
|
482
|
+
// Decoration helpers — strip blink cursor and (optionally) hint text from
|
|
483
|
+
// a list item's content. We strip hint by anchoring on the EXACT text from
|
|
484
|
+
// _rowHint() rather than a generic regex, so we cannot accidentally erase
|
|
485
|
+
// unrelated content that happens to contain similar tags.
|
|
486
|
+
const _BLINK_RE = / █/g;
|
|
487
|
+
function _stripBlink(raw) {
|
|
488
|
+
return (raw ?? '').replace(_BLINK_RE, '');
|
|
489
|
+
}
|
|
490
|
+
function _stripHint(raw) {
|
|
491
|
+
const hint = _rowHint();
|
|
492
|
+
if (!hint) return raw;
|
|
493
|
+
const noBlink = (raw ?? '').replace(_BLINK_RE, '');
|
|
494
|
+
if (noBlink.endsWith(hint)) return noBlink.slice(0, -hint.length);
|
|
495
|
+
return noBlink;
|
|
496
|
+
}
|
|
497
|
+
function _stripDecorations(raw) {
|
|
498
|
+
return _stripHint(_stripBlink(raw));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function _updateHint(idx) {
|
|
502
|
+
const items = trackList.items;
|
|
503
|
+
// Restore previously hinted row — pad with spaces to overwrite ghost hint text
|
|
504
|
+
const _pad = ' '.repeat(60);
|
|
505
|
+
if (_hintIdx >= 0 && _hintIdx !== idx && items[_hintIdx]) {
|
|
506
|
+
items[_hintIdx].setContent(_hintBase + _pad);
|
|
507
|
+
}
|
|
508
|
+
// Add hint to the new row, saving its clean base first
|
|
509
|
+
if (idx >= 0 && items[idx]) {
|
|
510
|
+
_hintBase = _stripDecorations(items[idx].content);
|
|
511
|
+
items[idx].setContent(_hintBase + _rowHint());
|
|
512
|
+
} else {
|
|
513
|
+
_hintBase = '';
|
|
514
|
+
}
|
|
515
|
+
_hintIdx = idx;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// -------------------------------------------------------------------------
|
|
519
|
+
// Playback state
|
|
520
|
+
|
|
521
|
+
let _playingProcess = null;
|
|
522
|
+
let _playingTrackId = null;
|
|
523
|
+
|
|
524
|
+
// Kill the entire process group so child audio processes (ffplay, play, mpg123) all die
|
|
525
|
+
function _killPlayingProcess() {
|
|
526
|
+
if (_playingProcess) {
|
|
527
|
+
const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
|
|
528
|
+
try {
|
|
529
|
+
if (_isWin) {
|
|
530
|
+
// Windows: kill the process tree via taskkill (process group kill doesn't work)
|
|
531
|
+
spawn('taskkill', ['/F', '/T', '/PID', String(_playingProcess.pid)], {
|
|
532
|
+
stdio: 'ignore', windowsHide: true,
|
|
533
|
+
});
|
|
534
|
+
} else {
|
|
535
|
+
process.kill(-_playingProcess.pid, 'SIGTERM');
|
|
536
|
+
}
|
|
537
|
+
} catch (e) {
|
|
538
|
+
if (e.code !== 'ESRCH') { /* ignore */ }
|
|
539
|
+
}
|
|
540
|
+
_playingProcess = null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const _spawnEnv = buildAudioEnv();
|
|
545
|
+
const _detectedPlayer = detectMp3Player(_spawnEnv);
|
|
546
|
+
|
|
547
|
+
process.on('exit', () => { _killPlayingProcess(); });
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Preview a track by spawning an audio player.
|
|
551
|
+
* Second call with the same trackId stops playback (toggle).
|
|
552
|
+
*/
|
|
553
|
+
function _playTrack(trackId) {
|
|
554
|
+
const tracksDir = _getTracksDir();
|
|
555
|
+
const trackPath = path.resolve(tracksDir, trackId);
|
|
556
|
+
|
|
557
|
+
// Guard: path must stay inside tracksDir
|
|
558
|
+
const safeBase = path.resolve(tracksDir);
|
|
559
|
+
if (!trackPath.startsWith(safeBase + path.sep) && trackPath !== safeBase) {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Toggle: second press on the same track → stop
|
|
564
|
+
if (_playingTrackId === trackId) {
|
|
565
|
+
_killPlayingProcess();
|
|
566
|
+
_playingTrackId = null;
|
|
567
|
+
previewLine.setContent(_listFocused ? _hintText() : '');
|
|
568
|
+
screen.render();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Kill any previously playing track
|
|
573
|
+
_killPlayingProcess();
|
|
574
|
+
_playingTrackId = null;
|
|
575
|
+
|
|
576
|
+
if (!_detectedPlayer) {
|
|
577
|
+
const installHint = process.platform === 'win32'
|
|
578
|
+
? 'No MP3 player found. Install ffmpeg: winget install ffmpeg'
|
|
579
|
+
: 'No MP3 player found. Install ffmpeg: sudo apt install ffmpeg';
|
|
580
|
+
previewLine.setContent(`{red-fg}${installHint}{/red-fg}`);
|
|
581
|
+
screen.render();
|
|
582
|
+
setTimeout(() => { previewLine.setContent(_listFocused ? _hintText() : ''); screen.render(); }, 5000);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
|
|
587
|
+
// Spawn the detected player directly (no sh -c chain — avoids VLC/cvlc stderr issues)
|
|
588
|
+
_playingProcess = spawn(_detectedPlayer.bin, _detectedPlayer.args(trackPath), {
|
|
589
|
+
stdio: 'ignore', detached: !_isWin, windowsHide: true, env: _spawnEnv,
|
|
590
|
+
});
|
|
591
|
+
_playingTrackId = trackId;
|
|
592
|
+
|
|
593
|
+
const label = _allTracks.find(t => t.id === trackId)?.label ?? formatTrackLabel(trackId);
|
|
594
|
+
previewLine.setContent(`{${COLORS.playingFg}-fg}♪ Previewing: ${label} (Space again to stop){/${COLORS.playingFg}-fg}`);
|
|
595
|
+
screen.render();
|
|
596
|
+
|
|
597
|
+
_playingProcess.on('exit', () => {
|
|
598
|
+
if (_playingTrackId === trackId) {
|
|
599
|
+
_playingTrackId = null;
|
|
600
|
+
_playingProcess = null;
|
|
601
|
+
previewLine.setContent(_listFocused ? _hintText() : '');
|
|
602
|
+
refreshDisplay(); // clears (playing) label
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
_playingProcess.on('error', () => {
|
|
607
|
+
if (_playingTrackId === trackId) {
|
|
608
|
+
_killPlayingProcess();
|
|
609
|
+
_playingTrackId = null;
|
|
610
|
+
_playingProcess = null;
|
|
611
|
+
previewLine.setContent(_listFocused ? _hintText() : '');
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// -------------------------------------------------------------------------
|
|
617
|
+
// Display state
|
|
618
|
+
|
|
619
|
+
let _showFavoritesOnly = false;
|
|
620
|
+
let _allTracks = [];
|
|
621
|
+
|
|
622
|
+
function _buildAllTracks() {
|
|
623
|
+
const scanned = scanTracks();
|
|
624
|
+
const scannedIds = new Set(scanned.map(t => t.id));
|
|
625
|
+
// Append custom tracks not already present from disk scan
|
|
626
|
+
const custom = _getCustomTracks(configService)
|
|
627
|
+
.filter(id => !scannedIds.has(id))
|
|
628
|
+
.map(id => ({ id, label: formatTrackLabel(id), isBuiltIn: false }));
|
|
629
|
+
return [...scanned, ...custom];
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function _getVisibleTracks() {
|
|
633
|
+
if (!_showFavoritesOnly) return _allTracks;
|
|
634
|
+
const favs = getMusicFavorites(configService);
|
|
635
|
+
return _allTracks.filter(t => favs.includes(t.id));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function _getSelectedTrackId() {
|
|
639
|
+
const visible = _getVisibleTracks();
|
|
640
|
+
const entry = visible[trackList.selected];
|
|
641
|
+
return entry ? entry.id : null;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function _buildListItems(tracks, activeTrackId, favorites) {
|
|
645
|
+
return tracks.map(t => {
|
|
646
|
+
const isFav = favorites.includes(t.id);
|
|
647
|
+
const isActive = t.id === activeTrackId;
|
|
648
|
+
const isPrev = t.id === _playingTrackId;
|
|
649
|
+
const star = isFav ? '★' : ' ';
|
|
650
|
+
const dot = isPrev ? '♪' : (isActive ? '{green-fg}✓{/green-fg}' : ' ');
|
|
651
|
+
const tag = t.isBuiltIn ? '' : ' [custom]';
|
|
652
|
+
return ` ${star}${dot} ${t.label}${tag}${isPrev ? ' (playing)' : ''}`;
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function refreshDisplay() {
|
|
657
|
+
_refreshing = true;
|
|
658
|
+
const savedIdx = trackList.selected ?? 0;
|
|
659
|
+
|
|
660
|
+
_allTracks = _buildAllTracks();
|
|
661
|
+
const { enabled, track: activeTrackId } = _getMusic(configService);
|
|
662
|
+
const favorites = getMusicFavorites(configService);
|
|
663
|
+
const visible = _getVisibleTracks();
|
|
664
|
+
const items = _buildListItems(visible, activeTrackId, favorites);
|
|
665
|
+
|
|
666
|
+
const activeTrack = _allTracks.find(t => t.id === activeTrackId);
|
|
667
|
+
const activeLabel = (activeTrack?.label ?? formatTrackLabel(activeTrackId ?? '')) || 'None';
|
|
668
|
+
|
|
669
|
+
trackList.setItems(items.length > 0 ? items : [' (no tracks match filter)']);
|
|
670
|
+
// Restore selection (setItems resets to 0)
|
|
671
|
+
const maxIdx = Math.max(0, (items.length > 0 ? items.length : 1) - 1);
|
|
672
|
+
trackList.select(Math.min(savedIdx, maxIdx));
|
|
673
|
+
|
|
674
|
+
// Re-apply inline hint if list is focused
|
|
675
|
+
if (_listFocused) {
|
|
676
|
+
_hintIdx = -1;
|
|
677
|
+
_hintBase = '';
|
|
678
|
+
_updateHint(trackList.selected ?? 0);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
statusLine.setContent(
|
|
682
|
+
` ${_tl('musicStatusLabel')} ${formatMusicStatus(enabled)} | ${_tl('musicActiveTrack')} ${activeLabel} | ${_tl('musicFilterLabel')} ${_showFavoritesOnly ? _tl('musicFilterFavs') : _tl('musicFilterAll')}`
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
// Update "Currently Selected" header
|
|
686
|
+
activeTrackText.setContent(`{${COLORS.activeFg}-fg}✓ ${activeLabel}{/${COLORS.activeFg}-fg}`);
|
|
687
|
+
|
|
688
|
+
_refreshing = false;
|
|
689
|
+
if (typeof updateHeaderStatus === 'function') updateHeaderStatus();
|
|
690
|
+
screen.render();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// -------------------------------------------------------------------------
|
|
694
|
+
// Key bindings on trackList
|
|
695
|
+
|
|
696
|
+
// [Enter] → open save modal for selected track
|
|
697
|
+
trackList.key(['enter'], () => {
|
|
698
|
+
const trackId = _getSelectedTrackId();
|
|
699
|
+
if (!trackId) return;
|
|
700
|
+
const label = _allTracks.find(t => t.id === trackId)?.label ?? formatTrackLabel(trackId);
|
|
701
|
+
_openSaveModal(trackId, label);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Save-track modal: Save Locally | Save Globally & Locally | Cancel | Preview
|
|
706
|
+
*/
|
|
707
|
+
function _openSaveModal(trackId, displayName) {
|
|
708
|
+
const modal = blessed.box({
|
|
709
|
+
parent: screen,
|
|
710
|
+
top: 'center',
|
|
711
|
+
left: 'center',
|
|
712
|
+
width: 72,
|
|
713
|
+
height: 8,
|
|
714
|
+
border: { type: 'line' },
|
|
715
|
+
tags: true,
|
|
716
|
+
label: ` {${COLORS.activeFg}-fg}Set Background Track{/${COLORS.activeFg}-fg} `,
|
|
717
|
+
style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
blessed.text({
|
|
721
|
+
parent: modal,
|
|
722
|
+
top: 1,
|
|
723
|
+
left: 2,
|
|
724
|
+
right: 2,
|
|
725
|
+
content: `Set {${COLORS.valueFg}-fg}${displayName}{/${COLORS.valueFg}-fg} as your background track?`,
|
|
726
|
+
tags: true,
|
|
727
|
+
style: { bg: COLORS.contentBg },
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
const modalStatus = blessed.text({
|
|
731
|
+
parent: modal,
|
|
732
|
+
top: 3,
|
|
733
|
+
left: 2,
|
|
734
|
+
right: 2,
|
|
735
|
+
tags: true,
|
|
736
|
+
content: `{${COLORS.dimFg}-fg}Press Preview to audition this track{/${COLORS.dimFg}-fg}`,
|
|
737
|
+
style: { bg: COLORS.contentBg },
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
function _close() {
|
|
741
|
+
_killPlayingProcess();
|
|
742
|
+
_playingTrackId = null;
|
|
743
|
+
previewLine.setContent(_listFocused ? _hintText() : '');
|
|
744
|
+
modal.destroy();
|
|
745
|
+
trackList.focus();
|
|
746
|
+
screen.render();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function _makeBtn(lbl, bg, left, top, onClick) {
|
|
750
|
+
const btn = blessed.button({
|
|
751
|
+
parent: modal,
|
|
752
|
+
content: lbl,
|
|
753
|
+
top,
|
|
754
|
+
left,
|
|
755
|
+
mouse: true,
|
|
756
|
+
keys: true,
|
|
757
|
+
shrink: true,
|
|
758
|
+
padding: { left: 1, right: 1 },
|
|
759
|
+
style: {
|
|
760
|
+
bg,
|
|
761
|
+
fg: 'white',
|
|
762
|
+
focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
763
|
+
hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
btn.key(['enter', 'space'], () => { _close(); onClick(); });
|
|
767
|
+
btn.on('click', () => btn.press());
|
|
768
|
+
return btn;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function _saveLocally() {
|
|
772
|
+
_setMusic(configService, { track: trackId });
|
|
773
|
+
applyTrackToAudioEffects(trackId);
|
|
774
|
+
refreshDisplay();
|
|
775
|
+
_showTrackChangedNotice(displayName);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function _saveGlobally() {
|
|
779
|
+
configService.setGlobal('backgroundMusic', { track: trackId });
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const okLocalBtn = _makeBtn('Save Locally', COLORS.btnDefault, 2, 5, () => {
|
|
783
|
+
_saveLocally();
|
|
784
|
+
});
|
|
785
|
+
const okGlobalBtn = _makeBtn('Save Globally & Locally', '#1565c0', 18, 5, () => {
|
|
786
|
+
_saveLocally();
|
|
787
|
+
_saveGlobally();
|
|
788
|
+
});
|
|
789
|
+
const cancelBtn = _makeBtn('Cancel', '#546e7a', 46, 5, () => {});
|
|
790
|
+
|
|
791
|
+
// Preview button — does NOT close the modal; plays/stops the track inline
|
|
792
|
+
const previewBtn = blessed.button({
|
|
793
|
+
parent: modal,
|
|
794
|
+
content: 'Preview',
|
|
795
|
+
top: 5,
|
|
796
|
+
left: 58,
|
|
797
|
+
mouse: true,
|
|
798
|
+
keys: true,
|
|
799
|
+
shrink: true,
|
|
800
|
+
padding: { left: 1, right: 1 },
|
|
801
|
+
style: {
|
|
802
|
+
bg: '#e65100',
|
|
803
|
+
fg: 'white',
|
|
804
|
+
focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
805
|
+
hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
previewBtn.key(['enter', 'space'], () => {
|
|
809
|
+
const isPlaying = _playingTrackId === trackId;
|
|
810
|
+
_playTrack(trackId);
|
|
811
|
+
modalStatus.setContent(isPlaying
|
|
812
|
+
? `{${COLORS.dimFg}-fg}Stopped.{/${COLORS.dimFg}-fg}`
|
|
813
|
+
: `{${COLORS.playingFg}-fg}♪ Playing: ${displayName}…{/${COLORS.playingFg}-fg}`
|
|
814
|
+
);
|
|
815
|
+
screen.render();
|
|
816
|
+
});
|
|
817
|
+
previewBtn.on('click', () => previewBtn.press());
|
|
818
|
+
|
|
819
|
+
// Tab/arrow navigation: SaveLocal → SaveGlobal → Cancel → Preview → SaveLocal
|
|
820
|
+
okLocalBtn.key(['tab', 'right'], () => { okGlobalBtn.focus(); screen.render(); });
|
|
821
|
+
okGlobalBtn.key(['tab', 'right'], () => { cancelBtn.focus(); screen.render(); });
|
|
822
|
+
cancelBtn.key(['tab', 'right'], () => { previewBtn.focus(); screen.render(); });
|
|
823
|
+
previewBtn.key(['tab', 'right'], () => { okLocalBtn.focus(); screen.render(); });
|
|
824
|
+
previewBtn.key(['left'], () => { cancelBtn.focus(); screen.render(); });
|
|
825
|
+
cancelBtn.key(['left'], () => { okGlobalBtn.focus(); screen.render(); });
|
|
826
|
+
okGlobalBtn.key(['left'], () => { okLocalBtn.focus(); screen.render(); });
|
|
827
|
+
okLocalBtn.key(['left'], () => { previewBtn.focus(); screen.render(); });
|
|
828
|
+
|
|
829
|
+
modal.key(['escape', 'q'], _close);
|
|
830
|
+
|
|
831
|
+
modal.setFront();
|
|
832
|
+
okLocalBtn.focus();
|
|
833
|
+
screen.render();
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function _showTrackChangedNotice(displayName) {
|
|
837
|
+
const notice = blessed.box({
|
|
838
|
+
parent: screen,
|
|
839
|
+
top: 'center',
|
|
840
|
+
left: 'center',
|
|
841
|
+
width: 50,
|
|
842
|
+
height: 5,
|
|
843
|
+
border: { type: 'line' },
|
|
844
|
+
tags: true,
|
|
845
|
+
label: ` {${COLORS.activeFg}-fg}Done{/${COLORS.activeFg}-fg} `,
|
|
846
|
+
style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
|
|
847
|
+
content: `\n {${COLORS.activeFg}-fg}✓ Track set: ${displayName}{/${COLORS.activeFg}-fg}`,
|
|
848
|
+
});
|
|
849
|
+
notice.setFront();
|
|
850
|
+
screen.render();
|
|
851
|
+
let noticeDestroyed = false;
|
|
852
|
+
setTimeout(() => { if (!noticeDestroyed) { notice.destroy(); noticeDestroyed = true; screen.render(); } }, 2000);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// [Space] → preview/stop track (toggle)
|
|
856
|
+
trackList.key(['space'], () => {
|
|
857
|
+
const trackId = _getSelectedTrackId();
|
|
858
|
+
if (trackId) {
|
|
859
|
+
_playTrack(trackId);
|
|
860
|
+
refreshDisplay();
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// [m/M] → toggle music enabled in config
|
|
865
|
+
trackList.key(['m', 'M'], () => {
|
|
866
|
+
const { enabled } = _getMusic(configService);
|
|
867
|
+
_setMusic(configService, { enabled: !enabled });
|
|
868
|
+
refreshDisplay();
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
// [*] → toggle favorite
|
|
872
|
+
trackList.key(['*'], () => {
|
|
873
|
+
const trackId = _getSelectedTrackId();
|
|
874
|
+
if (trackId) {
|
|
875
|
+
toggleMusicFavorite(configService, trackId);
|
|
876
|
+
refreshDisplay();
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// [f/F] → toggle favorites filter
|
|
881
|
+
trackList.key(['f', 'F'], () => {
|
|
882
|
+
_showFavoritesOnly = !_showFavoritesOnly;
|
|
883
|
+
refreshDisplay();
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
// [↑] at top of list → jump to main header tab bar (second press)
|
|
887
|
+
let _prevTrackSel = -1;
|
|
888
|
+
trackList.key(['up'], () => {
|
|
889
|
+
const cur = trackList.selected ?? 0;
|
|
890
|
+
if (cur === 0 && _prevTrackSel === 0 && typeof focusMainTabBar === 'function') {
|
|
891
|
+
focusMainTabBar();
|
|
892
|
+
setTimeout(() => { trackList.select(0); screen.render(); }, 0);
|
|
893
|
+
}
|
|
894
|
+
_prevTrackSel = cur;
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
// Escape at the list level → return to header tab bar
|
|
898
|
+
trackList.key(['escape'], () => {
|
|
899
|
+
if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
// ↓ at the last item → descend into the button row (Toggle Music gets focus first)
|
|
903
|
+
// Track previous selection so arriving at last item doesn't immediately jump
|
|
904
|
+
let _prevTrackSelDown = -1;
|
|
905
|
+
trackList.key(['down'], () => {
|
|
906
|
+
const visible = _getVisibleTracks();
|
|
907
|
+
const cur = trackList.selected ?? 0;
|
|
908
|
+
const lastIdx = visible.length - 1;
|
|
909
|
+
if (cur >= lastIdx && _prevTrackSelDown >= lastIdx) {
|
|
910
|
+
toggleBtn.focus();
|
|
911
|
+
screen.render();
|
|
912
|
+
}
|
|
913
|
+
_prevTrackSelDown = cur;
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
// ←/→ navigate between the two buttons
|
|
917
|
+
toggleBtn.key(['right'], () => { addCustomTrackBtn.focus(); screen.render(); });
|
|
918
|
+
addCustomTrackBtn.key(['right'], () => { toggleBtn.focus(); screen.render(); });
|
|
919
|
+
toggleBtn.key(['left'], () => { addCustomTrackBtn.focus(); screen.render(); });
|
|
920
|
+
addCustomTrackBtn.key(['left'], () => { toggleBtn.focus(); screen.render(); });
|
|
921
|
+
|
|
922
|
+
// ↑ or Escape from any button → back to track list
|
|
923
|
+
toggleBtn.key(['up', 'escape'], () => { trackList.focus(); screen.render(); });
|
|
924
|
+
addCustomTrackBtn.key(['up', 'escape'], () => { trackList.focus(); screen.render(); });
|
|
925
|
+
|
|
926
|
+
// Blinking █ on selected row while list is focused
|
|
927
|
+
let _tlBlink = { interval: null, on: false, sel: -1 };
|
|
928
|
+
process.on('exit', () => { if (_tlBlink.interval) clearInterval(_tlBlink.interval); });
|
|
929
|
+
function _tlTick() {
|
|
930
|
+
_tlBlink.on = !_tlBlink.on;
|
|
931
|
+
const items = trackList.items;
|
|
932
|
+
const cur = trackList.selected ?? 0;
|
|
933
|
+
// Clean old row — only strip blink (hint, if any, is preserved)
|
|
934
|
+
if (_tlBlink.sel !== cur && _tlBlink.sel >= 0 && items[_tlBlink.sel]) {
|
|
935
|
+
items[_tlBlink.sel].setContent(_stripBlink(items[_tlBlink.sel].content));
|
|
936
|
+
}
|
|
937
|
+
_tlBlink.sel = cur;
|
|
938
|
+
if (items[cur]) {
|
|
939
|
+
const raw = _stripBlink(items[cur].content);
|
|
940
|
+
items[cur].setContent(_tlBlink.on ? `${raw} █` : raw);
|
|
941
|
+
}
|
|
942
|
+
screen.render();
|
|
943
|
+
}
|
|
944
|
+
trackList.on('focus', () => {
|
|
945
|
+
_listFocused = true;
|
|
946
|
+
_tlBlink.on = true;
|
|
947
|
+
_tlBlink.sel = trackList.selected ?? 0;
|
|
948
|
+
_hintIdx = -1;
|
|
949
|
+
_hintBase = '';
|
|
950
|
+
_updateHint(_tlBlink.sel);
|
|
951
|
+
const items = trackList.items;
|
|
952
|
+
// Append blink cursor — content already has hint from _updateHint
|
|
953
|
+
if (items[_tlBlink.sel]) {
|
|
954
|
+
const raw = _stripBlink(items[_tlBlink.sel].content);
|
|
955
|
+
items[_tlBlink.sel].setContent(raw + ' █');
|
|
956
|
+
}
|
|
957
|
+
if (!_playingTrackId) previewLine.setContent(_hintText());
|
|
958
|
+
screen.render();
|
|
959
|
+
_tlBlink.interval = setInterval(_tlTick, 500);
|
|
960
|
+
});
|
|
961
|
+
trackList.on('blur', () => {
|
|
962
|
+
_listFocused = false;
|
|
963
|
+
if (!_playingTrackId) previewLine.setContent('');
|
|
964
|
+
if (_tlBlink.interval) { clearInterval(_tlBlink.interval); _tlBlink.interval = null; }
|
|
965
|
+
const items = trackList.items;
|
|
966
|
+
// Strip blink from selected row, strip both from the hinted row
|
|
967
|
+
const sel = trackList.selected ?? 0;
|
|
968
|
+
if (items[sel]) {
|
|
969
|
+
if (sel === _hintIdx) {
|
|
970
|
+
items[sel].setContent(_stripDecorations(items[sel].content));
|
|
971
|
+
} else {
|
|
972
|
+
items[sel].setContent(_stripBlink(items[sel].content));
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
if (_hintIdx >= 0 && _hintIdx !== sel && items[_hintIdx]) {
|
|
976
|
+
items[_hintIdx].setContent(_stripDecorations(items[_hintIdx].content));
|
|
977
|
+
}
|
|
978
|
+
_hintIdx = -1;
|
|
979
|
+
_hintBase = '';
|
|
980
|
+
screen.render();
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
// Refresh status text on cursor movement
|
|
984
|
+
trackList.on('select item', () => {
|
|
985
|
+
if (_refreshing) return;
|
|
986
|
+
_updateHint(trackList.selected ?? 0);
|
|
987
|
+
if (_tlBlink.interval) _tlTick(); // move █ to newly selected row
|
|
988
|
+
const { enabled, track: activeTrackId } = _getMusic(configService);
|
|
989
|
+
const activeTrack = _allTracks.find(t => t.id === activeTrackId);
|
|
990
|
+
const activeLabel = (activeTrack?.label ?? formatTrackLabel(activeTrackId ?? '')) || 'None';
|
|
991
|
+
statusLine.setContent(
|
|
992
|
+
` ${_tl('musicStatusLabel')} ${formatMusicStatus(enabled)} | ${_tl('musicActiveTrack')} ${activeLabel} | ${_tl('musicFilterLabel')} ${_showFavoritesOnly ? _tl('musicFilterFavs') : _tl('musicFilterAll')}`
|
|
993
|
+
);
|
|
994
|
+
screen.render();
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
// Type-to-jump: press a letter to jump to first track whose label starts with it
|
|
998
|
+
const _trackJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'm', 'f']);
|
|
999
|
+
trackList.on('keypress', (ch, key) => {
|
|
1000
|
+
if (!ch || key.ctrl || key.meta) return;
|
|
1001
|
+
const lower = ch.toLowerCase();
|
|
1002
|
+
if (!/^[a-z]$/.test(lower)) return;
|
|
1003
|
+
if (_trackJumpBlocked.has(lower)) return;
|
|
1004
|
+
const tracks = _getVisibleTracks();
|
|
1005
|
+
const count = tracks.length;
|
|
1006
|
+
if (count === 0) return;
|
|
1007
|
+
const start = trackList.selected ?? 0;
|
|
1008
|
+
for (let i = 1; i <= count; i++) {
|
|
1009
|
+
const idx = (start + i) % count;
|
|
1010
|
+
// Strip leading emoji/symbols to get first letter of track name
|
|
1011
|
+
const firstLetter = tracks[idx].label.replace(/^[^a-zA-Z]*/, '')[0]?.toLowerCase() ?? '';
|
|
1012
|
+
if (firstLetter === lower) {
|
|
1013
|
+
trackList.select(idx);
|
|
1014
|
+
screen.render();
|
|
1015
|
+
break;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// -------------------------------------------------------------------------
|
|
1021
|
+
// -------------------------------------------------------------------------
|
|
1022
|
+
// Language refresh
|
|
1023
|
+
|
|
1024
|
+
function refreshMusicLabels() {
|
|
1025
|
+
builtInHdr.setContent(`{${COLORS.sectionHdr}-fg}${_tl('musicBuiltInHeader')}${'─'.repeat(48)}{/${COLORS.sectionHdr}-fg}`);
|
|
1026
|
+
musicStatusHdr.setContent(`{${COLORS.sectionHdr}-fg}${_tl('musicStatusHeader')}${'─'.repeat(52)}{/${COLORS.sectionHdr}-fg}`);
|
|
1027
|
+
toggleBtn.setContent(_tl('musicToggleBtn'));
|
|
1028
|
+
addCustomTrackBtn.setContent(_tl('musicAddCustomBtn'));
|
|
1029
|
+
refreshDisplay();
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
if (languageService) {
|
|
1033
|
+
languageService.onChange(() => { refreshMusicLabels(); screen.render(); });
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Tab Component Contract
|
|
1037
|
+
|
|
1038
|
+
return {
|
|
1039
|
+
box,
|
|
1040
|
+
|
|
1041
|
+
show() {
|
|
1042
|
+
box.show();
|
|
1043
|
+
refreshDisplay();
|
|
1044
|
+
screen.render();
|
|
1045
|
+
},
|
|
1046
|
+
|
|
1047
|
+
hide() {
|
|
1048
|
+
// Stop any preview when leaving the tab
|
|
1049
|
+
_killPlayingProcess();
|
|
1050
|
+
_playingTrackId = null;
|
|
1051
|
+
previewLine.setContent('');
|
|
1052
|
+
box.hide();
|
|
1053
|
+
screen.render();
|
|
1054
|
+
},
|
|
1055
|
+
|
|
1056
|
+
onFocus() {
|
|
1057
|
+
trackList.focus();
|
|
1058
|
+
screen.render();
|
|
1059
|
+
},
|
|
1060
|
+
|
|
1061
|
+
onBlur() {
|
|
1062
|
+
// Stop preview when focus leaves Music tab
|
|
1063
|
+
_killPlayingProcess();
|
|
1064
|
+
_playingTrackId = null;
|
|
1065
|
+
},
|
|
1066
|
+
|
|
1067
|
+
getFooterText() {
|
|
1068
|
+
return _tl('musicFooter');
|
|
1069
|
+
},
|
|
1070
|
+
|
|
1071
|
+
getFooterColor() {
|
|
1072
|
+
return COLORS.footerBg;
|
|
1073
|
+
},
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
}
|