eva-md 0.5.1 → 0.7.3

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.
Files changed (2) hide show
  1. package/eva.js +461 -212
  2. package/package.json +3 -3
package/eva.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * EVA.md v0.5.1 — EVA Media Downloader by .maxon
3
+ * EVA.md v0.7.3 — EVA Media Downloader by .maxon
4
4
  * Pure Node.js Interactive TUI (Zero external npm dependencies!)
5
5
  * Works just like Gemini CLI (`node eva.js` or `./eva.js`), with full mouse & keyboard support.
6
6
  *
@@ -22,12 +22,12 @@
22
22
  * --write-subs Download subtitles file
23
23
  * --embed-subs Embed subtitles into media container
24
24
  * --write-thumbnail Save thumbnail as a separate image file
25
- * File Naming & Archive (Именование файлов):
25
+ * File Naming (Именование файлов — во вкладке Settings):
26
26
  * -o "<template>" Output template (e.g. "%(playlist_index)s. %(title)s.%(ext)s")
27
- * --download-archive <file> Record downloaded track IDs in archive.txt
28
27
  * Playlists (Плейлисты):
29
28
  * --playlist-items <range> Download specific items (e.g. "1-5")
30
29
  * --playlist-reverse Download playlist in reverse order
30
+ * --download-archive <file> Record downloaded track IDs in archive.txt
31
31
  * Network & Bypass (Сеть и обход блокировок — во вкладке Settings):
32
32
  * -4 Force IPv4 connection (fixes DNS issues)
