feat(scrcpy): support server v1.21

This commit is contained in:
Simon Chan 2021-12-23 14:24:58 +08:00
parent 89bda8fa96
commit 916405c8b7
12 changed files with 352 additions and 232 deletions

View file

@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"postinstall": "fetch-scrcpy-server 1.21",
"dev": "next dev",
"build": "next build",
"start": "next start",

View file

@ -1,7 +1,7 @@
import { CommandBar, Dialog, Dropdown, ICommandBarItemProps, Icon, IconButton, IDropdownOption, LayerHost, Position, ProgressIndicator, SpinButton, Stack, Toggle, TooltipHost } from "@fluentui/react";
import { useId } from "@fluentui/react-hooks";
import { EventEmitter } from "@yume-chan/event";
import { AndroidKeyCode, AndroidKeyEventAction, AndroidMotionEventAction, H264Decoder, H264DecoderConstructor, pushServer, ScrcpyClient, ScrcpyLogLevel, ScrcpyOptions1_18, ScrcpyScreenOrientation, TinyH264Decoder, WebCodecsDecoder } from "@yume-chan/scrcpy";
import { AndroidKeyCode, AndroidKeyEventAction, AndroidMotionEventAction, CodecOptions, DEFAULT_SERVER_PATH, H264Decoder, H264DecoderConstructor, pushServer, ScrcpyClient, ScrcpyLogLevel, ScrcpyOptions1_21, ScrcpyScreenOrientation, TinyH264Decoder, WebCodecsDecoder } from "@yume-chan/scrcpy";
import { action, autorun, makeAutoObservable, observable, runInAction } from "mobx";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
@ -10,11 +10,10 @@ import React, { useEffect, useState } from "react";
import { DemoMode, DeviceView, DeviceViewRef, ExternalLink } from "../components";
import { global } from "../state";
import { CommonStackTokens, formatSpeed, Icons, RouteStackProps } from "../utils";
import SCRCPY_SERVER_VERSION from '@yume-chan/scrcpy/bin/version';
const SERVER_URL = new URL('@yume-chan/scrcpy/bin/scrcpy-server?url', import.meta.url).toString();
export const ScrcpyServerVersion = '1.19';
class FetchWithProgress {
public readonly promise: Promise<ArrayBuffer>;
@ -400,8 +399,9 @@ class ScrcpyPageState {
const encoders = await ScrcpyClient.getEncoders(
global.device,
new ScrcpyOptions1_18({
version: ScrcpyServerVersion,
DEFAULT_SERVER_PATH,
SCRCPY_SERVER_VERSION,
new ScrcpyOptions1_21({
logLevel: ScrcpyLogLevel.Debug,
bitRate: 4_000_000,
tunnelForward: this.tunnelForward,
@ -456,17 +456,20 @@ class ScrcpyPageState {
});
await client.start(
new ScrcpyOptions1_18({
version: ScrcpyServerVersion,
DEFAULT_SERVER_PATH,
SCRCPY_SERVER_VERSION,
new ScrcpyOptions1_21({
logLevel: ScrcpyLogLevel.Debug,
maxSize: this.resolution,
bitRate: this.bitRate,
orientation: ScrcpyScreenOrientation.Unlocked,
lockVideoOrientation: ScrcpyScreenOrientation.Unlocked,
tunnelForward: this.tunnelForward,
encoder: this.selectedEncoder ?? encoders[0],
encoderName: this.selectedEncoder ?? encoders[0],
codecOptions: new CodecOptions({
profile: decoder.maxProfile,
level: decoder.maxLevel,
})
}),
}),
);
runInAction(() => {

View file

@ -3,3 +3,49 @@
TypeScript implementation of [Scrcpy](https://github.com/Genymobile/scrcpy) client.
It uses the official Scrcpy server releases.
## Download Server Binary
This package has a script `fetch-scrcpy-server` to help you download the official server binary.
The server binary is subject to [Apache License 2.0](https://github.com/Genymobile/scrcpy/blob/master/LICENSE).
Usage:
```
$ npx fetch-scrcpy-server <version>
```
For example:
```
$ npx fetch-scrcpy-server 1.21
```
You can also add it to the `postinstall` script of your `package.json` so it will run automatically when you do `npm install`:
```json
"scripts": {
"postinstall": "fetch-scrcpy-server 1.21",
},
```
It will download the binary to `bin/scrcpy` and write the version string to `bin/version.js`. You can import the version string with
```js
import SCRCPY_SERVER_VERSION from '@yume-chan/scrcpy/bin/version';
```
And import the server binary with [file-loader](https://v4.webpack.js.org/loaders/file-loader/) (Webpack 4) or [Asset Modules](https://webpack.js.org/guides/asset-modules/) (Webpack 5).
## Option versions
Scrcpy server has no backward compatibility on options input format. Currently the following versions are supported:
| versions | type |
| --------- | ------------------- |
| 1.16~1.17 | `ScrcpyOptions1_16` |
| 1.18~1.20 | `ScrcpyOptions1_18` |
| 1.21 | `ScrcpyOptions1_21` |
You must use the correct type according to the server version.

View file

@ -22,11 +22,13 @@
"bugs": {
"url": "https://github.com/yume-chan/ya-webadb/issues"
},
"bin": {
"fetch-scrcpy-server": "scripts/fetch-server.js"
},
"main": "cjs/index.js",
"module": "esm/index.js",
"types": "dts/index.d.ts",
"scripts": {
"postinstall": "node scripts/fetch-server",
"build": "build-ts-package",
"build:watch": "build-ts-package --incremental",
"test": "jest --coverage",

16
libraries/scrcpy/scripts/fetch-server.js Normal file → Executable file
View file

@ -1,26 +1,28 @@
#!/usr/bin/env node
const { fetchVersion } = require('gh-release-fetch');
const path = require('path');
const fs = require('fs').promises;
const SERVER_VERSION = '1.19';
(async () => {
console.log('Downloading scrcpy server binary...');
const serverVerision = process.argv[2];
console.log(`Downloading Scrcpy server binary version ${serverVerision}...`);
const binFolder = path.resolve(__dirname, '..', 'bin');
await fetchVersion({
repository: 'Genymobile/scrcpy',
version: `v${SERVER_VERSION}`,
package: `scrcpy-server-v${SERVER_VERSION}`,
version: `v${serverVerision}`,
package: `scrcpy-server-v${serverVerision}`,
destination: binFolder,
extract: false,
});
await fs.rename(
path.resolve(binFolder, `scrcpy-server-v${SERVER_VERSION}`),
path.resolve(binFolder, `scrcpy-server-v${serverVerision}`),
path.resolve(binFolder, 'scrcpy-server')
);
fs.writeFile(path.resolve(binFolder, 'version'), SERVER_VERSION);
fs.writeFile(path.resolve(binFolder, 'version.js'), `export default '${serverVerision}';`);
fs.writeFile(path.resolve(binFolder, 'version.d.ts'), `export default '${serverVerision}';`);
})();

View file

@ -66,7 +66,7 @@ class LineReader {
function* parseScrcpyOutput(text: string): Generator<ScrcpyOutput> {
const lines = new LineReader(text);
let line: string | undefined;
while (line = lines.next()) {
while ((line = lines.next()) !== undefined) {
if (line === '') {
continue;
}
@ -176,7 +176,12 @@ export class ScrcpyClient {
pushServer(device, file, options);
}
public static async getEncoders(device: Adb, options: ScrcpyOptions): Promise<string[]> {
public static async getEncoders(
device: Adb,
path: string,
version: string,
options: ScrcpyOptions
): Promise<string[]> {
const client = new ScrcpyClient(device);
const encoderNameRegex = options.getOutputEncoderNameRegex();
@ -201,6 +206,8 @@ export class ScrcpyClient {
// Scrcpy server will open connections, before initializing encoder
// Thus although an invalid encoder name is given, the start process will success
await client.startCore(
path,
version,
options.formatGetEncoderListArguments(),
options.createConnection(device)
);
@ -256,6 +263,8 @@ export class ScrcpyClient {
}
private async startCore(
path: string,
version: string,
serverArguments: string[],
connection: ScrcpyClientConnection
): Promise<void> {
@ -265,7 +274,14 @@ export class ScrcpyClient {
await connection.initialize();
process = await this.device.childProcess.spawn(
serverArguments,
[
`CLASSPATH=${path}`,
'app_process',
/* unused */ '/',
'com.genymobile.scrcpy.Server',
version,
...serverArguments
],
{
// Scrcpy server doesn't split stdout and stderr,
// so disable Shell Protocol to simplify processing
@ -302,9 +318,15 @@ export class ScrcpyClient {
}
}
public start(options: ScrcpyOptions) {
public start(
path: string,
version: string,
options: ScrcpyOptions
) {
this.options = options;
return this.startCore(
path,
version,
options.formatServerArguments(),
options.createConnection(this.device)
);

View file

@ -0,0 +1,183 @@
import { Adb } from "@yume-chan/adb";
import Struct, { placeholder } from "@yume-chan/struct";
import { AndroidCodecLevel, AndroidCodecProfile } from "../codec";
import { ScrcpyClientConnection, ScrcpyClientForwardConnection, ScrcpyClientReverseConnection } from "../connection";
import { AndroidKeyEventAction, ScrcpyControlMessageType } from "../message";
import { ScrcpyLogLevel, ScrcpyOptions, ScrcpyScreenOrientation, toScrcpyOption, ToScrcpyOption } from "./common";
export interface CodecOptionsType {
profile: AndroidCodecProfile;
level: AndroidCodecLevel;
}
export class CodecOptions implements ToScrcpyOption {
public value: CodecOptionsType;
public constructor({
profile = AndroidCodecProfile.Baseline,
level = AndroidCodecLevel.Level4,
}: Partial<CodecOptionsType>) {
this.value = {
profile,
level,
};
}
public toScrcpyOption(): string {
return Object.entries(this.value)
.map(([key, value]) => `${key}=${value}`)
.join(',');
}
}
export interface ScrcpyOptions1_16Type {
logLevel: ScrcpyLogLevel;
/**
* The maximum value of both width and height.
*/
maxSize: number;
bitRate: number;
maxFps: number;
/**
* The orientation of the video stream.
*
* It will not keep the device screen in specific orientation,
* only the captured video will in this orientation.
*/
lockVideoOrientation: ScrcpyScreenOrientation;
tunnelForward: boolean;
// Because Scrcpy 1.21 changed the empty value from '-' to '',
// We mark properties which can be empty with `| undefined`
crop: string | undefined;
sendFrameMeta: boolean;
control: boolean;
displayId: number;
showTouches: boolean;
stayAwake: boolean;
codecOptions: CodecOptions | undefined;
encoderName: string | undefined;
}
export const ScrcpyBackOrScreenOnEvent1_16 =
new Struct()
.uint8('type', placeholder<ScrcpyControlMessageType.BackOrScreenOn>());
export class ScrcpyOptions1_16<T extends ScrcpyOptions1_16Type = ScrcpyOptions1_16Type> implements ScrcpyOptions {
public value: T;
public constructor({
logLevel = ScrcpyLogLevel.Error,
maxSize = 0,
bitRate = 8_000_000,
maxFps = 0,
lockVideoOrientation = ScrcpyScreenOrientation.Unlocked,
tunnelForward = false,
crop,
sendFrameMeta = true,
control = true,
displayId = 0,
showTouches = false,
stayAwake = true,
codecOptions,
encoderName,
}: Partial<ScrcpyOptions1_16Type>) {
if (new.target === ScrcpyOptions1_16 &&
logLevel === ScrcpyLogLevel.Verbose) {
logLevel = ScrcpyLogLevel.Debug;
}
if (new.target === ScrcpyOptions1_16 &&
lockVideoOrientation === ScrcpyScreenOrientation.Initial) {
lockVideoOrientation = ScrcpyScreenOrientation.Unlocked;
}
this.value = {
logLevel,
maxSize,
bitRate,
maxFps,
lockVideoOrientation,
tunnelForward,
crop,
sendFrameMeta,
control,
displayId,
showTouches,
stayAwake,
codecOptions,
encoderName,
} as T;
}
protected getArgumnetOrder(): (keyof T)[] {
return [
'logLevel',
'maxSize',
'bitRate',
'maxFps',
'lockVideoOrientation',
'tunnelForward',
'crop',
'sendFrameMeta',
'control',
'displayId',
'showTouches',
'stayAwake',
'codecOptions',
'encoderName',
];
}
public formatServerArguments(): string[] {
return this.getArgumnetOrder().map(key => {
return toScrcpyOption(this.value[key], '-');
});
}
public formatGetEncoderListArguments(): string[] {
return this.getArgumnetOrder().map(key => {
if (key === 'encoderName') {
// Provide an invalid encoder name
// So the server will return all available encoders
return '_';
}
return toScrcpyOption(this.value[key], '-');
});
}
public createConnection(device: Adb): ScrcpyClientConnection {
if (this.value.tunnelForward) {
return new ScrcpyClientForwardConnection(device);
} else {
return new ScrcpyClientReverseConnection(device);
}
}
public getOutputEncoderNameRegex(): RegExp {
return /^\s+scrcpy --encoder-name '(.*?)'/;
}
public createBackOrScreenOnEvent(action: AndroidKeyEventAction, device: Adb) {
if (action === AndroidKeyEventAction.Down) {
return ScrcpyBackOrScreenOnEvent1_16.serialize(
{ type: ScrcpyControlMessageType.BackOrScreenOn },
device.backend
);
}
return undefined;
}
}

View file

@ -1,181 +0,0 @@
import { Adb } from "@yume-chan/adb";
import Struct, { placeholder } from "@yume-chan/struct";
import { AndroidCodecLevel, AndroidCodecProfile } from "../codec";
import { ScrcpyClientConnection, ScrcpyClientForwardConnection, ScrcpyClientReverseConnection } from "../connection";
import { AndroidKeyEventAction, ScrcpyControlMessageType } from "../message";
import { DEFAULT_SERVER_PATH, ScrcpyLogLevel, ScrcpyOptions, ScrcpyScreenOrientation } from "./common";
export interface ScrcpyOptions1_17Init {
path?: string,
version?: string;
logLevel?: ScrcpyLogLevel;
/**
* The maximum value of both width and height.
*/
maxSize?: number | undefined;
bitRate: number;
maxFps?: number;
/**
* The orientation of the video stream.
*
* It will not keep the device screen in specific orientation,
* only the captured video will in this orientation.
*/
orientation?: ScrcpyScreenOrientation;
tunnelForward?: boolean;
profile?: AndroidCodecProfile;
level?: AndroidCodecLevel;
encoder?: string;
}
export const ScrcpyBackOrScreenOnEvent1_17 =
new Struct()
.uint8('type', placeholder<ScrcpyControlMessageType.BackOrScreenOn>());
export class ScrcpyOptions1_17 implements ScrcpyOptions {
path: string;
version: string;
logLevel: ScrcpyLogLevel;
/**
* The maximum value of both width and height.
*/
maxSize: number;
bitRate: number;
maxFps: number;
/**
* The orientation of the video stream.
*
* It will not keep the device screen in specific orientation,
* only the captured video will in this orientation.
*/
orientation: ScrcpyScreenOrientation;
tunnelForward: boolean;
profile: AndroidCodecProfile;
level: AndroidCodecLevel;
encoder: string;
public constructor({
path = DEFAULT_SERVER_PATH,
version = '1.17',
logLevel = ScrcpyLogLevel.Error,
maxSize = 0,
bitRate,
maxFps = 0,
orientation = ScrcpyScreenOrientation.Unlocked,
tunnelForward = false,
profile = AndroidCodecProfile.Baseline,
level = AndroidCodecLevel.Level4,
encoder = '-',
}: ScrcpyOptions1_17Init) {
this.path = path;
this.version = version;
if (logLevel === ScrcpyLogLevel.Verbose) {
logLevel = ScrcpyLogLevel.Debug;
}
this.logLevel = logLevel;
this.maxSize = maxSize;
this.bitRate = bitRate;
this.maxFps = maxFps;
if (orientation === ScrcpyScreenOrientation.Initial) {
orientation = ScrcpyScreenOrientation.Unlocked;
}
this.orientation = orientation;
this.tunnelForward = tunnelForward;
this.profile = profile;
this.level = level;
this.encoder = encoder;
}
public formatServerArguments() {
return [
`CLASSPATH=${this.path}`,
'app_process',
/* unused */ '/',
'com.genymobile.scrcpy.Server',
this.version,
this.logLevel,
this.maxSize.toString(), // (0: unlimited)
this.bitRate.toString(),
this.maxFps.toString(),
this.orientation.toString(),
this.tunnelForward.toString(),
/* crop */ '-',
/* send_frame_meta */ 'true', // always send frame meta (packet boundaries + timestamp)
/* control */ 'true',
/* display_id */ '0',
/* show_touches */ 'false',
/* stay_awake */ 'true',
/* codec_options */ `profile=${this.profile},level=${this.level}`,
this.encoder,
];
}
public formatGetEncoderListArguments() {
return [
`CLASSPATH=${this.path}`,
'app_process',
/* unused */ '/',
'com.genymobile.scrcpy.Server',
this.version,
this.logLevel,
this.maxSize.toString(), // (0: unlimited)
this.bitRate.toString(),
this.maxFps.toString(),
this.orientation.toString(),
this.tunnelForward.toString(),
/* crop */ '-',
/* send_frame_meta */ 'true', // always send frame meta (packet boundaries + timestamp)
/* control */ 'true',
/* display_id */ '0',
/* show_touches */ 'false',
/* stay_awake */ 'true',
/* codec_options */ `profile=${this.profile},level=${this.level}`,
// Provide an invalid encoder name
// So the server will return all available encoders
/* encoder_name */ '_',
];
}
public createConnection(device: Adb): ScrcpyClientConnection {
if (this.tunnelForward) {
return new ScrcpyClientForwardConnection(device);
} else {
return new ScrcpyClientReverseConnection(device);
}
}
public getOutputEncoderNameRegex(): RegExp {
return /^\s+scrcpy --encoder-name '(.*?)'/;
}
public createBackOrScreenOnEvent(action: AndroidKeyEventAction, device: Adb) {
if (action === AndroidKeyEventAction.Down) {
return ScrcpyBackOrScreenOnEvent1_17.serialize(
{ type: ScrcpyControlMessageType.BackOrScreenOn },
device.backend
);
}
return undefined;
}
}

View file

@ -1,11 +1,10 @@
import { Adb } from "@yume-chan/adb";
import Struct, { placeholder } from "@yume-chan/struct";
import { AndroidKeyEventAction, ScrcpyControlMessageType } from "../message";
import { ScrcpyOptions1_17, ScrcpyOptions1_17Init } from "./1_17";
import { ScrcpyLogLevel, ScrcpyScreenOrientation } from "./common";
import { ScrcpyOptions1_16, ScrcpyOptions1_16Type } from "./1_16";
export interface ScrcpyOptions1_18Init extends ScrcpyOptions1_17Init {
powerOffOnClose?: boolean;
export interface ScrcpyOptions1_18Type extends ScrcpyOptions1_16Type {
powerOffOnClose: boolean;
}
export const ScrcpyBackOrScreenOnEvent1_18 =
@ -13,40 +12,24 @@ export const ScrcpyBackOrScreenOnEvent1_18 =
.uint8('type', placeholder<ScrcpyControlMessageType.BackOrScreenOn>())
.uint8('action', placeholder<AndroidKeyEventAction>());
export class ScrcpyOptions1_18 extends ScrcpyOptions1_17 {
powerOffOnClose: boolean;
constructor(init: ScrcpyOptions1_18Init) {
export class ScrcpyOptions1_18<T extends ScrcpyOptions1_18Type = ScrcpyOptions1_18Type> extends ScrcpyOptions1_16<T> {
constructor(init: Partial<ScrcpyOptions1_18Type>) {
super(init);
const {
logLevel = ScrcpyLogLevel.Error,
orientation = ScrcpyScreenOrientation.Unlocked,
powerOffOnClose = false,
} = init;
this.logLevel = logLevel;
this.orientation = orientation;
this.powerOffOnClose = powerOffOnClose;
this.value.powerOffOnClose = powerOffOnClose;
}
public override formatServerArguments(): string[] {
return [
...super.formatServerArguments(),
this.powerOffOnClose.toString()
];
}
public override formatGetEncoderListArguments(): string[] {
return [
...super.formatGetEncoderListArguments(),
this.powerOffOnClose.toString()
];
protected override getArgumnetOrder(): (keyof T)[] {
return super.getArgumnetOrder().concat(['powerOffOnClose']);
}
public override getOutputEncoderNameRegex(): RegExp {
return /^\s+scrcpy --encoder '(.*?)'/;
}
public createBackOrScreenOnEvent(action: AndroidKeyEventAction, device: Adb) {
public override createBackOrScreenOnEvent(action: AndroidKeyEventAction, device: Adb) {
return ScrcpyBackOrScreenOnEvent1_18.serialize(
{
type: ScrcpyControlMessageType.BackOrScreenOn,

View file

@ -0,0 +1,36 @@
import { ScrcpyOptions1_18, ScrcpyOptions1_18Type } from './1_18';
import { toScrcpyOption } from "./common";
export interface ScrcpyOptions1_21Type extends ScrcpyOptions1_18Type {
clipboardAutosync?: boolean;
}
function toSnakeCase(input: string): string {
return input.replace(/([A-Z])/g, '_$1').toLowerCase();
}
export class ScrcpyOptions1_21<T extends ScrcpyOptions1_21Type = ScrcpyOptions1_21Type> extends ScrcpyOptions1_18<T> {
public constructor(init: Partial<ScrcpyOptions1_21Type>) {
super(init);
const {
clipboardAutosync = true,
} = init;
this.value.clipboardAutosync = clipboardAutosync;
}
public override formatServerArguments(): string[] {
return Object.entries(this.value).map(([key, value]) => {
return `${toSnakeCase(key)}=${toScrcpyOption(value, '')}`;
});
}
public formatGetEncoderListArguments(): string[] {
return Object.entries(this.value).map(([key, value]) => {
if (key === 'encoderName') {
value = '_';
}
return `${toSnakeCase(key)}=${toScrcpyOption(value, '')}`;
});
}
}

View file

@ -32,3 +32,25 @@ export interface ScrcpyOptions {
createBackOrScreenOnEvent(action: AndroidKeyEventAction, device: Adb): ArrayBuffer | undefined;
}
export interface ToScrcpyOption {
toScrcpyOption(): string;
}
export function isToScrcpyOption(value: any): value is ToScrcpyOption {
return typeof value === 'object' &&
value !== null &&
typeof value.toScrcpyOption === 'function';
}
export function toScrcpyOption(value: any, empty: string): string {
if (value === undefined) {
return empty;
}
if (isToScrcpyOption(value)) {
return value.toScrcpyOption();
}
return `${value}`;
}

View file

@ -1,3 +1,4 @@
export * from './1_17';
export * from './1_16';
export * from './1_18';
export * from './1_21';
export * from './common';