dsh-android-tool 0.0.0-stage → 0.1.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.
@@ -0,0 +1,681 @@
1
+ /**
2
+ * scrcpy 视频推流(host 侧最小客户端)。
3
+ *
4
+ * 做法:
5
+ * 1. adb push 随包的 scrcpy-server;
6
+ * 2. adb forward 一个本地端口到设备 localabstract:scrcpy_<scid>;
7
+ * 3. 启动 com.genymobile.scrcpy.Server 5.0.1(audio=false / control=false);
8
+ * 4. 连本地端口,解析 codec id、session packet、12 字节帧头;
9
+ * 5. 通过 WebSocket 把每一帧推给浏览器,WebCodecs 解码到 canvas。
10
+ *
11
+ * 任何一步失败都返回结构化错误,由上层回退到截图轮询 / 独立 scrcpy 窗口。
12
+ */
13
+
14
+ import { spawn } from 'node:child_process'
15
+ import { randomInt } from 'node:crypto'
16
+ import { existsSync } from 'node:fs'
17
+ import net from 'node:net'
18
+ import { setTimeout as delay } from 'node:timers/promises'
19
+ import {
20
+ AndroidKeyEventAction,
21
+ AndroidMotionEventAction,
22
+ ScrcpyControlMessageSerializer,
23
+ ScrcpyOptions5_0_1,
24
+ } from '@yume-chan/scrcpy'
25
+ import {
26
+ joinAnnexB,
27
+ parseFrameHeader,
28
+ parseSessionPacket,
29
+ readCodecId,
30
+ scrcpySocketName,
31
+ spsToCodecString,
32
+ splitAnnexB,
33
+ extractSpsPps,
34
+ } from './scrcpy-protocol.js'
35
+ import { handshakeResponse, WsConnection } from './ws.js'
36
+
37
+ /** 远端 server 路径。 */
38
+ const REMOTE_SERVER = '/data/local/tmp/scrcpy-server.jar'
39
+
40
+ /** 杀进程树。 */
41
+ function killTree(child) {
42
+ if (child === undefined || child === null || child.killed) return
43
+ try {
44
+ if (process.platform === 'win32') {
45
+ const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
46
+ killer.on('error', () => {})
47
+ } else {
48
+ child.kill('SIGKILL')
49
+ }
50
+ } catch {
51
+ // 忽略
52
+ }
53
+ }
54
+
55
+ /** 找一个空闲的本地 TCP 端口。 */
56
+ function getFreePort() {
57
+ return new Promise((resolvePromise) => {
58
+ const server = net.createServer()
59
+ server.unref()
60
+ server.on('error', () => resolvePromise(0))
61
+ server.listen({ port: 0, host: '127.0.0.1' }, () => {
62
+ const address = server.address()
63
+ const port = typeof address === 'object' && address !== null ? address.port : 0
64
+ server.close(() => resolvePromise(port))
65
+ })
66
+ })
67
+ }
68
+
69
+ /** 连一次本地端口(只看 TCP connect,不等业务数据)。 */
70
+ function connectOnce(port, timeoutMs = 1500) {
71
+ return new Promise((resolvePromise) => {
72
+ const client = net.connect({ port, host: '127.0.0.1' })
73
+ let settled = false
74
+ const timer = setTimeout(() => {
75
+ if (settled) return
76
+ settled = true
77
+ client.destroy()
78
+ resolvePromise({ ok: false, error: 'TCP 连接超时' })
79
+ }, timeoutMs)
80
+ client.once('error', (error) => {
81
+ if (settled) return
82
+ settled = true
83
+ clearTimeout(timer)
84
+ client.destroy()
85
+ resolvePromise({ ok: false, error: error instanceof Error ? error.message : String(error) })
86
+ })
87
+ client.once('connect', () => {
88
+ if (settled) return
89
+ settled = true
90
+ clearTimeout(timer)
91
+ resolvePromise({ ok: true, socket: client })
92
+ })
93
+ })
94
+ }
95
+
96
+ /**
97
+ * 连 scrcpy 的两个 socket(video + control),并等 video 真的收到首包。
98
+ *
99
+ * 为什么不能只看 TCP connect:adb forward 在 device 端还没有 LocalServerSocket
100
+ * 监听时也会先 accept 再 close。另外开启 control 后,server 会先 accept video,
101
+ * 再 accept control,两个都连上之后才开始发视频数据——所以必须成对连接。
102
+ */
103
+ async function connectVideoControl(port, timeoutMs) {
104
+ const deadline = Date.now() + timeoutMs
105
+ let lastError = ''
106
+ while (Date.now() < deadline) {
107
+ const video = await connectOnce(port)
108
+ if (video.ok !== true) {
109
+ lastError = video.error
110
+ await delay(220)
111
+ continue
112
+ }
113
+ const control = await connectOnce(port)
114
+ if (control.ok !== true) {
115
+ video.socket.destroy()
116
+ lastError = control.error
117
+ await delay(220)
118
+ continue
119
+ }
120
+ const firstChunk = await new Promise((resolvePromise) => {
121
+ let settled = false
122
+ const timer = setTimeout(() => {
123
+ if (settled) return
124
+ settled = true
125
+ resolvePromise(undefined)
126
+ }, 1800)
127
+ const finish = (chunk) => {
128
+ if (settled) return
129
+ settled = true
130
+ clearTimeout(timer)
131
+ resolvePromise(chunk)
132
+ }
133
+ video.socket.once('data', (chunk) => {
134
+ video.socket.pause()
135
+ finish(chunk)
136
+ })
137
+ video.socket.once('close', () => finish(undefined))
138
+ video.socket.once('error', () => finish(undefined))
139
+ })
140
+ if (Buffer.isBuffer(firstChunk) && firstChunk.length > 0) {
141
+ return { ok: true, video: video.socket, control: control.socket, firstChunk }
142
+ }
143
+ video.socket.destroy()
144
+ control.socket.destroy()
145
+ lastError = '连接在首包前被关闭'
146
+ await delay(220)
147
+ }
148
+ return { ok: false, error: lastError || '连接 scrcpy server 超时' }
149
+ }
150
+
151
+ export class ScrcpyStream {
152
+ /**
153
+ * @param {object} options
154
+ * @param {import('./adb.js').Adb} options.adb
155
+ * @param {() => object} options.getConfig
156
+ * @param {string} options.pluginRoot
157
+ * @param {object} [options.logger]
158
+ */
159
+ constructor(options) {
160
+ this.adb = options.adb
161
+ this.getConfig = options.getConfig
162
+ this.pluginRoot = options.pluginRoot
163
+ this.logger = options.logger
164
+
165
+ this.clients = new Set()
166
+ this.serverChild = undefined
167
+ this.socket = undefined
168
+ this.controlSocket = undefined
169
+ this.controlSerializer = new ScrcpyControlMessageSerializer(new ScrcpyOptions5_0_1({ control: true }))
170
+ this.controlError = ''
171
+ this.port = 0
172
+ this.scid = 0
173
+ this.serial = ''
174
+ this.running = false
175
+ this.error = ''
176
+ this.width = 0
177
+ this.height = 0
178
+ this.codec = ''
179
+ this.sps = undefined
180
+ this.pps = undefined
181
+ this.buffer = Buffer.alloc(0)
182
+ this.phase = 'codec'
183
+ this.frameCounter = 0
184
+ this.framesSent = 0
185
+ this.bytesSent = 0
186
+ this.starting = undefined
187
+ this.stopping = false
188
+ this.lastStage = 'idle'
189
+ }
190
+
191
+ status() {
192
+ return {
193
+ running: this.running,
194
+ serial: this.serial,
195
+ width: this.width,
196
+ height: this.height,
197
+ codec: this.codec,
198
+ clients: this.clients.size,
199
+ error: this.error || undefined,
200
+ control: this.controlReady(),
201
+ controlError: this.controlError || undefined,
202
+ port: this.port,
203
+ scid: this.scid,
204
+ socketName: this.scid ? scrcpySocketName(this.scid) : '',
205
+ stage: this.lastStage,
206
+ framesSent: this.framesSent,
207
+ bytesSent: this.bytesSent,
208
+ build: 'android-tool-scrcpy-2',
209
+ }
210
+ }
211
+
212
+ /** 当前元信息。 */
213
+ meta() {
214
+ return {
215
+ type: 'meta',
216
+ serial: this.serial,
217
+ codec: this.codec || 'avc1.42e01e',
218
+ width: this.width,
219
+ height: this.height,
220
+ running: this.running,
221
+ }
222
+ }
223
+
224
+ /** 处理面板通过 WebSocket 发来的实时触控消息。 */
225
+ handleControlMessage(message) {
226
+ if (message === null || typeof message !== 'object') return
227
+ if (message.type === 'touch') {
228
+ const action = { down: 0, up: 1, move: 2, cancel: 3 }[String(message.action)]
229
+ if (action === undefined) return
230
+ this.touch(action, Number(message.x), Number(message.y), action === 1 ? 0 : 1)
231
+ } else if (message.type === 'key' && Number.isFinite(Number(message.keyCode))) {
232
+ void this.controlKey(Number(message.keyCode))
233
+ } else if (message.type === 'scroll') {
234
+ this.controlScroll(String(message.direction ?? 'down'), Number(message.amount) || 1)
235
+ }
236
+ }
237
+
238
+ /** 按名字发触摸事件(面板拖拽用)。 */
239
+ controlTouch(action, x, y) {
240
+ const map = { down: AndroidMotionEventAction.Down, up: AndroidMotionEventAction.Up, move: AndroidMotionEventAction.Move, cancel: AndroidMotionEventAction.Cancel }
241
+ const mapped = map[String(action)]
242
+ if (mapped === undefined) return false
243
+ return this.touch(mapped, x, y, mapped === AndroidMotionEventAction.Up ? 0 : 1)
244
+ }
245
+
246
+ /** control 通道是否可用(scrcpy 控制消息比 adb shell input 延迟低很多)。 */
247
+ controlReady() {
248
+ return this.controlSocket !== undefined && this.controlSocket.destroyed !== true
249
+ }
250
+
251
+ /** 写一条 scrcpy 控制消息。 */
252
+ writeControl(bytes) {
253
+ if (this.controlReady() !== true) return false
254
+ try {
255
+ this.controlSocket.write(Buffer.from(bytes))
256
+ return true
257
+ } catch (error) {
258
+ this.controlError = error instanceof Error ? error.message : String(error)
259
+ return false
260
+ }
261
+ }
262
+
263
+ /** 触摸事件(坐标是视频坐标系 0..videoWidth / 0..videoHeight)。 */
264
+ touch(action, x, y, pressure = 1) {
265
+ const width = this.width || 0
266
+ const height = this.height || 0
267
+ if (!(width > 0) || !(height > 0)) return false
268
+ const bytes = this.controlSerializer.injectTouch({
269
+ action,
270
+ pointerId: -2n,
271
+ pointerX: Math.max(0, Math.min(width, Math.round(x))),
272
+ pointerY: Math.max(0, Math.min(height, Math.round(y))),
273
+ videoWidth: width,
274
+ videoHeight: height,
275
+ pressure,
276
+ buttons: 0,
277
+ })
278
+ return this.writeControl(bytes)
279
+ }
280
+
281
+ async controlTap(x, y) {
282
+ if (this.touch(AndroidMotionEventAction.Down, x, y, 1) !== true) return false
283
+ await delay(10)
284
+ return this.touch(AndroidMotionEventAction.Up, x, y, 0)
285
+ }
286
+
287
+ async controlSwipe(x1, y1, x2, y2, durationMs = 250) {
288
+ const duration = Math.max(60, Math.min(2000, Number(durationMs) || 250))
289
+ const steps = Math.max(3, Math.min(40, Math.round(duration / 20)))
290
+ if (this.touch(AndroidMotionEventAction.Down, x1, y1, 1) !== true) return false
291
+ for (let index = 1; index < steps; index += 1) {
292
+ await delay(Math.max(8, Math.round(duration / steps)))
293
+ this.touch(AndroidMotionEventAction.Move, x1 + ((x2 - x1) * index) / steps, y1 + ((y2 - y1) * index) / steps, 1)
294
+ }
295
+ return this.touch(AndroidMotionEventAction.Up, x2, y2, 0)
296
+ }
297
+
298
+ async controlKey(keyCode) {
299
+ if (this.writeControl(this.controlSerializer.injectKeyCode({ action: AndroidKeyEventAction.Down, keyCode, repeat: 0, metaState: 0 })) !== true) return false
300
+ await delay(8)
301
+ return this.writeControl(this.controlSerializer.injectKeyCode({ action: AndroidKeyEventAction.Up, keyCode, repeat: 0, metaState: 0 }))
302
+ }
303
+
304
+ controlText(text) {
305
+ return this.writeControl(this.controlSerializer.injectText(String(text ?? '')))
306
+ }
307
+
308
+ controlScroll(direction, amount = 1) {
309
+ const width = this.width || 0
310
+ const height = this.height || 0
311
+ if (!(width > 0) || !(height > 0)) return false
312
+ const value = Math.max(0.1, Math.min(20, Number(amount) || 1))
313
+ const scrollX = direction === 'left' ? value : direction === 'right' ? -value : 0
314
+ const scrollY = direction === 'up' ? value : direction === 'down' ? -value : 0
315
+ return this.writeControl(
316
+ this.controlSerializer.injectScroll({
317
+ pointerX: Math.round(width / 2),
318
+ pointerY: Math.round(height / 2),
319
+ videoWidth: width,
320
+ videoHeight: height,
321
+ scrollX,
322
+ scrollY,
323
+ }),
324
+ )
325
+ }
326
+
327
+ controlPower(on) {
328
+ return this.writeControl(this.controlSerializer.setDisplayPower(on === true))
329
+ }
330
+
331
+ /** 广播文本。 */
332
+ broadcastText(text) {
333
+ for (const client of this.clients) client.sendText(text)
334
+ }
335
+
336
+ /** 广播二进制帧。 */
337
+ broadcastBinary(payload) {
338
+ for (const client of this.clients) client.sendBinary(payload)
339
+ }
340
+
341
+ /** WebSocket upgrade 入口。 */
342
+ handleUpgrade(req, socket, head) {
343
+ const response = handshakeResponse(req)
344
+ if (response === undefined) {
345
+ try {
346
+ socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
347
+ } catch {
348
+ // 忽略
349
+ }
350
+ return
351
+ }
352
+ try {
353
+ socket.write(response)
354
+ } catch {
355
+ return
356
+ }
357
+ const connection = new WsConnection(socket, req)
358
+ this.clients.add(connection)
359
+ connection.onClose = () => this.clients.delete(connection)
360
+ connection.onMessage = (frame) => {
361
+ if (frame.opcode !== 0x1) return
362
+ try {
363
+ const message = JSON.parse(frame.payload.toString('utf8'))
364
+ this.handleControlMessage(message)
365
+ } catch {
366
+ // 忽略坏消息
367
+ }
368
+ }
369
+ connection.sendText(JSON.stringify(this.meta()))
370
+ if (Buffer.isBuffer(head) && head.length > 0) connection.handleData(head)
371
+ }
372
+
373
+ /**
374
+ * push server。
375
+ *
376
+ * 不能只推一次就缓存:scrcpy.exe(用户点「打开 scrcpy 窗口」)退出时会按
377
+ * 自己的 cleanup 规则删掉 /data/local/tmp/scrcpy-server.jar。缓存会导致下一次
378
+ * 启动 app_process 时 ClassNotFoundException。733KB 的推送只要几毫秒,每次都推。
379
+ */
380
+ async pushServer(serial) {
381
+ const paths = this.adb.resolveScrcpy()
382
+ if (!existsSync(paths.serverPath)) {
383
+ return { ok: false, error: `找不到 scrcpy-server:${paths.serverPath}` }
384
+ }
385
+ const pushed = await this.adb.run(['push', paths.serverPath, REMOTE_SERVER], { serial, timeoutMs: 90000 })
386
+ if (pushed.ok !== true) {
387
+ return { ok: false, error: `推送 scrcpy-server 失败:${pushed.error ?? ''}` }
388
+ }
389
+ return { ok: true }
390
+ }
391
+
392
+ /** 启动推流。 */
393
+ async start(serial) {
394
+ const target = String(serial ?? '').trim()
395
+ if (target.length === 0) return { ok: false, error: '没有选中设备。' }
396
+ if (this.running && this.serial === target) return { ok: true, ...this.status() }
397
+ if (this.starting !== undefined) return this.starting
398
+ this.starting = this.#start(target)
399
+ try {
400
+ return await this.starting
401
+ } finally {
402
+ this.starting = undefined
403
+ }
404
+ }
405
+
406
+ async #start(serial) {
407
+ await this.stop()
408
+ this.stopping = false
409
+ this.error = ''
410
+ this.controlError = ''
411
+
412
+ const adb = this.adb.resolve()
413
+ if (!adb.ok) return { ok: false, error: adb.error }
414
+ this.lastStage = 'push'
415
+ const pushed = await this.pushServer(serial)
416
+ if (pushed.ok !== true) return { ok: false, error: pushed.error }
417
+
418
+ const settings = this.getConfig()
419
+ this.scid = randomInt(1, 0x7fffffff)
420
+ this.port = await getFreePort()
421
+ if (!this.port) return { ok: false, error: '找不到空闲端口给 scrcpy 使用。' }
422
+
423
+ this.lastStage = 'forward'
424
+ const forward = await this.adb.run(['forward', `tcp:${this.port}`, `localabstract:${scrcpySocketName(this.scid)}`], {
425
+ serial,
426
+ timeoutMs: 15000,
427
+ })
428
+ if (forward.ok !== true) {
429
+ return { ok: false, error: `adb forward 失败:${forward.error ?? ''}` }
430
+ }
431
+
432
+ const args = [
433
+ '-s',
434
+ serial,
435
+ 'shell',
436
+ `CLASSPATH=${REMOTE_SERVER}`,
437
+ 'app_process',
438
+ '/',
439
+ 'com.genymobile.scrcpy.Server',
440
+ '5.0.1',
441
+ // server 端 Options.parse 用 Integer.parseInt(value, 0x10) 解析 scid,
442
+ // 即这个值本身按十六进制读;socket 名又是 %08x。因此这里必须传十六进制。
443
+ `scid=${this.scid.toString(16)}`,
444
+ 'log_level=info',
445
+ 'audio=false',
446
+ 'control=true',
447
+ 'cleanup=false',
448
+ 'tunnel_forward=true',
449
+ 'send_device_meta=false',
450
+ 'send_dummy_byte=false',
451
+ `max_size=${settings.videoMaxSize}`,
452
+ `video_bit_rate=${settings.videoBitRate}`,
453
+ `max_fps=${settings.videoFps}`,
454
+ 'video_codec=h264',
455
+ ]
456
+
457
+ this.lastStage = 'spawn'
458
+ let child
459
+ try {
460
+ child = spawn(adb.path, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] })
461
+ } catch (error) {
462
+ await this.removeForward()
463
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
464
+ }
465
+ this.serverChild = child
466
+ let stderr = ''
467
+ child.stderr.on('data', (chunk) => {
468
+ stderr += String(chunk)
469
+ if (stderr.length > 4000) stderr = stderr.slice(-4000)
470
+ })
471
+ child.on('exit', () => {
472
+ if (this.serverChild === child) {
473
+ this.serverChild = undefined
474
+ if (this.running) {
475
+ this.running = false
476
+ this.error = stderr.trim() || 'scrcpy server 已退出'
477
+ this.broadcastText(JSON.stringify({ type: 'status', running: false, error: this.error }))
478
+ }
479
+ }
480
+ })
481
+
482
+ this.lastStage = 'connect'
483
+ const connected = await connectVideoControl(this.port, 12000)
484
+ if (connected.ok !== true) {
485
+ killTree(child)
486
+ this.serverChild = undefined
487
+ await this.removeForward()
488
+ return { ok: false, error: connected.error || stderr.trim() || '连接 scrcpy server 失败' }
489
+ }
490
+
491
+ this.socket = connected.video
492
+ this.controlSocket = connected.control
493
+ // 触摸/按键都是很小的包,Nagle 会攒包导致 40–200ms 的控制延迟。
494
+ try {
495
+ this.socket.setNoDelay(true)
496
+ this.controlSocket.setNoDelay(true)
497
+ } catch {
498
+ // 忽略
499
+ }
500
+ this.controlSocket.on('error', (error) => {
501
+ this.controlError = error instanceof Error ? error.message : String(error)
502
+ })
503
+ this.controlSocket.on('close', () => {
504
+ if (this.controlSocket === undefined) return
505
+ this.controlSocket = undefined
506
+ })
507
+ // 设备可能通过 control socket 回传剪贴板等消息,先读掉,避免缓冲堆积。
508
+ this.controlSocket.on('data', () => {})
509
+ this.serial = serial
510
+ this.running = true
511
+ this.lastStage = 'streaming'
512
+ this.buffer = Buffer.alloc(0)
513
+ this.phase = 'codec'
514
+ this.sps = undefined
515
+ this.pps = undefined
516
+ this.codec = ''
517
+ this.width = 0
518
+ this.height = 0
519
+
520
+ if (Buffer.isBuffer(connected.firstChunk) && connected.firstChunk.length > 0) this.feed(connected.firstChunk)
521
+ this.socket.on('data', (chunk) => this.feed(chunk))
522
+ this.socket.resume()
523
+ this.socket.on('error', (error) => {
524
+ if (this.stopping) return
525
+ this.error = error instanceof Error ? error.message : String(error)
526
+ void this.stop()
527
+ })
528
+ this.socket.on('close', () => {
529
+ if (this.stopping) return
530
+ if (this.running) {
531
+ this.error = this.error || 'scrcpy 视频连接已关闭'
532
+ void this.stop()
533
+ }
534
+ })
535
+
536
+ return { ok: true, ...this.status() }
537
+ }
538
+
539
+ /** 解析输入字节流。 */
540
+ feed(chunk) {
541
+ this.buffer = Buffer.concat([this.buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)])
542
+ let progressed = true
543
+ while (progressed) {
544
+ progressed = false
545
+ if (this.phase === 'codec') {
546
+ if (this.buffer.length < 4) return
547
+ const codec = readCodecId(this.buffer.subarray(0, 4))
548
+ this.buffer = this.buffer.subarray(4)
549
+ if (codec?.name !== 'h264') {
550
+ this.error = `不支持的视频编码:${codec?.name ?? codec?.id ?? '未知'}`
551
+ this.running = false
552
+ this.broadcastText(JSON.stringify({ type: 'status', running: false, error: this.error }))
553
+ return
554
+ }
555
+ this.phase = 'session'
556
+ progressed = true
557
+ continue
558
+ }
559
+ if (this.phase === 'session') {
560
+ if (this.buffer.length < 12) return
561
+ const session = parseSessionPacket(this.buffer.subarray(0, 12))
562
+ if (session === undefined) {
563
+ this.error = 'scrcpy session packet 解析失败'
564
+ this.running = false
565
+ return
566
+ }
567
+ this.buffer = this.buffer.subarray(12)
568
+ this.width = session.width
569
+ this.height = session.height
570
+ this.phase = 'frame'
571
+ this.broadcastText(JSON.stringify(this.meta()))
572
+ progressed = true
573
+ continue
574
+ }
575
+
576
+ // phase === 'frame'
577
+ if (this.buffer.length < 12) return
578
+ // 旋转时会重新插一个 session packet。
579
+ if ((this.buffer[0] & 0x80) !== 0) {
580
+ const session = parseSessionPacket(this.buffer.subarray(0, 12))
581
+ if (session !== undefined) {
582
+ this.buffer = this.buffer.subarray(12)
583
+ this.width = session.width
584
+ this.height = session.height
585
+ this.broadcastText(JSON.stringify(this.meta()))
586
+ progressed = true
587
+ continue
588
+ }
589
+ }
590
+ const header = parseFrameHeader(this.buffer.subarray(0, 12))
591
+ if (header === undefined || header.size <= 0) {
592
+ this.buffer = this.buffer.subarray(1)
593
+ continue
594
+ }
595
+ if (this.buffer.length < 12 + header.size) return
596
+ const payload = this.buffer.subarray(12, 12 + header.size)
597
+ this.buffer = this.buffer.subarray(12 + header.size)
598
+
599
+ if (header.config === true) {
600
+ const nals = splitAnnexB(payload)
601
+ const { sps, pps } = extractSpsPps(nals)
602
+ if (sps !== undefined) this.sps = sps
603
+ if (pps !== undefined) this.pps = pps
604
+ if (this.sps !== undefined && this.codec === '') {
605
+ this.codec = spsToCodecString(this.sps) ?? ''
606
+ this.broadcastText(JSON.stringify(this.meta()))
607
+ }
608
+ progressed = true
609
+ continue
610
+ }
611
+
612
+ if (header.media === true) {
613
+ let data = Buffer.from(payload)
614
+ if (header.key === true && this.sps !== undefined) {
615
+ data = Buffer.concat([joinAnnexB([this.sps, this.pps].filter((item) => item !== undefined)), data])
616
+ }
617
+ this.frameCounter += 1
618
+ const stamps = header.pts > 0 ? header.pts : this.frameCounter * 16666
619
+ const frame = Buffer.allocUnsafe(9 + data.length)
620
+ frame[0] = header.key === true ? 1 : 0
621
+ frame.writeDoubleBE(stamps, 1)
622
+ data.copy(frame, 9)
623
+ this.framesSent += 1
624
+ this.bytesSent += frame.length
625
+ this.broadcastBinary(frame)
626
+ progressed = true
627
+ }
628
+ }
629
+ }
630
+
631
+ /** 移除 forward。 */
632
+ async removeForward() {
633
+ if (this.port === 0) return
634
+ try {
635
+ await this.adb.run(['forward', '--remove', `tcp:${this.port}`], { timeoutMs: 5000 })
636
+ } catch {
637
+ // 忽略
638
+ }
639
+ this.port = 0
640
+ }
641
+
642
+ /** 停止。 */
643
+ async stop() {
644
+ this.stopping = true
645
+ this.running = false
646
+ if (this.socket !== undefined) {
647
+ try {
648
+ this.socket.destroy()
649
+ } catch {
650
+ // 忽略
651
+ }
652
+ this.socket = undefined
653
+ }
654
+ if (this.controlSocket !== undefined) {
655
+ try {
656
+ this.controlSocket.destroy()
657
+ } catch {
658
+ // 忽略
659
+ }
660
+ this.controlSocket = undefined
661
+ }
662
+ if (this.serverChild !== undefined) {
663
+ killTree(this.serverChild)
664
+ this.serverChild = undefined
665
+ }
666
+ await this.removeForward()
667
+ this.width = 0
668
+ this.height = 0
669
+ this.phase = 'codec'
670
+ this.buffer = Buffer.alloc(0)
671
+ this.broadcastText(JSON.stringify({ type: 'status', running: false, ...(this.error ? { error: this.error } : {}) }))
672
+ this.stopping = false
673
+ return { ok: true }
674
+ }
675
+
676
+ dispose() {
677
+ void this.stop()
678
+ for (const client of this.clients) client.close()
679
+ this.clients.clear()
680
+ }
681
+ }