@emaxe/tuigram 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +15 -0
- package/CHANGELOG.md +21 -1
- package/CHANGELOG.ru.md +21 -1
- package/README.md +47 -2
- package/README.ru.md +47 -2
- package/package.json +2 -1
- package/src/cli/videoSetup.js +187 -0
- package/src/config.js +6 -0
- package/src/index.js +8 -0
- package/src/state.js +31 -8
- package/src/telegram/dialogs.js +6 -2
- package/src/telegram/formatter.js +1 -1
- package/src/telegram/messages.js +90 -6
- package/src/ui/app.js +200 -35
- package/src/ui/components/chatList.js +156 -100
- package/src/ui/components/chatView.js +259 -78
- package/src/ui/components/inputBox.js +12 -4
- package/src/ui/components/modals/actionModal.js +5 -0
- package/src/ui/components/modals/helpModal.js +2 -1
- package/src/ui/components/modals/videoPlayerModal.js +274 -0
- package/src/ui/screen.js +108 -0
- package/src/ui/theme.js +1 -0
- package/src/utils/image.js +310 -6
- package/src/utils/mouse.js +1 -1
- package/src/utils/video.js +495 -0
package/src/utils/image.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import jpegJs from "jpeg-js";
|
|
8
8
|
import { PNG } from "pngjs";
|
|
9
|
+
import { stringCellWidth } from "./mouse.js";
|
|
9
10
|
|
|
10
11
|
/** Заголовок стандартного JPEG для распаковки Telegram PhotoStrippedSize. */
|
|
11
12
|
const JPEG_HEADER = Buffer.from([
|
|
@@ -216,6 +217,9 @@ export function resizeRgba(src, srcW, srcH, dstW, dstH) {
|
|
|
216
217
|
return dst;
|
|
217
218
|
}
|
|
218
219
|
|
|
220
|
+
/** Таблица предвычисленных двухсимвольных hex-значений 00-ff для быстрого рендеринга. */
|
|
221
|
+
const HEX_TABLE = Array.from({ length: 256 }, (_, i) => (i < 16 ? "0" : "") + i.toString(16));
|
|
222
|
+
|
|
219
223
|
/**
|
|
220
224
|
* Преобразует компоненты цвета R, G, B в hex-строку вида "#rrggbb".
|
|
221
225
|
* @param {number} r
|
|
@@ -224,10 +228,7 @@ export function resizeRgba(src, srcW, srcH, dstW, dstH) {
|
|
|
224
228
|
* @returns {string}
|
|
225
229
|
*/
|
|
226
230
|
export function rgbaToHex(r, g, b) {
|
|
227
|
-
|
|
228
|
-
const hexG = (g < 16 ? "0" : "") + g.toString(16);
|
|
229
|
-
const hexB = (b < 16 ? "0" : "") + b.toString(16);
|
|
230
|
-
return `#${hexR}${hexG}${hexB}`;
|
|
231
|
+
return `#${HEX_TABLE[r & 0xff]}${HEX_TABLE[g & 0xff]}${HEX_TABLE[b & 0xff]}`;
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
/**
|
|
@@ -258,8 +259,8 @@ export function rgbaToHalfBlockBlessed(data, width, height) {
|
|
|
258
259
|
const topOffset = topRowOffset + x * 4;
|
|
259
260
|
const botOffset = botRowOffset + x * 4;
|
|
260
261
|
|
|
261
|
-
const topHex =
|
|
262
|
-
const botHex =
|
|
262
|
+
const topHex = `#${HEX_TABLE[data[topOffset]]}${HEX_TABLE[data[topOffset + 1]]}${HEX_TABLE[data[topOffset + 2]]}`;
|
|
263
|
+
const botHex = `#${HEX_TABLE[data[botOffset]]}${HEX_TABLE[data[botOffset + 1]]}${HEX_TABLE[data[botOffset + 2]]}`;
|
|
263
264
|
|
|
264
265
|
if (topHex !== currentFg) {
|
|
265
266
|
line += `{${topHex}-fg}`;
|
|
@@ -341,3 +342,306 @@ export function renderStrippedThumbnail(strippedBytes, { maxWidth = 36, maxHeigh
|
|
|
341
342
|
return "";
|
|
342
343
|
}
|
|
343
344
|
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Извлекает исходные размеры медиа (ширину и высоту в пикселях) из объекта сообщения Telegram.
|
|
348
|
+
* @param {object} rawMessage исходное сообщение или объект медиа
|
|
349
|
+
* @returns {{ width: number, height: number }|null}
|
|
350
|
+
*/
|
|
351
|
+
export function getMediaDimensions(rawMessage) {
|
|
352
|
+
const media = rawMessage?.media || rawMessage;
|
|
353
|
+
if (!media) return null;
|
|
354
|
+
|
|
355
|
+
// 1. Фотография
|
|
356
|
+
if (media.className === "MessageMediaPhoto" || media.photo) {
|
|
357
|
+
const photo = media.photo || media;
|
|
358
|
+
let maxW = 0;
|
|
359
|
+
let maxH = 0;
|
|
360
|
+
for (const s of photo.sizes || []) {
|
|
361
|
+
if (s && typeof s.w === "number" && typeof s.h === "number" && s.w > 0 && s.h > 0) {
|
|
362
|
+
if (s.w * s.h > maxW * maxH) {
|
|
363
|
+
maxW = s.w;
|
|
364
|
+
maxH = s.h;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (maxW > 0 && maxH > 0) {
|
|
369
|
+
return { width: maxW, height: maxH };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 2. Документ (видео, изображение, gif)
|
|
374
|
+
if (media.className === "MessageMediaDocument" || media.document) {
|
|
375
|
+
const doc = media.document || media;
|
|
376
|
+
const attributes = doc.attributes || [];
|
|
377
|
+
for (const attr of attributes) {
|
|
378
|
+
if ((attr.className === "DocumentAttributeVideo" || attr.className === "DocumentAttributeImageSize") && attr.w && attr.h) {
|
|
379
|
+
return { width: attr.w, height: attr.h };
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
let maxW = 0;
|
|
383
|
+
let maxH = 0;
|
|
384
|
+
for (const t of doc.thumbs || []) {
|
|
385
|
+
if (t && typeof t.w === "number" && typeof t.h === "number" && t.w > 0 && t.h > 0) {
|
|
386
|
+
if (t.w * t.h > maxW * maxH) {
|
|
387
|
+
maxW = t.w;
|
|
388
|
+
maxH = t.h;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (maxW > 0 && maxH > 0) {
|
|
393
|
+
return { width: maxW, height: maxH };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// 3. Веб-страница (встроенное медиа статьи/ссылки)
|
|
398
|
+
if (media.className === "MessageMediaWebPage" || media.webpage) {
|
|
399
|
+
const page = media.webpage || media;
|
|
400
|
+
if (page.photo) {
|
|
401
|
+
const dims = getMediaDimensions(page.photo);
|
|
402
|
+
if (dims) return dims;
|
|
403
|
+
}
|
|
404
|
+
if (page.document) {
|
|
405
|
+
const dims = getMediaDimensions(page.document);
|
|
406
|
+
if (dims) return dims;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Проверяет, является ли вложение визуальным медиа (фото, видео, анимация),
|
|
415
|
+
* для которого отображается визуальное превью в ленте сообщений.
|
|
416
|
+
* @param {object} rawMessage
|
|
417
|
+
* @returns {boolean}
|
|
418
|
+
*/
|
|
419
|
+
export function isPreviewableMedia(rawMessage) {
|
|
420
|
+
const media = rawMessage?.media || rawMessage;
|
|
421
|
+
if (!media) return false;
|
|
422
|
+
|
|
423
|
+
if (media.className === "MessageMediaPhoto" || media.photo) {
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (media.className === "MessageMediaDocument" || media.document) {
|
|
428
|
+
const doc = media.document || media;
|
|
429
|
+
if (typeof doc.mimeType === "string") {
|
|
430
|
+
if (doc.mimeType.startsWith("video/") || doc.mimeType.startsWith("image/")) {
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const attributes = doc.attributes || [];
|
|
436
|
+
for (const attr of attributes) {
|
|
437
|
+
if (
|
|
438
|
+
attr.className === "DocumentAttributeVideo" ||
|
|
439
|
+
attr.className === "DocumentAttributeImageSize" ||
|
|
440
|
+
attr.className === "DocumentAttributeAnimated" ||
|
|
441
|
+
attr.className === "DocumentAttributeSticker"
|
|
442
|
+
) {
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (Array.isArray(doc.thumbs) && doc.thumbs.length > 0) {
|
|
448
|
+
const isAudio = attributes.some((a) => a.className === "DocumentAttributeAudio");
|
|
449
|
+
if (!isAudio) {
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (media.className === "MessageMediaWebPage" || media.webpage) {
|
|
456
|
+
const page = media.webpage || media;
|
|
457
|
+
return Boolean(page.photo || (page.document && isPreviewableMedia(page.document)));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Цветовая палитра по умолчанию для прелоадера медиа-вложений. */
|
|
464
|
+
const DEFAULT_PRELOADER_PALETTE = {
|
|
465
|
+
bg: "#1f2335",
|
|
466
|
+
border: "#3b4261",
|
|
467
|
+
fg: "#8288a6",
|
|
468
|
+
accent: "#7aa2f7",
|
|
469
|
+
dim: "#6874a0",
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Генерирует строку Blessed-разметки для прелоадера / плейсхолдера медиа-вложения
|
|
474
|
+
* точного целевого размера, чтобы избежать скачков интерфейса и сдвигов сообщений
|
|
475
|
+
* при асинхронной подгрузке превью.
|
|
476
|
+
*
|
|
477
|
+
* @param {object} rawMessage исходное сообщение или объект медиа
|
|
478
|
+
* @param {object} [options]
|
|
479
|
+
* @param {number} [options.maxWidth=36]
|
|
480
|
+
* @param {number} [options.maxHeight=14]
|
|
481
|
+
* @param {string} [options.customLabel] пользовательская подпись
|
|
482
|
+
* @param {object} [options.palette] цвета темы (border, bg, fg, accent)
|
|
483
|
+
* @returns {string}
|
|
484
|
+
*/
|
|
485
|
+
export function renderMediaPreloader(rawMessage, {
|
|
486
|
+
maxWidth = 36,
|
|
487
|
+
maxHeight = 14,
|
|
488
|
+
customLabel,
|
|
489
|
+
palette,
|
|
490
|
+
} = {}) {
|
|
491
|
+
const dims = getMediaDimensions(rawMessage);
|
|
492
|
+
const srcW = dims?.width || 320;
|
|
493
|
+
const srcH = dims?.height || 240;
|
|
494
|
+
const { dstW, rows } = calculateTargetDimensions(srcW, srcH, maxWidth, maxHeight);
|
|
495
|
+
|
|
496
|
+
const p = { ...DEFAULT_PRELOADER_PALETTE, ...(palette || {}) };
|
|
497
|
+
|
|
498
|
+
const media = rawMessage?.media || rawMessage;
|
|
499
|
+
const isDoc = media?.className === "MessageMediaDocument" || Boolean(media?.document);
|
|
500
|
+
const doc = media?.document || (isDoc ? media : null);
|
|
501
|
+
const attributes = doc?.attributes || [];
|
|
502
|
+
|
|
503
|
+
const isVideo = (typeof doc?.mimeType === "string" && doc.mimeType.startsWith("video/")) ||
|
|
504
|
+
attributes.some((a) => a?.className === "DocumentAttributeVideo");
|
|
505
|
+
const isGif = doc?.mimeType === "image/gif" ||
|
|
506
|
+
attributes.some((a) => a?.className === "DocumentAttributeAnimated");
|
|
507
|
+
const isSticker = attributes.some((a) => a?.className === "DocumentAttributeSticker");
|
|
508
|
+
|
|
509
|
+
// Определение длительности видео, если доступна
|
|
510
|
+
let duration = 0;
|
|
511
|
+
if (isVideo) {
|
|
512
|
+
const videoAttr = attributes.find((a) => a?.className === "DocumentAttributeVideo");
|
|
513
|
+
if (videoAttr?.duration) {
|
|
514
|
+
duration = videoAttr.duration;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Подготовка текстовых меток
|
|
519
|
+
let primaryLabel = customLabel || "";
|
|
520
|
+
if (!primaryLabel) {
|
|
521
|
+
if (isVideo) {
|
|
522
|
+
primaryLabel = dstW >= 24 ? "⏳ Загрузка видео..." : (dstW >= 16 ? "⏳ Видео..." : "⏳ Видео");
|
|
523
|
+
} else if (isGif) {
|
|
524
|
+
primaryLabel = dstW >= 22 ? "⏳ Загрузка GIF..." : "⏳ GIF";
|
|
525
|
+
} else if (isSticker) {
|
|
526
|
+
primaryLabel = dstW >= 24 ? "⏳ Загрузка стикера..." : "⏳ Стикер";
|
|
527
|
+
} else {
|
|
528
|
+
primaryLabel = dstW >= 22 ? "⏳ Загрузка фото..." : (dstW >= 16 ? "⏳ Фото..." : "⏳ Фото");
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
let secondaryLabel = "";
|
|
533
|
+
if (duration > 0 && dims?.width && dims?.height) {
|
|
534
|
+
const minSec = `${Math.floor(duration / 60)}:${String(Math.floor(duration % 60)).padStart(2, "0")}`;
|
|
535
|
+
secondaryLabel = dstW >= 22 ? `${minSec} · ${dims.width}×${dims.height}` : minSec;
|
|
536
|
+
} else if (duration > 0) {
|
|
537
|
+
secondaryLabel = `${Math.floor(duration / 60)}:${String(Math.floor(duration % 60)).padStart(2, "0")}`;
|
|
538
|
+
} else if (dims?.width && dims?.height) {
|
|
539
|
+
secondaryLabel = `${dims.width}×${dims.height}`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const innerWidth = Math.max(1, dstW - 2);
|
|
543
|
+
const innerHeight = Math.max(1, rows - 2);
|
|
544
|
+
|
|
545
|
+
const borderTag = `{${p.border}-fg}`;
|
|
546
|
+
const borderClose = `{/${p.border}-fg}`;
|
|
547
|
+
const bgTag = p.bg ? `{${p.bg}-bg}` : "";
|
|
548
|
+
const bgClose = p.bg ? `{/${p.bg}-bg}` : "";
|
|
549
|
+
const textTag = `{${p.fg}-fg}`;
|
|
550
|
+
const textClose = `{/${p.fg}-fg}`;
|
|
551
|
+
const dimTag = `{${p.dim}-fg}`;
|
|
552
|
+
const dimClose = `{/${p.dim}-fg}`;
|
|
553
|
+
|
|
554
|
+
// Если места слишком мало для рамки (высота 1-2 строки)
|
|
555
|
+
if (rows <= 1) {
|
|
556
|
+
const line = `[ ${primaryLabel} ]`;
|
|
557
|
+
const pad = Math.max(0, dstW - stringCellWidth(line));
|
|
558
|
+
return `${bgTag}${textTag}${line}${" ".repeat(pad)}${textClose}${bgClose}`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (rows === 2) {
|
|
562
|
+
const topLine = `${borderTag}┌─ ${borderClose}${textTag}${primaryLabel}${textClose} ${borderTag}${"─".repeat(Math.max(0, innerWidth - stringCellWidth(primaryLabel) - 3))}┐${borderClose}`;
|
|
563
|
+
const botLine = `${borderTag}└${"─".repeat(innerWidth)}┘${borderClose}`;
|
|
564
|
+
return `${bgTag}${topLine}${bgClose}\n${bgTag}${botLine}${bgClose}`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const lines = [];
|
|
568
|
+
|
|
569
|
+
// Верхняя граница
|
|
570
|
+
lines.push(`${bgTag}${borderTag}┌${"─".repeat(innerWidth)}┐${borderClose}${bgClose}`);
|
|
571
|
+
|
|
572
|
+
// Вычисление строки размещения подписей
|
|
573
|
+
const hasSecondary = Boolean(secondaryLabel) && innerHeight >= 3 && stringCellWidth(secondaryLabel) <= innerWidth;
|
|
574
|
+
const contentLinesCount = hasSecondary ? 2 : 1;
|
|
575
|
+
const startContentRow = Math.max(0, Math.floor((innerHeight - contentLinesCount) / 2));
|
|
576
|
+
|
|
577
|
+
for (let r = 0; r < innerHeight; r++) {
|
|
578
|
+
let content = "";
|
|
579
|
+
if (r === startContentRow) {
|
|
580
|
+
let label = primaryLabel;
|
|
581
|
+
if (stringCellWidth(label) > innerWidth) {
|
|
582
|
+
label = label.slice(0, Math.max(1, innerWidth - 1)) + "…";
|
|
583
|
+
}
|
|
584
|
+
const width = stringCellWidth(label);
|
|
585
|
+
const leftPad = Math.max(0, Math.floor((innerWidth - width) / 2));
|
|
586
|
+
const rightPad = Math.max(0, innerWidth - width - leftPad);
|
|
587
|
+
content = " ".repeat(leftPad) + textTag + label + textClose + " ".repeat(rightPad);
|
|
588
|
+
} else if (hasSecondary && r === startContentRow + 1) {
|
|
589
|
+
let label = secondaryLabel;
|
|
590
|
+
if (stringCellWidth(label) > innerWidth) {
|
|
591
|
+
label = label.slice(0, Math.max(1, innerWidth - 1));
|
|
592
|
+
}
|
|
593
|
+
const width = stringCellWidth(label);
|
|
594
|
+
const leftPad = Math.max(0, Math.floor((innerWidth - width) / 2));
|
|
595
|
+
const rightPad = Math.max(0, innerWidth - width - leftPad);
|
|
596
|
+
content = " ".repeat(leftPad) + dimTag + label + dimClose + " ".repeat(rightPad);
|
|
597
|
+
} else {
|
|
598
|
+
content = " ".repeat(innerWidth);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
lines.push(`${bgTag}${borderTag}│${borderClose}${content}${borderTag}│${borderClose}${bgClose}`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Нижняя граница
|
|
605
|
+
lines.push(`${bgTag}${borderTag}└${"─".repeat(innerWidth)}┘${borderClose}${bgClose}`);
|
|
606
|
+
|
|
607
|
+
return lines.join("\n");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Извлекает готовое превью изображения из кэша по объекту сообщения, если оно уже рендерилось.
|
|
612
|
+
* @param {object} rawMessage
|
|
613
|
+
* @param {object} [options]
|
|
614
|
+
* @param {number} [options.maxWidth=36]
|
|
615
|
+
* @param {number} [options.maxHeight=14]
|
|
616
|
+
* @returns {string|null}
|
|
617
|
+
*/
|
|
618
|
+
export function getCachedImagePreview(rawMessage, { maxWidth = 36, maxHeight = 14 } = {}) {
|
|
619
|
+
const media = rawMessage?.media || rawMessage;
|
|
620
|
+
if (!media) return null;
|
|
621
|
+
|
|
622
|
+
const photo = media.photo || (media.className === "MessageMediaPhoto" ? media : null);
|
|
623
|
+
const doc = media.document || (media.className === "MessageMediaDocument" ? media : null);
|
|
624
|
+
|
|
625
|
+
const keysToCheck = [];
|
|
626
|
+
if (photo?.id) {
|
|
627
|
+
keysToCheck.push(`photo_${photo.id}@${maxWidth}x${maxHeight}`);
|
|
628
|
+
keysToCheck.push(`photo_full_${photo.id}@${maxWidth}x${maxHeight}`);
|
|
629
|
+
}
|
|
630
|
+
if (doc?.id) {
|
|
631
|
+
keysToCheck.push(`doc_${doc.id}@${maxWidth}x${maxHeight}`);
|
|
632
|
+
keysToCheck.push(`doc_full_${doc.id}@${maxWidth}x${maxHeight}`);
|
|
633
|
+
}
|
|
634
|
+
if (rawMessage?.id) {
|
|
635
|
+
keysToCheck.push(`msg_${rawMessage.id}@${maxWidth}x${maxHeight}`);
|
|
636
|
+
keysToCheck.push(`photo_full_${rawMessage.id}@${maxWidth}x${maxHeight}`);
|
|
637
|
+
keysToCheck.push(`doc_full_${rawMessage.id}@${maxWidth}x${maxHeight}`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
for (const key of keysToCheck) {
|
|
641
|
+
if (imagePreviewCache.has(key)) {
|
|
642
|
+
return imagePreviewCache.get(key);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return null;
|
|
647
|
+
}
|
package/src/utils/mouse.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import unicode from "neo-blessed/lib/unicode.js";
|
|
6
6
|
|
|
7
7
|
/** Пиктограммы и эмодзи, занимающие две ячейки терминала. */
|
|
8
|
-
const EMOJI_REGEX = /\p{Extended_Pictographic}/u;
|
|
8
|
+
const EMOJI_REGEX = /\p{Extended_Pictographic}|[\u{1F000}-\u{1FAFF}]|[\u{2600}-\u{27BF}]/u;
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Вычисляет ширину строки в терминальных ячейках.
|