33
33
  * --proxy <url> Use HTTP/SOCKS proxy (e.g. http://127.0.0.1:8080)
@@ -46,11 +46,12 @@ const { spawn, spawnSync } = require('child_process');
46
46
  // Constants & Configuration
47
47
  // ---------------------------------------------------------------------------
48
48
 
49
- const APP_VERSION = 'v0.5.1 (Node.js)';
49
+ const APP_VERSION = 'v0.7.3 (Node.js)';
50
50
  const CONFIG_PATH = path.join(os.homedir(), '.config', 'eva_md', 'config.json');
51
51
  const MAX_LOG_LINES = 200;
52
52
  const MAX_LOG_LINE_LENGTH = 500;
53
53
  const MAX_DOWNLOAD_RETRIES = 3;
54
+ const LIBRARY_DOUBLE_CLICK_MS = 400;
54
55
  const ANSI_ESCAPE_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g;
55
56
 
56
57
  // Strictly 1-cell narrow glyphs (CP437/WGL4 safe) so Windows Terminal / PowerShell NEVER shifts borders!
@@ -78,18 +79,25 @@ const BROWSERS = ['none', 'chrome', 'firefox', 'edge', 'brave', 'opera', 'vivald
78
79
  const NAMING_PRESETS = [
79
80
  { label: 'Плейлист: 01. Название файла.mp4(формат)', tpl: '%(playlist_index)s. %(title)s.%(ext)s' },
80
81
  { label: 'Стандарт: Название файла.mp4(формат)', tpl: '%(title)s.%(ext)s' },
81
- { label: 'Автор - Название: Автор - Название.mp4(формат)', tpl: '%(uploader)s - %(title)s.%(ext)s' },
82
+ { label: 'Автор: Автор - Название.mp4(формат)', tpl: '%(uploader)s - %(title)s.%(ext)s' },
82
83
  ];
83
84
 
84
85
  const ASCII_LOGO = [
85
- '███████╗██╗ ██╗ █████╗ ⠀⠨⣔⢺⢿⡿⠟⠛⠋⠩⠥⠄⠀⠌⠉⠝⠻⠿⣿⣿⣷⣿⣿⣳⡀',
86
- '██╔════╝██║ ██║██╔══██╗ ███╗ ███╗ ██████╗⠀⠀ ⢳⣭⢯⡆⠁⠠⠀⢀⣠⣤⠀⠀⠈⠀⠀⠈⡑⢬⢻⣿⣿⣿⣿⣧⡂⠌⠀ ',
87
- '█████╗ ╚██╗ ██╔╝███████║ ██╔████╔██╗██╔══██╗ ⠈⠲⣽⣻⣽⢦⠀⠠⠝⣿⣷⡀⠀⠶⠂⠀⣸⣿⣟⠕⢼⣿⣿⣿⢿⢦',
88
- '██╔══╝ ╚████╔╝ ██╔══██║ ██║╚██╔╝██║██║ ██║ ⢣⣼⣻⣿⣟⢇⠀⡜⠤⠻⢿⣄⣀⣀⠼⢟⡻⣃⠀⠘⣿⣿⠟',
89
- '███████╗ ╚██╔╝ ██║ ██║ ██╗██║ ╚═╝ ██║██████╔╝ ⠐⢮⡿⣽⣿⣿⣞⡵⢢⣌⣦⣕⣢⣬⣧⣺⣿⣿⣿⣿⠝',
90
- '╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ⠲⠕⠻⢿⣿⡻⣿⢯⢟⡿⢯⠟⠛⠻⠝⠋',
86
+ ' :::::::::: ::: ::: ::: ⡌⣔⣥⣔⣪⣯⣿⣿⣮⣳⣾⣻⣾⣽⢮⣷⢿⣦',
87
+ ' :+: :+: :+: :+: :+: ⠨⣔⢺⢿⡿⠟⠛⠋⠩⠥⠄⠀⠌⠉⠝⠻⠿⣿⣿⣷⣿⣿⣳',
88
+ ' +:+ +:+ +:+ +:+ +:+ ⢳⣭⢯⡆⠁⠠⠀⢀⣠⣤⠀⠀⠈⠀⠀⠈⡑⢬⢻⣿⣿⣿⣿⣧',
89
+ ' +#++:++# +#+ +:+ +#++:++#++: ⠈⠲⣽⣻⣽⢦⠀⠠⠝⣿⣷⡀⠀⠶⠂⠀⣸⣿⣟⠕⢼⣿⣿⣿⢿',
90
+ " +#+ +#+ +#+ +#+ +#+ ⢣⣼⣻⣿⣟⢇⠀⡜⠤⠻⢿⣄⣀⣀⠼⢟⡻⣃⠀⠘⣿⣿⠟",
91
+ ' #+# #+#+#+# #+# #+# ⠐⢮⡿⣽⣿⣿⣞⡵⢢⣌⣦⣕⣢⣬⣧⣺⣿⣿⣿⣿⠝',
92
+ "########## ## ### ### ⠲⠕⠻⢿⣿⡻⣿⢯⢟⡿⢯⠟⠛⠻⠝⠋",
91
93
  ];
92
94
 
95
+ const STRETCHED_ASCII_LOGO = ASCII_LOGO.map((line) => {
96
+ const chars = Array.from(line);
97
+ const width = Math.round(chars.length * 1.2);
98
+ return Array.from({ length: width }, (_, index) => chars[Math.floor(index / 1.2)]).join('');
99
+ });
100
+
93
101
  // ANSI Styling Helpers
94
102
  const CSI = '\x1b[';
95
103
  const ANSI = {
@@ -151,6 +159,16 @@ function formatBytesToMB(bytes) {
151
159
  return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
152
160
  }
153
161
 
162
+ function formatEta(eta) {
163
+ const parts = String(eta || '').split(':');
164
+ if (!parts.length || parts.length > 3 || parts.some((part) => !/^\d+$/.test(part))) return '00:00';
165
+
166
+ const seconds = parts.reduce((total, part) => total * 60 + Number(part), 0);
167
+ const minutes = Math.floor(seconds / 60);
168
+ const remainingSeconds = seconds % 60;
169
+ return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
170
+ }
171
+
154
172
  function sanitizeLogLine(line) {
155
173
  return String(line)
156
174
  .replace(ANSI_ESCAPE_RE, '')
@@ -343,15 +361,11 @@ function buildYtdlpArgs(opts, targetUrl = null) {
343
361
  if (opts.embedSubs) args.push('--embed-subs');
344
362
  if (opts.writeThumbnail) args.push('--write-thumbnail');
345
363
 
346
- // File Naming & Archive
364
+ // File Naming
347
365
  const outDir = resolveOutputDir(opts.outputDir);
348
366
  const tpl = opts.usePlaylistTemplate ? opts.outputTemplate : '%(title)s.%(ext)s';
349
367
  args.push('-o', path.join(outDir, tpl));
350
368
 
351
- if (opts.useDownloadArchive && opts.archiveFile.trim()) {
352
- args.push('--download-archive', path.join(outDir, opts.archiveFile.trim()));
353
- }
354
-
355
369
  // Playlists
356
370
  if (opts.usePlaylistItems && opts.playlistItems.trim()) {
357
371
  args.push('--playlist-items', opts.playlistItems.trim());
@@ -359,6 +373,9 @@ function buildYtdlpArgs(opts, targetUrl = null) {
359
373
  if (opts.playlistReverse) {
360
374
  args.push('--playlist-reverse');
361
375
  }
376
+ if (opts.useDownloadArchive && opts.archiveFile.trim()) {
377
+ args.push('--download-archive', path.join(outDir, opts.archiveFile.trim()));
378
+ }
362
379
 
363
380
  // Network & Bypass
364
381
  if (opts.forceIpv4) {
@@ -404,6 +421,71 @@ function detectSource(url, defaultId = 1) {
404
421
  return { id: found.id, name: found.name };
405
422
  }
406
423
 
424
+ function isVideoUrl(url) {
425
+ try {
426
+ const parsed = new URL(url);
427
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
428
+ const pathname = parsed.pathname.toLowerCase();
429
+
430
+ if (/\.(mp4|m4v|mkv|mov|webm|avi)$/i.test(pathname)) return true;
431
+ if (host === 'youtu.be') return pathname.length > 1;
432
+ if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
433
+ if (host === 'music.youtube.com') return false;
434
+ return (
435
+ (pathname === '/watch' && parsed.searchParams.has('v')) ||
436
+ /^\/(shorts|live|embed|v)\//.test(pathname)
437
+ );
438
+ }
439
+ if (host === 'vkvideo.ru') return /^\/video/.test(pathname);
440
+ if (host === 'vk.com') return /^\/video/.test(pathname) || pathname === '/video_ext.php';
441
+ return false;
442
+ } catch (_) {
443
+ return false;
444
+ }
445
+ }
446
+
447
+ function isAudioUrl(url) {
448
+ try {
449
+ const parsed = new URL(url);
450
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
451
+ const pathname = parsed.pathname.toLowerCase();
452
+
453
+ if (/\.(mp3|m4a|m4b|m4p|aac|flac|wav|ogg|opus|oga|wma)$/i.test(pathname)) return true;
454
+ if (host === 'music.youtube.com') return true;
455
+ if (
456
+ host === 'bandcamp.com' ||
457
+ host.endsWith('.bandcamp.com') ||
458
+ host === 'soundcloud.com' ||
459
+ host.endsWith('.soundcloud.com') ||
460
+ host === 'mixcloud.com' ||
461
+ host.endsWith('.mixcloud.com') ||
462
+ host === 'audiomack.com' ||
463
+ host.endsWith('.audiomack.com') ||
464
+ host === 'audius.co' ||
465
+ host.endsWith('.audius.co')
466
+ ) {
467
+ return true;
468
+ }
469
+ return host === 'vk.com' && /^\/audio/.test(pathname);
470
+ } catch (_) {
471
+ return false;
472
+ }
473
+ }
474
+
475
+ function applyVideoModeForUrl(opts, url) {
476
+ if (!isVideoUrl(url)) return false;
477
+ opts.bestVideoMerge = true;
478
+ opts.extractAudio = false;
479
+ return true;
480
+ }
481
+
482
+ function applyAudioModeForUrl(opts, url) {
483
+ if (!isAudioUrl(url)) return false;
484
+ opts.bestVideoMerge = false;
485
+ opts.extractAudio = true;
486
+ return true;
487
+ }
488
+
407
489
  function isYouTubePlaylistUrl(url) {
408
490
  try {
409
491
  const parsed = new URL(url);
@@ -1059,7 +1141,14 @@ class EvaNodeTUI {
1059
1141
  this.selectedQueueItem = null;
1060
1142
  this.queueScrollOffset = 0;
1061
1143
  this.queueViewport = null;
1144
+ this.libraryScrollOffset = 0;
1145
+ this.libraryViewport = null;
1146
+ this.selectedLibraryFile = null;
1147
+ this.lastLibraryClick = null;
1062
1148
  this.statusBanner = '';
1149
+ this.cliMarqueeTimer = null;
1150
+ this.cliMarqueeText = '';
1151
+ this.cliMarqueeOffset = 0;
1063
1152
 
1064
1153
  this.modal = null;
1065
1154
  }
@@ -1082,10 +1171,51 @@ class EvaNodeTUI {
1082
1171
  }
1083
1172
 
1084
1173
  quit() {
1174
+ if (this.cliMarqueeTimer) {
1175
+ clearInterval(this.cliMarqueeTimer);
1176
+ this.cliMarqueeTimer = null;
1177
+ }
1085
1178
  process.stdout.write(ANSI.mouseOff + ANSI.showCursor + ANSI.altScreenOff + ANSI.reset + '\n');
1086
1179
  process.exit(0);
1087
1180
  }
1088
1181
 
1182
+ _stopCliMarquee() {
1183
+ if (this.cliMarqueeTimer) {
1184
+ clearInterval(this.cliMarqueeTimer);
1185
+ this.cliMarqueeTimer = null;
1186
+ }
1187
+ }
1188
+
1189
+ _renderCliPreview(scr, x, y, width, text) {
1190
+ if (text !== this.cliMarqueeText) {
1191
+ this.cliMarqueeText = text;
1192
+ this.cliMarqueeOffset = 0;
1193
+ }
1194
+
1195
+ if (text.length <= width) {
1196
+ this._stopCliMarquee();
1197
+ scr.putStr(y, x, text, ANSI.fgYellow);
1198
+ return;
1199
+ }
1200
+
1201
+ const gap = ' ';
1202
+ const stream = text + gap + text;
1203
+ const loopLength = text.length + gap.length;
1204
+ const start = this.cliMarqueeOffset % loopLength;
1205
+ scr.putStr(y, x, stream.slice(start, start + width).padEnd(width), ANSI.fgYellow);
1206
+
1207
+ if (!this.cliMarqueeTimer && !this.modal) {
1208
+ this.cliMarqueeTimer = setInterval(() => {
1209
+ if (this.activeTab !== 1 || this.modal) {
1210
+ this._stopCliMarquee();
1211
+ return;
1212
+ }
1213
+ this.cliMarqueeOffset = (this.cliMarqueeOffset + 1) % loopLength;
1214
+ this.render();
1215
+ }, 175);
1216
+ }
1217
+ }
1218
+
1089
1219
  registerWidget(scr, id, y, x, label, action, { checked = null, radio = null, isCta = false, maxWidth = 0 } = {}) {
1090
1220
  this.focusOrder.push(id);
1091
1221
  const isFocused = this.focusId === id;
@@ -1103,7 +1233,7 @@ class EvaNodeTUI {
1103
1233
  ? ANSI.bgGray + ANSI.fgWhite + ANSI.bold
1104
1234
  : ANSI.bgWhite + ANSI.fgBlack + ANSI.bold;
1105
1235
  } else if (isCta) {
1106
- style = id === 'url_input'
1236
+ style = ['url_input', 'edit_proxy', 'cycle_browser'].includes(id)
1107
1237
  ? ANSI.fgWhite + ANSI.bold
1108
1238
  : id === 'btn_add_queue'
1109
1239
  ? ANSI.fgGreen + ANSI.bold
@@ -1129,7 +1259,7 @@ class EvaNodeTUI {
1129
1259
  }
1130
1260
 
1131
1261
  const textX = x + prefix.length;
1132
- const editMarker = '[ред.]';
1262
+ const editMarker = '[✎ ]';
1133
1263
  const editMarkerIndex = visibleLabel.lastIndexOf(editMarker);
1134
1264
  if (!isFocused && editMarkerIndex >= 0) {
1135
1265
  const folderLabel = 'Папка:';
@@ -1139,6 +1269,10 @@ class EvaNodeTUI {
1139
1269
  scr.putStr(y, textX + folderLabelIndex, folderLabel, ANSI.fgWhite);
1140
1270
  const afterFolderIndex = folderLabelIndex + folderLabel.length;
1141
1271
  scr.putStr(y, textX + afterFolderIndex, visibleLabel.slice(afterFolderIndex, editMarkerIndex), style);
1272
+ } else if (id === 'edit_outdir' && visibleLabel.startsWith('🗁')) {
1273
+ const iconLength = '🗁'.length;
1274
+ scr.putStr(y, textX, visibleLabel.slice(0, iconLength), style + ANSI.bold);
1275
+ scr.putStr(y, textX + iconLength, visibleLabel.slice(iconLength, editMarkerIndex), style);
1142
1276
  } else {
1143
1277
  scr.putStr(y, textX, visibleLabel.slice(0, editMarkerIndex), style);
1144
1278
  }
@@ -1181,6 +1315,8 @@ class EvaNodeTUI {
1181
1315
  // -------------------------------------------------------------------------
1182
1316
 
1183
1317
  render() {
1318
+ if (this.activeTab !== 1 || this.modal) this._stopCliMarquee();
1319
+
1184
1320
  if (this.selectedQueueItem) {
1185
1321
  const selectedIndex = this.manager.items.indexOf(this.selectedQueueItem);
1186
1322
  if (selectedIndex >= 0) this.selectedQueueIdx = selectedIndex;
@@ -1194,6 +1330,7 @@ class EvaNodeTUI {
1194
1330
  this.focusOrder = [];
1195
1331
  this.hitboxes = [];
1196
1332
  this.queueViewport = null;
1333
+ this.libraryViewport = null;
1197
1334
 
1198
1335
  if (rows < 22 || cols < 76) {
1199
1336
  scr.putStr(1, 2, `Окно терминала слишком маленькое (${cols}x${rows}). Минимум: 78x22.`, ANSI.fgRed + ANSI.bold);
@@ -1214,15 +1351,22 @@ class EvaNodeTUI {
1214
1351
  }
1215
1352
 
1216
1353
  _renderHeader(scr, cols) {
1217
- ASCII_LOGO.forEach((line, i) => {
1354
+ const logoRight = 2 + Math.max(...STRETCHED_ASCII_LOGO.map((line) => line.length));
1355
+ STRETCHED_ASCII_LOGO.forEach((line, i) => {
1218
1356
  scr.putStr(1 + i, 2, line, ANSI.fgWhite + ANSI.bold);
1219
1357
  });
1220
1358
  scr.putStr(7, 2, 'EVA MEDIA DOWNLOADER', ANSI.fgCyan);
1221
- scr.putStr(2, cols - APP_VERSION.length - 3, APP_VERSION, ANSI.fgWhite);
1359
+ const versionX = cols - APP_VERSION.length - 3;
1360
+ if (versionX > logoRight + 1) {
1361
+ scr.putStr(2, versionX, APP_VERSION, ANSI.fgWhite);
1362
+ }
1222
1363
  if (cols >= 90) {
1223
1364
  const tagline = 'Music. Videos. Your collection.';
1224
- scr.putStr(5, cols - tagline.length - 3, tagline, ANSI.fgWhite);
1225
- scr.putStr(6, cols - tagline.length - 3, '─'.repeat(tagline.length), ANSI.fgCyan);
1365
+ const taglineX = cols - tagline.length - 3;
1366
+ if (taglineX > logoRight + 1) {
1367
+ scr.putStr(5, taglineX, tagline, ANSI.fgWhite);
1368
+ scr.putStr(6, taglineX, '─'.repeat(tagline.length), ANSI.fgCyan);
1369
+ }
1226
1370
  }
1227
1371
  }
1228
1372
 
@@ -1286,17 +1430,10 @@ class EvaNodeTUI {
1286
1430
  const activeItems = items.filter((item) => item.status === 'Downloading');
1287
1431
  const doneCnt = items.filter((i) => i.status === 'Done').length;
1288
1432
 
1289
- let pct = 0;
1290
- if (activeItems.length > 0) {
1291
- const sum = activeItems.reduce((acc, i) => acc + (i.progress || 0), 0);
1292
- pct = Math.round(sum / activeItems.length);
1293
- } else if (items.length > 0 && doneCnt === items.length) {
1294
- pct = 100;
1295
- }
1296
-
1297
- const queueStatus = `│ ${activeItems.length}↓ ${doneCnt}✓ ${pct}%`;
1433
+ const eta = formatEta(activeItems[0] && activeItems[0].etaStr);
1434
+ const queueStatus = `│ ${activeItems.length}↓ ${doneCnt}✓ ${eta}`;
1298
1435
  const queueStyle = activeItems.length > 0 ? ANSI.fgGreen + ANSI.bold : ANSI.fgWhite;
1299
- const queueStartX = cols - queueStatus.length;
1436
+ const queueStartX = cols - queueStatus.length - 1;
1300
1437
  scr.putStr(rows - 1, queueStartX, queueStatus, queueStyle);
1301
1438
 
1302
1439
  const statusX = queueStartX + queueStatus.length + 2;
@@ -1358,96 +1495,95 @@ class EvaNodeTUI {
1358
1495
  y += 7;
1359
1496
 
1360
1497
  // --- Box 2: Download Options & yt-dlp Flags ---
1361
- const box2H = 11;
1498
+ const box2H = 12;
1362
1499
  scr.clipRight = previousClipRight;
1363
1500
  scr.drawBox(y, x, box2H, w, 'Download Options Flags');
1364
1501
  scr.clipRight = x + w - 2;
1365
1502
  const c1 = x + 2;
1366
1503
 
1367
- // Row 1: Audio (-x, metadata, thumbnail)
1368
- scr.putStr(y + 1, c1, 'АУДИО:', ANSI.fgCyan + ANSI.bold);
1369
- let ax = c1 + 7;
1370
- ax =
1504
+ const renderAudioFormat = (row) => {
1505
+ scr.putStr(row, c1, 'ФОРМАТ:', ANSI.fgCyan + ANSI.bold);
1506
+ let fx = c1 + 8;
1507
+ AUDIO_FORMATS.forEach((fmt) => {
1508
+ fx =
1509
+ this.registerWidget(
1510
+ scr,
1511
+ `fmt_${fmt}`,
1512
+ row,
1513
+ fx,
1514
+ fmt,
1515
+ () => {
1516
+ this.opts.audioFormat = fmt;
1517
+ this.opts.extractAudio = true;
1518
+ this.opts.bestVideoMerge = false;
1519
+ this.statusBanner = `Формат: --audio-format ${fmt}`;
1520
+ this.render();
1521
+ },
1522
+ { radio: this.opts.audioFormat === fmt }
1523
+ ) + 2;
1524
+ });
1525
+ };
1526
+
1527
+ const renderAudioQuality = (row) => {
1528
+ scr.putStr(row, c1, 'БИТРЕЙТ:', ANSI.fgCyan + ANSI.bold);
1529
+ let qx = c1 + 9;
1530
+ AUDIO_QUALITIES.forEach((q) => {
1531
+ qx =
1532
+ this.registerWidget(
1533
+ scr,
1534
+ `qual_${q.val}`,
1535
+ row,
1536
+ qx,
1537
+ q.label,
1538
+ () => {
1539
+ this.opts.audioQuality = q.val;
1540
+ this.statusBanner = `Качество: --audio-quality ${q.val}`;
1541
+ this.render();
1542
+ },
1543
+ { radio: this.opts.audioQuality === q.val }
1544
+ ) + 2;
1545
+ });
1546
+ };
1547
+
1548
+ {
1549
+ const videoX = c1 + Math.floor((w - 4) / 2);
1550
+ const videoRight = x + w - 2;
1551
+
1552
+ scr.putStr(y + 1, c1, 'АУДИО:', ANSI.fgCyan + ANSI.bold);
1371
1553
  this.registerWidget(
1372
1554
  scr,
1373
1555
  'flag_x',
1374
1556
  y + 1,
1375
- ax,
1557
+ c1 + 7,
1376
1558
  'Только аудио',
1377
1559
  () => this._toggle('extractAudio'),
1378
- { checked: this.opts.extractAudio }
1379
- ) + 2;
1380
- ax =
1560
+ { checked: this.opts.extractAudio, maxWidth: videoX - c1 - 8 }
1561
+ );
1562
+ this.registerWidget(
1563
+ scr,
1564
+ 'flag_thumb',
1565
+ y + 2,
1566
+ c1 + 7,
1567
+ 'Встроить обложку',
1568
+ () => this._toggle('embedThumbnail'),
1569
+ { checked: this.opts.embedThumbnail, maxWidth: videoX - c1 - 8 }
1570
+ );
1381
1571
  this.registerWidget(
1382
1572
  scr,
1383
1573
  'flag_meta',
1384
- y + 1,
1385
- ax,
1574
+ y + 3,
1575
+ c1 + 7,
1386
1576
  'Встроить метаданные',
1387
1577
  () => this._toggle('embedMetadata'),
1388
- { checked: this.opts.embedMetadata }
1389
- ) + 2;
1390
- this.registerWidget(
1391
- scr,
1392
- 'flag_thumb',
1393
- y + 1,
1394
- ax,
1395
- 'Встроить обложку',
1396
- () => this._toggle('embedThumbnail'),
1397
- { checked: this.opts.embedThumbnail, maxWidth: x + w - ax - 2 }
1398
- );
1399
-
1400
- // Row 2: Format (--audio-format)
1401
- scr.putStr(y + 2, c1, 'ФОРМАТ:', ANSI.fgCyan + ANSI.bold);
1402
- let fx = c1 + 8;
1403
- AUDIO_FORMATS.forEach((fmt) => {
1404
- fx =
1405
- this.registerWidget(
1406
- scr,
1407
- `fmt_${fmt}`,
1408
- y + 2,
1409
- fx,
1410
- fmt,
1411
- () => {
1412
- this.opts.audioFormat = fmt;
1413
- this.opts.extractAudio = true;
1414
- this.opts.bestVideoMerge = false;
1415
- this.statusBanner = `Формат: --audio-format ${fmt}`;
1416
- this.render();
1417
- },
1418
- { radio: this.opts.audioFormat === fmt }
1419
- ) + 2;
1420
- });
1421
-
1422
- // Row 3: Quality (--audio-quality)
1423
- scr.putStr(y + 3, c1, 'БИТРЕЙТ:', ANSI.fgCyan + ANSI.bold);
1424
- let qx = c1 + 9;
1425
- AUDIO_QUALITIES.forEach((q) => {
1426
- qx =
1427
- this.registerWidget(
1428
- scr,
1429
- `qual_${q.val}`,
1430
- y + 3,
1431
- qx,
1432
- q.label,
1433
- () => {
1434
- this.opts.audioQuality = q.val;
1435
- this.statusBanner = `Качество: --audio-quality ${q.val}`;
1436
- this.render();
1437
- },
1438
- { radio: this.opts.audioQuality === q.val }
1439
- ) + 2;
1440
- });
1578
+ { checked: this.opts.embedMetadata, maxWidth: videoX - c1 - 8 }
1579
+ );
1441
1580
 
1442
- // Row 4: Video quality and subtitle file
1443
- scr.putStr(y + 4, c1, 'ВИДЕО:', ANSI.fgCyan + ANSI.bold);
1444
- let vx = c1 + 7;
1445
- vx =
1581
+ scr.putStr(y + 1, videoX, 'ВИДЕО:', ANSI.fgCyan + ANSI.bold);
1446
1582
  this.registerWidget(
1447
1583
  scr,
1448
1584
  'flag_bvbab',
1449
- y + 4,
1450
- vx,
1585
+ y + 1,
1586
+ videoX + 7,
1451
1587
  'Видео+Аудио',
1452
1588
  () => {
1453
1589
  this.opts.bestVideoMerge = !this.opts.bestVideoMerge;
@@ -1455,123 +1591,116 @@ class EvaNodeTUI {
1455
1591
  this.statusBanner = this.opts.bestVideoMerge ? 'Режим видео -f "bv*+ba/b"' : 'Режим аудио -x';
1456
1592
  this.render();
1457
1593
  },
1458
- { checked: this.opts.bestVideoMerge }
1459
- ) + 2;
1460
- vx =
1461
- this.registerWidget(scr, 'flag_wsubs', y + 4, vx, 'Файл субтитров', () => this._toggle('writeSubs'), {
1462
- checked: this.opts.writeSubs,
1463
- }) + 2;
1464
- let mediaOptionsX =
1465
- this.registerWidget(scr, 'flag_esubs', y + 5, c1 + 7, 'Встроить субтитры', () => this._toggle('embedSubs'), {
1466
- checked: this.opts.embedSubs,
1467
- }) + 2;
1468
- this.registerWidget(scr, 'flag_wthumb', y + 5, mediaOptionsX, 'Скачать обложку', () => this._toggle('writeThumbnail'), {
1469
- checked: this.opts.writeThumbnail,
1470
- maxWidth: x + w - mediaOptionsX - 2,
1471
- });
1472
-
1473
- // Row 6: Naming & Archive (-o & --download-archive)
1474
- scr.putStr(y + 6, c1, 'ФАЙЛЫ:', ANSI.fgCyan + ANSI.bold);
1475
- const rowRight = x + w - 2;
1476
- const templateX = c1 + 9;
1477
- const rowWidth = rowRight - templateX + 1;
1478
- const templateMaxWidth = Math.max(4, 4 + Math.floor(Math.max(0, rowWidth - 24) / 2));
1479
- const templateEnd = this.registerWidget(
1480
- scr,
1481
- 'flag_otpl',
1482
- y + 6,
1483
- templateX,
1484
- `-o "${this.opts.outputTemplate}"`,
1485
- () => this._toggle('usePlaylistTemplate'),
1486
- {
1487
- checked: this.opts.usePlaylistTemplate,
1488
- maxWidth: templateMaxWidth,
1489
- }
1490
- );
1491
- const templateEditEnd = this.registerWidget(scr, 'edit_otpl', y + 6, templateEnd + 1, '[ред.]', () => {
1492
- return this.openPrompt('Шаблон имени (-o)', this.opts.outputTemplate, (v) => {
1493
- this.opts.outputTemplate = v || '%(title)s.%(ext)s';
1494
- });
1495
- }, { maxWidth: 6 });
1594
+ { checked: this.opts.bestVideoMerge, maxWidth: videoRight - videoX - 6 }
1595
+ );
1596
+ this.registerWidget(
1597
+ scr,
1598
+ 'flag_wthumb',
1599
+ y + 2,
1600
+ videoX + 7,
1601
+ 'Скачать обложку',
1602
+ () => this._toggle('writeThumbnail'),
1603
+ { checked: this.opts.writeThumbnail, maxWidth: videoRight - videoX - 6 }
1604
+ );
1605
+ this.registerWidget(
1606
+ scr,
1607
+ 'flag_wsubs',
1608
+ y + 3,
1609
+ videoX + 7,
1610
+ 'Файл субтитров',
1611
+ () => this._toggle('writeSubs'),
1612
+ { checked: this.opts.writeSubs, maxWidth: videoRight - videoX - 6 }
1613
+ );
1614
+ this.registerWidget(
1615
+ scr,
1616
+ 'flag_esubs',
1617
+ y + 4,
1618
+ videoX + 7,
1619
+ 'Встроить субтитры',
1620
+ () => this._toggle('embedSubs'),
1621
+ { checked: this.opts.embedSubs, maxWidth: videoRight - videoX - 6 }
1622
+ );
1496
1623
 
1497
- const archiveX = templateEditEnd + 2;
1498
- const archiveMaxWidth = Math.max(4, rowRight - archiveX - '[ред.]'.length);
1499
- const archiveEnd = this.registerWidget(
1500
- scr,
1501
- 'flag_archive',
1502
- y + 6,
1503
- archiveX,
1504
- `История загрузки ${this.opts.archiveFile}`,
1505
- () => this._toggle('useDownloadArchive'),
1506
- {
1507
- checked: this.opts.useDownloadArchive,
1508
- maxWidth: archiveMaxWidth,
1509
- }
1510
- );
1511
- this.registerWidget(scr, 'edit_archive', y + 6, archiveEnd + 1, '[ред.]', () => {
1512
- return this.openPrompt('Файл архива (--download-archive)', this.opts.archiveFile, (v) => {
1513
- this.opts.archiveFile = v || 'archive.txt';
1514
- });
1515
- }, { maxWidth: 6 });
1624
+ renderAudioQuality(y + 5);
1625
+ renderAudioFormat(y + 6);
1626
+ }
1516
1627
 
1517
- // Row 7: Playlists (--playlist-items & --playlist-reverse)
1518
- scr.putStr(y + 7, c1, 'ПЛЕЙЛИСТ:', ANSI.fgCyan + ANSI.bold);
1628
+ // Row 8: Playlists (--playlist-items, --playlist-reverse & --download-archive)
1629
+ scr.putStr(y + 8, c1, 'ПЛЕЙЛИСТ:', ANSI.fgCyan + ANSI.bold);
1630
+ const rowRight = x + w - 2;
1519
1631
  let px =
1520
1632
  this.registerWidget(
1521
1633
  scr,
1522
1634
  'flag_plitems',
1523
- y + 7,
1524
- c1 + 9,
1525
- `Выбрать файлы ${this.opts.playlistItems}`,
1635
+ y + 8,
1636
+ c1 + 10,
1637
+ `Скачать № ${this.opts.playlistItems}`,
1526
1638
  () => this._toggle('usePlaylistItems'),
1527
- { checked: this.opts.usePlaylistItems }
1639
+ { checked: this.opts.usePlaylistItems, maxWidth: 18 }
1528
1640
  ) + 1;
1529
1641
  px =
1530
- this.registerWidget(scr, 'edit_plitems', y + 7, px, '[ред.]', () =>
1642
+ this.registerWidget(scr, 'edit_plitems', y + 8, px, '[✎ ]', () =>
1531
1643
  this.openPrompt('Номера треков (--playlist-items, напр. 1-5)', this.opts.playlistItems, (v) => {
1532
1644
  this.opts.playlistItems = v || '1-5';
1533
1645
  })
1534
- ) + 3;
1646
+ ) + 2;
1535
1647
 
1536
- this.registerWidget(
1648
+ px =
1649
+ this.registerWidget(
1650
+ scr,
1651
+ 'flag_plrev',
1652
+ y + 8,
1653
+ px,
1654
+ 'С конца',
1655
+ () => this._toggle('playlistReverse'),
1656
+ { checked: this.opts.playlistReverse, maxWidth: Math.max(5, rowRight - px - 1) }
1657
+ ) + 2;
1658
+
1659
+ const archiveEditWidth = '[✎ ]'.length;
1660
+ const archiveFlagWidth = Math.max(5, rowRight - px - archiveEditWidth);
1661
+ const archiveEnd = this.registerWidget(
1537
1662
  scr,
1538
- 'flag_plrev',
1539
- y + 7,
1663
+ 'flag_archive',
1664
+ y + 8,
1540
1665
  px,
1541
- 'Загрузить плейлист с конца',
1542
- () => this._toggle('playlistReverse'),
1543
- { checked: this.opts.playlistReverse, maxWidth: x + w - px - 2 }
1666
+ `История ${this.opts.archiveFile}`,
1667
+ () => this._toggle('useDownloadArchive'),
1668
+ { checked: this.opts.useDownloadArchive, maxWidth: archiveFlagWidth }
1544
1669
  );
1670
+ if (archiveEnd + 1 <= rowRight) {
1671
+ this.registerWidget(scr, 'edit_archive', y + 8, archiveEnd + 1, '[✎ ]', () => {
1672
+ return this.openPrompt('Файл архива (--download-archive)', this.opts.archiveFile, (v) => {
1673
+ this.opts.archiveFile = v || 'archive.txt';
1674
+ });
1675
+ }, { maxWidth: Math.min(6, rowRight - archiveEnd) });
1676
+ }
1545
1677
 
1546
- // Row 9: Output directory & Add to Queue CTA button
1678
+ // Keep the output controls on the last inner row of the options box.
1679
+ const outputControlsRow = y + box2H - 2;
1547
1680
  const maxDirLen = Math.max(16, w - 48);
1548
1681
  const shortDir =
1549
1682
  this.opts.outputDir.length > maxDirLen
1550
1683
  ? '…' + this.opts.outputDir.slice(-(maxDirLen - 1))
1551
1684
  : this.opts.outputDir;
1552
1685
  let dx =
1553
- this.registerWidget(scr, 'edit_outdir', y + 9, c1, `Папка: ${shortDir} [ред.]`, () =>
1686
+ this.registerWidget(scr, 'edit_outdir', outputControlsRow, c1, `📁: ${shortDir} [✎ ]`, () =>
1554
1687
  this.openPrompt('Папка сохранения', this.opts.outputDir, (v) => {
1555
1688
  this.opts.outputDir = v || path.join(os.homedir(), 'Downloads');
1556
1689
  })
1557
1690
  ) + 3;
1558
1691
 
1559
- const ctaLabel = 'Скачать (Enter / a)';
1692
+ const ctaLabel = '[A] Скачать';
1560
1693
  const ctaX = Math.max(dx, x + w - ctaLabel.length - 3);
1561
- this.registerWidget(scr, 'btn_add_queue', y + 9, ctaX, ctaLabel, () => this._actionAddToQueue(), {
1694
+ this.registerWidget(scr, 'btn_add_queue', outputControlsRow, ctaX, ctaLabel, () => this._actionAddToQueue(), {
1562
1695
  isCta: true,
1563
1696
  });
1564
1697
 
1565
1698
  y += box2H;
1566
1699
  scr.clipRight = previousClipRight;
1567
1700
 
1568
- // Word-wrapped CLI Preview Lines (never overflows the window width)
1701
+ // Scroll long command previews horizontally so they remain readable in compact windows.
1569
1702
  const fullCliText = `CLI: ${buildCommandPreview(this.opts)}`;
1570
- const wrappedCli = wrapTextByWords(fullCliText, w - 2);
1571
- const availCliRows = Math.max(1, rows - 2 - y);
1572
- wrappedCli.slice(0, availCliRows).forEach((line, idx) => {
1573
- scr.putStr(y + idx, x + 1, line, ANSI.fgYellow);
1574
- });
1703
+ this._renderCliPreview(scr, x + 1, y, w - 2, fullCliText);
1575
1704
  }
1576
1705
 
1577
1706
  // -------------------------------------------------------------------------
@@ -1754,23 +1883,102 @@ class EvaNodeTUI {
1754
1883
  const nameColW = Math.max(1, contentWidth - numberColW - formatColW - sizeColW - 5);
1755
1884
  const formatX = numberColW + 1 + nameColW + 1;
1756
1885
  const sizeX = formatX + formatColW + 1;
1886
+ const listTop = 11;
1887
+ const listRows = Math.max(1, h - 4);
1888
+ const maxScrollOffset = Math.max(0, files.length - listRows);
1889
+ this.libraryScrollOffset = Math.max(0, Math.min(this.libraryScrollOffset, maxScrollOffset));
1890
+ this.libraryViewport = { x, right: x + w, top: listTop, rows: listRows, count: files.length };
1891
+ scr.putStr(10, x, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
1757
1892
  if (files.length === 0) {
1758
- scr.putStr(10, x + 3, `В папке ${outDir} пока нет скачанных медиафайлов.`.slice(0, w - 6), ANSI.fgWhite);
1893
+ scr.putStr(listTop, x + 3, `No media files found in ${outDir}.`.slice(0, w - 6), ANSI.fgWhite);
1759
1894
  } else {
1760
- scr.putStr(10, x + 3, '#', ANSI.fgCyan + ANSI.bold);
1761
- scr.putStr(10, x + 3 + numberColW + 1, 'Имя файла', ANSI.fgCyan + ANSI.bold);
1762
- scr.putStr(10, x + 3 + formatX, 'Формат', ANSI.fgCyan + ANSI.bold);
1763
- scr.putStr(10, x + 3 + sizeX, 'Размер', ANSI.fgCyan + ANSI.bold);
1764
- const visibleFileCount = Math.max(0, Math.min(files.length, rows - 15));
1765
- files.slice(0, visibleFileCount).forEach((f, i) => {
1895
+ scr.putStr(9, x + 3, '#', ANSI.fgCyan + ANSI.bold);
1896
+ scr.putStr(9, x + 3 + numberColW + 1, 'Title', ANSI.fgCyan + ANSI.bold);
1897
+ scr.putStr(9, x + 3 + formatX, 'Format', ANSI.fgCyan + ANSI.bold);
1898
+ scr.putStr(9, x + 3 + sizeX, 'Size', ANSI.fgCyan + ANSI.bold);
1899
+ files.slice(this.libraryScrollOffset, this.libraryScrollOffset + listRows).forEach((f, rowOffset) => {
1900
+ const fileIndex = this.libraryScrollOffset + rowOffset;
1766
1901
  const mb = formatBytesToMB(f.stat.size);
1767
- const row = 12 + i;
1902
+ const row = listTop + rowOffset;
1768
1903
  const filename = fitTextToCellWidth(f.name, nameColW);
1769
- scr.putStr(row, x + 3, String(i + 1).padEnd(numberColW), ANSI.fgWhite);
1770
- scr.putStrWithin(row, x + 3 + numberColW + 1, filename, nameColW, ANSI.fgWhite);
1771
- scr.putStrWithin(row, x + 3 + formatX, f.ext.slice(1, formatColW + 1).padEnd(formatColW), formatColW, ANSI.fgWhite);
1772
- scr.putStrWithin(row, x + 3 + sizeX, mb.slice(0, sizeColW).padStart(sizeColW), sizeColW, ANSI.fgWhite);
1904
+ const isSelected = f.full === this.selectedLibraryFile;
1905
+ const rowStyle = isSelected ? ANSI.bgWhite + ANSI.fgBlack + ANSI.bold : ANSI.fgWhite;
1906
+ if (isSelected) scr.putStr(row, x + 2, ' '.repeat(w - 4), rowStyle);
1907
+ scr.putStr(row, x + 3, String(fileIndex + 1).padEnd(numberColW), rowStyle);
1908
+ scr.putStrWithin(row, x + 3 + numberColW + 1, filename, nameColW, rowStyle);
1909
+ scr.putStrWithin(row, x + 3 + formatX, f.ext.slice(1, formatColW + 1).padEnd(formatColW), formatColW, rowStyle);
1910
+ scr.putStrWithin(row, x + 3 + sizeX, mb.slice(0, sizeColW).padStart(sizeColW), sizeColW, rowStyle);
1911
+ this.hitboxes.push({
1912
+ id: `library_file_${fileIndex}`,
1913
+ y: row,
1914
+ x1: x + 2,
1915
+ x2: x + w - 3,
1916
+ action: () => this._selectLibraryFile(f),
1917
+ });
1918
+ });
1919
+
1920
+ if (files.length > listRows) {
1921
+ const scrollbarX = x + w - 2;
1922
+ const thumbSize = Math.max(1, Math.floor((listRows * listRows) / files.length));
1923
+ const thumbTravel = listRows - thumbSize;
1924
+ const thumbTop = maxScrollOffset > 0
1925
+ ? Math.round((this.libraryScrollOffset / maxScrollOffset) * thumbTravel)
1926
+ : 0;
1927
+ for (let rowOffset = 0; rowOffset < listRows; rowOffset++) {
1928
+ const isThumb = rowOffset >= thumbTop && rowOffset < thumbTop + thumbSize;
1929
+ const scrollbarY = listTop + rowOffset;
1930
+ scr.putStr(scrollbarY, scrollbarX, isThumb ? '█' : '│', isThumb ? ANSI.fgCyan : ANSI.fgGray);
1931
+ this.hitboxes.push({
1932
+ id: 'library_scroll',
1933
+ y: scrollbarY,
1934
+ x1: scrollbarX,
1935
+ x2: scrollbarX,
1936
+ action: () => {
1937
+ const nextThumbTop = Math.max(0, Math.min(thumbTravel, rowOffset - Math.floor(thumbSize / 2)));
1938
+ this.libraryScrollOffset = thumbTravel > 0
1939
+ ? Math.round((nextThumbTop / thumbTravel) * maxScrollOffset)
1940
+ : 0;
1941
+ this.render();
1942
+ },
1943
+ });
1944
+ }
1945
+ }
1946
+ }
1947
+ }
1948
+
1949
+ _selectLibraryFile(file) {
1950
+ const now = Date.now();
1951
+ const isDoubleClick =
1952
+ this.lastLibraryClick &&
1953
+ this.lastLibraryClick.path === file.full &&
1954
+ now - this.lastLibraryClick.time <= LIBRARY_DOUBLE_CLICK_MS;
1955
+
1956
+ this.selectedLibraryFile = file.full;
1957
+ this.lastLibraryClick = isDoubleClick ? null : { path: file.full, time: now };
1958
+ if (isDoubleClick) {
1959
+ this._openLibraryFile(file.full);
1960
+ return;
1961
+ }
1962
+ this.render();
1963
+ }
1964
+
1965
+ _openLibraryFile(filePath) {
1966
+ const opener = process.platform === 'win32'
1967
+ ? 'explorer.exe'
1968
+ : process.platform === 'darwin'
1969
+ ? 'open'
1970
+ : 'xdg-open';
1971
+
1972
+ try {
1973
+ const child = spawn(opener, [filePath], { detached: true, stdio: 'ignore' });
1974
+ child.once('error', (error) => {
1975
+ this.statusBanner = `Не удалось открыть файл: ${error.message}`;
1976
+ this.render();
1773
1977
  });
1978
+ child.unref();
1979
+ } catch (error) {
1980
+ this.statusBanner = `Не удалось открыть файл: ${error.message}`;
1981
+ this.render();
1774
1982
  }
1775
1983
  }
1776
1984
 
@@ -1804,11 +2012,11 @@ class EvaNodeTUI {
1804
2012
  () => this._toggle('useProxy'),
1805
2013
  { checked: this.opts.useProxy }
1806
2014
  ) + 2;
1807
- this.registerWidget(scr, 'edit_proxy', 12, px, '[изменить прокси]', () =>
1808
- this.openPrompt('Прокси (--proxy)', this.opts.proxyUrl, (v) => {
2015
+ this.registerWidget(scr, 'edit_proxy', 12, px, '[изменить прокси]', () => {
2016
+ return this.openPrompt('Прокси (--proxy)', this.opts.proxyUrl, (v) => {
1809
2017
  this.opts.proxyUrl = v || 'http://127.0.0.1:8080';
1810
- })
1811
- );
2018
+ });
2019
+ }, { isCta: true });
1812
2020
 
1813
2021
  let ckx =
1814
2022
  this.registerWidget(
@@ -1827,9 +2035,30 @@ class EvaNodeTUI {
1827
2035
  this.opts.useCookiesBrowser = nextB !== 'none';
1828
2036
  this.statusBanner = `Cookies браузер: ${nextB}`;
1829
2037
  this.render();
1830
- });
2038
+ }, { isCta: true });
2039
+
2040
+ const namingHeading = 'ШАБЛОНЫ ИМЕНОВАНИЯ ФАЙЛОВ';
2041
+ scr.putStr(15, c1, namingHeading, ANSI.fgCyan + ANSI.bold);
2042
+ const namingTemplateX = c1 + namingHeading.length + 1;
2043
+ const namingRight = x + w - 2;
2044
+ const namingTemplateEnd = this.registerWidget(
2045
+ scr,
2046
+ 'flag_otpl',
2047
+ 15,
2048
+ namingTemplateX,
2049
+ `-o "${this.opts.outputTemplate}"`,
2050
+ () => this._toggle('usePlaylistTemplate'),
2051
+ {
2052
+ checked: this.opts.usePlaylistTemplate,
2053
+ maxWidth: Math.max(5, namingRight - namingTemplateX - '[✎ ]'.length),
2054
+ }
2055
+ );
2056
+ this.registerWidget(scr, 'edit_otpl', 15, namingTemplateEnd + 1, '[✎ ]', () => {
2057
+ return this.openPrompt('Шаблон имени (-o)', this.opts.outputTemplate, (v) => {
2058
+ this.opts.outputTemplate = v || '%(title)s.%(ext)s';
2059
+ });
2060
+ }, { maxWidth: 6 });
1831
2061
 
1832
- scr.putStr(15, c1, 'ШАБЛОНЫ ИМЕНОВАНИЯ ФАЙЛОВ (-o):', ANSI.fgCyan + ANSI.bold);
1833
2062
  NAMING_PRESETS.forEach((p, idx) => {
1834
2063
  this.registerWidget(
1835
2064
  scr,
@@ -1907,8 +2136,8 @@ class EvaNodeTUI {
1907
2136
  'Интегрированные флаги yt-dlp:',
1908
2137
  ' • Аудио: -x, --audio-format (mp3/flac/m4a/wav), --audio-quality 0, --embed-metadata, --embed-thumbnail',
1909
2138
  ' • Видео: -f "bv*+ba/b", --write-subs, --embed-subs, --write-thumbnail, --merge-output-format mp4',
1910
- ' • Файлы: -o "%(playlist_index)s. %(title)s.%(ext)s", --download-archive archive.txt',
1911
- ' • Плейлисты: --playlist-items 1-5, --playlist-reverse',
2139
+ ' • Файлы (вкладка Settings): -o "%(playlist_index)s. %(title)s.%(ext)s"',
2140
+ ' • Плейлисты: --playlist-items 1-5, --playlist-reverse, --download-archive archive.txt',
1912
2141
  ' • Сеть (вкладка Settings): -4, --proxy http://127.0.0.1:8080, --cookies-from-browser chrome',
1913
2142
  ` `,
1914
2143
  ` `,
@@ -1941,11 +2170,17 @@ class EvaNodeTUI {
1941
2170
  _setUrl(val) {
1942
2171
  this.opts.url = val;
1943
2172
  if (val) {
2173
+ const videoMode = applyVideoModeForUrl(this.opts, val);
2174
+ const audioMode = !videoMode && applyAudioModeForUrl(this.opts, val);
1944
2175
  const detected = detectSource(val, this.opts.sourceId);
1945
2176
  if (this.opts.sourceId !== 1) {
1946
2177
  this.opts.sourceId = detected.id;
1947
2178
  }
1948
- this.statusBanner = `URL (${detected.name})`;
2179
+ this.statusBanner = videoMode
2180
+ ? `Видео+Аудио (${detected.name})`
2181
+ : audioMode
2182
+ ? `Только аудио (${detected.name})`
2183
+ : `URL (${detected.name})`;
1949
2184
  }
1950
2185
  this.render();
1951
2186
  }
@@ -2030,14 +2265,20 @@ class EvaNodeTUI {
2030
2265
  const mx = parseInt(mouseMatch[2], 10) - 1;
2031
2266
  const my = parseInt(mouseMatch[3], 10) - 1;
2032
2267
  const isPress = mouseMatch[4] === 'M';
2033
- if (this.activeTab === 2 && (btn === 64 || btn === 65)) {
2034
- const viewport = this.queueViewport;
2268
+ if ((this.activeTab === 2 || this.activeTab === 3) && (btn === 64 || btn === 65)) {
2269
+ const isQueue = this.activeTab === 2;
2270
+ const viewport = isQueue ? this.queueViewport : this.libraryViewport;
2035
2271
  if (viewport && mx >= viewport.x && mx < viewport.right &&
2036
2272
  my >= viewport.top && my < viewport.top + viewport.rows) {
2037
- const maxOffset = Math.max(0, this.manager.items.length - viewport.rows);
2038
- this.queueScrollOffset = Math.max(0, Math.min(maxOffset, this.queueScrollOffset + (btn === 64 ? -1 : 1)));
2039
- this.selectedQueueIdx = this.queueScrollOffset;
2040
- this.selectedQueueItem = this.manager.items[this.selectedQueueIdx] || null;
2273
+ if (isQueue) {
2274
+ const maxOffset = Math.max(0, this.manager.items.length - viewport.rows);
2275
+ this.queueScrollOffset = Math.max(0, Math.min(maxOffset, this.queueScrollOffset + (btn === 64 ? -1 : 1)));
2276
+ this.selectedQueueIdx = this.queueScrollOffset;
2277
+ this.selectedQueueItem = this.manager.items[this.selectedQueueIdx] || null;
2278
+ } else {
2279
+ const maxOffset = Math.max(0, viewport.count - viewport.rows);
2280
+ this.libraryScrollOffset = Math.max(0, Math.min(maxOffset, this.libraryScrollOffset + (btn === 64 ? -1 : 1)));
2281
+ }
2041
2282
  this.render();
2042
2283
  }
2043
2284
  return;
@@ -2047,7 +2288,7 @@ class EvaNodeTUI {
2047
2288
  mx >= h.x1 && mx <= h.x2 && (h.y === my || (h.nav && Math.abs(h.y - my) === 1))
2048
2289
  );
2049
2290
  if (hit) {
2050
- if (hit.id !== 'q_scroll') this.focusId = hit.id;
2291
+ if (hit.id !== 'q_scroll' && hit.id !== 'library_scroll') this.focusId = hit.id;
2051
2292
  hit.action();
2052
2293
  }
2053
2294
  }
@@ -2137,7 +2378,10 @@ function main() {
2137
2378
  const urlArg = argv.find((a) => !a.startsWith('-')) || '';
2138
2379
 
2139
2380
  const opts = loadSavedOptions();
2140
- if (urlArg) opts.url = urlArg;
2381
+ if (urlArg) {
2382
+ opts.url = urlArg;
2383
+ if (!applyVideoModeForUrl(opts, urlArg)) applyAudioModeForUrl(opts, urlArg);
2384
+ }
2141
2385
 
2142
2386
  if (isPrintCmd) {
2143
2387
  console.log(buildCommandPreview(opts));
@@ -2192,11 +2436,16 @@ module.exports = {
2192
2436
  buildPlaylistDiscoveryArgs,
2193
2437
  buildCommandPreview,
2194
2438
  detectSource,
2439
+ isVideoUrl,
2440
+ isAudioUrl,
2441
+ applyVideoModeForUrl,
2442
+ applyAudioModeForUrl,
2195
2443
  isYouTubePlaylistUrl,
2196
2444
  playlistEntryUrl,
2197
2445
  ScreenBuffer,
2198
2446
  DownloadManager,
2199
2447
  formatBytesToMB,
2448
+ formatEta,
2200
2449
  fitTextToCellWidth,
2201
2450
  wrapTextByWords,
2202
2451
  };
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "eva-md",
3
- "version": "0.5.1",
4
- "description": "EVA Media Downloader CLI",
3
+ "version": "0.7.3",
4
+ "description": "Eva Media Downloader CLI",
5
5
  "main": "eva.js",
6
6
  "bin": {
7
- "eva": "./eva.js"
7
+ "eva": "eva.js"
8
8
  },
9
9
  "files": [
10
10
  "eva.js",