eva-md 0.4.3 → 0.5.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.
- package/eva.js +225 -172
- 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
|
+
* EVA.md v0.5.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,7 +46,7 @@ const { spawn, spawnSync } = require('child_process');
|
|
|
46
46
|
// Constants & Configuration
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
48
|
|
|
49
|
-
const APP_VERSION = 'v0.
|
|
49
|
+
const APP_VERSION = 'v0.5.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;
|
|
@@ -104,7 +104,6 @@ const ANSI = {
|
|
|
104
104
|
fgBlack: `${CSI}30m`,
|
|
105
105
|
fgGray: `${CSI}38;2;105;105;105m`,
|
|
106
106
|
fgOrange: `${CSI}38;2;222;91;9m`, // Текст цвета RGB(222, 91, 9)
|
|
107
|
-
bgOrange: `${CSI}48;2;222;91;9m`,
|
|
108
107
|
bgGray: `${CSI}48;2;105;105;105m`,
|
|
109
108
|
bgWhite: `${CSI}47m`,
|
|
110
109
|
bgCyan: `${CSI}46m`,
|
|
@@ -143,6 +142,10 @@ function shellQuote(arg) {
|
|
|
143
142
|
return arg;
|
|
144
143
|
}
|
|
145
144
|
|
|
145
|
+
function resolveOutputDir(outputDir) {
|
|
146
|
+
return outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
|
|
147
|
+
}
|
|
148
|
+
|
|
146
149
|
function formatBytesToMB(bytes) {
|
|
147
150
|
if (!bytes || bytes <= 0) return '—';
|
|
148
151
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
@@ -341,7 +344,7 @@ function buildYtdlpArgs(opts, targetUrl = null) {
|
|
|
341
344
|
if (opts.writeThumbnail) args.push('--write-thumbnail');
|
|
342
345
|
|
|
343
346
|
// File Naming & Archive
|
|
344
|
-
const outDir = opts.outputDir
|
|
347
|
+
const outDir = resolveOutputDir(opts.outputDir);
|
|
345
348
|
const tpl = opts.usePlaylistTemplate ? opts.outputTemplate : '%(title)s.%(ext)s';
|
|
346
349
|
args.push('-o', path.join(outDir, tpl));
|
|
347
350
|
|
|
@@ -480,7 +483,7 @@ class DownloadManager {
|
|
|
480
483
|
_createQueueItem(opts, url, title = null, entry = null, entryIndex = 0) {
|
|
481
484
|
const src = detectSource(url, opts.sourceId);
|
|
482
485
|
const fmt = opts.bestVideoMerge && !opts.extractAudio ? 'mp4/mkv' : opts.audioFormat;
|
|
483
|
-
const outDir = opts.outputDir
|
|
486
|
+
const outDir = resolveOutputDir(opts.outputDir);
|
|
484
487
|
const itemOpts = { ...opts, usePlaylistItems: false, playlistReverse: false };
|
|
485
488
|
if (entry && itemOpts.usePlaylistTemplate) {
|
|
486
489
|
const playlistIndex = String(entry.playlist_index || entryIndex + 1).padStart(2, '0');
|
|
@@ -987,6 +990,7 @@ class ScreenBuffer {
|
|
|
987
990
|
constructor(cols, rows) {
|
|
988
991
|
this.cols = cols;
|
|
989
992
|
this.rows = rows;
|
|
993
|
+
this.clipRight = null;
|
|
990
994
|
this.chars = Array.from({ length: rows }, () => Array(cols).fill(' '));
|
|
991
995
|
this.styles = Array.from({ length: rows }, () => Array(cols).fill(ANSI.fgWhite));
|
|
992
996
|
}
|
|
@@ -996,7 +1000,7 @@ class ScreenBuffer {
|
|
|
996
1000
|
const str = String(text);
|
|
997
1001
|
for (let i = 0; i < str.length; i++) {
|
|
998
1002
|
const cx = x + i;
|
|
999
|
-
if (cx >= 0 && cx < this.cols - 1) {
|
|
1003
|
+
if (cx >= 0 && cx < this.cols - 1 && (this.clipRight === null || cx <= this.clipRight)) {
|
|
1000
1004
|
this.chars[y][cx] = str[i];
|
|
1001
1005
|
this.styles[y][cx] = style;
|
|
1002
1006
|
}
|
|
@@ -1053,10 +1057,11 @@ class EvaNodeTUI {
|
|
|
1053
1057
|
this.hitboxes = [];
|
|
1054
1058
|
this.selectedQueueIdx = 0;
|
|
1055
1059
|
this.selectedQueueItem = null;
|
|
1060
|
+
this.queueScrollOffset = 0;
|
|
1061
|
+
this.queueViewport = null;
|
|
1056
1062
|
this.statusBanner = '';
|
|
1057
1063
|
|
|
1058
|
-
|
|
1059
|
-
this.modal = null; // { label, value, cursor, onConfirm }
|
|
1064
|
+
this.modal = null;
|
|
1060
1065
|
}
|
|
1061
1066
|
|
|
1062
1067
|
start() {
|
|
@@ -1081,101 +1086,84 @@ class EvaNodeTUI {
|
|
|
1081
1086
|
process.exit(0);
|
|
1082
1087
|
}
|
|
1083
1088
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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);
|
|
1089
|
+
registerWidget(scr, id, y, x, label, action, { checked = null, radio = null, isCta = false, maxWidth = 0 } = {}) {
|
|
1090
|
+
this.focusOrder.push(id);
|
|
1091
|
+
const isFocused = this.focusId === id;
|
|
1092
|
+
let prefix = '';
|
|
1093
|
+
|
|
1094
|
+
if (checked !== null) {
|
|
1095
|
+
prefix = checked ? '[x] ' : '[ ] ';
|
|
1096
|
+
} else if (radio !== null) {
|
|
1097
|
+
prefix = radio ? '(•) ' : '( ) ';
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
let style = ANSI.fgGray;
|
|
1101
|
+
if (isFocused) {
|
|
1102
|
+
style = isCta
|
|
1103
|
+
? ANSI.bgGray + ANSI.fgWhite + ANSI.bold
|
|
1104
|
+
: ANSI.bgWhite + ANSI.fgBlack + ANSI.bold;
|
|
1105
|
+
} else if (isCta) {
|
|
1106
|
+
style = id === 'url_input'
|
|
1107
|
+
? ANSI.fgWhite + ANSI.bold
|
|
1108
|
+
: id === 'btn_add_queue'
|
|
1109
|
+
? ANSI.fgGreen + ANSI.bold
|
|
1110
|
+
: ANSI.fgCyan + ANSI.bold;
|
|
1111
|
+
} else if (checked || radio) {
|
|
1112
|
+
style = ANSI.fgOrange + ANSI.bold;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (checked !== null) {
|
|
1116
|
+
scr.putStr(y, x, '[', ANSI.fgGray);
|
|
1124
1117
|
scr.putStr(y, x + 1, checked ? 'x' : ' ', checked ? ANSI.fgOrange + ANSI.bold : ANSI.dim);
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
// Расчёт доступной ширины текста
|
|
1133
|
-
const availableWidth = maxWidth > 0
|
|
1134
|
-
? Math.max(0, maxWidth - prefix.length)
|
|
1135
|
-
: Infinity;
|
|
1136
|
-
|
|
1137
|
-
let visibleLabel = label;
|
|
1118
|
+
scr.putStr(y, x + 2, '] ', ANSI.fgGray);
|
|
1119
|
+
} else if (radio !== null) {
|
|
1120
|
+
scr.putStr(y, x, '(', ANSI.fgGray);
|
|
1121
|
+
scr.putStr(y, x + 1, radio ? '•' : ' ', radio ? ANSI.fgOrange + ANSI.bold : ANSI.dim);
|
|
1122
|
+
scr.putStr(y, x + 2, ') ', ANSI.fgGray);
|
|
1123
|
+
}
|
|
1138
1124
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1125
|
+
const availableWidth = maxWidth > 0 ? Math.max(0, maxWidth - prefix.length) : Infinity;
|
|
1126
|
+
let visibleLabel = label;
|
|
1127
|
+
if (visibleLabel.length > availableWidth) {
|
|
1128
|
+
visibleLabel = availableWidth > 0 ? visibleLabel.slice(0, availableWidth - 1) + '…' : '';
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const textX = x + prefix.length;
|
|
1132
|
+
const editMarker = '[ред.]';
|
|
1133
|
+
const editMarkerIndex = visibleLabel.lastIndexOf(editMarker);
|
|
1134
|
+
if (!isFocused && editMarkerIndex >= 0) {
|
|
1135
|
+
const folderLabel = 'Папка:';
|
|
1136
|
+
const folderLabelIndex = visibleLabel.indexOf(folderLabel);
|
|
1137
|
+
if (folderLabelIndex >= 0 && folderLabelIndex < editMarkerIndex) {
|
|
1138
|
+
scr.putStr(y, textX, visibleLabel.slice(0, folderLabelIndex), style);
|
|
1139
|
+
scr.putStr(y, textX + folderLabelIndex, folderLabel, ANSI.fgWhite);
|
|
1140
|
+
const afterFolderIndex = folderLabelIndex + folderLabel.length;
|
|
1141
|
+
scr.putStr(y, textX + afterFolderIndex, visibleLabel.slice(afterFolderIndex, editMarkerIndex), style);
|
|
1142
|
+
} else {
|
|
1143
|
+
scr.putStr(y, textX, visibleLabel.slice(0, editMarkerIndex), style);
|
|
1144
|
+
}
|
|
1145
|
+
scr.putStr(y, textX + editMarkerIndex, editMarker, ANSI.fgWhite);
|
|
1146
|
+
scr.putStr(
|
|
1147
|
+
y,
|
|
1148
|
+
textX + editMarkerIndex + editMarker.length,
|
|
1149
|
+
visibleLabel.slice(editMarkerIndex + editMarker.length),
|
|
1150
|
+
style
|
|
1151
|
+
);
|
|
1157
1152
|
} else {
|
|
1158
|
-
scr.putStr(y, textX, visibleLabel
|
|
1153
|
+
scr.putStr(y, textX, visibleLabel, style);
|
|
1159
1154
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1155
|
+
|
|
1156
|
+
const totalLength = prefix.length + visibleLabel.length;
|
|
1157
|
+
this.hitboxes.push({
|
|
1158
|
+
id,
|
|
1159
|
+
y,
|
|
1160
|
+
x1: x,
|
|
1161
|
+
x2: Math.min(x + totalLength - 1, scr.clipRight === null ? scr.cols - 2 : scr.clipRight),
|
|
1162
|
+
action,
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
return x + totalLength;
|
|
1164
1166
|
}
|
|
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
1167
|
|
|
1180
1168
|
|
|
1181
1169
|
openPrompt(label, currentVal, onConfirm) {
|
|
@@ -1183,7 +1171,6 @@ class EvaNodeTUI {
|
|
|
1183
1171
|
this.modal = {
|
|
1184
1172
|
label,
|
|
1185
1173
|
value: val,
|
|
1186
|
-
cursor: val.length,
|
|
1187
1174
|
onConfirm,
|
|
1188
1175
|
};
|
|
1189
1176
|
this.render();
|
|
@@ -1206,6 +1193,7 @@ class EvaNodeTUI {
|
|
|
1206
1193
|
|
|
1207
1194
|
this.focusOrder = [];
|
|
1208
1195
|
this.hitboxes = [];
|
|
1196
|
+
this.queueViewport = null;
|
|
1209
1197
|
|
|
1210
1198
|
if (rows < 22 || cols < 76) {
|
|
1211
1199
|
scr.putStr(1, 2, `Окно терминала слишком маленькое (${cols}x${rows}). Минимум: 78x22.`, ANSI.fgRed + ANSI.bold);
|
|
@@ -1213,7 +1201,19 @@ class EvaNodeTUI {
|
|
|
1213
1201
|
return;
|
|
1214
1202
|
}
|
|
1215
1203
|
|
|
1216
|
-
|
|
1204
|
+
this._renderHeader(scr, cols);
|
|
1205
|
+
const { x, w } = this._renderNavigation(scr, rows, cols);
|
|
1206
|
+
this._renderActiveTab(scr, x, w, rows);
|
|
1207
|
+
|
|
1208
|
+
if (this.focusOrder.length > 0 && !this.focusOrder.includes(this.focusId)) {
|
|
1209
|
+
this.focusId = this.focusOrder[0];
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
this._renderFooter(scr, cols, rows);
|
|
1213
|
+
process.stdout.write(scr.renderToAnsi());
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
_renderHeader(scr, cols) {
|
|
1217
1217
|
ASCII_LOGO.forEach((line, i) => {
|
|
1218
1218
|
scr.putStr(1 + i, 2, line, ANSI.fgWhite + ANSI.bold);
|
|
1219
1219
|
});
|
|
@@ -1224,8 +1224,9 @@ class EvaNodeTUI {
|
|
|
1224
1224
|
scr.putStr(5, cols - tagline.length - 3, tagline, ANSI.fgWhite);
|
|
1225
1225
|
scr.putStr(6, cols - tagline.length - 3, '─'.repeat(tagline.length), ANSI.fgCyan);
|
|
1226
1226
|
}
|
|
1227
|
+
}
|
|
1227
1228
|
|
|
1228
|
-
|
|
1229
|
+
_renderNavigation(scr, rows, cols) {
|
|
1229
1230
|
const sidebarW = 19;
|
|
1230
1231
|
const navItems = [
|
|
1231
1232
|
{ tab: 1, label: '≡ General' },
|
|
@@ -1258,21 +1259,20 @@ class EvaNodeTUI {
|
|
|
1258
1259
|
scr.putStr(r, sidebarW + 1, '│', ANSI.fgWhite);
|
|
1259
1260
|
}
|
|
1260
1261
|
|
|
1261
|
-
// 3. Main Content Area
|
|
1262
1262
|
const mainX = sidebarW + 3;
|
|
1263
1263
|
const mainW = cols - mainX - 2;
|
|
1264
|
+
return { x: mainX, w: mainW };
|
|
1265
|
+
}
|
|
1264
1266
|
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
else if (this.activeTab ===
|
|
1268
|
-
else if (this.activeTab ===
|
|
1269
|
-
else if (this.activeTab ===
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
this.focusId = this.focusOrder[0];
|
|
1273
|
-
}
|
|
1267
|
+
_renderActiveTab(scr, x, w, rows) {
|
|
1268
|
+
if (this.activeTab === 1) this._renderDownloadTab(scr, x, w, rows);
|
|
1269
|
+
else if (this.activeTab === 2) this._renderQueueTab(scr, x, w, rows);
|
|
1270
|
+
else if (this.activeTab === 3) this._renderLibraryTab(scr, x, w, rows);
|
|
1271
|
+
else if (this.activeTab === 4) this._renderSettingsTab(scr, x, w, rows);
|
|
1272
|
+
else if (this.activeTab === 5) this._renderAboutTab(scr, x, w, rows);
|
|
1273
|
+
}
|
|
1274
1274
|
|
|
1275
|
-
|
|
1275
|
+
_renderFooter(scr, cols, rows) {
|
|
1276
1276
|
if (this.modal) {
|
|
1277
1277
|
const prefix = ` * ${this.modal.label}: `;
|
|
1278
1278
|
scr.putStr(rows - 2, 0, prefix, ANSI.bgCyan + ANSI.fgBlack + ANSI.bold);
|
|
@@ -1283,7 +1283,7 @@ class EvaNodeTUI {
|
|
|
1283
1283
|
scr.putStr(rows - 1, 2, footerLeft, ANSI.fgCyan + ANSI.bold);
|
|
1284
1284
|
|
|
1285
1285
|
const items = this.manager.items;
|
|
1286
|
-
const activeItems = items.filter((
|
|
1286
|
+
const activeItems = items.filter((item) => item.status === 'Downloading');
|
|
1287
1287
|
const doneCnt = items.filter((i) => i.status === 'Done').length;
|
|
1288
1288
|
|
|
1289
1289
|
let pct = 0;
|
|
@@ -1294,22 +1294,16 @@ class EvaNodeTUI {
|
|
|
1294
1294
|
pct = 100;
|
|
1295
1295
|
}
|
|
1296
1296
|
|
|
1297
|
-
const
|
|
1298
|
-
const
|
|
1299
|
-
const
|
|
1300
|
-
|
|
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);
|
|
1297
|
+
const queueStatus = `│ ${activeItems.length}↓ ${doneCnt}✓ ${pct}%`;
|
|
1298
|
+
const queueStyle = activeItems.length > 0 ? ANSI.fgGreen + ANSI.bold : ANSI.fgWhite;
|
|
1299
|
+
const queueStartX = cols - queueStatus.length;
|
|
1300
|
+
scr.putStr(rows - 1, queueStartX, queueStatus, queueStyle);
|
|
1305
1301
|
|
|
1306
|
-
const
|
|
1307
|
-
if (this.statusBanner &&
|
|
1308
|
-
scr.putStr(rows - 1,
|
|
1302
|
+
const statusX = queueStartX + queueStatus.length + 2;
|
|
1303
|
+
if (this.statusBanner && statusX < cols - 10) {
|
|
1304
|
+
scr.putStr(rows - 1, statusX, `│ ${this.statusBanner}`.slice(0, cols - statusX - 2), ANSI.fgCyan);
|
|
1309
1305
|
}
|
|
1310
1306
|
}
|
|
1311
|
-
|
|
1312
|
-
process.stdout.write(scr.renderToAnsi());
|
|
1313
1307
|
}
|
|
1314
1308
|
|
|
1315
1309
|
// -------------------------------------------------------------------------
|
|
@@ -1318,9 +1312,11 @@ class EvaNodeTUI {
|
|
|
1318
1312
|
|
|
1319
1313
|
_renderDownloadTab(scr, x, w, rows) {
|
|
1320
1314
|
let y = 8;
|
|
1315
|
+
const previousClipRight = scr.clipRight;
|
|
1321
1316
|
|
|
1322
1317
|
// --- Box 1: Select a source (1-9) & URL input ---
|
|
1323
1318
|
scr.drawBox(y, x, 7, w, 'Select a source');
|
|
1319
|
+
scr.clipRight = x + w - 2;
|
|
1324
1320
|
const colWidth = Math.floor(w / 3);
|
|
1325
1321
|
const col1X = x + 2;
|
|
1326
1322
|
const col2X = x + colWidth;
|
|
@@ -1344,7 +1340,9 @@ class EvaNodeTUI {
|
|
|
1344
1340
|
);
|
|
1345
1341
|
});
|
|
1346
1342
|
|
|
1343
|
+
scr.clipRight = previousClipRight;
|
|
1347
1344
|
scr.putStr(y + 4, x + 0, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
|
|
1345
|
+
scr.clipRight = x + w - 2;
|
|
1348
1346
|
const srcObj = SOURCES.find((s) => s.id === this.opts.sourceId) || SOURCES[0];
|
|
1349
1347
|
const urlDisp = this.opts.url ? this.opts.url : `(нажмите 'u' или кликните для ввода URL — ${srcObj.hint})`;
|
|
1350
1348
|
this.registerWidget(
|
|
@@ -1361,7 +1359,9 @@ class EvaNodeTUI {
|
|
|
1361
1359
|
|
|
1362
1360
|
// --- Box 2: Download Options & yt-dlp Flags ---
|
|
1363
1361
|
const box2H = 11;
|
|
1362
|
+
scr.clipRight = previousClipRight;
|
|
1364
1363
|
scr.drawBox(y, x, box2H, w, 'Download Options Flags');
|
|
1364
|
+
scr.clipRight = x + w - 2;
|
|
1365
1365
|
const c1 = x + 2;
|
|
1366
1366
|
|
|
1367
1367
|
// Row 1: Audio (-x, metadata, thumbnail)
|
|
@@ -1472,43 +1472,47 @@ class EvaNodeTUI {
|
|
|
1472
1472
|
|
|
1473
1473
|
// Row 6: Naming & Archive (-o & --download-archive)
|
|
1474
1474
|
scr.putStr(y + 6, c1, 'ФАЙЛЫ:', ANSI.fgCyan + ANSI.bold);
|
|
1475
|
-
const
|
|
1476
|
-
const
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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 });
|
|
1496
1496
|
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
})
|
|
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
|
+
}
|
|
1511
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 });
|
|
1512
1516
|
|
|
1513
1517
|
// Row 7: Playlists (--playlist-items & --playlist-reverse)
|
|
1514
1518
|
scr.putStr(y + 7, c1, 'ПЛЕЙЛИСТ:', ANSI.fgCyan + ANSI.bold);
|
|
@@ -1559,6 +1563,7 @@ class EvaNodeTUI {
|
|
|
1559
1563
|
});
|
|
1560
1564
|
|
|
1561
1565
|
y += box2H;
|
|
1566
|
+
scr.clipRight = previousClipRight;
|
|
1562
1567
|
|
|
1563
1568
|
// Word-wrapped CLI Preview Lines (never overflows the window width)
|
|
1564
1569
|
const fullCliText = `CLI: ${buildCommandPreview(this.opts)}`;
|
|
@@ -1595,10 +1600,19 @@ class EvaNodeTUI {
|
|
|
1595
1600
|
this.selectedQueueItem = items[this.selectedQueueIdx] || null;
|
|
1596
1601
|
|
|
1597
1602
|
const listRows = Math.max(1, h - 6);
|
|
1603
|
+
this.queueViewport = { x, right: x + w, top: y + 3, rows: listRows };
|
|
1604
|
+
const maxScrollOffset = Math.max(0, items.length - listRows);
|
|
1605
|
+
this.queueScrollOffset = Math.max(0, Math.min(this.queueScrollOffset, maxScrollOffset));
|
|
1606
|
+
if (this.selectedQueueIdx < this.queueScrollOffset) {
|
|
1607
|
+
this.queueScrollOffset = this.selectedQueueIdx;
|
|
1608
|
+
} else if (this.selectedQueueIdx >= this.queueScrollOffset + listRows) {
|
|
1609
|
+
this.queueScrollOffset = this.selectedQueueIdx - listRows + 1;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1598
1612
|
if (items.length === 0) {
|
|
1599
1613
|
scr.putStr(y + 3, x + 4, 'Очередь пуста. Добавьте трек во вкладке [1] Download.', ANSI.fgWhite);
|
|
1600
1614
|
} else {
|
|
1601
|
-
const startI =
|
|
1615
|
+
const startI = this.queueScrollOffset;
|
|
1602
1616
|
const slice = items.slice(startI, startI + listRows);
|
|
1603
1617
|
slice.forEach((it, rowOffset) => {
|
|
1604
1618
|
const idx = startI + rowOffset;
|
|
@@ -1647,6 +1661,35 @@ class EvaNodeTUI {
|
|
|
1647
1661
|
},
|
|
1648
1662
|
});
|
|
1649
1663
|
});
|
|
1664
|
+
|
|
1665
|
+
if (items.length > listRows) {
|
|
1666
|
+
const scrollbarX = x + w - 2;
|
|
1667
|
+
const thumbSize = Math.max(1, Math.floor((listRows * listRows) / items.length));
|
|
1668
|
+
const thumbTravel = listRows - thumbSize;
|
|
1669
|
+
const thumbTop = maxScrollOffset > 0
|
|
1670
|
+
? Math.round((this.queueScrollOffset / maxScrollOffset) * thumbTravel)
|
|
1671
|
+
: 0;
|
|
1672
|
+
for (let rowOffset = 0; rowOffset < listRows; rowOffset++) {
|
|
1673
|
+
const isThumb = rowOffset >= thumbTop && rowOffset < thumbTop + thumbSize;
|
|
1674
|
+
const scrollbarY = y + 3 + rowOffset;
|
|
1675
|
+
scr.putStr(scrollbarY, scrollbarX, isThumb ? '█' : '│', isThumb ? ANSI.fgCyan : ANSI.fgGray);
|
|
1676
|
+
this.hitboxes.push({
|
|
1677
|
+
id: 'q_scroll',
|
|
1678
|
+
y: scrollbarY,
|
|
1679
|
+
x1: scrollbarX,
|
|
1680
|
+
x2: scrollbarX,
|
|
1681
|
+
action: () => {
|
|
1682
|
+
const nextThumbTop = Math.max(0, Math.min(thumbTravel, rowOffset - Math.floor(thumbSize / 2)));
|
|
1683
|
+
this.queueScrollOffset = thumbTravel > 0
|
|
1684
|
+
? Math.round((nextThumbTop / thumbTravel) * maxScrollOffset)
|
|
1685
|
+
: 0;
|
|
1686
|
+
this.selectedQueueIdx = this.queueScrollOffset;
|
|
1687
|
+
this.selectedQueueItem = items[this.selectedQueueIdx] || null;
|
|
1688
|
+
this.render();
|
|
1689
|
+
},
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1650
1693
|
}
|
|
1651
1694
|
|
|
1652
1695
|
const ctrlY = y + h - 2;
|
|
@@ -1687,7 +1730,7 @@ class EvaNodeTUI {
|
|
|
1687
1730
|
_renderLibraryTab(scr, x, w, rows) {
|
|
1688
1731
|
const h = rows - 9;
|
|
1689
1732
|
scr.drawBox(8, x, h, w, `Downloaded Media Library (${this.opts.outputDir})`);
|
|
1690
|
-
const outDir = this.opts.outputDir
|
|
1733
|
+
const outDir = resolveOutputDir(this.opts.outputDir);
|
|
1691
1734
|
const exts = new Set(['.mp3', '.m4a', '.flac', '.wav', '.mp4', '.mkv', '.webm', '.opus', '.ogg']);
|
|
1692
1735
|
let files = [];
|
|
1693
1736
|
try {
|
|
@@ -1708,7 +1751,7 @@ class EvaNodeTUI {
|
|
|
1708
1751
|
const numberColW = 4;
|
|
1709
1752
|
const formatColW = 7;
|
|
1710
1753
|
const sizeColW = 10;
|
|
1711
|
-
const nameColW = Math.
|
|
1754
|
+
const nameColW = Math.max(1, contentWidth - numberColW - formatColW - sizeColW - 5);
|
|
1712
1755
|
const formatX = numberColW + 1 + nameColW + 1;
|
|
1713
1756
|
const sizeX = formatX + formatColW + 1;
|
|
1714
1757
|
if (files.length === 0) {
|
|
@@ -1722,13 +1765,11 @@ class EvaNodeTUI {
|
|
|
1722
1765
|
files.slice(0, visibleFileCount).forEach((f, i) => {
|
|
1723
1766
|
const mb = formatBytesToMB(f.stat.size);
|
|
1724
1767
|
const row = 12 + i;
|
|
1725
|
-
const filename = f.name
|
|
1726
|
-
? `${f.name.slice(0, Math.max(0, nameColW - 1))}…`
|
|
1727
|
-
: f.name;
|
|
1768
|
+
const filename = fitTextToCellWidth(f.name, nameColW);
|
|
1728
1769
|
scr.putStr(row, x + 3, String(i + 1).padEnd(numberColW), ANSI.fgWhite);
|
|
1729
|
-
scr.
|
|
1730
|
-
scr.
|
|
1731
|
-
scr.
|
|
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);
|
|
1732
1773
|
});
|
|
1733
1774
|
}
|
|
1734
1775
|
}
|
|
@@ -1965,7 +2006,6 @@ class EvaNodeTUI {
|
|
|
1965
2006
|
}
|
|
1966
2007
|
if (data === '\x15') {
|
|
1967
2008
|
this.modal.value = '';
|
|
1968
|
-
this.modal.cursor = 0;
|
|
1969
2009
|
this.render();
|
|
1970
2010
|
return;
|
|
1971
2011
|
}
|
|
@@ -1990,12 +2030,24 @@ class EvaNodeTUI {
|
|
|
1990
2030
|
const mx = parseInt(mouseMatch[2], 10) - 1;
|
|
1991
2031
|
const my = parseInt(mouseMatch[3], 10) - 1;
|
|
1992
2032
|
const isPress = mouseMatch[4] === 'M';
|
|
2033
|
+
if (this.activeTab === 2 && (btn === 64 || btn === 65)) {
|
|
2034
|
+
const viewport = this.queueViewport;
|
|
2035
|
+
if (viewport && mx >= viewport.x && mx < viewport.right &&
|
|
2036
|
+
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;
|
|
2041
|
+
this.render();
|
|
2042
|
+
}
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
1993
2045
|
if (isPress && btn === 0) {
|
|
1994
2046
|
const hit = this.hitboxes.find((h) =>
|
|
1995
2047
|
mx >= h.x1 && mx <= h.x2 && (h.y === my || (h.nav && Math.abs(h.y - my) === 1))
|
|
1996
2048
|
);
|
|
1997
2049
|
if (hit) {
|
|
1998
|
-
this.focusId = hit.id;
|
|
2050
|
+
if (hit.id !== 'q_scroll') this.focusId = hit.id;
|
|
1999
2051
|
hit.action();
|
|
2000
2052
|
}
|
|
2001
2053
|
}
|
|
@@ -2142,6 +2194,7 @@ module.exports = {
|
|
|
2142
2194
|
detectSource,
|
|
2143
2195
|
isYouTubePlaylistUrl,
|
|
2144
2196
|
playlistEntryUrl,
|
|
2197
|
+
ScreenBuffer,
|
|
2145
2198
|
DownloadManager,
|
|
2146
2199
|
formatBytesToMB,
|
|
2147
2200
|
fitTextToCellWidth,
|