@9b9387/android-stream-scrcpy 0.1.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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +351 -0
  3. package/assets/scrcpy-server-v4.0.jar +0 -0
  4. package/dist/backend/adb/scrcpy-adb-client.d.ts +13 -0
  5. package/dist/backend/adb/scrcpy-adb-client.js +49 -0
  6. package/dist/backend/index.d.ts +6 -0
  7. package/dist/backend/index.js +6 -0
  8. package/dist/backend/io/buffered-stream-reader.d.ts +10 -0
  9. package/dist/backend/io/buffered-stream-reader.js +40 -0
  10. package/dist/backend/scrcpy-backend.d.ts +42 -0
  11. package/dist/backend/scrcpy-backend.js +250 -0
  12. package/dist/backend/server/options.d.ts +26 -0
  13. package/dist/backend/server/options.js +31 -0
  14. package/dist/backend/server/server-command.d.ts +3 -0
  15. package/dist/backend/server/server-command.js +24 -0
  16. package/dist/backend/server/socket-name.d.ts +2 -0
  17. package/dist/backend/server/socket-name.js +4 -0
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.js +4 -0
  20. package/dist/protocol/core/binary.d.ts +5 -0
  21. package/dist/protocol/core/binary.js +23 -0
  22. package/dist/protocol/core/types.d.ts +15 -0
  23. package/dist/protocol/core/types.js +1 -0
  24. package/dist/protocol/index.d.ts +3 -0
  25. package/dist/protocol/index.js +3 -0
  26. package/dist/protocol/registry.d.ts +3 -0
  27. package/dist/protocol/registry.js +16 -0
  28. package/dist/protocol/v4_0/codecs.d.ts +14 -0
  29. package/dist/protocol/v4_0/codecs.js +22 -0
  30. package/dist/protocol/v4_0/control-message-types.d.ts +115 -0
  31. package/dist/protocol/v4_0/control-message-types.js +47 -0
  32. package/dist/protocol/v4_0/control-message.d.ts +2 -0
  33. package/dist/protocol/v4_0/control-message.js +149 -0
  34. package/dist/protocol/v4_0/device-message.d.ts +17 -0
  35. package/dist/protocol/v4_0/device-message.js +33 -0
  36. package/dist/protocol/v4_0/frame-header.d.ts +19 -0
  37. package/dist/protocol/v4_0/frame-header.js +29 -0
  38. package/dist/protocol/v4_0/index.d.ts +9 -0
  39. package/dist/protocol/v4_0/index.js +21 -0
  40. package/dist/protocol/v4_0/server-options.d.ts +19 -0
  41. package/dist/protocol/v4_0/server-options.js +38 -0
  42. package/dist/protocol/v4_0/string-payload.d.ts +2 -0
  43. package/dist/protocol/v4_0/string-payload.js +23 -0
  44. package/dist/service/index.d.ts +3 -0
  45. package/dist/service/index.js +3 -0
  46. package/dist/service/packet-queue-subscriber.d.ts +11 -0
  47. package/dist/service/packet-queue-subscriber.js +56 -0
  48. package/dist/service/scrcpy-stream-service.d.ts +26 -0
  49. package/dist/service/scrcpy-stream-service.js +178 -0
  50. package/dist/service/types.d.ts +25 -0
  51. package/dist/service/types.js +14 -0
  52. package/dist/snapshot/ffmpeg-snapshot-cache.d.ts +34 -0
  53. package/dist/snapshot/ffmpeg-snapshot-cache.js +195 -0
  54. package/dist/snapshot/index.d.ts +2 -0
  55. package/dist/snapshot/index.js +2 -0
  56. package/dist/snapshot/types.d.ts +15 -0
  57. package/dist/snapshot/types.js +1 -0
  58. package/dist/websocket/binary-packet.d.ts +2 -0
  59. package/dist/websocket/binary-packet.js +33 -0
  60. package/dist/websocket/control-json.d.ts +2 -0
  61. package/dist/websocket/control-json.js +129 -0
  62. package/dist/websocket/index.d.ts +4 -0
  63. package/dist/websocket/index.js +4 -0
  64. package/dist/websocket/scrcpy-websocket-bridge.d.ts +10 -0
  65. package/dist/websocket/scrcpy-websocket-bridge.js +75 -0
  66. package/dist/websocket/types.d.ts +9 -0
  67. package/dist/websocket/types.js +4 -0
  68. package/package.json +90 -0
