@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.
@@ -0,0 +1,274 @@
1
+ import blessed from "neo-blessed";
2
+ import { fg } from "../../theme.js";
3
+ import { config } from "../../../config.js";
4
+ import { findFfmpegPath, findFfplayPath, spawnVideoPlayer, spawnAudioPlayer } from "../../../utils/video.js";
5
+ import { formatDuration } from "../../../utils/time.js";
6
+
7
+ /**
8
+ * Создаёт полноэкранное модальное окно воспроизведения видео в терминале.
9
+ *
10
+ * Декодирует видеопоток через ffmpeg и выводит кадры в Unicode Half-Block псевдографике,
11
+ * синхронизируя аудиодорожку через системный аудиоплеер (afplay / ffplay).
12
+ *
13
+ * @param {blessed.Widgets.Screen} screen
14
+ * @param {object} theme
15
+ * @param {object} callbacks
16
+ * @param {(msg: object, onProgress?: (p: number) => void) => Promise<string>} [callbacks.onLoadVideoFile]
17
+ * @param {(msg: object, size: { maxWidth: number, maxHeight: number }) => string} [callbacks.onRenderPlaceholder]
18
+ * @returns {{ modal: object, play: (msg: object) => void, hide: () => void, isVisible: () => boolean }}
19
+ */
20
+ export function createVideoPlayerModal(screen, theme, { onLoadVideoFile, onRenderPlaceholder } = {}) {
21
+ const modal = blessed.box({
22
+ parent: screen,
23
+ top: 0,
24
+ left: 0,
25
+ width: "100%",
26
+ height: "100%",
27
+ hidden: true,
28
+ mouse: true,
29
+ style: {
30
+ bg: theme.bg,
31
+ fg: theme.fg,
32
+ },
33
+ });
34
+
35
+ const canvas = blessed.box({
36
+ parent: modal,
37
+ top: 0,
38
+ left: 0,
39
+ right: 0,
40
+ bottom: 1,
41
+ tags: true,
42
+ align: "center",
43
+ valign: "middle",
44
+ style: {
45
+ bg: theme.bg,
46
+ fg: theme.fg,
47
+ },
48
+ });
49
+
50
+ const footer = blessed.box({
51
+ parent: modal,
52
+ bottom: 0,
53
+ left: 0,
54
+ right: 0,
55
+ height: 1,
56
+ tags: true,
57
+ style: {
58
+ bg: theme.status.bg,
59
+ fg: theme.status.fg,
60
+ },
61
+ });
62
+
63
+ let currentMsg = null;
64
+ let currentVideoPath = null;
65
+ let videoProcess = null;
66
+ let audioProcess = null;
67
+ let isPaused = false;
68
+ let statusText = "";
69
+ let previousFocus = null;
70
+ let token = 0;
71
+ let frameCount = 0;
72
+
73
+ /** Размер холста видео в ячейках/пикселях. */
74
+ function viewportDimensions() {
75
+ const screenW = screen.width || 80;
76
+ const screenH = screen.height || 24;
77
+ const width = Math.max(10, Math.min(screenW - 2, 80));
78
+ // Высота в пикселях: 2 пикселя на 1 терминальную строку
79
+ let height = Math.max(8, Math.min((screenH - 2) * 2, 60));
80
+ if (height % 2 !== 0) height -= 1;
81
+ return { width, height };
82
+ }
83
+
84
+ function renderFooter() {
85
+ const id = currentMsg ? `#${currentMsg.id}` : "";
86
+ const status = statusText ? `${fg(theme.warning, statusText)} ${fg(theme.dim, "│")} ` : "";
87
+ const pauseTag = isPaused ? `${fg(theme.danger, "[ПАУЗА]")} ${fg(theme.dim, "│")} ` : "";
88
+ const fpsTag = videoProcess ? `${fg(theme.dim, `FPS: ${config.videoFps}`)} ${fg(theme.dim, "│")} ` : "";
89
+
90
+ footer.setContent(
91
+ ` ${fg(theme.accent, id)} ${fg(theme.dim, "│")} ${pauseTag}${status}${fpsTag}${fg(theme.muted, "[Space] Пауза · [r] С начала · [Esc/q] Закрыть")}`
92
+ );
93
+ }
94
+
95
+ function stopProcesses() {
96
+ if (videoProcess) {
97
+ videoProcess.kill();
98
+ videoProcess = null;
99
+ }
100
+ if (audioProcess) {
101
+ audioProcess.kill();
102
+ audioProcess = null;
103
+ }
104
+ isPaused = false;
105
+ }
106
+
107
+ function hide() {
108
+ token++;
109
+ stopProcesses();
110
+ modal.hide();
111
+ currentMsg = null;
112
+ currentVideoPath = null;
113
+ statusText = "";
114
+ frameCount = 0;
115
+ canvas.setContent("");
116
+ if (previousFocus) {
117
+ previousFocus.focus();
118
+ previousFocus = null;
119
+ }
120
+ screen.render();
121
+ }
122
+
123
+ function togglePause() {
124
+ if (!videoProcess) return;
125
+ if (isPaused) {
126
+ videoProcess.resume();
127
+ audioProcess?.resume();
128
+ isPaused = false;
129
+ } else {
130
+ videoProcess.pause();
131
+ audioProcess?.pause();
132
+ isPaused = true;
133
+ }
134
+ renderFooter();
135
+ screen.render();
136
+ }
137
+
138
+ function startPlayback(filePath, myToken) {
139
+ if (myToken !== token) return;
140
+ stopProcesses();
141
+
142
+ const ffmpegPath = findFfmpegPath();
143
+ if (!ffmpegPath) {
144
+ statusText = "ffmpeg не найден. Запустите: tuigram install-video";
145
+ renderFooter();
146
+ screen.render();
147
+ return;
148
+ }
149
+
150
+ const { width, height } = viewportDimensions();
151
+ statusText = "";
152
+ frameCount = 0;
153
+ renderFooter();
154
+
155
+ // Запуск аудиодорожки
156
+ if (config.videoAudio) {
157
+ const ffplayPath = findFfplayPath();
158
+ audioProcess = spawnAudioPlayer(filePath, { ffplayPath });
159
+ }
160
+
161
+ // Запуск декодирования видео
162
+ videoProcess = spawnVideoPlayer(ffmpegPath, filePath, {
163
+ width,
164
+ height,
165
+ fps: config.videoFps,
166
+ onFrame: (frameText) => {
167
+ if (myToken !== token) return;
168
+ frameCount++;
169
+ canvas.setContent(frameText);
170
+ renderFooter();
171
+ screen.render();
172
+ },
173
+ onEnd: () => {
174
+ if (myToken !== token) return;
175
+ statusText = "Воспроизведение завершено";
176
+ if (audioProcess) {
177
+ audioProcess.kill();
178
+ audioProcess = null;
179
+ }
180
+ renderFooter();
181
+ screen.render();
182
+ },
183
+ onError: (err) => {
184
+ if (myToken !== token) return;
185
+ statusText = `Ошибка декодирования: ${err.message}`;
186
+ if (audioProcess) {
187
+ audioProcess.kill();
188
+ audioProcess = null;
189
+ }
190
+ renderFooter();
191
+ screen.render();
192
+ },
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Открывает полноэкранный плеер для воспроизведения видеосообщения.
198
+ * @param {object} msg нормализованное сообщение
199
+ */
200
+ async function play(msg) {
201
+ if (!msg) return;
202
+
203
+ const myToken = ++token;
204
+ stopProcesses();
205
+
206
+ currentMsg = msg;
207
+ currentVideoPath = null;
208
+ previousFocus = screen.focused;
209
+ isPaused = false;
210
+
211
+ // Показываем статичную миниатюру на время подготовки
212
+ const { width, height } = viewportDimensions();
213
+ const placeholder = onRenderPlaceholder?.(msg, { maxWidth: width, maxHeight: Math.floor(height / 2) }) || "";
214
+ canvas.setContent(placeholder);
215
+
216
+ modal.show();
217
+ modal.setFront();
218
+ modal.focus();
219
+
220
+ if (!config.enableVideo) {
221
+ statusText = "Проигрывание видео выключено. Включите ENABLE_VIDEO=true в .env";
222
+ renderFooter();
223
+ screen.render();
224
+ return;
225
+ }
226
+
227
+ const ffmpegPath = findFfmpegPath();
228
+ if (!ffmpegPath) {
229
+ statusText = "ffmpeg не найден. Запустите: tuigram install-video";
230
+ renderFooter();
231
+ screen.render();
232
+ return;
233
+ }
234
+
235
+ statusText = "Загрузка видеофайла...";
236
+ renderFooter();
237
+ screen.render();
238
+
239
+ try {
240
+ const filePath = await onLoadVideoFile?.(msg, (p) => {
241
+ if (myToken !== token) return;
242
+ const percent = Math.round((p || 0) * 100);
243
+ statusText = `Загрузка: ${percent}%...`;
244
+ renderFooter();
245
+ screen.render();
246
+ });
247
+
248
+ if (myToken !== token || !filePath) return;
249
+ currentVideoPath = filePath;
250
+ startPlayback(filePath, myToken);
251
+ } catch (err) {
252
+ if (myToken !== token) return;
253
+ statusText = `Ошибка загрузки: ${err.message}`;
254
+ renderFooter();
255
+ screen.render();
256
+ }
257
+ }
258
+
259
+ modal.key(["space"], togglePause);
260
+ modal.key(["r", "R"], () => {
261
+ if (currentVideoPath) {
262
+ startPlayback(currentVideoPath, token);
263
+ }
264
+ });
265
+ modal.key(["escape", "q"], hide);
266
+ modal.on("click", togglePause);
267
+
268
+ return {
269
+ modal,
270
+ play,
271
+ hide,
272
+ isVisible: () => modal.visible,
273
+ };
274
+ }
package/src/ui/screen.js CHANGED
@@ -1,5 +1,113 @@
1
1
  import blessed from "neo-blessed";
2
2
 
3
+ // Патч neo-blessed: предотвращаем дублирование обработчиков keypress и циклы фокуса.
4
+ // В neo-blessed при повторном focus() на уже сфокусированном элементе происходил blur+focus,
5
+ // а readInput() создавал висячие обработчики keypress в nextTick, из-за чего вводимые символы
6
+ // дублировались (например, "ттуутт??").
7
+ if (!blessed.__tuigramPatched) {
8
+ blessed.__tuigramPatched = true;
9
+
10
+ const origElementFocus = blessed.element.prototype.focus;
11
+ blessed.element.prototype.focus = function() {
12
+ if (this.screen?.focused === this) return this;
13
+ return origElementFocus.call(this);
14
+ };
15
+
16
+ const origScreenFocusPush = blessed.screen.prototype.focusPush;
17
+ blessed.screen.prototype.focusPush = function(el) {
18
+ if (!el) return;
19
+ const old = this.history[this.history.length - 1];
20
+ if (old === el) return;
21
+ return origScreenFocusPush.call(this, el);
22
+ };
23
+
24
+ blessed.textarea.prototype.readInput = function(callback) {
25
+ const self = this;
26
+ const focused = this.screen.focused === this;
27
+
28
+ if (this._reading) return;
29
+ this._reading = true;
30
+
31
+ this._callback = callback;
32
+
33
+ if (!focused) {
34
+ this.screen.saveFocus();
35
+ this.focus();
36
+ }
37
+
38
+ this.screen.grabKeys = true;
39
+ this._updateCursor();
40
+ this.screen.program.showCursor();
41
+
42
+ if (this.__listener) {
43
+ this.removeListener("keypress", this.__listener);
44
+ delete this.__listener;
45
+ }
46
+ if (this.__done) {
47
+ this.removeListener("blur", this.__done);
48
+ delete this.__done;
49
+ }
50
+
51
+ this._done = function fn(err, value) {
52
+ if (!self._reading) return;
53
+
54
+ if (fn.done) return;
55
+ fn.done = true;
56
+
57
+ self._reading = false;
58
+
59
+ delete self._callback;
60
+ delete self._done;
61
+
62
+ if (self.__listener) {
63
+ self.removeListener("keypress", self.__listener);
64
+ delete self.__listener;
65
+ }
66
+ if (self.__done) {
67
+ self.removeListener("blur", self.__done);
68
+ delete self.__done;
69
+ }
70
+
71
+ self.screen.program.hideCursor();
72
+ self.screen.grabKeys = false;
73
+
74
+ if (!focused) {
75
+ self.screen.restoreFocus();
76
+ }
77
+
78
+ if (self.options.inputOnFocus) {
79
+ self.screen.rewindFocus();
80
+ }
81
+
82
+ if (err === "stop") return;
83
+
84
+ if (err) {
85
+ self.emit("error", err);
86
+ } else if (value != null) {
87
+ self.emit("submit", value);
88
+ } else {
89
+ self.emit("cancel", value);
90
+ }
91
+ self.emit("action", value);
92
+
93
+ if (!callback) return;
94
+ return err ? callback(err) : callback(null, value);
95
+ };
96
+
97
+ setImmediate(() => {
98
+ if (!self._reading) return;
99
+ if (self.__listener) {
100
+ self.removeListener("keypress", self.__listener);
101
+ }
102
+ self.__listener = self._listener.bind(self);
103
+ self.on("keypress", self.__listener);
104
+ });
105
+
106
+ this.__done = this._done.bind(this, null, null);
107
+ this.on("blur", this.__done);
108
+ };
109
+ }
110
+
3
111
  /**
4
112
  * Глобальные сочетания, которые должны работать даже когда поле ввода или строка
5
113
  * поиска перехватили клавиатуру (blessed выставляет screen.grabKeys = true и
package/src/ui/theme.js CHANGED
@@ -68,6 +68,7 @@ function buildTheme(p) {
68
68
  outgoingName: p.green,
69
69
  time: p.dim,
70
70
  dateDivider: p.yellow,
71
+ unreadDivider: p.accent,
71
72
  replyBorder: p.muted,
72
73
  systemMsg: p.yellow,
73
74
  mediaFg: p.magenta,