@yume-chan/scrcpy
Version:
TypeScript implementation of Scrcpy.
340 lines (300 loc) • 10.7 kB
text/typescript
import { getUint32BigEndian } from "@yume-chan/no-data-view";
import type { ReadableStream } from "@yume-chan/stream-extra";
import {
BufferedReadableStream,
PushReadableStream,
} from "@yume-chan/stream-extra";
import type { ValueOrPromise } from "@yume-chan/struct";
import Struct, { placeholder } from "@yume-chan/struct";
import type {
AndroidMotionEventAction,
ScrcpyInjectTouchControlMessage,
} from "../control/index.js";
import {
CodecOptions,
ScrcpyOptions1_16,
ScrcpyUnsignedFloatFieldDefinition,
} from "./1_16/index.js";
import { ScrcpyOptions1_21 } from "./1_21.js";
import type { ScrcpyOptionsInit1_24 } from "./1_24.js";
import { ScrcpyOptions1_24 } from "./1_24.js";
import { ScrcpyOptions1_25 } from "./1_25/index.js";
import type { ScrcpyAudioStreamMetadata, ScrcpyVideoStream } from "./codec.js";
import { ScrcpyAudioCodec, ScrcpyVideoCodecId } from "./codec.js";
import type {
ScrcpyDisplay,
ScrcpyEncoder,
ScrcpyOptionValue,
} from "./types.js";
import { ScrcpyOptions } from "./types.js";
export const ScrcpyInjectTouchControlMessage2_0 = new Struct()
.uint8("type")
.uint8("action", placeholder<AndroidMotionEventAction>())
.uint64("pointerId")
.uint32("pointerX")
.uint32("pointerY")
.uint16("screenWidth")
.uint16("screenHeight")
.field("pressure", ScrcpyUnsignedFloatFieldDefinition)
.uint32("actionButton")
.uint32("buttons");
export type ScrcpyInjectTouchControlMessage2_0 =
(typeof ScrcpyInjectTouchControlMessage2_0)["TInit"];
export class ScrcpyInstanceId implements ScrcpyOptionValue {
static readonly NONE = new ScrcpyInstanceId(-1);
static random(): ScrcpyInstanceId {
// A random 31-bit unsigned integer
return new ScrcpyInstanceId((Math.random() * 0x80000000) | 0);
}
value: number;
constructor(value: number) {
this.value = value;
}
toOptionValue(): string | undefined {
if (this.value < 0) {
return undefined;
}
return this.value.toString(16);
}
}
export interface ScrcpyOptionsInit2_0
extends Omit<
ScrcpyOptionsInit1_24,
"bitRate" | "codecOptions" | "encoderName"
> {
scid?: ScrcpyInstanceId;
videoCodec?: "h264" | "h265" | "av1";
videoBitRate?: number;
videoCodecOptions?: CodecOptions;
videoEncoder?: string | undefined;
audio?: boolean;
audioCodec?: "raw" | "opus" | "aac";
audioBitRate?: number;
audioCodecOptions?: CodecOptions;
audioEncoder?: string | undefined;
listEncoders?: boolean;
listDisplays?: boolean;
sendCodecMeta?: boolean;
}
export function omit<T extends object, K extends keyof T>(
obj: T,
keys: K[],
): Omit<T, K> {
const result: Record<PropertyKey, unknown> = {};
for (const key in obj) {
if (!keys.includes(key as keyof T as K)) {
result[key] = obj[key];
}
}
return result as Omit<T, K>;
}
export class ScrcpyOptions2_0 extends ScrcpyOptions<ScrcpyOptionsInit2_0> {
static async parseAudioMetadata(
stream: ReadableStream<Uint8Array>,
sendCodecMeta: boolean,
mapMetadata: (value: number) => ScrcpyAudioCodec,
getOptionCodec: () => ScrcpyAudioCodec,
): Promise<ScrcpyAudioStreamMetadata> {
const buffered = new BufferedReadableStream(stream);
const buffer = await buffered.readExactly(4);
// Treat it as a 32-bit number for simpler comparisons
const codecMetadataValue = getUint32BigEndian(buffer, 0);
// Server will send `0x00_00_00_00` and `0x00_00_00_01` even if `sendCodecMeta` is false
switch (codecMetadataValue) {
case 0x00_00_00_00:
return {
type: "disabled",
};
case 0x00_00_00_01:
return {
type: "errored",
};
}
if (sendCodecMeta) {
return {
type: "success",
codec: mapMetadata(codecMetadataValue),
stream: buffered.release(),
};
}
return {
type: "success",
// Infer codec from `audioCodec` option
codec: getOptionCodec(),
stream: new PushReadableStream<Uint8Array>(async (controller) => {
// Put the first 4 bytes back
await controller.enqueue(buffer);
const stream = buffered.release();
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
await controller.enqueue(value);
}
}),
};
}
static readonly DEFAULTS = {
...omit(ScrcpyOptions1_24.DEFAULTS, [
"bitRate",
"codecOptions",
"encoderName",
]),
scid: ScrcpyInstanceId.NONE,
videoCodec: "h264",
videoBitRate: 8000000,
videoCodecOptions: new CodecOptions(),
videoEncoder: undefined,
audio: true,
audioCodec: "opus",
audioBitRate: 128000,
audioCodecOptions: new CodecOptions(),
audioEncoder: undefined,
listEncoders: false,
listDisplays: false,
sendCodecMeta: true,
} as const satisfies Required<ScrcpyOptionsInit2_0>;
override get defaults(): Required<ScrcpyOptionsInit2_0> {
return ScrcpyOptions2_0.DEFAULTS;
}
constructor(init: ScrcpyOptionsInit2_0) {
super(ScrcpyOptions1_25, init, ScrcpyOptions2_0.DEFAULTS);
}
override serialize(): string[] {
return ScrcpyOptions1_21.serialize(this.value, this.defaults);
}
override setListEncoders(): void {
this.value.listEncoders = true;
}
override setListDisplays(): void {
this.value.listDisplays = true;
}
override parseEncoder(line: string): ScrcpyEncoder | undefined {
let match = line.match(
/^\s+--video-codec=(\S+)\s+--video-encoder='([^']+)'$/,
);
if (match) {
return {
type: "video",
codec: match[1]!,
name: match[2]!,
};
}
match = line.match(
/^\s+--audio-codec=(\S+)\s+--audio-encoder='([^']+)'$/,
);
if (match) {
return {
type: "audio",
codec: match[1]!,
name: match[2]!,
};
}
return undefined;
}
override parseDisplay(line: string): ScrcpyDisplay | undefined {
const match = line.match(/^\s+--display=(\d+)\s+\(([^)]+)\)$/);
if (match) {
const display: ScrcpyDisplay = {
id: Number.parseInt(match[1]!, 10),
};
if (match[2] !== "size unknown") {
display.resolution = match[2]!;
}
return display;
}
return undefined;
}
override parseVideoStreamMetadata(
stream: ReadableStream<Uint8Array>,
): ValueOrPromise<ScrcpyVideoStream> {
const { sendDeviceMeta, sendCodecMeta } = this.value;
if (!sendDeviceMeta && !sendCodecMeta) {
let codec: ScrcpyVideoCodecId;
switch (this.value.videoCodec) {
case "h264":
codec = ScrcpyVideoCodecId.H264;
break;
case "h265":
codec = ScrcpyVideoCodecId.H265;
break;
case "av1":
codec = ScrcpyVideoCodecId.AV1;
break;
}
return { stream, metadata: { codec } };
}
return (async () => {
const buffered = new BufferedReadableStream(stream);
// `sendDeviceMeta` now only contains device name,
// can't use `super.parseVideoStreamMetadata` here
let deviceName: string | undefined;
if (sendDeviceMeta) {
deviceName = await ScrcpyOptions1_16.parseCString(buffered, 64);
}
let codec: ScrcpyVideoCodecId;
let width: number | undefined;
let height: number | undefined;
if (sendCodecMeta) {
codec = await ScrcpyOptions1_16.parseUint32BE(buffered);
width = await ScrcpyOptions1_16.parseUint32BE(buffered);
height = await ScrcpyOptions1_16.parseUint32BE(buffered);
} else {
switch (this.value.videoCodec) {
case "h264":
codec = ScrcpyVideoCodecId.H264;
break;
case "h265":
codec = ScrcpyVideoCodecId.H265;
break;
case "av1":
codec = ScrcpyVideoCodecId.AV1;
break;
}
}
return {
stream: buffered.release(),
metadata: { deviceName, codec, width, height },
};
})();
}
override parseAudioStreamMetadata(
stream: ReadableStream<Uint8Array>,
): ValueOrPromise<ScrcpyAudioStreamMetadata> {
return ScrcpyOptions2_0.parseAudioMetadata(
stream,
this.value.sendCodecMeta,
(value) => {
switch (value) {
case ScrcpyAudioCodec.RAW.metadataValue:
return ScrcpyAudioCodec.RAW;
case ScrcpyAudioCodec.OPUS.metadataValue:
return ScrcpyAudioCodec.OPUS;
case ScrcpyAudioCodec.AAC.metadataValue:
return ScrcpyAudioCodec.AAC;
default:
throw new Error(
`Unknown audio codec metadata value: ${value}`,
);
}
},
() => {
switch (this.value.audioCodec) {
case "raw":
return ScrcpyAudioCodec.RAW;
case "opus":
return ScrcpyAudioCodec.OPUS;
case "aac":
return ScrcpyAudioCodec.AAC;
}
},
);
}
override serializeInjectTouchControlMessage(
message: ScrcpyInjectTouchControlMessage,
): Uint8Array {
return ScrcpyInjectTouchControlMessage2_0.serialize(message);
}
}