@@ -0,0 +1,14 @@
1
+ export var StreamState;
2
+ (function (StreamState) {
3
+ StreamState["STOPPED"] = "stopped";
4
+ StreamState["STARTING"] = "starting";
5
+ StreamState["RUNNING"] = "running";
6
+ StreamState["STOPPING"] = "stopping";
7
+ StreamState["ERROR"] = "error";
8
+ })(StreamState || (StreamState = {}));
9
+ export var MediaKind;
10
+ (function (MediaKind) {
11
+ MediaKind["VIDEO"] = "video";
12
+ MediaKind["AUDIO"] = "audio";
13
+ MediaKind["SESSION"] = "session";
14
+ })(MediaKind || (MediaKind = {}));
@@ -0,0 +1,34 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { ScrcpyStreamService } from "../service/index.js";
3
+ import { Snapshot, SnapshotCacheOptions } from "./types.js";
4
+ export interface JpegExtractionResult {
5
+ frames: Buffer<ArrayBufferLike>[];
6
+ remainder: Buffer<ArrayBufferLike>;
7
+ }
8
+ export declare function extractJpegFrames(buffer: Buffer<ArrayBufferLike>): JpegExtractionResult;
9
+ export declare class FfmpegSnapshotCache extends EventEmitter {
10
+ private readonly service;
11
+ private readonly enabled;
12
+ private readonly fps;
13
+ private readonly quality;
14
+ private readonly ffmpegPath;
15
+ private readonly spawnProcess;
16
+ private process;
17
+ private subscription;
18
+ private stdoutBuffer;
19
+ private stderrTail;
20
+ private currentSnapshot;
21
+ private running;
22
+ constructor(service: ScrcpyStreamService, options?: SnapshotCacheOptions);
23
+ start(): void;
24
+ stop(): void;
25
+ latest(): Snapshot | null;
26
+ private buildFfmpegArgs;
27
+ private pumpVideoPackets;
28
+ private writePacket;
29
+ private handleStdout;
30
+ private handleStderr;
31
+ private handleProcessError;
32
+ private cleanup;
33
+ private formatStderrTail;
34
+ }
@@ -0,0 +1,195 @@
1
+ import { spawn } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import { once } from "node:events";
4
+ import { MediaKind, } from "../service/index.js";
5
+ import { VideoCodec } from "../protocol/index.js";
6
+ const JPEG_SOI = Buffer.from([0xff, 0xd8]);
7
+ const JPEG_EOI = Buffer.from([0xff, 0xd9]);
8
+ const DEFAULT_SNAPSHOT_FPS = 2;
9
+ const DEFAULT_JPEG_QUALITY = 85;
10
+ export function extractJpegFrames(buffer) {
11
+ const frames = [];
12
+ let offset = 0;
13
+ while (offset < buffer.length) {
14
+ const start = buffer.indexOf(JPEG_SOI, offset);
15
+ if (start === -1) {
16
+ const keep = buffer[buffer.length - 1] === 0xff
17
+ ? buffer.subarray(-1)
18
+ : Buffer.alloc(0);
19
+ return { frames, remainder: keep };
20
+ }
21
+ const end = buffer.indexOf(JPEG_EOI, start + JPEG_SOI.length);
22
+ if (end === -1) {
23
+ return { frames, remainder: buffer.subarray(start) };
24
+ }
25
+ const frameEnd = end + JPEG_EOI.length;
26
+ frames.push(Buffer.from(buffer.subarray(start, frameEnd)));
27
+ offset = frameEnd;
28
+ }
29
+ return { frames, remainder: Buffer.alloc(0) };
30
+ }
31
+ export class FfmpegSnapshotCache extends EventEmitter {
32
+ service;
33
+ enabled;
34
+ fps;
35
+ quality;
36
+ ffmpegPath;
37
+ spawnProcess;
38
+ process = null;
39
+ subscription = null;
40
+ stdoutBuffer = Buffer.alloc(0);
41
+ stderrTail = "";
42
+ currentSnapshot = null;
43
+ running = false;
44
+ constructor(service, options = {}) {
45
+ super();
46
+ this.service = service;
47
+ this.enabled = options.enabled !== false;
48
+ this.fps = normalizeFps(options.fps ?? DEFAULT_SNAPSHOT_FPS);
49
+ this.quality = normalizeJpegQuality(options.quality ?? DEFAULT_JPEG_QUALITY);
50
+ this.ffmpegPath = options.ffmpegPath ?? "ffmpeg";
51
+ this.spawnProcess =
52
+ options.spawnProcess ??
53
+ ((command, args) => spawn(command, args, {
54
+ stdio: ["pipe", "pipe", "pipe"],
55
+ }));
56
+ }
57
+ start() {
58
+ if (!this.enabled || this.running)
59
+ return;
60
+ const videoCodec = this.service.currentMeta?.videoCodec;
61
+ if (videoCodec && videoCodec !== VideoCodec.H264) {
62
+ throw new Error(`snapshot cache only supports h264 video, got ${videoCodec}`);
63
+ }
64
+ this.running = true;
65
+ this.process = this.spawnProcess(this.ffmpegPath, this.buildFfmpegArgs());
66
+ this.process.stdout.on("data", (chunk) => this.handleStdout(chunk));
67
+ this.process.stderr.on("data", (chunk) => {
68
+ this.handleStderr(chunk);
69
+ });
70
+ this.process.once("error", (e) => this.handleProcessError(e));
71
+ this.process.once("exit", (code, signal) => {
72
+ if (this.running) {
73
+ this.cleanup({ clearSnapshot: true, killProcess: false });
74
+ this.emit("error", new Error(`ffmpeg exited unexpectedly: code=${code} signal=${signal}${this.formatStderrTail()}`));
75
+ }
76
+ });
77
+ this.subscription = this.service.subscribe();
78
+ void this.pumpVideoPackets();
79
+ }
80
+ stop() {
81
+ this.cleanup({ clearSnapshot: false, killProcess: true });
82
+ }
83
+ latest() {
84
+ return this.currentSnapshot;
85
+ }
86
+ buildFfmpegArgs() {
87
+ return [
88
+ "-hide_banner",
89
+ "-loglevel",
90
+ "error",
91
+ "-f",
92
+ "h264",
93
+ "-i",
94
+ "pipe:0",
95
+ "-vf",
96
+ [
97
+ "setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709",
98
+ `fps=${this.fps}`,
99
+ "format=yuvj420p",
100
+ ].join(","),
101
+ "-q:v",
102
+ String(jpegQualityToMjpegQscale(this.quality)),
103
+ "-f",
104
+ "image2pipe",
105
+ "-vcodec",
106
+ "mjpeg",
107
+ "pipe:1",
108
+ ];
109
+ }
110
+ async pumpVideoPackets() {
111
+ const subscription = this.subscription;
112
+ const proc = this.process;
113
+ if (!subscription || !proc)
114
+ return;
115
+ try {
116
+ for await (const packet of subscription) {
117
+ if (!this.running)
118
+ break;
119
+ if (packet.kind !== MediaKind.VIDEO ||
120
+ packet.payload.byteLength === 0) {
121
+ continue;
122
+ }
123
+ await this.writePacket(proc, packet);
124
+ }
125
+ }
126
+ catch (e) {
127
+ if (this.running)
128
+ this.emit("error", e);
129
+ }
130
+ }
131
+ async writePacket(proc, packet) {
132
+ if (proc.stdin.write(packet.payload))
133
+ return;
134
+ await once(proc.stdin, "drain");
135
+ }
136
+ handleStdout(chunk) {
137
+ this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, chunk]);
138
+ const result = extractJpegFrames(this.stdoutBuffer);
139
+ this.stdoutBuffer = result.remainder;
140
+ for (const frame of result.frames) {
141
+ this.currentSnapshot = {
142
+ contentType: "image/jpeg",
143
+ data: frame,
144
+ timestampMs: Date.now(),
145
+ };
146
+ this.emit("snapshot", this.currentSnapshot);
147
+ }
148
+ }
149
+ handleStderr(chunk) {
150
+ const text = chunk.toString("utf8");
151
+ this.stderrTail = (this.stderrTail + text).slice(-4000);
152
+ this.emit("ffmpegLog", text);
153
+ }
154
+ handleProcessError(e) {
155
+ if (!this.running)
156
+ return;
157
+ this.cleanup({ clearSnapshot: true, killProcess: false });
158
+ this.emit("error", e);
159
+ }
160
+ cleanup(options) {
161
+ this.running = false;
162
+ this.subscription?.return?.();
163
+ this.subscription = null;
164
+ const proc = this.process;
165
+ this.process = null;
166
+ if (proc) {
167
+ proc.stdin.end();
168
+ if (options.killProcess)
169
+ proc.kill();
170
+ }
171
+ this.stdoutBuffer = Buffer.alloc(0);
172
+ this.stderrTail = "";
173
+ if (options.clearSnapshot)
174
+ this.currentSnapshot = null;
175
+ }
176
+ formatStderrTail() {
177
+ const tail = this.stderrTail.trim();
178
+ return tail ? ` stderr=${tail}` : "";
179
+ }
180
+ }
181
+ function normalizeFps(value) {
182
+ if (!Number.isFinite(value) || value <= 0 || value > 60) {
183
+ throw new Error(`snapshot fps must be a finite number between 0 and 60`);
184
+ }
185
+ return value;
186
+ }
187
+ function normalizeJpegQuality(value) {
188
+ if (!Number.isFinite(value) || value < 1 || value > 100) {
189
+ throw new Error(`snapshot JPEG quality must be a finite number from 1 to 100`);
190
+ }
191
+ return Math.round(value);
192
+ }
193
+ function jpegQualityToMjpegQscale(quality) {
194
+ return Math.max(2, Math.min(31, Math.round(31 - (quality / 100) * 29)));
195
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./ffmpeg-snapshot-cache.js";
2
+ export * from "./types.js";
@@ -0,0 +1,2 @@
1
+ export * from "./ffmpeg-snapshot-cache.js";
2
+ export * from "./types.js";
@@ -0,0 +1,15 @@
1
+ import { ChildProcessWithoutNullStreams } from "node:child_process";
2
+ export interface Snapshot {
3
+ contentType: "image/jpeg";
4
+ data: Buffer<ArrayBufferLike>;
5
+ timestampMs: number;
6
+ }
7
+ export interface SnapshotCacheOptions {
8
+ enabled?: boolean;
9
+ fps?: number;
10
+ quality?: number;
11
+ ffmpegPath?: string;
12
+ spawnProcess?: FfmpegProcessFactory;
13
+ }
14
+ export type FfmpegProcess = Pick<ChildProcessWithoutNullStreams, "stdin" | "stdout" | "stderr" | "kill" | "on" | "once">;
15
+ export type FfmpegProcessFactory = (command: string, args: string[]) => FfmpegProcess;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import { MediaPacket } from "../service/types.js";
2
+ export declare function serializeMediaPacket(packet: MediaPacket): Uint8Array;
@@ -0,0 +1,33 @@
1
+ import { MediaKind } from "../service/types.js";
2
+ import { WEBSOCKET_HEADER_SIZE, WEBSOCKET_KIND_AUDIO, WEBSOCKET_KIND_SESSION, WEBSOCKET_KIND_VIDEO, } from "./types.js";
3
+ export function serializeMediaPacket(packet) {
4
+ let kind = WEBSOCKET_KIND_VIDEO;
5
+ let flags = 0;
6
+ let pts = packet.ptsUs;
7
+ let size = packet.payload.length;
8
+ let payload = packet.payload;
9
+ if (packet.config)
10
+ flags |= 0x01;
11
+ if (packet.keyFrame)
12
+ flags |= 0x02;
13
+ if (packet.kind === MediaKind.AUDIO) {
14
+ kind = WEBSOCKET_KIND_AUDIO;
15
+ }
16
+ else if (packet.kind === MediaKind.SESSION) {
17
+ kind = WEBSOCKET_KIND_SESSION;
18
+ size = 0;
19
+ payload = new Uint8Array(0);
20
+ // The browser bridge uses the 64-bit timestamp slot for session dimensions:
21
+ // high 32 bits = width, low 32 bits = height.
22
+ pts = (BigInt(packet.width ?? 0) << 32n) | BigInt(packet.height ?? 0);
23
+ }
24
+ const buf = new Uint8Array(WEBSOCKET_HEADER_SIZE + payload.byteLength);
25
+ const view = new DataView(buf.buffer);
26
+ view.setUint8(0, kind);
27
+ view.setUint8(1, flags);
28
+ view.setUint16(2, 0);
29
+ view.setUint32(4, size);
30
+ view.setBigUint64(8, pts);
31
+ buf.set(payload, WEBSOCKET_HEADER_SIZE);
32
+ return buf;
33
+ }
@@ -0,0 +1,2 @@
1
+ import { ControlMessage } from "../protocol/index.js";
2
+ export declare function parseControlJson(payload: any): ControlMessage | null;
@@ -0,0 +1,129 @@
1
+ import { ACTION_DOWN, ACTION_MOVE, ACTION_UP, BUTTON_PRIMARY, ControlMessageType, KEY_ACTION_DOWN, KEY_ACTION_UP, POINTER_ID_GENERIC_FINGER, POINTER_ID_MOUSE, } from "../protocol/index.js";
2
+ export function parseControlJson(payload) {
3
+ const type = (payload.type || "").toLowerCase();
4
+ switch (type) {
5
+ case "touch": {
6
+ const action = parseTouchAction(payload.action);
7
+ return {
8
+ type: ControlMessageType.INJECT_TOUCH_EVENT,
9
+ action,
10
+ pointerId: parsePointerId(payload.pointerId),
11
+ x: payload.x,
12
+ y: payload.y,
13
+ screenWidth: payload.screenWidth,
14
+ screenHeight: payload.screenHeight,
15
+ pressure: payload.pressure ?? (action !== ACTION_UP ? 1.0 : 0.0),
16
+ actionButton: payload.actionButton ??
17
+ (action === ACTION_DOWN || action === ACTION_UP ? BUTTON_PRIMARY : 0),
18
+ buttons: payload.buttons ?? (action !== ACTION_UP ? BUTTON_PRIMARY : 0),
19
+ };
20
+ }
21
+ case "scroll":
22
+ return {
23
+ type: ControlMessageType.INJECT_SCROLL_EVENT,
24
+ x: payload.x,
25
+ y: payload.y,
26
+ screenWidth: payload.screenWidth,
27
+ screenHeight: payload.screenHeight,
28
+ hScroll: payload.hScroll ?? 0.0,
29
+ vScroll: payload.vScroll ?? 0.0,
30
+ buttons: payload.buttons ?? 0,
31
+ };
32
+ case "key":
33
+ return {
34
+ type: ControlMessageType.INJECT_KEYCODE,
35
+ action: parseKeyAction(payload.action),
36
+ keycode: payload.keycode,
37
+ repeat: payload.repeat ?? 0,
38
+ metaState: payload.metaState ?? 0,
39
+ };
40
+ case "text":
41
+ return { type: ControlMessageType.INJECT_TEXT, text: payload.text ?? "" };
42
+ case "back":
43
+ return {
44
+ type: ControlMessageType.BACK_OR_SCREEN_ON,
45
+ action: parseKeyAction(payload.action),
46
+ };
47
+ case "home":
48
+ return {
49
+ type: ControlMessageType.INJECT_KEYCODE,
50
+ action: parseKeyAction(payload.action),
51
+ keycode: 3,
52
+ };
53
+ case "app_switch":
54
+ return {
55
+ type: ControlMessageType.INJECT_KEYCODE,
56
+ action: parseKeyAction(payload.action),
57
+ keycode: 187,
58
+ };
59
+ case "power":
60
+ return {
61
+ type: ControlMessageType.INJECT_KEYCODE,
62
+ action: parseKeyAction(payload.action),
63
+ keycode: 26,
64
+ };
65
+ case "expand_notifications":
66
+ return { type: ControlMessageType.EXPAND_NOTIFICATION_PANEL };
67
+ case "expand_settings":
68
+ return { type: ControlMessageType.EXPAND_SETTINGS_PANEL };
69
+ case "collapse_panels":
70
+ return { type: ControlMessageType.COLLAPSE_PANELS };
71
+ case "rotate":
72
+ return { type: ControlMessageType.ROTATE_DEVICE };
73
+ case "reset_video":
74
+ return { type: ControlMessageType.RESET_VIDEO };
75
+ case "set_clipboard":
76
+ return {
77
+ type: ControlMessageType.SET_CLIPBOARD,
78
+ sequence: BigInt(payload.sequence ?? 0),
79
+ text: payload.text ?? "",
80
+ paste: !!payload.paste,
81
+ };
82
+ case "get_clipboard":
83
+ return {
84
+ type: ControlMessageType.GET_CLIPBOARD,
85
+ copyKey: payload.copyKey ?? 0,
86
+ };
87
+ case "set_display_power":
88
+ return { type: ControlMessageType.SET_DISPLAY_POWER, on: !!payload.on };
89
+ case "start_app":
90
+ return { type: ControlMessageType.START_APP, name: payload.name ?? "" };
91
+ default:
92
+ return null;
93
+ }
94
+ }
95
+ function parseTouchAction(value) {
96
+ if (typeof value === "number")
97
+ return value;
98
+ const text = String(value).toLowerCase();
99
+ if (text === "down" || text === "pointer_down")
100
+ return ACTION_DOWN;
101
+ if (text === "up" || text === "pointer_up")
102
+ return ACTION_UP;
103
+ if (text === "move")
104
+ return ACTION_MOVE;
105
+ throw new Error(`unknown touch action: ${value}`);
106
+ }
107
+ function parseKeyAction(value) {
108
+ if (typeof value === "number")
109
+ return value;
110
+ const text = String(value).toLowerCase();
111
+ if (text === "down")
112
+ return KEY_ACTION_DOWN;
113
+ if (text === "up")
114
+ return KEY_ACTION_UP;
115
+ throw new Error(`unknown key action: ${value}`);
116
+ }
117
+ function parsePointerId(value) {
118
+ if (value === null || value === undefined)
119
+ return POINTER_ID_GENERIC_FINGER;
120
+ if (typeof value === "string") {
121
+ const text = value.toLowerCase();
122
+ if (text === "mouse")
123
+ return POINTER_ID_MOUSE;
124
+ if (text === "finger")
125
+ return POINTER_ID_GENERIC_FINGER;
126
+ return BigInt(text);
127
+ }
128
+ return BigInt(value);
129
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./scrcpy-websocket-bridge.js";
2
+ export * from "./binary-packet.js";
3
+ export * from "./control-json.js";
4
+ export * from "./types.js";
@@ -0,0 +1,4 @@
1
+ export * from "./scrcpy-websocket-bridge.js";
2
+ export * from "./binary-packet.js";
3
+ export * from "./control-json.js";
4
+ export * from "./types.js";
@@ -0,0 +1,10 @@
1
+ import { ScrcpyStreamService } from "../service/scrcpy-stream-service.js";
2
+ import { WebSocketBridgeOptions } from "./types.js";
3
+ export declare class ScrcpyWebSocketBridge {
4
+ private service;
5
+ private wss;
6
+ constructor(service: ScrcpyStreamService, options?: WebSocketBridgeOptions);
7
+ close(): void;
8
+ private handleConnection;
9
+ private createInitMessage;
10
+ }
@@ -0,0 +1,75 @@
1
+ import { WebSocket, WebSocketServer } from "ws";
2
+ import { serializeMediaPacket } from "./binary-packet.js";
3
+ import { parseControlJson } from "./control-json.js";
4
+ import { WEBSOCKET_HEADER_SIZE, WEBSOCKET_KIND_AUDIO, WEBSOCKET_KIND_SESSION, WEBSOCKET_KIND_VIDEO, } from "./types.js";
5
+ export class ScrcpyWebSocketBridge {
6
+ service;
7
+ wss;
8
+ constructor(service, options = {}) {
9
+ this.service = service;
10
+ this.wss = new WebSocketServer({
11
+ port: options.port,
12
+ server: options.server,
13
+ path: options.path || "/ws/scrcpy",
14
+ });
15
+ this.wss.on("connection", (ws) => this.handleConnection(ws));
16
+ }
17
+ close() {
18
+ this.wss.close();
19
+ }
20
+ async handleConnection(ws) {
21
+ ws.send(JSON.stringify(this.createInitMessage()));
22
+ const subscription = this.service.subscribe();
23
+ ws.on("message", (data) => {
24
+ try {
25
+ const payload = JSON.parse(data.toString());
26
+ const msg = parseControlJson(payload);
27
+ if (msg)
28
+ this.service.sendControlMessage(msg);
29
+ }
30
+ catch (e) {
31
+ ws.send(JSON.stringify({ type: "error", message: e.message }));
32
+ }
33
+ });
34
+ ws.on("close", () => {
35
+ subscription.return?.();
36
+ });
37
+ try {
38
+ for await (const packet of subscription) {
39
+ if (ws.readyState !== WebSocket.OPEN)
40
+ break;
41
+ ws.send(serializeMediaPacket(packet));
42
+ }
43
+ }
44
+ catch {
45
+ ws.close();
46
+ }
47
+ }
48
+ createInitMessage() {
49
+ const meta = this.service.currentMeta;
50
+ return {
51
+ type: "init",
52
+ state: this.service.currentState,
53
+ device: {
54
+ name: meta?.deviceName ?? null,
55
+ width: meta?.width ?? null,
56
+ height: meta?.height ?? null,
57
+ video_codec: meta?.videoCodec ?? null,
58
+ audio_codec: meta?.audioCodec ?? null,
59
+ },
60
+ binary_header: {
61
+ size: WEBSOCKET_HEADER_SIZE,
62
+ kinds: {
63
+ video: WEBSOCKET_KIND_VIDEO,
64
+ audio: WEBSOCKET_KIND_AUDIO,
65
+ session: WEBSOCKET_KIND_SESSION,
66
+ },
67
+ flags: {
68
+ config: 0x01,
69
+ key_frame: 0x02,
70
+ client_resized: 0x04,
71
+ },
72
+ },
73
+ };
74
+ }
75
+ }
@@ -0,0 +1,9 @@
1
+ export interface WebSocketBridgeOptions {
2
+ port?: number;
3
+ server?: any;
4
+ path?: string;
5
+ }
6
+ export declare const WEBSOCKET_HEADER_SIZE = 16;
7
+ export declare const WEBSOCKET_KIND_VIDEO = 1;
8
+ export declare const WEBSOCKET_KIND_AUDIO = 2;
9
+ export declare const WEBSOCKET_KIND_SESSION = 3;
@@ -0,0 +1,4 @@
1
+ export const WEBSOCKET_HEADER_SIZE = 16;
2
+ export const WEBSOCKET_KIND_VIDEO = 1;
3
+ export const WEBSOCKET_KIND_AUDIO = 2;
4
+ export const WEBSOCKET_KIND_SESSION = 3;
package/package.json ADDED
@@ -0,0 +1,90 @@
1
+ {
2
+ "name": "@9b9387/android-stream-scrcpy",
3
+ "version": "0.1.0",
4
+ "description": "Versioned scrcpy protocol client for Node.js/Electron",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ },
13
+ "./protocol": {
14
+ "import": "./dist/protocol/index.js",
15
+ "types": "./dist/protocol/index.d.ts"
16
+ },
17
+ "./websocket": {
18
+ "import": "./dist/websocket/index.js",
19
+ "types": "./dist/websocket/index.d.ts"
20
+ },
21
+ "./snapshot": {
22
+ "import": "./dist/snapshot/index.js",
23
+ "types": "./dist/snapshot/index.d.ts"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "assets",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "scripts": {
37
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
38
+ "build": "npm run clean && tsc",
39
+ "typecheck": "tsc --noEmit",
40
+ "test": "vitest run",
41
+ "lint": "prettier --check .",
42
+ "format": "prettier --write .",
43
+ "prepack": "npm run build",
44
+ "prepublishOnly": "npm run typecheck && npm run test && npm run lint && npm run build",
45
+ "example:demo": "npm run build && node --loader ts-node/esm examples/demo.ts",
46
+ "example:server": "npm run build && node --loader ts-node/esm examples/server.ts"
47
+ },
48
+ "keywords": [
49
+ "scrcpy",
50
+ "android",
51
+ "adb",
52
+ "streaming",
53
+ "electron",
54
+ "nextjs",
55
+ "typescript"
56
+ ],
57
+ "license": "MIT",
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+ssh://git@github.com/9b9387/android-stream.git",
61
+ "directory": "node-lib"
62
+ },
63
+ "bugs": {
64
+ "url": "https://github.com/9b9387/android-stream/issues"
65
+ },
66
+ "homepage": "https://github.com/9b9387/android-stream/tree/main/node-lib#readme",
67
+ "publishConfig": {
68
+ "access": "public"
69
+ },
70
+ "dependencies": {
71
+ "@devicefarmer/adbkit": "^3.2.3"
72
+ },
73
+ "peerDependencies": {
74
+ "ws": "^8.16.0"
75
+ },
76
+ "peerDependenciesMeta": {
77
+ "ws": {
78
+ "optional": true
79
+ }
80
+ },
81
+ "devDependencies": {
82
+ "@types/node": "^20.11.0",
83
+ "@types/ws": "^8.18.1",
84
+ "prettier": "^3.8.3",
85
+ "ts-node": "^10.9.2",
86
+ "typescript": "^5.9.3",
87
+ "vitest": "^4.1.6",
88
+ "ws": "^8.20.1"
89
+ }
90
+ }