eva-md 0.4.3 → 0.6.1

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 +333 -189
  2. package/package.json +1 -1
package/eva.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * EVA.md v0.3.3 — EVA Media Downloader by .maxon
3
+ * EVA.md v0.6.1 — 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
  *
@@ -46,11 +46,12 @@ const { spawn, spawnSync } = require('child_process');
46
46
  // Constants & Configuration
47
47
  // ---------------------------------------------------------------------------
48
48
 
49
- const APP_VERSION = 'v0.4.3 (Node.js)';
49
+ const APP_VERSION = 'v0.6.1 (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,7 +79,7 @@ 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 = [
@@ -104,7 +105,6 @@ const ANSI = {
104
105
  fgBlack: `${CSI}30m`,
105
106
  fgGray: `${CSI}38;2;105;105;105m`,
106
107
  fgOrange: `${CSI}38;2;222;91;9m`, // Текст цвета RGB(222, 91, 9)
107
- bgOrange: `${CSI}48;2;222;91;9m`,
108
108
  bgGray: `${CSI}48;2;105;105;105m`,
109
109
  bgWhite: `${CSI}47m`,
110
110
  bgCyan: `${CSI}46m`,
@@ -143,6 +143,10 @@ function shellQuote(arg) {
143
143
  return arg;
144
144
  }
145
145
 
146
+ function resolveOutputDir(outputDir) {
147
+ return outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
148
+ }
149
+
146
150
  function formatBytesToMB(bytes) {
147
151
  if (!bytes || bytes <= 0) return '—';
148
152
  return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
@@ -341,7 +345,7 @@ function buildYtdlpArgs(opts, targetUrl = null) {
341
345
  if (opts.writeThumbnail) args.push('--write-thumbnail');
342
346
 
343
347
  // File Naming & Archive
344
- const outDir = opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
348
+ const outDir = resolveOutputDir(opts.outputDir);
345
349
  const tpl = opts.usePlaylistTemplate ? opts.outputTemplate : '%(title)s.%(ext)s';
346
350
  args.push('-o', path.join(outDir, tpl));
347
351
 
@@ -480,7 +484,7 @@ class DownloadManager {
480
484
  _createQueueItem(opts, url, title = null, entry = null, entryIndex = 0) {
481
485
  const src = detectSource(url, opts.sourceId);
482
486
  const fmt = opts.bestVideoMerge && !opts.extractAudio ? 'mp4/mkv' : opts.audioFormat;
483
- const outDir = opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
487
+ const outDir = resolveOutputDir(opts.outputDir);
484
488
  const itemOpts = { ...opts, usePlaylistItems: false, playlistReverse: false };
485
489
  if (entry && itemOpts.usePlaylistTemplate) {
486
490
  const playlistIndex = String(entry.playlist_index || entryIndex + 1).padStart(2, '0');
@@ -987,6 +991,7 @@ class ScreenBuffer {
987
991
  constructor(cols, rows) {
988
992
  this.cols = cols;
989
993
  this.rows = rows;
994
+ this.clipRight = null;
990
995
  this.chars = Array.from({ length: rows }, () => Array(cols).fill(' '));
991
996
  this.styles = Array.from({ length: rows }, () => Array(cols).fill(ANSI.fgWhite));
992
997
  }
@@ -996,7 +1001,7 @@ class ScreenBuffer {
996
1001
  const str = String(text);
997
1002
  for (let i = 0; i < str.length; i++) {
998
1003
  const cx = x + i;
999
- if (cx >= 0 && cx < this.cols - 1) {
1004
+ if (cx >= 0 && cx < this.cols - 1 && (this.clipRight === null || cx <= this.clipRight)) {
1000
1005
  this.chars[y][cx] = str[i];
1001
1006
  this.styles[y][cx] = style;
1002
1007
  }
@@ -1053,10 +1058,15 @@ class EvaNodeTUI {
1053
1058
  this.hitboxes = [];
1054
1059
  this.selectedQueueIdx = 0;
1055
1060
  this.selectedQueueItem = null;
1061
+ this.queueScrollOffset = 0;
1062
+ this.queueViewport = null;
1063
+ this.libraryScrollOffset = 0;
1064
+ this.libraryViewport = null;
1065
+ this.selectedLibraryFile = null;
1066
+ this.lastLibraryClick = null;
1056
1067
  this.statusBanner = '';
1057
1068
 
1058
- // Inline modal prompt state
1059
- this.modal = null; // { label, value, cursor, onConfirm }
1069
+ this.modal = null;
1060
1070
  }
1061
1071
 
1062
1072
  start() {
@@ -1081,101 +1091,84 @@ class EvaNodeTUI {
1081
1091
  process.exit(0);
1082
1092
  }
1083
1093
 
1084
- registerWidget(scr, id, y, x, label, action, { checked = null, radio = null, isCta = false, maxWidth = 0 } = {}) {
1085
- this.focusOrder.push(id);
1086
- const isFocused = this.focusId === id;
1087
-
1088
- // Префикс и его цвет
1089
- let prefix = '';
1090
- let prefixStyle = ANSI.fgWhite;
1091
-
1092
- if (checked !== null) {
1093
- prefix = checked ? '[x] ' : '[ ] ';
1094
- prefixStyle = checked
1095
- ? ANSI.fgGreen + ANSI.bold
1096
- : ANSI.dim + ANSI.fgWhite;
1097
- } else if (radio !== null) {
1098
- prefix = radio ? '(•) ' : '( ) ';
1099
- prefixStyle = radio
1100
- ? ANSI.fgCyan + ANSI.bold
1101
- : ANSI.dim + ANSI.fgWhite;
1102
- }
1103
-
1104
- // Стиль основного текста
1105
- let style = ANSI.fgGray;
1106
-
1107
- if (isFocused) {
1108
- style = isCta
1109
- ? ANSI.bgGray + ANSI.fgWhite + ANSI.bold
1110
- : ANSI.bgWhite + ANSI.fgBlack + ANSI.bold;
1111
- } else if (isCta) {
1112
- style = id === 'url_input'
1113
- ? ANSI.fgWhite + ANSI.bold
1114
- : id === 'btn_add_queue'
1115
- ? ANSI.fgGreen + ANSI.bold
1116
- : ANSI.fgCyan + ANSI.bold;
1117
- } else if (checked || radio) {
1118
- style = ANSI.fgOrange + ANSI.bold;
1119
- }
1120
-
1121
- // Вывод префикса с раздельными цветами
1122
- if (checked !== null) {
1123
- scr.putStr(y, x, '[', ANSI.fgGray);
1094
+ registerWidget(scr, id, y, x, label, action, { checked = null, radio = null, isCta = false, maxWidth = 0 } = {}) {
1095
+ this.focusOrder.push(id);
1096
+ const isFocused = this.focusId === id;
1097
+ let prefix = '';
1098
+
1099
+ if (checked !== null) {
1100
+ prefix = checked ? '[x] ' : '[ ] ';
1101
+ } else if (radio !== null) {
1102
+ prefix = radio ? '(•) ' : '( ) ';
1103
+ }
1104
+
1105
+ let style = ANSI.fgGray;
1106
+ if (isFocused) {
1107
+ style = isCta
1108
+ ? ANSI.bgGray + ANSI.fgWhite + ANSI.bold
1109
+ : ANSI.bgWhite + ANSI.fgBlack + ANSI.bold;
1110
+ } else if (isCta) {
1111
+ style = ['url_input', 'edit_proxy', 'cycle_browser'].includes(id)
1112
+ ? ANSI.fgWhite + ANSI.bold
1113
+ : id === 'btn_add_queue'
1114
+ ? ANSI.fgGreen + ANSI.bold
1115
+ : ANSI.fgCyan + ANSI.bold;
1116
+ } else if (checked || radio) {
1117
+ style = ANSI.fgOrange + ANSI.bold;
1118
+ }
1119
+
1120
+ if (checked !== null) {
1121
+ scr.putStr(y, x, '[', ANSI.fgGray);
1124
1122
  scr.putStr(y, x + 1, checked ? 'x' : ' ', checked ? ANSI.fgOrange + ANSI.bold : ANSI.dim);
1125
- scr.putStr(y, x + 2, '] ', ANSI.fgGray);
1126
- } else if (radio !== null) {
1127
- scr.putStr(y, x, '(', ANSI.fgGray);
1128
- scr.putStr(y, x + 1, radio ? '•' : ' ', radio ? ANSI.fgOrange + ANSI.bold : ANSI.dim);
1129
- scr.putStr(y, x + 2, ') ', ANSI.fgGray);
1130
- }
1131
-
1132
- // Расчёт доступной ширины текста
1133
- const availableWidth = maxWidth > 0
1134
- ? Math.max(0, maxWidth - prefix.length)
1135
- : Infinity;
1136
-
1137
- let visibleLabel = label;
1123
+ scr.putStr(y, x + 2, '] ', ANSI.fgGray);
1124
+ } else if (radio !== null) {
1125
+ scr.putStr(y, x, '(', ANSI.fgGray);
1126
+ scr.putStr(y, x + 1, radio ? '•' : ' ', radio ? ANSI.fgOrange + ANSI.bold : ANSI.dim);
1127
+ scr.putStr(y, x + 2, ') ', ANSI.fgGray);
1128
+ }
1138
1129
 
1139
- if (visibleLabel.length > availableWidth) {
1140
- visibleLabel = availableWidth > 0
1141
- ? visibleLabel.slice(0, availableWidth - 1) + '…'
1142
- : '';
1143
- }
1144
-
1145
- // Вывод основного текста
1146
- const textX = x + (prefix ? prefix.length : 0);
1147
- const editMarker = '[ред.]';
1148
- const editMarkerIndex = visibleLabel.lastIndexOf(editMarker);
1149
- if (!isFocused && editMarkerIndex >= 0) {
1150
- const folderLabel = 'Папка:';
1151
- const folderLabelIndex = visibleLabel.indexOf(folderLabel);
1152
- if (folderLabelIndex >= 0 && folderLabelIndex < editMarkerIndex) {
1153
- scr.putStr(y, textX, visibleLabel.slice(0, folderLabelIndex), style);
1154
- scr.putStr(y, textX + folderLabelIndex, folderLabel, ANSI.fgWhite);
1155
- const afterFolderIndex = folderLabelIndex + folderLabel.length;
1156
- scr.putStr(y, textX + afterFolderIndex, visibleLabel.slice(afterFolderIndex, editMarkerIndex), style);
1130
+ const availableWidth = maxWidth > 0 ? Math.max(0, maxWidth - prefix.length) : Infinity;
1131
+ let visibleLabel = label;
1132
+ if (visibleLabel.length > availableWidth) {
1133
+ visibleLabel = availableWidth > 0 ? visibleLabel.slice(0, availableWidth - 1) + '…' : '';
1134
+ }
1135
+
1136
+ const textX = x + prefix.length;
1137
+ const editMarker = '[✎ ]';
1138
+ const editMarkerIndex = visibleLabel.lastIndexOf(editMarker);
1139
+ if (!isFocused && editMarkerIndex >= 0) {
1140
+ const folderLabel = 'Папка:';
1141
+ const folderLabelIndex = visibleLabel.indexOf(folderLabel);
1142
+ if (folderLabelIndex >= 0 && folderLabelIndex < editMarkerIndex) {
1143
+ scr.putStr(y, textX, visibleLabel.slice(0, folderLabelIndex), style);
1144
+ scr.putStr(y, textX + folderLabelIndex, folderLabel, ANSI.fgWhite);
1145
+ const afterFolderIndex = folderLabelIndex + folderLabel.length;
1146
+ scr.putStr(y, textX + afterFolderIndex, visibleLabel.slice(afterFolderIndex, editMarkerIndex), style);
1147
+ } else {
1148
+ scr.putStr(y, textX, visibleLabel.slice(0, editMarkerIndex), style);
1149
+ }
1150
+ scr.putStr(y, textX + editMarkerIndex, editMarker, ANSI.fgWhite);
1151
+ scr.putStr(
1152
+ y,
1153
+ textX + editMarkerIndex + editMarker.length,
1154
+ visibleLabel.slice(editMarkerIndex + editMarker.length),
1155
+ style
1156
+ );
1157
1157
  } else {
1158
- scr.putStr(y, textX, visibleLabel.slice(0, editMarkerIndex), style);
1158
+ scr.putStr(y, textX, visibleLabel, style);
1159
1159
  }
1160
- scr.putStr(y, textX + editMarkerIndex, editMarker, ANSI.fgWhite);
1161
- scr.putStr(y, textX + editMarkerIndex + editMarker.length, visibleLabel.slice(editMarkerIndex + editMarker.length), style);
1162
- } else {
1163
- scr.putStr(y, textX, visibleLabel, style);
1160
+
1161
+ const totalLength = prefix.length + visibleLabel.length;
1162
+ this.hitboxes.push({
1163
+ id,
1164
+ y,
1165
+ x1: x,
1166
+ x2: Math.min(x + totalLength - 1, scr.clipRight === null ? scr.cols - 2 : scr.clipRight),
1167
+ action,
1168
+ });
1169
+
1170
+ return x + totalLength;
1164
1171
  }
1165
-
1166
- // Область взаимодействия
1167
- const totalLength = (prefix ? prefix.length : 0) + visibleLabel.length;
1168
-
1169
- this.hitboxes.push({
1170
- id,
1171
- y,
1172
- x1: x,
1173
- x2: x + totalLength - 1,
1174
- action
1175
- });
1176
-
1177
- return x + totalLength;
1178
- }
1179
1172
 
1180
1173
 
1181
1174
  openPrompt(label, currentVal, onConfirm) {
@@ -1183,7 +1176,6 @@ class EvaNodeTUI {
1183
1176
  this.modal = {
1184
1177
  label,
1185
1178
  value: val,
1186
- cursor: val.length,
1187
1179
  onConfirm,
1188
1180
  };
1189
1181
  this.render();
@@ -1206,6 +1198,8 @@ class EvaNodeTUI {
1206
1198
 
1207
1199
  this.focusOrder = [];
1208
1200
  this.hitboxes = [];
1201
+ this.queueViewport = null;
1202
+ this.libraryViewport = null;
1209
1203
 
1210
1204
  if (rows < 22 || cols < 76) {
1211
1205
  scr.putStr(1, 2, `Окно терминала слишком маленькое (${cols}x${rows}). Минимум: 78x22.`, ANSI.fgRed + ANSI.bold);
@@ -1213,7 +1207,19 @@ class EvaNodeTUI {
1213
1207
  return;
1214
1208
  }
1215
1209
 
1216
- // 1. Header Logo & Tagline
1210
+ this._renderHeader(scr, cols);
1211
+ const { x, w } = this._renderNavigation(scr, rows, cols);
1212
+ this._renderActiveTab(scr, x, w, rows);
1213
+
1214
+ if (this.focusOrder.length > 0 && !this.focusOrder.includes(this.focusId)) {
1215
+ this.focusId = this.focusOrder[0];
1216
+ }
1217
+
1218
+ this._renderFooter(scr, cols, rows);
1219
+ process.stdout.write(scr.renderToAnsi());
1220
+ }
1221
+
1222
+ _renderHeader(scr, cols) {
1217
1223
  ASCII_LOGO.forEach((line, i) => {
1218
1224
  scr.putStr(1 + i, 2, line, ANSI.fgWhite + ANSI.bold);
1219
1225
  });
@@ -1224,8 +1230,9 @@ class EvaNodeTUI {
1224
1230
  scr.putStr(5, cols - tagline.length - 3, tagline, ANSI.fgWhite);
1225
1231
  scr.putStr(6, cols - tagline.length - 3, '─'.repeat(tagline.length), ANSI.fgCyan);
1226
1232
  }
1233
+ }
1227
1234
 
1228
- // 2. Left Sidebar Navigation (Strictly 1-cell narrow CP437/WGL4 characters so borders NEVER shift)
1235
+ _renderNavigation(scr, rows, cols) {
1229
1236
  const sidebarW = 19;
1230
1237
  const navItems = [
1231
1238
  { tab: 1, label: '≡ General' },
@@ -1258,21 +1265,20 @@ class EvaNodeTUI {
1258
1265
  scr.putStr(r, sidebarW + 1, '│', ANSI.fgWhite);
1259
1266
  }
1260
1267
 
1261
- // 3. Main Content Area
1262
1268
  const mainX = sidebarW + 3;
1263
1269
  const mainW = cols - mainX - 2;
1270
+ return { x: mainX, w: mainW };
1271
+ }
1264
1272
 
1265
- if (this.activeTab === 1) this._renderDownloadTab(scr, mainX, mainW, rows);
1266
- else if (this.activeTab === 2) this._renderQueueTab(scr, mainX, mainW, rows);
1267
- else if (this.activeTab === 3) this._renderLibraryTab(scr, mainX, mainW, rows);
1268
- else if (this.activeTab === 4) this._renderSettingsTab(scr, mainX, mainW, rows);
1269
- else if (this.activeTab === 5) this._renderAboutTab(scr, mainX, mainW, rows);
1270
-
1271
- if (this.focusOrder.length > 0 && !this.focusOrder.includes(this.focusId)) {
1272
- this.focusId = this.focusOrder[0];
1273
- }
1273
+ _renderActiveTab(scr, x, w, rows) {
1274
+ if (this.activeTab === 1) this._renderDownloadTab(scr, x, w, rows);
1275
+ else if (this.activeTab === 2) this._renderQueueTab(scr, x, w, rows);
1276
+ else if (this.activeTab === 3) this._renderLibraryTab(scr, x, w, rows);
1277
+ else if (this.activeTab === 4) this._renderSettingsTab(scr, x, w, rows);
1278
+ else if (this.activeTab === 5) this._renderAboutTab(scr, x, w, rows);
1279
+ }
1274
1280
 
1275
- // 4. Bottom Status Bar with Live Queue Counter & Progress Bar (or Modal Input)
1281
+ _renderFooter(scr, cols, rows) {
1276
1282
  if (this.modal) {
1277
1283
  const prefix = ` * ${this.modal.label}: `;
1278
1284
  scr.putStr(rows - 2, 0, prefix, ANSI.bgCyan + ANSI.fgBlack + ANSI.bold);
@@ -1283,7 +1289,7 @@ class EvaNodeTUI {
1283
1289
  scr.putStr(rows - 1, 2, footerLeft, ANSI.fgCyan + ANSI.bold);
1284
1290
 
1285
1291
  const items = this.manager.items;
1286
- const activeItems = items.filter((i) => i.status === 'Downloading');
1292
+ const activeItems = items.filter((item) => item.status === 'Downloading');
1287
1293
  const doneCnt = items.filter((i) => i.status === 'Done').length;
1288
1294
 
1289
1295
  let pct = 0;
@@ -1294,22 +1300,16 @@ class EvaNodeTUI {
1294
1300
  pct = 100;
1295
1301
  }
1296
1302
 
1297
- const barW = 12;
1298
- const filled = Math.round((pct / 100) * barW);
1299
- const bar = '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, barW - filled));
1303
+ const queueStatus = `│ ${activeItems.length}↓ ${doneCnt}✓ ${pct}%`;
1304
+ const queueStyle = activeItems.length > 0 ? ANSI.fgGreen + ANSI.bold : ANSI.fgWhite;
1305
+ const queueStartX = cols - queueStatus.length;
1306
+ scr.putStr(rows - 1, queueStartX, queueStatus, queueStyle);
1300
1307
 
1301
- const queueStatusStr = `│ ${activeItems.length}↓ ${doneCnt}✓ ${pct}%`;
1302
- const qStyle = activeItems.length > 0 ? ANSI.fgGreen + ANSI.bold : ANSI.fgWhite;
1303
- const qStartX = cols - queueStatusStr.length - 0;
1304
- scr.putStr(rows - 1, qStartX, queueStatusStr, qStyle);
1305
-
1306
- const afterQueueX = qStartX + queueStatusStr.length + 2;
1307
- if (this.statusBanner && afterQueueX < cols - 10) {
1308
- scr.putStr(rows - 1, afterQueueX, `│ ${this.statusBanner}`.slice(0, cols - afterQueueX - 2), ANSI.fgCyan);
1308
+ const statusX = queueStartX + queueStatus.length + 2;
1309
+ if (this.statusBanner && statusX < cols - 10) {
1310
+ scr.putStr(rows - 1, statusX, `│ ${this.statusBanner}`.slice(0, cols - statusX - 2), ANSI.fgCyan);
1309
1311
  }
1310
1312
  }
1311
-
1312
- process.stdout.write(scr.renderToAnsi());
1313
1313
  }
1314
1314
 
1315
1315
  // -------------------------------------------------------------------------
@@ -1318,9 +1318,11 @@ class EvaNodeTUI {
1318
1318
 
1319
1319
  _renderDownloadTab(scr, x, w, rows) {
1320
1320
  let y = 8;
1321
+ const previousClipRight = scr.clipRight;
1321
1322
 
1322
1323
  // --- Box 1: Select a source (1-9) & URL input ---
1323
1324
  scr.drawBox(y, x, 7, w, 'Select a source');
1325
+ scr.clipRight = x + w - 2;
1324
1326
  const colWidth = Math.floor(w / 3);
1325
1327
  const col1X = x + 2;
1326
1328
  const col2X = x + colWidth;
@@ -1344,7 +1346,9 @@ class EvaNodeTUI {
1344
1346
  );
1345
1347
  });
1346
1348
 
1349
+ scr.clipRight = previousClipRight;
1347
1350
  scr.putStr(y + 4, x + 0, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
1351
+ scr.clipRight = x + w - 2;
1348
1352
  const srcObj = SOURCES.find((s) => s.id === this.opts.sourceId) || SOURCES[0];
1349
1353
  const urlDisp = this.opts.url ? this.opts.url : `(нажмите 'u' или кликните для ввода URL — ${srcObj.hint})`;
1350
1354
  this.registerWidget(
@@ -1361,7 +1365,9 @@ class EvaNodeTUI {
1361
1365
 
1362
1366
  // --- Box 2: Download Options & yt-dlp Flags ---
1363
1367
  const box2H = 11;
1368
+ scr.clipRight = previousClipRight;
1364
1369
  scr.drawBox(y, x, box2H, w, 'Download Options Flags');
1370
+ scr.clipRight = x + w - 2;
1365
1371
  const c1 = x + 2;
1366
1372
 
1367
1373
  // Row 1: Audio (-x, metadata, thumbnail)
@@ -1472,43 +1478,47 @@ class EvaNodeTUI {
1472
1478
 
1473
1479
  // Row 6: Naming & Archive (-o & --download-archive)
1474
1480
  scr.putStr(y + 6, c1, 'ФАЙЛЫ:', ANSI.fgCyan + ANSI.bold);
1475
- const maxTplLen = Math.max(14, Math.floor((w - 20) / 4));
1476
- const shortTpl =
1477
- this.opts.outputTemplate.length > maxTplLen
1478
- ? this.opts.outputTemplate.slice(0, maxTplLen - 1) + '…'
1479
- : this.opts.outputTemplate;
1480
- let nx =
1481
- this.registerWidget(
1482
- scr,
1483
- 'flag_otpl',
1484
- y + 6,
1485
- c1 + 9,
1486
- `-o "${shortTpl}"`,
1487
- () => this._toggle('usePlaylistTemplate'),
1488
- { checked: this.opts.usePlaylistTemplate }
1489
- ) + 1;
1490
- nx =
1491
- this.registerWidget(scr, 'edit_otpl', y + 6, nx, '[ред.]', () =>
1492
- this.openPrompt('Шаблон имени (-o)', this.opts.outputTemplate, (v) => {
1493
- this.opts.outputTemplate = v || '%(title)s.%(ext)s';
1494
- })
1495
- ) + 3;
1481
+ const rowRight = x + w - 2;
1482
+ const templateX = c1 + 9;
1483
+ const rowWidth = rowRight - templateX + 1;
1484
+ const templateMaxWidth = Math.max(4, 4 + Math.floor(Math.max(0, rowWidth - 24) / 2));
1485
+ const templateEnd = this.registerWidget(
1486
+ scr,
1487
+ 'flag_otpl',
1488
+ y + 6,
1489
+ templateX,
1490
+ `-o "${this.opts.outputTemplate}"`,
1491
+ () => this._toggle('usePlaylistTemplate'),
1492
+ {
1493
+ checked: this.opts.usePlaylistTemplate,
1494
+ maxWidth: templateMaxWidth,
1495
+ }
1496
+ );
1497
+ const templateEditEnd = this.registerWidget(scr, 'edit_otpl', y + 6, templateEnd + 1, '[✎ ]', () => {
1498
+ return this.openPrompt('Шаблон имени (-o)', this.opts.outputTemplate, (v) => {
1499
+ this.opts.outputTemplate = v || '%(title)s.%(ext)s';
1500
+ });
1501
+ }, { maxWidth: 6 });
1496
1502
 
1497
- let arx =
1498
- this.registerWidget(
1499
- scr,
1500
- 'flag_archive',
1501
- y + 6,
1502
- nx,
1503
- `История загрузки ${this.opts.archiveFile}`,
1504
- () => this._toggle('useDownloadArchive'),
1505
- { checked: this.opts.useDownloadArchive }
1506
- ) + 1;
1507
- this.registerWidget(scr, 'edit_archive', y + 6, arx, '[ред.]', () =>
1508
- this.openPrompt('Файл архива (--download-archive)', this.opts.archiveFile, (v) => {
1509
- this.opts.archiveFile = v || 'archive.txt';
1510
- })
1503
+ const archiveX = templateEditEnd + 2;
1504
+ const archiveMaxWidth = Math.max(4, rowRight - archiveX - '[✎ ]'.length);
1505
+ const archiveEnd = this.registerWidget(
1506
+ scr,
1507
+ 'flag_archive',
1508
+ y + 6,
1509
+ archiveX,
1510
+ `История загрузки ${this.opts.archiveFile}`,
1511
+ () => this._toggle('useDownloadArchive'),
1512
+ {
1513
+ checked: this.opts.useDownloadArchive,
1514
+ maxWidth: archiveMaxWidth,
1515
+ }
1511
1516
  );
1517
+ this.registerWidget(scr, 'edit_archive', y + 6, archiveEnd + 1, '[✎ ]', () => {
1518
+ return this.openPrompt('Файл архива (--download-archive)', this.opts.archiveFile, (v) => {
1519
+ this.opts.archiveFile = v || 'archive.txt';
1520
+ });
1521
+ }, { maxWidth: 6 });
1512
1522
 
1513
1523
  // Row 7: Playlists (--playlist-items & --playlist-reverse)
1514
1524
  scr.putStr(y + 7, c1, 'ПЛЕЙЛИСТ:', ANSI.fgCyan + ANSI.bold);
@@ -1523,7 +1533,7 @@ class EvaNodeTUI {
1523
1533
  { checked: this.opts.usePlaylistItems }
1524
1534
  ) + 1;
1525
1535
  px =
1526
- this.registerWidget(scr, 'edit_plitems', y + 7, px, '[ред.]', () =>
1536
+ this.registerWidget(scr, 'edit_plitems', y + 7, px, '[✎ ]', () =>
1527
1537
  this.openPrompt('Номера треков (--playlist-items, напр. 1-5)', this.opts.playlistItems, (v) => {
1528
1538
  this.opts.playlistItems = v || '1-5';
1529
1539
  })
@@ -1546,7 +1556,7 @@ class EvaNodeTUI {
1546
1556
  ? '…' + this.opts.outputDir.slice(-(maxDirLen - 1))
1547
1557
  : this.opts.outputDir;
1548
1558
  let dx =
1549
- this.registerWidget(scr, 'edit_outdir', y + 9, c1, `Папка: ${shortDir} [ред.]`, () =>
1559
+ this.registerWidget(scr, 'edit_outdir', y + 9, c1, `📁: ${shortDir} [✎ ]`, () =>
1550
1560
  this.openPrompt('Папка сохранения', this.opts.outputDir, (v) => {
1551
1561
  this.opts.outputDir = v || path.join(os.homedir(), 'Downloads');
1552
1562
  })
@@ -1559,6 +1569,7 @@ class EvaNodeTUI {
1559
1569
  });
1560
1570
 
1561
1571
  y += box2H;
1572
+ scr.clipRight = previousClipRight;
1562
1573
 
1563
1574
  // Word-wrapped CLI Preview Lines (never overflows the window width)
1564
1575
  const fullCliText = `CLI: ${buildCommandPreview(this.opts)}`;
@@ -1595,10 +1606,19 @@ class EvaNodeTUI {
1595
1606
  this.selectedQueueItem = items[this.selectedQueueIdx] || null;
1596
1607
 
1597
1608
  const listRows = Math.max(1, h - 6);
1609
+ this.queueViewport = { x, right: x + w, top: y + 3, rows: listRows };
1610
+ const maxScrollOffset = Math.max(0, items.length - listRows);
1611
+ this.queueScrollOffset = Math.max(0, Math.min(this.queueScrollOffset, maxScrollOffset));
1612
+ if (this.selectedQueueIdx < this.queueScrollOffset) {
1613
+ this.queueScrollOffset = this.selectedQueueIdx;
1614
+ } else if (this.selectedQueueIdx >= this.queueScrollOffset + listRows) {
1615
+ this.queueScrollOffset = this.selectedQueueIdx - listRows + 1;
1616
+ }
1617
+
1598
1618
  if (items.length === 0) {
1599
1619
  scr.putStr(y + 3, x + 4, 'Очередь пуста. Добавьте трек во вкладке [1] Download.', ANSI.fgWhite);
1600
1620
  } else {
1601
- const startI = Math.max(0, this.selectedQueueIdx - listRows + 1);
1621
+ const startI = this.queueScrollOffset;
1602
1622
  const slice = items.slice(startI, startI + listRows);
1603
1623
  slice.forEach((it, rowOffset) => {
1604
1624
  const idx = startI + rowOffset;
@@ -1647,6 +1667,35 @@ class EvaNodeTUI {
1647
1667
  },
1648
1668
  });
1649
1669
  });
1670
+
1671
+ if (items.length > listRows) {
1672
+ const scrollbarX = x + w - 2;
1673
+ const thumbSize = Math.max(1, Math.floor((listRows * listRows) / items.length));
1674
+ const thumbTravel = listRows - thumbSize;
1675
+ const thumbTop = maxScrollOffset > 0
1676
+ ? Math.round((this.queueScrollOffset / maxScrollOffset) * thumbTravel)
1677
+ : 0;
1678
+ for (let rowOffset = 0; rowOffset < listRows; rowOffset++) {
1679
+ const isThumb = rowOffset >= thumbTop && rowOffset < thumbTop + thumbSize;
1680
+ const scrollbarY = y + 3 + rowOffset;
1681
+ scr.putStr(scrollbarY, scrollbarX, isThumb ? '█' : '│', isThumb ? ANSI.fgCyan : ANSI.fgGray);
1682
+ this.hitboxes.push({
1683
+ id: 'q_scroll',
1684
+ y: scrollbarY,
1685
+ x1: scrollbarX,
1686
+ x2: scrollbarX,
1687
+ action: () => {
1688
+ const nextThumbTop = Math.max(0, Math.min(thumbTravel, rowOffset - Math.floor(thumbSize / 2)));
1689
+ this.queueScrollOffset = thumbTravel > 0
1690
+ ? Math.round((nextThumbTop / thumbTravel) * maxScrollOffset)
1691
+ : 0;
1692
+ this.selectedQueueIdx = this.queueScrollOffset;
1693
+ this.selectedQueueItem = items[this.selectedQueueIdx] || null;
1694
+ this.render();
1695
+ },
1696
+ });
1697
+ }
1698
+ }
1650
1699
  }
1651
1700
 
1652
1701
  const ctrlY = y + h - 2;
@@ -1687,7 +1736,7 @@ class EvaNodeTUI {
1687
1736
  _renderLibraryTab(scr, x, w, rows) {
1688
1737
  const h = rows - 9;
1689
1738
  scr.drawBox(8, x, h, w, `Downloaded Media Library (${this.opts.outputDir})`);
1690
- const outDir = this.opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
1739
+ const outDir = resolveOutputDir(this.opts.outputDir);
1691
1740
  const exts = new Set(['.mp3', '.m4a', '.flac', '.wav', '.mp4', '.mkv', '.webm', '.opus', '.ogg']);
1692
1741
  let files = [];
1693
1742
  try {
@@ -1708,28 +1757,105 @@ class EvaNodeTUI {
1708
1757
  const numberColW = 4;
1709
1758
  const formatColW = 7;
1710
1759
  const sizeColW = 10;
1711
- const nameColW = Math.min(60, Math.max(1, contentWidth - numberColW - formatColW - sizeColW - 5));
1760
+ const nameColW = Math.max(1, contentWidth - numberColW - formatColW - sizeColW - 5);
1712
1761
  const formatX = numberColW + 1 + nameColW + 1;
1713
1762
  const sizeX = formatX + formatColW + 1;
1763
+ const listTop = 11;
1764
+ const listRows = Math.max(1, h - 4);
1765
+ const maxScrollOffset = Math.max(0, files.length - listRows);
1766
+ this.libraryScrollOffset = Math.max(0, Math.min(this.libraryScrollOffset, maxScrollOffset));
1767
+ this.libraryViewport = { x, right: x + w, top: listTop, rows: listRows, count: files.length };
1768
+ scr.putStr(10, x, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
1714
1769
  if (files.length === 0) {
1715
- scr.putStr(10, x + 3, `В папке ${outDir} пока нет скачанных медиафайлов.`.slice(0, w - 6), ANSI.fgWhite);
1770
+ scr.putStr(listTop, x + 3, `No media files found in ${outDir}.`.slice(0, w - 6), ANSI.fgWhite);
1716
1771
  } else {
1717
- scr.putStr(10, x + 3, '#', ANSI.fgCyan + ANSI.bold);
1718
- scr.putStr(10, x + 3 + numberColW + 1, 'Имя файла', ANSI.fgCyan + ANSI.bold);
1719
- scr.putStr(10, x + 3 + formatX, 'Формат', ANSI.fgCyan + ANSI.bold);
1720
- scr.putStr(10, x + 3 + sizeX, 'Размер', ANSI.fgCyan + ANSI.bold);
1721
- const visibleFileCount = Math.max(0, Math.min(files.length, rows - 15));
1722
- files.slice(0, visibleFileCount).forEach((f, i) => {
1772
+ scr.putStr(9, x + 3, '#', ANSI.fgCyan + ANSI.bold);
1773
+ scr.putStr(9, x + 3 + numberColW + 1, 'Title', ANSI.fgCyan + ANSI.bold);
1774
+ scr.putStr(9, x + 3 + formatX, 'Format', ANSI.fgCyan + ANSI.bold);
1775
+ scr.putStr(9, x + 3 + sizeX, 'Size', ANSI.fgCyan + ANSI.bold);
1776
+ files.slice(this.libraryScrollOffset, this.libraryScrollOffset + listRows).forEach((f, rowOffset) => {
1777
+ const fileIndex = this.libraryScrollOffset + rowOffset;
1723
1778
  const mb = formatBytesToMB(f.stat.size);
1724
- const row = 12 + i;
1725
- const filename = f.name.length > nameColW
1726
- ? `${f.name.slice(0, Math.max(0, nameColW - 1))}…`
1727
- : f.name;
1728
- scr.putStr(row, x + 3, String(i + 1).padEnd(numberColW), ANSI.fgWhite);
1729
- scr.putStr(row, x + 3 + numberColW + 1, filename.slice(0, nameColW).padEnd(nameColW), ANSI.fgWhite);
1730
- scr.putStr(row, x + 3 + formatX, f.ext.slice(1, formatColW + 1).padEnd(formatColW), ANSI.fgWhite);
1731
- scr.putStr(row, x + 3 + sizeX, mb.slice(0, sizeColW).padStart(sizeColW), ANSI.fgWhite);
1779
+ const row = listTop + rowOffset;
1780
+ const filename = fitTextToCellWidth(f.name, nameColW);
1781
+ const isSelected = f.full === this.selectedLibraryFile;
1782
+ const rowStyle = isSelected ? ANSI.bgWhite + ANSI.fgBlack + ANSI.bold : ANSI.fgWhite;
1783
+ if (isSelected) scr.putStr(row, x + 2, ' '.repeat(w - 4), rowStyle);
1784
+ scr.putStr(row, x + 3, String(fileIndex + 1).padEnd(numberColW), rowStyle);
1785
+ scr.putStrWithin(row, x + 3 + numberColW + 1, filename, nameColW, rowStyle);
1786
+ scr.putStrWithin(row, x + 3 + formatX, f.ext.slice(1, formatColW + 1).padEnd(formatColW), formatColW, rowStyle);
1787
+ scr.putStrWithin(row, x + 3 + sizeX, mb.slice(0, sizeColW).padStart(sizeColW), sizeColW, rowStyle);
1788
+ this.hitboxes.push({
1789
+ id: `library_file_${fileIndex}`,
1790
+ y: row,
1791
+ x1: x + 2,
1792
+ x2: x + w - 3,
1793
+ action: () => this._selectLibraryFile(f),
1794
+ });
1732
1795
  });
1796
+
1797
+ if (files.length > listRows) {
1798
+ const scrollbarX = x + w - 2;
1799
+ const thumbSize = Math.max(1, Math.floor((listRows * listRows) / files.length));
1800
+ const thumbTravel = listRows - thumbSize;
1801
+ const thumbTop = maxScrollOffset > 0
1802
+ ? Math.round((this.libraryScrollOffset / maxScrollOffset) * thumbTravel)
1803
+ : 0;
1804
+ for (let rowOffset = 0; rowOffset < listRows; rowOffset++) {
1805
+ const isThumb = rowOffset >= thumbTop && rowOffset < thumbTop + thumbSize;
1806
+ const scrollbarY = listTop + rowOffset;
1807
+ scr.putStr(scrollbarY, scrollbarX, isThumb ? '█' : '│', isThumb ? ANSI.fgCyan : ANSI.fgGray);
1808
+ this.hitboxes.push({
1809
+ id: 'library_scroll',
1810
+ y: scrollbarY,
1811
+ x1: scrollbarX,
1812
+ x2: scrollbarX,
1813
+ action: () => {
1814
+ const nextThumbTop = Math.max(0, Math.min(thumbTravel, rowOffset - Math.floor(thumbSize / 2)));
1815
+ this.libraryScrollOffset = thumbTravel > 0
1816
+ ? Math.round((nextThumbTop / thumbTravel) * maxScrollOffset)
1817
+ : 0;
1818
+ this.render();
1819
+ },
1820
+ });
1821
+ }
1822
+ }
1823
+ }
1824
+ }
1825
+
1826
+ _selectLibraryFile(file) {
1827
+ const now = Date.now();
1828
+ const isDoubleClick =
1829
+ this.lastLibraryClick &&
1830
+ this.lastLibraryClick.path === file.full &&
1831
+ now - this.lastLibraryClick.time <= LIBRARY_DOUBLE_CLICK_MS;
1832
+
1833
+ this.selectedLibraryFile = file.full;
1834
+ this.lastLibraryClick = isDoubleClick ? null : { path: file.full, time: now };
1835
+ if (isDoubleClick) {
1836
+ this._openLibraryFile(file.full);
1837
+ return;
1838
+ }
1839
+ this.render();
1840
+ }
1841
+
1842
+ _openLibraryFile(filePath) {
1843
+ const opener = process.platform === 'win32'
1844
+ ? 'explorer.exe'
1845
+ : process.platform === 'darwin'
1846
+ ? 'open'
1847
+ : 'xdg-open';
1848
+
1849
+ try {
1850
+ const child = spawn(opener, [filePath], { detached: true, stdio: 'ignore' });
1851
+ child.once('error', (error) => {
1852
+ this.statusBanner = `Не удалось открыть файл: ${error.message}`;
1853
+ this.render();
1854
+ });
1855
+ child.unref();
1856
+ } catch (error) {
1857
+ this.statusBanner = `Не удалось открыть файл: ${error.message}`;
1858
+ this.render();
1733
1859
  }
1734
1860
  }
1735
1861
 
@@ -1763,11 +1889,11 @@ class EvaNodeTUI {
1763
1889
  () => this._toggle('useProxy'),
1764
1890
  { checked: this.opts.useProxy }
1765
1891
  ) + 2;
1766
- this.registerWidget(scr, 'edit_proxy', 12, px, '[изменить прокси]', () =>
1767
- this.openPrompt('Прокси (--proxy)', this.opts.proxyUrl, (v) => {
1892
+ this.registerWidget(scr, 'edit_proxy', 12, px, '[изменить прокси]', () => {
1893
+ return this.openPrompt('Прокси (--proxy)', this.opts.proxyUrl, (v) => {
1768
1894
  this.opts.proxyUrl = v || 'http://127.0.0.1:8080';
1769
- })
1770
- );
1895
+ });
1896
+ }, { isCta: true });
1771
1897
 
1772
1898
  let ckx =
1773
1899
  this.registerWidget(
@@ -1786,7 +1912,7 @@ class EvaNodeTUI {
1786
1912
  this.opts.useCookiesBrowser = nextB !== 'none';
1787
1913
  this.statusBanner = `Cookies браузер: ${nextB}`;
1788
1914
  this.render();
1789
- });
1915
+ }, { isCta: true });
1790
1916
 
1791
1917
  scr.putStr(15, c1, 'ШАБЛОНЫ ИМЕНОВАНИЯ ФАЙЛОВ (-o):', ANSI.fgCyan + ANSI.bold);
1792
1918
  NAMING_PRESETS.forEach((p, idx) => {
@@ -1965,7 +2091,6 @@ class EvaNodeTUI {
1965
2091
  }
1966
2092
  if (data === '\x15') {
1967
2093
  this.modal.value = '';
1968
- this.modal.cursor = 0;
1969
2094
  this.render();
1970
2095
  return;
1971
2096
  }
@@ -1990,12 +2115,30 @@ class EvaNodeTUI {
1990
2115
  const mx = parseInt(mouseMatch[2], 10) - 1;
1991
2116
  const my = parseInt(mouseMatch[3], 10) - 1;
1992
2117
  const isPress = mouseMatch[4] === 'M';
2118
+ if ((this.activeTab === 2 || this.activeTab === 3) && (btn === 64 || btn === 65)) {
2119
+ const isQueue = this.activeTab === 2;
2120
+ const viewport = isQueue ? this.queueViewport : this.libraryViewport;
2121
+ if (viewport && mx >= viewport.x && mx < viewport.right &&
2122
+ my >= viewport.top && my < viewport.top + viewport.rows) {
2123
+ if (isQueue) {
2124
+ const maxOffset = Math.max(0, this.manager.items.length - viewport.rows);
2125
+ this.queueScrollOffset = Math.max(0, Math.min(maxOffset, this.queueScrollOffset + (btn === 64 ? -1 : 1)));
2126
+ this.selectedQueueIdx = this.queueScrollOffset;
2127
+ this.selectedQueueItem = this.manager.items[this.selectedQueueIdx] || null;
2128
+ } else {
2129
+ const maxOffset = Math.max(0, viewport.count - viewport.rows);
2130
+ this.libraryScrollOffset = Math.max(0, Math.min(maxOffset, this.libraryScrollOffset + (btn === 64 ? -1 : 1)));
2131
+ }
2132
+ this.render();
2133
+ }
2134
+ return;
2135
+ }
1993
2136
  if (isPress && btn === 0) {
1994
2137
  const hit = this.hitboxes.find((h) =>
1995
2138
  mx >= h.x1 && mx <= h.x2 && (h.y === my || (h.nav && Math.abs(h.y - my) === 1))
1996
2139
  );
1997
2140
  if (hit) {
1998
- this.focusId = hit.id;
2141
+ if (hit.id !== 'q_scroll' && hit.id !== 'library_scroll') this.focusId = hit.id;
1999
2142
  hit.action();
2000
2143
  }
2001
2144
  }
@@ -2142,6 +2285,7 @@ module.exports = {
2142
2285
  detectSource,
2143
2286
  isYouTubePlaylistUrl,
2144
2287
  playlistEntryUrl,
2288
+ ScreenBuffer,
2145
2289
  DownloadManager,
2146
2290
  formatBytesToMB,
2147
2291
  fitTextToCellWidth,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eva-md",
3
- "version": "0.4.3",
3
+ "version": "0.6.1",
4
4
  "description": "EVA Media Downloader CLI",
5
5
  "main": "eva.js",
6
6
  "bin": {