初始化

This commit is contained in:
2025-07-30 13:39:32 +08:00
commit d1f2452b28
253 changed files with 32087 additions and 0 deletions

6
.eslintignore Normal file
View File

@@ -0,0 +1,6 @@
src/public/**/*.js
vendor/**/*.js
vendor/**/*.ts
src/app/Util.ts
*.js
typings/**/*.d.ts

22
.eslintrc Normal file
View File

@@ -0,0 +1,22 @@
{
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
"prettier"
],
"plugins": [
"progress",
"@typescript-eslint",
"prettier"
],
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module"
},
"rules": {
"progress/activate": 1,
"import/no-absolute-path": "off"
},
"overrides": [
]
}

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/node_modules
/dist
/build
/.idea

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
include=dev

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 120,
"tabWidth": 4
}

19
LICENSE Normal file
View File

@@ -0,0 +1,19 @@
Copyright (C) 2021 by Netris, JSC.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

227
README.md Normal file
View File

@@ -0,0 +1,227 @@
# ws scrcpy
Web client for [Genymobile/scrcpy][scrcpy] and more.
## Requirements
Browser must support the following technologies:
* WebSockets
* Media Source Extensions and h264 decoding;
* WebWorkers
* WebAssembly
Server:
* Node.js v10+
* node-gyp ([installation](https://github.com/nodejs/node-gyp#installation))
* `adb` executable must be available in the PATH environment variable
Device:
* Android 5.0+ (API 21+)
* Enabled [adb debugging](https://developer.android.com/studio/command-line/adb.html#Enabling)
* On some devices, you also need to enable
[an additional option](https://github.com/Genymobile/scrcpy/issues/70#issuecomment-373286323)
to control it using keyboard and mouse.
## Build and Start
Make sure you have installed [node.js](https://nodejs.org/en/download/),
[node-gyp](https://github.com/nodejs/node-gyp) and
[build tools](https://github.com/nodejs/node-gyp#installation)
```shell
git clone https://github.com/NetrisTV/ws-scrcpy.git
cd ws-scrcpy
## For stable version find latest tag and switch to it:
# git tag -l
# git checkout vX.Y.Z
npm install
npm start
```
## Supported features
### Android
#### Screen casting
The modified [version][fork] of [Genymobile/scrcpy][scrcpy] used to stream
H264-video, which then decoded by one of included decoders:
##### Mse Player
Based on [xevokk/h264-converter][xevokk/h264-converter].
HTML5 Video.<br>
Requires [Media Source API][MSE] and `video/mp4; codecs="avc1.42E01E"`
[support][isTypeSupported]. Creates mp4 containers from NALU, received from a
device, then feeds them to [MediaSource][MediaSource]. In theory, it can use
hardware acceleration.
##### Broadway Player
Based on [mbebenita/Broadway][broadway] and
[131/h264-live-player][h264-live-player].<br>
Software video-decoder compiled into wasm-module.
Requires [WebAssembly][wasm] and preferably [WebGL][webgl] support.
##### TinyH264 Player
Based on [udevbe/tinyh264][tinyh264].<br>
Software video-decoder compiled into wasm-module. A slightly updated version of
[mbebenita/Broadway][broadway].
Requires [WebAssembly][wasm], [WebWorkers][workers], [WebGL][webgl] support.
##### WebCodecs Player
Decoding is done by browser built-in (software/hardware) media decoder.
Requires [WebCodecs][webcodecs] support. At the moment, available only in
[Chromium](https://www.chromestatus.com/feature/5669293909868544) and derivatives.
#### Remote control
* Touch events (including multi-touch)
* Multi-touch emulation: <kbd>CTRL</kbd> to start with center at the center of
the screen, <kbd>SHIFT</kbd> + <kbd>CTRL</kbd> to start with center at the
current point
* Mouse wheel and touchpad vertical/horizontal scrolling
* Capturing keyboard events
* Injecting text (ASCII only)
* Copy to/from device clipboard
* Device "rotation"
#### File push
Drag & drop an APK file to push it to the `/data/local/tmp` directory. You can
install it manually from the included [xtermjs/xterm.js][xterm.js] terminal
emulator (see below).
#### Remote shell
Control your device from `adb shell` in your browser.
#### Debug WebPages/WebView
[/docs/Devtools.md](/docs/Devtools.md)
#### File listing
* List files
* Upload files by drag & drop
* Download files
### iOS
***Experimental Feature***: *is not built by default*
(see [custom build](#custom-build))
#### Screen Casting
Requires [ws-qvh][ws-qvh] available in `PATH`.
#### MJPEG Server
Enable `USE_WDA_MJPEG_SERVER` in the build configuration file
(see [custom build](#custom-build)).
Alternative way to stream screen content. It does not
require additional software as `ws-qvh`, but may require more resources as each
frame encoded as jpeg image.
#### Remote control
To control device we use [appium/WebDriverAgent][WebDriverAgent].
Functionality limited to:
* Simple touch
* Scroll
* Home button click
Make sure you did properly [setup WebDriverAgent](https://appium.io/docs/en/drivers/ios-xcuitest-real-devices/).
WebDriverAgent project is located under `node_modules/appium-webdriveragent/`.
You might want to enable `AssistiveTouch` on your device: `Settings/General/Accessibility`.
## Custom Build
You can customize project before build by overriding the
[default configuration](/webpack/default.build.config.json) in
[build.config.override.json](/build.config.override.json):
* `INCLUDE_APPL` - include code for iOS device tracking and control
* `INCLUDE_GOOG` - include code for Android device tracking and control
* `INCLUDE_ADB_SHELL` - [remote shell](#remote-shell) for android devices
([xtermjs/xterm.js][xterm.js], [Tyriar/node-pty][node-pty])
* `INCLUDE_DEV_TOOLS` - [dev tools](#debug-webpageswebview) for web pages and
web views on android devices
* `INCLUDE_FILE_LISTING` - minimalistic [file management](#file-listing)
* `USE_BROADWAY` - include [Broadway Player](#broadway-player)
* `USE_H264_CONVERTER` - include [Mse Player](#mse-player)
* `USE_TINY_H264` - include [TinyH264 Player](#tinyh264-player)
* `USE_WEBCODECS` - include [WebCodecs Player](#webcodecs-player)
* `USE_WDA_MJPEG_SERVER` - configure WebDriverAgent to start MJPEG server
* `USE_QVH_SERVER` - include support for [ws-qvh][ws-qvh]
* `SCRCPY_LISTENS_ON_ALL_INTERFACES` - WebSocket server in `scrcpy-server.jar`
will listen for connections on all available interfaces. When `true`, it allows
connecting to device directly from a browser. Otherwise, the connection must be
established over adb.
## Run configuration
You can specify a path to a configuration file in `WS_SCRCPY_CONFIG`
environment variable.
If you want to have another pathname than "/" you can specify it in the
`WS_SCRCPY_PATHNAME` environment variable.
Configuration file format: [Configuration.d.ts](/src/types/Configuration.d.ts).
Configuration file example: [config.example.yaml](/config.example.yaml).
## Known issues
* The server on the Android Emulator listens on the internal interface and not
available from the outside. Select `proxy over adb` from the interfaces list.
* TinyH264Player may fail to start, try to reload the page.
* MsePlayer reports too many dropped frames in quality statistics: needs
further investigation.
* On Safari file upload does not show progress (it works in one piece).
## Security warning
Be advised and keep in mind:
* There is no encryption between browser and node.js server (you can [configure](#run-configuration) HTTPS).
* There is no encryption between browser and WebSocket server on android device.
* There is no authorization on any level.
* The modified version of scrcpy with integrated WebSocket server is listening
for connections on all network interfaces (see [custom build](#custom-build)).
* The modified version of scrcpy will keep running after the last client
disconnected.
## Related projects
* [Genymobile/scrcpy][scrcpy]
* [xevokk/h264-converter][xevokk/h264-converter]
* [131/h264-live-player][h264-live-player]
* [mbebenita/Broadway][broadway]
* [DeviceFarmer/adbkit][adbkit]
* [xtermjs/xterm.js][xterm.js]
* [udevbe/tinyh264][tinyh264]
* [danielpaulus/quicktime_video_hack][qvh]
## scrcpy websocket fork
Currently, support of WebSocket protocol added to v1.19 of scrcpy
* [Prebuilt package](/vendor/Genymobile/scrcpy/scrcpy-server.jar)
* [Source code][fork]
[fork]: https://github.com/NetrisTV/scrcpy/tree/feature/websocket-v1.19.x
[scrcpy]: https://github.com/Genymobile/scrcpy
[xevokk/h264-converter]: https://github.com/xevokk/h264-converter
[h264-live-player]: https://github.com/131/h264-live-player
[broadway]: https://github.com/mbebenita/Broadway
[adbkit]: https://github.com/DeviceFarmer/adbkit
[xterm.js]: https://github.com/xtermjs/xterm.js
[tinyh264]: https://github.com/udevbe/tinyh264
[node-pty]: https://github.com/Tyriar/node-pty
[WebDriverAgent]: https://github.com/appium/WebDriverAgent
[qvh]: https://github.com/danielpaulus/quicktime_video_hack
[ws-qvh]: https://github.com/NetrisTV/ws-qvh
[MSE]: https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API
[isTypeSupported]: https://developer.mozilla.org/en-US/docs/Web/API/MediaSource/isTypeSupported
[MediaSource]: https://developer.mozilla.org/en-US/docs/Web/API/MediaSource
[wasm]: https://developer.mozilla.org/en-US/docs/WebAssembly
[webgl]: https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API
[workers]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
[webcodecs]: https://w3c.github.io/webcodecs/

4
SECURITY.md Normal file
View File

@@ -0,0 +1,4 @@
# Reporting Security Issues
If you discover a security issue, please report it by sending an
email to [drauggres@gmail.com](mailto:drauggres@gmail.com).

View File

@@ -0,0 +1 @@
{}

40
config.example.yaml Normal file
View File

@@ -0,0 +1,40 @@
# Run configuration example. See full config file spec in src/types/Configuration.d.ts
# Device trackers
## track android devices (default: true, if was INCLUDE_GOOG enabled in build config)
runGoogTracker: false
## track iOS devices (default: true, if was INCLUDE_APPL enabled in build config)
runApplTracker: false;
# HTTP[s] servers configuration
server:
- secure: false
port: 8000
redirectToSecure:
port: 8443
host: first-mobile-stand.example.com
- secure: true
port: 8443
options:
certPath: /Users/example/ssl/STAR_example_com.crt
keyPath: /Users/example/ssl/STAR_example_com.key
# Announce remote device trackers. The server doesn't check their availability.
remoteHostList:
- useProxy: true # optional, default: false
type: android # required, "android" | "ios" | ["android", "ios"]
secure: true # required, false for HTTP, true for HTTPS
hostname: second-mobile-stand.example.com
port: 8443
- useProxy: true
type: ios
secure: true
hostname: second-mobile-stand.example.com
port: 8443
- useProxy: true
type: # short variant
- ios
- android
secure: true
hostname: third-mobile-stand.example.com
port: 8443

33
docs/Devtools.md Normal file
View File

@@ -0,0 +1,33 @@
# Devtools
Forward and proxy a WebKit debug-socket from an android device to your browser
## How it works
### Server
1. Find devtools sockets: `adb shell 'grep -a devtools_remote /proc/net/unix'`
2. For each socket request `/json` and `/json/version`
3. Replace websocket address in response with our hostname
4. Combine all data and send to a client
### Client
Though each debuggable page explicitly specifies `devtoolsFrontendUrl` it is
possible that provided version of devtools frontend will not work in your
browser. To ensure that you will be able to debug webpage/webview, client
creates three links:
- `inspect` - this is a link provided by a remote browser in the answer for
`/json` request (only WebSocket address is changed). When this link points to
a local version of devtools (bundled with debuggable browser) you will not able
to open it, because only WebSocket forwarding is implemented at the moment.
- `bundled` - link to a version of devtools bundled with your (chromium based)
browser without specifying revision or version of the remote target. You will
get same link in the `chrome://inspect` page of Chromium browser.
e.g. `devtools://devtools/bundled/inspector.html?ws=<WebSocketAddress>`
- `remote` - link to a bundled devtools but with specified revision and version
of remote target. This link is visible only when original link in
`devtoolsFrontendUrl` contains revision. You will get same link in the
`chrome://inspect` page of Chrome browser.
e.g. `devtools://devtools/remote/serve_rev/@<Revision>/inspector.html?remoteVersion=<Version>&remoteFrontend=true&ws=<WebSocketAddress>`
**You can't open two last links with click or `open link in new tab`.**
You must copy link and open it manually. This is browser restriction.

46
docs/debug.md Normal file
View File

@@ -0,0 +1,46 @@
### Client
1. Build dev version (will include source maps):
> npm run dist:dev
2. Run from `dist` directory:
> npm run start
3. Use the browser's built-in developer tools or your favorite IDE.
### Node.js server
1. `npm run dist:dev`
2. `cd dist`
3. `node --inspect-brk ./index.js`
__HINT__: you might want to set `DEBUG` environment variable (see [debug](https://github.com/visionmedia/debug)):
> DEBUG=* node --inspect-brk ./index.js
### Android server (`scrcpy-server.jar`)
Source code is available [here](https://github.com/NetrisTV/scrcpy/tree/feature/websocket-server)
__HINT__: you might want to build a dev version.
To debug the server:
1. start node server
2. kill server from UI (click button with cross and PID number).
3. upload server package to a device:
> adb push server/build/outputs/apk/debug/server-debug.apk /data/local/tmp/scrcpy-server.jar
4. setup port forwarding:
> adb forward tcp:5005 tcp:5005
5. connect to device with adb shell:
> adb shell
6.1. for Android 8 and below run this in adb shell (single line):
> CLASSPATH=/data/local/tmp/scrcpy-server.jar app_process -agentlib:jdwp=transport=dt_socket,suspend=y,server=y,address=5005 / com.genymobile.scrcpy.Server 1.17-ws5 DEBUG web 8886
6.2. for Android 9 and above:
> CLASSPATH=/data/local/tmp/scrcpy-server.jar app_process -XjdwpProvider:internal -XjdwpOptions:transport=dt_socket,suspend=y,server=y,address=5005 / com.genymobile.scrcpy.Server 1.17-ws5 web DEBUG 8886
7. Open project (scrcpy, not ws-scrcpy) in Android Studio, create `Remote` Debug configuration with:
> Host: localhost, Port: 5005
Connect the debugger to the remote server on the device.

30
docs/scheme.md Normal file
View File

@@ -0,0 +1,30 @@
```
+--------------------------+ +------------------------------+
| Android device | | Server |
| | | |
| +----------------------+ | | +--------------------------+ |
| | adb | | Run scrcpy | | adb (client) | |
| | |<---------------| | |
| | (usb/tcp) | | | | | |
| +----------------------+ | | +--------------------------+ |
| | | |
| +----------------------+ | | +--------------------------+ |
| | scrcpy | | | | nodejs | |
| | | | | | | |
----| (ws://0.0.0.0:8886/) | | | | (http://0.0.0.0:8000/) |----
| | +----------------------+ | | +--------------------------+ | |
| +--------------------------+ +------------------------------+ |
| |
| |
| |
| |
| |
| HTTP: |
| +------------------------------+ < static (html, js...)|
|Web-socket: | Client | |
|< Input events | | Web-socket: |
|> Video stream | +--------------------------+ | < Device list |
-------------------| Web-browser |-----------------------------
| +--------------------------+ |
+------------------------------+
```

9411
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

77
package.json Normal file
View File

@@ -0,0 +1,77 @@
{
"name": "ws-scrcpy",
"version": "0.9.0-dev",
"description": "Web client for scrcpy and more",
"scripts": {
"clean": "npx rimraf dist",
"dist:dev": "webpack --config webpack/ws-scrcpy.dev.ts --stats-error-details",
"dist:prod": "webpack --config webpack/ws-scrcpy.prod.ts --stats-error-details",
"dist": "npm run dist:prod",
"start": "npm run dist && cd dist && npm start",
"script:dist:start": "node ./index.js",
"lint": "eslint src/ --ext .ts",
"format": "eslint src/ --fix --ext .ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Sergey Volkov <drauggres@gmail.com>",
"license": "MIT",
"dependencies": {
"@dead50f7/adbkit": "^2.11.4",
"express": "^4.21.2",
"ios-device-lib": "^0.9.2",
"node-mjpeg-proxy": "^0.3.2",
"node-pty": "^0.10.1",
"portfinder": "^1.0.28",
"python-shell": "^4.0.0",
"tslib": "^2.3.1",
"ws": "^8.18.0",
"xml2js": "^0.6.2",
"yaml": "^2.2.2"
},
"devDependencies": {
"@dead50f7/generate-package-json-webpack-plugin": "^2.6.1",
"@types/bluebird": "^3.5.36",
"@types/dom-webcodecs": "^0.1.3",
"@types/express": "^4.17.13",
"@types/node": "^12.20.47",
"@types/node-forge": "^0.10.0",
"@types/npmlog": "^4.1.4",
"@types/webpack-node-externals": "^2.5.3",
"@types/ws": "^7.4.7",
"@typescript-eslint/eslint-plugin": "^5.18.0",
"@typescript-eslint/parser": "^5.18.0",
"buffer": "^6.0.3",
"cross-env": "^7.0.3",
"css-loader": "^6.8.1",
"eslint": "^8.12.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-progress": "0.0.1",
"file-loader": "^6.2.0",
"h264-converter": "^0.1.4",
"html-webpack-plugin": "^5.5.0",
"ifdef-loader": "^2.3.2",
"mini-css-extract-plugin": "^2.6.1",
"mkdirp": "^1.0.4",
"path-browserify": "^1.0.1",
"prettier": "^2.6.2",
"recursive-copy": "^2.0.14",
"rimraf": "^3.0.0",
"svg-inline-loader": "^0.8.2",
"sylvester.js": "^0.1.1",
"tinyh264": "^0.0.7",
"ts-loader": "^9.3.1",
"ts-node": "^10.9.1",
"typescript": "^4.7.4",
"webpack": "^5.94.0",
"webpack-cli": "^4.10.0",
"webpack-node-externals": "^2.5.2",
"worker-loader": "^3.0.8",
"xterm": "^4.5.0",
"xterm-addon-attach": "^0.6.0",
"xterm-addon-fit": "^0.5.0"
},
"optionalDependencies": {
"appium-xcuitest-driver": "^3.62.0"
}
}

14
src/app/Attribute.ts Normal file
View File

@@ -0,0 +1,14 @@
export const Attribute = {
COMMAND: 'data-command',
FULL_NAME: 'data-full-name',
NAME: 'data-name',
PID: 'data-pid',
UDID: 'data-udid',
URL: 'data-url',
USE_PROXY: 'data-use-proxy',
SECURE: 'data-secure',
HOSTNAME: 'data-hostname',
PORT: 'data-port',
PATHNAME: 'data-pathname',
VALUE: 'data-value',
};

61
src/app/DisplayInfo.ts Normal file
View File

@@ -0,0 +1,61 @@
import Size from './Size';
export class DisplayInfo {
public static readonly DEFAULT_DISPLAY = 0x00000000;
public static readonly FLAG_ROUND = 0b10000;
public static readonly FLAG_PRESENTATION = 0b1000;
public static readonly FLAG_PRIVATE = 0b100;
public static readonly FLAG_SECURE = 0b10;
public static readonly FLAG_SUPPORTS_PROTECTED_BUFFERS = 0b1;
public static readonly INVALID_DISPLAY = -1;
public static readonly BUFFER_LENGTH = 24;
constructor(
public readonly displayId: number,
public readonly size: Size,
public readonly rotation: number,
public readonly layerStack: number,
public readonly flags: number,
) {}
public toBuffer(): Buffer {
const temp = Buffer.alloc(DisplayInfo.BUFFER_LENGTH);
let offset = 0;
offset = temp.writeInt32BE(this.displayId, offset);
offset = temp.writeInt32BE(this.size.width, offset);
offset = temp.writeInt32BE(this.size.height, offset);
offset = temp.writeInt32BE(this.rotation, offset);
offset = temp.writeInt32BE(this.layerStack, offset);
temp.writeInt32BE(this.flags, offset);
return temp;
}
public toString(): string {
// prettier-ignore
return `DisplayInfo{displayId=${
this.displayId}, size=${
this.size}, rotation=${
this.rotation}, layerStack=${
this.layerStack}, flags=${
this.flags}}`;
}
public static fromBuffer(buffer: Buffer): DisplayInfo {
if (buffer.length !== DisplayInfo.BUFFER_LENGTH) {
throw Error(`Incorrect buffer length. Expected: ${DisplayInfo.BUFFER_LENGTH}, received: ${buffer.length}`);
}
let offset = 0;
const displayId = buffer.readInt32BE(offset);
offset += 4;
const width = buffer.readInt32BE(offset);
offset += 4;
const height = buffer.readInt32BE(offset);
offset += 4;
const rotation = buffer.readInt32BE(offset);
offset += 4;
const layerStack = buffer.readInt32BE(offset);
offset += 4;
const flags = buffer.readInt32BE(offset);
return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags);
}
}

3
src/app/ErrorHandler.ts Normal file
View File

@@ -0,0 +1,3 @@
export default class ErrorHandler {
constructor(readonly OnError: (ev: string | Event) => void) {}
}

19
src/app/MotionEvent.ts Normal file
View File

@@ -0,0 +1,19 @@
export default class MotionEvent {
public static ACTION_DOWN = 0;
public static ACTION_UP = 1;
public static ACTION_MOVE = 2;
/**
* Button constant: Primary button (left mouse button).
*/
public static BUTTON_PRIMARY: number = 1 << 0;
/**
* Button constant: Secondary button (right mouse button).
*/
public static BUTTON_SECONDARY: number = 1 << 1;
/**
* Button constant: Tertiary button (middle mouse button).
*/
public static BUTTON_TERTIARY: number = 1 << 2;
}

40
src/app/Point.ts Normal file
View File

@@ -0,0 +1,40 @@
export interface PointInterface {
x: number;
y: number;
}
export default class Point {
readonly x: number;
readonly y: number;
constructor(x: number, y: number) {
this.x = Math.round(x);
this.y = Math.round(y);
}
public equals(o: Point): boolean {
if (this === o) {
return true;
}
if (o === null) {
return false;
}
return this.x === o.x && this.y === o.y;
}
public distance(to: Point): number {
const x = this.x - to.x;
const y = this.y - to.y;
return Math.sqrt(x * x + y * y);
}
public toString(): string {
return `Point{x=${this.x}, y=${this.y}}`;
}
public toJSON(): PointInterface {
return {
x: this.x,
y: this.y,
};
}
}

55
src/app/Position.ts Normal file
View File

@@ -0,0 +1,55 @@
import Point, { PointInterface } from './Point';
import Size, { SizeInterface } from './Size';
export interface PositionInterface {
point: PointInterface;
screenSize: SizeInterface;
}
export default class Position {
public constructor(readonly point: Point, readonly screenSize: Size) {}
public equals(o: Position): boolean {
if (this === o) {
return true;
}
if (o === null) {
return false;
}
return this.point.equals(o.point) && this.screenSize.equals(o.screenSize);
}
public rotate(rotation: number): Position {
switch (rotation) {
case 1:
return new Position(
new Point(this.screenSize.height - this.point.y, this.point.x),
this.screenSize.rotate(),
);
case 2:
return new Position(
new Point(this.screenSize.width - this.point.x, this.screenSize.height - this.point.y),
this.screenSize,
);
case 3:
return new Position(
new Point(this.point.y, this.screenSize.width - this.point.x),
this.screenSize.rotate(),
);
default:
return this;
}
}
public toString(): string {
return `Position{point=${this.point}, screenSize=${this.screenSize}}`;
}
public toJSON(): PositionInterface {
return {
point: this.point.toJSON(),
screenSize: this.screenSize.toJSON(),
};
}
}

62
src/app/Rect.ts Normal file
View File

@@ -0,0 +1,62 @@
interface RectInterface {
left: number;
top: number;
right: number;
bottom: number;
}
export default class Rect {
constructor(readonly left: number, readonly top: number, readonly right: number, readonly bottom: number) {
this.left = left;
this.top = top;
this.right = right;
this.bottom = bottom;
}
public static equals(a?: Rect | null, b?: Rect | null): boolean {
if (!a && !b) {
return true;
}
return !!a && !!b && a.equals(b);
}
public static copy(a?: Rect | null): Rect | null {
if (!a) {
return null;
}
return new Rect(a.left, a.top, a.right, a.bottom);
}
public equals(o: Rect | null): boolean {
if (this === o) {
return true;
}
if (!o) {
return false;
}
return this.left === o.left && this.top === o.top && this.right === o.right && this.bottom === o.bottom;
}
public getWidth(): number {
return this.right - this.left;
}
public getHeight(): number {
return this.bottom - this.top;
}
public toString(): string {
// prettier-ignore
return `Rect{left=${
this.left}, top=${
this.top}, right=${
this.right}, bottom=${
this.bottom}}`;
}
public toJSON(): RectInterface {
return {
left: this.left,
right: this.right,
top: this.top,
bottom: this.bottom,
};
}
}

33
src/app/ScreenInfo.ts Normal file
View File

@@ -0,0 +1,33 @@
import Rect from './Rect';
import Size from './Size';
export default class ScreenInfo {
public static readonly BUFFER_LENGTH: number = 25;
constructor(readonly contentRect: Rect, readonly videoSize: Size, readonly deviceRotation: number) {}
public static fromBuffer(buffer: Buffer): ScreenInfo {
const left = buffer.readInt32BE(0);
const top = buffer.readInt32BE(4);
const right = buffer.readInt32BE(8);
const bottom = buffer.readInt32BE(12);
const width = buffer.readInt32BE(16);
const height = buffer.readInt32BE(20);
const deviceRotation = buffer.readUInt8(24);
return new ScreenInfo(new Rect(left, top, right, bottom), new Size(width, height), deviceRotation);
}
public equals(o?: ScreenInfo | null): boolean {
if (!o) {
return false;
}
return (
this.contentRect.equals(o.contentRect) &&
this.videoSize.equals(o.videoSize) &&
this.deviceRotation === o.deviceRotation
);
}
public toString(): string {
return `ScreenInfo{contentRect=${this.contentRect}, videoSize=${this.videoSize}, deviceRotation=${this.deviceRotation}}`;
}
}

70
src/app/Size.ts Normal file
View File

@@ -0,0 +1,70 @@
export interface SizeInterface {
width: number;
height: number;
}
export default class Size {
public readonly w: number;
public readonly h: number;
constructor(readonly width: number, readonly height: number) {
this.w = width;
this.h = height;
}
public static equals(a?: Size | null, b?: Size | null): boolean {
if (!a && !b) {
return true;
}
return !!a && !!b && a.equals(b);
}
public static copy(a?: Size | null): Size | null {
if (!a) {
return null;
}
return new Size(a.width, a.height);
}
length(): number {
return this.w * this.h;
}
public rotate(): Size {
return new Size(this.height, this.width);
}
public equals(o: Size | null | undefined): boolean {
if (this === o) {
return true;
}
if (!o) {
return false;
}
return this.width === o.width && this.height === o.height;
}
public intersect(o: Size | undefined | null): Size {
if (!o) {
return this;
}
const minH = Math.min(this.height, o.height);
const minW = Math.min(this.width, o.width);
return new Size(minW, minH);
}
public getHalfSize(): Size {
return new Size(this.width >>> 1, this.height >>> 1);
}
public toString(): string {
return `Size{width=${this.width}, height=${this.height}}`;
}
public toJSON(): SizeInterface {
return {
width: this.width,
height: this.height,
};
}
}

191
src/app/UIEventsCode.ts Normal file
View File

@@ -0,0 +1,191 @@
// https://w3c.github.io/uievents-code/
export default class UIEventsCode {
// 3.1.1.1. Writing System Keys
public static readonly Backquote: string = 'Backquote';
public static readonly Backslash: string = 'Backslash';
public static readonly BracketLeft: string = 'BracketLeft';
public static readonly BracketRight: string = 'BracketRight';
public static readonly Comma: string = 'Comma';
public static readonly Digit0: string = 'Digit0';
public static readonly Digit1: string = 'Digit1';
public static readonly Digit2: string = 'Digit2';
public static readonly Digit3: string = 'Digit3';
public static readonly Digit4: string = 'Digit4';
public static readonly Digit5: string = 'Digit5';
public static readonly Digit6: string = 'Digit6';
public static readonly Digit7: string = 'Digit7';
public static readonly Digit8: string = 'Digit8';
public static readonly Digit9: string = 'Digit9';
public static readonly Equal: string = 'Equal';
public static readonly IntlBackslash: string = 'IntlBackslash';
public static readonly IntlRo: string = 'IntlRo';
public static readonly IntlYen: string = 'IntlYen';
public static readonly KeyA: string = 'KeyA';
public static readonly KeyB: string = 'KeyB';
public static readonly KeyC: string = 'KeyC';
public static readonly KeyD: string = 'KeyD';
public static readonly KeyE: string = 'KeyE';
public static readonly KeyF: string = 'KeyF';
public static readonly KeyG: string = 'KeyG';
public static readonly KeyH: string = 'KeyH';
public static readonly KeyI: string = 'KeyI';
public static readonly KeyJ: string = 'KeyJ';
public static readonly KeyK: string = 'KeyK';
public static readonly KeyL: string = 'KeyL';
public static readonly KeyM: string = 'KeyM';
public static readonly KeyN: string = 'KeyN';
public static readonly KeyO: string = 'KeyO';
public static readonly KeyP: string = 'KeyP';
public static readonly KeyQ: string = 'KeyQ';
public static readonly KeyR: string = 'KeyR';
public static readonly KeyS: string = 'KeyS';
public static readonly KeyT: string = 'KeyT';
public static readonly KeyU: string = 'KeyU';
public static readonly KeyV: string = 'KeyV';
public static readonly KeyW: string = 'KeyW';
public static readonly KeyX: string = 'KeyX';
public static readonly KeyY: string = 'KeyY';
public static readonly KeyZ: string = 'KeyZ';
public static readonly Minus: string = 'Minus';
public static readonly Period: string = 'Period';
public static readonly Quote: string = 'Quote';
public static readonly Semicolon: string = 'Semicolon';
public static readonly Slash: string = 'Slash';
// 3.1.1.2. Functional Keys
public static readonly AltLeft: string = 'AltLeft';
public static readonly AltRight: string = 'AltRight';
public static readonly Backspace: string = 'Backspace';
public static readonly CapsLock: string = 'CapsLock';
public static readonly ContextMenu: string = 'ContextMenu';
public static readonly ControlLeft: string = 'ControlLeft';
public static readonly ControlRight: string = 'ControlRight';
public static readonly Enter: string = 'Enter';
public static readonly MetaLeft: string = 'MetaLeft';
public static readonly MetaRight: string = 'MetaRight';
public static readonly ShiftLeft: string = 'ShiftLeft';
public static readonly ShiftRight: string = 'ShiftRight';
public static readonly Space: string = 'Space';
public static readonly Tab: string = 'Tab';
public static readonly Convert: string = 'Convert';
public static readonly KanaMode: string = 'KanaMode';
public static readonly Lang1: string = 'Lang1';
public static readonly Lang2: string = 'Lang2';
public static readonly Lang3: string = 'Lang3';
public static readonly Lang4: string = 'Lang4';
public static readonly Lang5: string = 'Lang5';
public static readonly NonConvert: string = 'NonConvert';
// 3.1.2. Control Pad Section
public static readonly Delete: string = 'Delete';
public static readonly End: string = 'End';
public static readonly Help: string = 'Help';
public static readonly Home: string = 'Home';
public static readonly Insert: string = 'Insert';
public static readonly PageDown: string = 'PageDown';
public static readonly PageUp: string = 'PageUp';
// 3.1.3. Arrow Pad Section
public static readonly ArrowDown: string = 'ArrowDown';
public static readonly ArrowLeft: string = 'ArrowLeft';
public static readonly ArrowRight: string = 'ArrowRight';
public static readonly ArrowUp: string = 'ArrowUp';
// 3.1.4. Numpad Section
public static readonly NumLock: string = 'NumLock';
public static readonly Numpad0: string = 'Numpad0';
public static readonly Numpad1: string = 'Numpad1';
public static readonly Numpad2: string = 'Numpad2';
public static readonly Numpad3: string = 'Numpad3';
public static readonly Numpad4: string = 'Numpad4';
public static readonly Numpad5: string = 'Numpad5';
public static readonly Numpad6: string = 'Numpad6';
public static readonly Numpad7: string = 'Numpad7';
public static readonly Numpad8: string = 'Numpad8';
public static readonly Numpad9: string = 'Numpad9';
public static readonly NumpadAdd: string = 'NumpadAdd';
public static readonly NumpadBackspace: string = 'NumpadBackspace';
public static readonly NumpadClear: string = 'NumpadClear';
public static readonly NumpadClearEntry: string = 'NumpadClearEntry';
public static readonly NumpadComma: string = 'NumpadComma';
public static readonly NumpadDecimal: string = 'NumpadDecimal';
public static readonly NumpadDivide: string = 'NumpadDivide';
public static readonly NumpadEnter: string = 'NumpadEnter';
public static readonly NumpadEqual: string = 'NumpadEqual';
public static readonly NumpadHash: string = 'NumpadHash';
public static readonly NumpadMemoryAdd: string = 'NumpadMemoryAdd';
public static readonly NumpadMemoryClear: string = 'NumpadMemoryClear';
public static readonly NumpadMemoryRecall: string = 'NumpadMemoryRecall';
public static readonly NumpadMemoryStore: string = 'NumpadMemoryStore';
public static readonly NumpadMemorySubtract: string = 'NumpadMemorySubtract';
public static readonly NumpadMultiply: string = 'NumpadMultiply';
public static readonly NumpadParenLeft: string = 'NumpadParenLeft';
public static readonly NumpadParenRight: string = 'NumpadParenRight';
public static readonly NumpadStar: string = 'NumpadStar';
public static readonly NumpadSubtract: string = 'NumpadSubtract';
// 3.1.5. Function Section
public static readonly Escape: string = 'Escape';
public static readonly F1: string = 'F1';
public static readonly F2: string = 'F2';
public static readonly F3: string = 'F3';
public static readonly F4: string = 'F4';
public static readonly F5: string = 'F5';
public static readonly F6: string = 'F6';
public static readonly F7: string = 'F7';
public static readonly F8: string = 'F8';
public static readonly F9: string = 'F9';
public static readonly F10: string = 'F10';
public static readonly F11: string = 'F11';
public static readonly F12: string = 'F12';
public static readonly Fn: string = 'Fn';
public static readonly FnLock: string = 'FnLock';
public static readonly PrintScreen: string = 'PrintScreen';
public static readonly ScrollLock: string = 'ScrollLock';
public static readonly Pause: string = 'Pause';
// 3.1.6. Media Keys
public static readonly BrowserBack: string = 'BrowserBack';
public static readonly BrowserFavorites: string = 'BrowserFavorites';
public static readonly BrowserForward: string = 'BrowserForward';
public static readonly BrowserHome: string = 'BrowserHome';
public static readonly BrowserRefresh: string = 'BrowserRefresh';
public static readonly BrowserSearch: string = 'BrowserSearch';
public static readonly BrowserStop: string = 'BrowserStop';
public static readonly Eject: string = 'Eject';
public static readonly LaunchApp1: string = 'LaunchApp1';
public static readonly LaunchApp2: string = 'LaunchApp2';
public static readonly LaunchMail: string = 'LaunchMail';
public static readonly MediaPlayPause: string = 'MediaPlayPause';
public static readonly MediaSelect: string = 'MediaSelect';
public static readonly MediaStop: string = 'MediaStop';
public static readonly MediaTrackNext: string = 'MediaTrackNext';
public static readonly MediaTrackPrevious: string = 'MediaTrackPrevious';
public static readonly Power: string = 'Power';
public static readonly Sleep: string = 'Sleep';
public static readonly AudioVolumeDown: string = 'AudioVolumeDown';
public static readonly AudioVolumeMute: string = 'AudioVolumeMute';
public static readonly AudioVolumeUp: string = 'AudioVolumeUp';
public static readonly WakeUp: string = 'WakeUp';
// 3.1.7. Legacy, Non-Standard and Special Keys
public static readonly Hyper: string = 'Hyper';
public static readonly Super: string = 'Super';
public static readonly Turbo: string = 'Turbo';
public static readonly Abort: string = 'Abort';
public static readonly Resume: string = 'Resume';
public static readonly Suspend: string = 'Suspend';
public static readonly Again: string = 'Again';
public static readonly Copy: string = 'Copy';
public static readonly Cut: string = 'Cut';
public static readonly Find: string = 'Find';
public static readonly Open: string = 'Open';
public static readonly Paste: string = 'Paste';
public static readonly Props: string = 'Props';
public static readonly Select: string = 'Select';
public static readonly Undo: string = 'Undo';
public static readonly Hiragana: string = 'Hiragana';
public static readonly Katakana: string = 'Katakana';
public static readonly Unidentified: string = 'Unidentified';
}

219
src/app/Util.ts Normal file
View File

@@ -0,0 +1,219 @@
export default class Util {
private static SUFFIX: Record<number, string> = {
0: 'B',
1: 'KiB',
2: 'MiB',
3: 'GiB',
4: 'TiB',
};
private static supportsPassiveValue: boolean | undefined;
public static filterTrailingZeroes(bytes: Uint8Array): Uint8Array {
let b = 0;
return bytes
.reverse()
.filter((i) => b || (b = i))
.reverse();
}
public static prettyBytes(value: number): string {
let suffix = 0;
while (value >= 512) {
suffix++;
value /= 1024;
}
return `${value.toFixed(suffix ? 1 : 0)}${Util.SUFFIX[suffix]}`;
}
public static escapeUdid(udid: string): string {
return 'udid_' + udid.replace(/[. :]/g, '_');
}
public static parse(params: URLSearchParams, name: string, required?: boolean): string | null {
const value = params.get(name);
if (required && value === null) {
throw TypeError(`Missing required parameter "${name}"`);
}
return value;
}
public static parseString(params: URLSearchParams, name: string, required?: boolean): string {
const value = params.get(name);
if (required && value === null) {
throw TypeError(`Missing required parameter "${name}"`);
}
return value || '';
}
public static parseBoolean(params: URLSearchParams, name: string, required?: boolean): boolean {
const value = this.parse(params, name, required);
return value === '1' || (!!value && value.toString() === 'true');
}
public static parseInt(params: URLSearchParams, name: string, required?: boolean): number {
const value = this.parse(params, name, required);
if (value === null) {
return 0;
}
const int = parseInt(value, 10);
if (isNaN(int)) {
return 0;
}
return int;
}
public static parseBooleanEnv(input: string | string[] | boolean | undefined | null): boolean | undefined {
if (typeof input === 'boolean') {
return input;
}
if (typeof input === 'undefined' || input === null) {
return undefined;
}
if (Array.isArray(input)) {
input = input[input.length - 1];
}
return input === '1' || input.toLowerCase() === 'true';
}
public static parseStringEnv(input: string | string[] | undefined | null): string | undefined {
if (typeof input === 'undefined' || input === null) {
return undefined;
}
if (Array.isArray(input)) {
input = input[input.length - 1];
}
return input;
}
public static parseIntEnv(input: string | string[] | number | undefined | null): number | undefined {
if (typeof input === 'number') {
return input;
}
if (typeof input === 'undefined' || input === null) {
return undefined;
}
if (Array.isArray(input)) {
input = input[input.length - 1];
}
const int = parseInt(input, 10);
if (isNaN(int)) {
return undefined;
}
return int;
}
// https://github.com/google/closure-library/blob/51e5a5ac373aefa354a991816ec418d730e29a7e/closure/goog/crypt/crypt.js#L117
/*
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS-IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/* tslint:disable */
/**
* Converts a JS string to a UTF-8 "byte" array.
* @param {string} str 16-bit unicode string.
* @return {!Array<number>} UTF-8 byte array.
*/
static stringToUtf8ByteArray = function(str: string) {
// TODO(user): Use native implementations if/when available
var out = [], p = 0;
for (var i = 0; i < str.length; i++) {
var c = str.charCodeAt(i);
if (c < 128) {
out[p++] = c;
} else if (c < 2048) {
out[p++] = (c >> 6) | 192;
out[p++] = (c & 63) | 128;
} else if (
((c & 0xFC00) == 0xD800) && (i + 1) < str.length &&
((str.charCodeAt(i + 1) & 0xFC00) == 0xDC00)) {
// Surrogate Pair
c = 0x10000 + ((c & 0x03FF) << 10) + (str.charCodeAt(++i) & 0x03FF);
out[p++] = (c >> 18) | 240;
out[p++] = ((c >> 12) & 63) | 128;
out[p++] = ((c >> 6) & 63) | 128;
out[p++] = (c & 63) | 128;
} else {
out[p++] = (c >> 12) | 224;
out[p++] = ((c >> 6) & 63) | 128;
out[p++] = (c & 63) | 128;
}
}
return Uint8Array.from(out);
};
/**
* Converts a UTF-8 byte array to JavaScript's 16-bit Unicode.
* @param {Uint8Array|Array<number>} bytes UTF-8 byte array.
* @return {string} 16-bit Unicode string.
*/
static utf8ByteArrayToString(bytes: Uint8Array): string {
// TODO(user): Use native implementations if/when available
var out = [], pos = 0, c = 0;
while (pos < bytes.length) {
var c1 = bytes[pos++];
if (c1 < 128) {
out[c++] = String.fromCharCode(c1);
} else if (c1 > 191 && c1 < 224) {
var c2 = bytes[pos++];
out[c++] = String.fromCharCode((c1 & 31) << 6 | c2 & 63);
} else if (c1 > 239 && c1 < 365) {
// Surrogate Pair
var c2 = bytes[pos++];
var c3 = bytes[pos++];
var c4 = bytes[pos++];
var u = ((c1 & 7) << 18 | (c2 & 63) << 12 | (c3 & 63) << 6 | c4 & 63) -
0x10000;
out[c++] = String.fromCharCode(0xD800 + (u >> 10));
out[c++] = String.fromCharCode(0xDC00 + (u & 1023));
} else {
var c2 = bytes[pos++];
var c3 = bytes[pos++];
out[c++] =
String.fromCharCode((c1 & 15) << 12 | (c2 & 63) << 6 | c3 & 63);
}
}
return out.join('');
};
/* tslint:enable */
// https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md
static supportsPassive(): boolean {
if (typeof Util.supportsPassiveValue === 'boolean') {
return Util.supportsPassiveValue;
}
// Test via a getter in the options object to see if the passive property is accessed
let supportsPassive = false;
try {
const opts = Object.defineProperty({}, 'passive', {
get: function() {
supportsPassive = true;
}
});
// @ts-ignore
window.addEventListener('testPassive', null, opts);
// @ts-ignore
window.removeEventListener('testPassive', null, opts);
} catch (error: any) {}
return Util.supportsPassiveValue = supportsPassive;
// Use our detect's results. passive applied if supported, capture will be false either way.
// elem.addEventListener('touchstart', fn, supportsPassive ? { passive: true } : false);
}
static setImmediate(fn: () => any): void {
Promise.resolve().then(fn);
}
}

228
src/app/VideoSettings.ts Normal file
View File

@@ -0,0 +1,228 @@
import Rect from './Rect';
import Size from './Size';
import Util from './Util';
interface Settings {
crop?: Rect | null;
bitrate: number;
bounds?: Size | null;
maxFps: number;
iFrameInterval: number;
sendFrameMeta?: boolean;
lockedVideoOrientation?: number;
displayId?: number;
codecOptions?: string;
encoderName?: string;
}
export default class VideoSettings {
public static readonly BASE_BUFFER_LENGTH: number = 35;
public readonly crop?: Rect | null = null;
public readonly bitrate: number = 0;
public readonly bounds?: Size | null = null;
public readonly maxFps: number = 0;
public readonly iFrameInterval: number = 0;
public readonly sendFrameMeta: boolean = false;
public readonly lockedVideoOrientation: number = -1;
public readonly displayId: number = 0;
public readonly codecOptions?: string;
public readonly encoderName?: string;
constructor(data?: Settings, public readonly bytesLength: number = VideoSettings.BASE_BUFFER_LENGTH) {
if (data) {
this.crop = data.crop;
this.bitrate = data.bitrate;
this.bounds = data.bounds;
this.maxFps = data.maxFps;
this.iFrameInterval = data.iFrameInterval;
this.sendFrameMeta = data.sendFrameMeta || false;
this.lockedVideoOrientation = data.lockedVideoOrientation || -1;
if (typeof data.displayId === 'number' && !isNaN(data.displayId) && data.displayId >= 0) {
this.displayId = data.displayId;
}
if (data.codecOptions) {
this.codecOptions = data.codecOptions.trim();
}
if (data.encoderName) {
this.encoderName = data.encoderName.trim();
}
}
}
public static fromBuffer(buffer: Buffer): VideoSettings {
let offset = 0;
const bitrate = buffer.readInt32BE(offset);
offset += 4;
const maxFps = buffer.readInt32BE(offset);
offset += 4;
const iFrameInterval = buffer.readInt8(offset);
offset += 1;
const width = buffer.readInt16BE(offset);
offset += 2;
const height = buffer.readInt16BE(offset);
offset += 2;
const left = buffer.readInt16BE(offset);
offset += 2;
const top = buffer.readInt16BE(offset);
offset += 2;
const right = buffer.readInt16BE(offset);
offset += 2;
const bottom = buffer.readInt16BE(offset);
offset += 2;
const sendFrameMeta = !!buffer.readInt8(offset);
offset += 1;
const lockedVideoOrientation = buffer.readInt8(offset);
offset += 1;
const displayId = buffer.readInt32BE(offset);
offset += 4;
let bounds: Size | null = null;
let crop: Rect | null = null;
if (width !== 0 && height !== 0) {
bounds = new Size(width, height);
}
if (left || top || right || bottom) {
crop = new Rect(left, top, right, bottom);
}
let codecOptions;
let encoderName;
const codecOptionsLength = buffer.readInt32BE(offset);
offset += 4;
if (codecOptionsLength) {
const codecOptionsBytes = buffer.slice(offset, offset + codecOptionsLength);
offset += codecOptionsLength;
codecOptions = Util.utf8ByteArrayToString(codecOptionsBytes);
}
const encoderNameLength = buffer.readInt32BE(offset);
offset += 4;
if (encoderNameLength) {
const encoderNameBytes = buffer.slice(offset, offset + encoderNameLength);
offset += encoderNameLength;
encoderName = Util.utf8ByteArrayToString(encoderNameBytes);
}
return new VideoSettings(
{
crop,
bitrate,
bounds,
maxFps,
iFrameInterval,
lockedVideoOrientation,
displayId,
sendFrameMeta,
codecOptions,
encoderName,
},
offset,
);
}
public static copy(a: VideoSettings): VideoSettings {
return new VideoSettings(
{
bitrate: a.bitrate,
crop: Rect.copy(a.crop),
bounds: Size.copy(a.bounds),
maxFps: a.maxFps,
iFrameInterval: a.iFrameInterval,
lockedVideoOrientation: a.lockedVideoOrientation,
displayId: a.displayId,
sendFrameMeta: a.sendFrameMeta,
codecOptions: a.codecOptions,
encoderName: a.encoderName,
},
a.bytesLength,
);
}
public equals(o?: VideoSettings | null): boolean {
if (!o) {
return false;
}
return (
this.encoderName === o.encoderName &&
this.codecOptions === o.codecOptions &&
Rect.equals(this.crop, o.crop) &&
this.lockedVideoOrientation === o.lockedVideoOrientation &&
this.displayId === o.displayId &&
Size.equals(this.bounds, o.bounds) &&
this.bitrate === o.bitrate &&
this.maxFps === o.maxFps &&
this.iFrameInterval === o.iFrameInterval
);
}
public toBuffer(): Buffer {
let additionalLength = 0;
let codecOptionsBytes;
let encoderNameBytes;
if (this.codecOptions) {
codecOptionsBytes = Util.stringToUtf8ByteArray(this.codecOptions);
additionalLength += codecOptionsBytes.length;
}
if (this.encoderName) {
encoderNameBytes = Util.stringToUtf8ByteArray(this.encoderName);
additionalLength += encoderNameBytes.length;
}
const buffer = Buffer.alloc(VideoSettings.BASE_BUFFER_LENGTH + additionalLength);
const { width = 0, height = 0 } = this.bounds || {};
const { left = 0, top = 0, right = 0, bottom = 0 } = this.crop || {};
let offset = 0;
offset = buffer.writeInt32BE(this.bitrate, offset);
offset = buffer.writeInt32BE(this.maxFps, offset);
offset = buffer.writeInt8(this.iFrameInterval, offset);
offset = buffer.writeInt16BE(width, offset);
offset = buffer.writeInt16BE(height, offset);
offset = buffer.writeInt16BE(left, offset);
offset = buffer.writeInt16BE(top, offset);
offset = buffer.writeInt16BE(right, offset);
offset = buffer.writeInt16BE(bottom, offset);
offset = buffer.writeInt8(this.sendFrameMeta ? 1 : 0, offset);
offset = buffer.writeInt8(this.lockedVideoOrientation, offset);
offset = buffer.writeInt32BE(this.displayId, offset);
if (codecOptionsBytes) {
offset = buffer.writeInt32BE(codecOptionsBytes.length, offset);
buffer.fill(codecOptionsBytes, offset);
offset += codecOptionsBytes.length;
} else {
offset = buffer.writeInt32BE(0, offset);
}
if (encoderNameBytes) {
offset = buffer.writeInt32BE(encoderNameBytes.length, offset);
buffer.fill(encoderNameBytes, offset);
offset += encoderNameBytes.length;
} else {
buffer.writeInt32BE(0, offset);
}
return buffer;
}
public toString(): string {
// prettier-ignore
return `VideoSettings{bitrate=${
this.bitrate}, maxFps=${
this.maxFps}, iFrameInterval=${
this.iFrameInterval}, bounds=${
this.bounds}, crop=${
this.crop}, metaFrame=${
this.sendFrameMeta}, lockedVideoOrientation=${
this.lockedVideoOrientation}, displayId=${
this.displayId}, codecOptions=${
this.codecOptions}, encoderName=${
this.encoderName}}`;
}
public toJSON(): Settings {
return {
bitrate: this.bitrate,
maxFps: this.maxFps,
iFrameInterval: this.iFrameInterval,
bounds: this.bounds,
crop: this.crop,
sendFrameMeta: this.sendFrameMeta,
lockedVideoOrientation: this.lockedVideoOrientation,
displayId: this.displayId,
codecOptions: this.codecOptions,
encoderName: this.encoderName,
};
}
}

View File

@@ -0,0 +1,80 @@
import { BaseDeviceTracker } from '../../client/BaseDeviceTracker';
import { ACTION } from '../../../common/Action';
import ApplDeviceDescriptor from '../../../types/ApplDeviceDescriptor';
import Util from '../../Util';
import { html } from '../../ui/HtmlTag';
import { DeviceState } from '../../../common/DeviceState';
import { HostItem } from '../../../types/Configuration';
import { ChannelCode } from '../../../common/ChannelCode';
import { Tool } from '../../client/Tool';
export class DeviceTracker extends BaseDeviceTracker<ApplDeviceDescriptor, never> {
public static ACTION = ACTION.APPL_DEVICE_LIST;
protected static tools: Set<Tool> = new Set();
private static instancesByUrl: Map<string, DeviceTracker> = new Map();
public static start(hostItem: HostItem): DeviceTracker {
const url = this.buildUrlForTracker(hostItem).toString();
let instance = this.instancesByUrl.get(url);
if (!instance) {
instance = new DeviceTracker(hostItem, url);
}
return instance;
}
public static getInstance(hostItem: HostItem): DeviceTracker {
return this.start(hostItem);
}
protected tableId = 'appl_devices_list';
constructor(params: HostItem, directUrl: string) {
super({ ...params, action: DeviceTracker.ACTION }, directUrl);
DeviceTracker.instancesByUrl.set(directUrl, this);
this.buildDeviceTable();
this.openNewConnection();
}
protected onSocketOpen(): void {
// do nothing;
}
protected buildDeviceRow(tbody: Element, device: ApplDeviceDescriptor): void {
const blockClass = 'desc-block';
const fullName = `${this.id}_${Util.escapeUdid(device.udid)}`;
const isActive = device.state === DeviceState.CONNECTED;
const servicesId = `device_services_${fullName}`;
const row = html`<div class="device ${isActive ? 'active' : 'not-active'}">
<div class="device-header">
<div class="device-name">"${device.name}"</div>
<div class="device-model">${device.model}</div>
<div class="device-serial">${device.udid}</div>
<div class="device-version">
<div class="release-version">${device.version}</div>
</div>
<div class="device-state" title="State: ${device.state}"></div>
</div>
<div id="${servicesId}" class="services"></div>
</div>`.content;
const services = row.getElementById(servicesId);
if (!services) {
return;
}
DeviceTracker.tools.forEach((tool) => {
const entry = tool.createEntryForDeviceList(device, blockClass, this.params);
if (entry) {
if (Array.isArray(entry)) {
entry.forEach((item) => {
item && services.appendChild(item);
});
} else {
services.appendChild(entry);
}
}
});
tbody.appendChild(row);
}
protected getChannelCode(): string {
return ChannelCode.ATRC;
}
}

View File

@@ -0,0 +1,247 @@
import { BaseClient } from '../../client/BaseClient';
import { ParamsStream } from '../../../types/ParamsStream';
import { SimpleInteractionHandler } from '../../interactionHandler/SimpleInteractionHandler';
import { BasePlayer, PlayerClass } from '../../player/BasePlayer';
import ScreenInfo from '../../ScreenInfo';
import { WdaProxyClient } from './WdaProxyClient';
import { ACTION } from '../../../common/Action';
import { ApplMoreBox } from '../toolbox/ApplMoreBox';
import { ApplToolBox } from '../toolbox/ApplToolBox';
import Size from '../../Size';
import Util from '../../Util';
import ApplDeviceDescriptor from '../../../types/ApplDeviceDescriptor';
import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
import { DeviceTracker } from './DeviceTracker';
import { WdaStatus } from '../../../common/WdaStatus';
import { MessageRunWdaResponse } from '../../../types/MessageRunWdaResponse';
const WAIT_CLASS = 'wait';
const TAG = 'StreamClient';
export interface StreamClientEvents {
'wda:status': WdaStatus;
}
export abstract class StreamClient<T extends ParamsStream> extends BaseClient<T, StreamClientEvents> {
public static ACTION = 'MUST_OVERRIDE';
protected static players: Map<string, PlayerClass> = new Map<string, PlayerClass>();
public static registerPlayer(playerClass: PlayerClass): void {
if (playerClass.isSupported()) {
this.players.set(playerClass.playerFullName, playerClass);
}
}
public static getPlayers(): PlayerClass[] {
return Array.from(this.players.values());
}
private static getPlayerClass(playerName?: string): PlayerClass | undefined {
let playerClass: PlayerClass | undefined;
for (const value of this.players.values()) {
if (value.playerFullName === playerName || value.playerCodeName === playerName) {
playerClass = value;
}
}
return playerClass;
}
public static createPlayer(udid: string, playerName?: string): BasePlayer {
if (!playerName) {
throw Error('Must provide player name');
}
const playerClass = this.getPlayerClass(playerName);
if (!playerClass) {
throw Error(`Unsupported player "${playerName}"`);
}
return new playerClass(udid);
}
public static createEntryForDeviceList(
descriptor: ApplDeviceDescriptor,
blockClass: string,
params: ParamsDeviceTracker,
): Array<HTMLElement | DocumentFragment | undefined> {
const entries: Array<HTMLElement | DocumentFragment> = [];
const players = this.getPlayers();
players.forEach((playerClass) => {
const { playerCodeName, playerFullName } = playerClass;
const playerTd = document.createElement('div');
playerTd.classList.add(blockClass);
playerTd.setAttribute(DeviceTracker.AttributePlayerFullName, encodeURIComponent(playerFullName));
playerTd.setAttribute(DeviceTracker.AttributePlayerCodeName, encodeURIComponent(playerCodeName));
const q: any = {
action: this.ACTION,
player: playerCodeName,
udid: descriptor.udid,
};
const link = DeviceTracker.buildLink(q, `Stream (${playerFullName})`, params);
playerTd.appendChild(link);
entries.push(playerTd);
});
return entries;
}
protected static getMaxSize(controlButtons: HTMLElement): Size | undefined {
if (!controlButtons) {
return;
}
const body = document.body;
const width = (body.clientWidth - controlButtons.clientWidth) & ~15;
const height = body.clientHeight & ~15;
return new Size(width, height);
}
private waitForWda?: Promise<void>;
protected touchHandler?: SimpleInteractionHandler;
protected readonly wdaProxy: WdaProxyClient;
protected name: string;
protected udid: string;
protected deviceName = '';
protected videoWrapper: HTMLElement;
protected deviceView?: HTMLDivElement;
protected moreBox?: HTMLElement;
protected player?: BasePlayer;
protected constructor(params: T) {
super(params);
this.udid = this.params.udid;
this.wdaProxy = new WdaProxyClient({ ...this.params, action: ACTION.PROXY_WDA });
this.name = `[${TAG}:${this.udid}]`;
this.videoWrapper = document.createElement('div');
this.videoWrapper.className = `video`;
this.setWdaStatusNotification(WdaStatus.STARTING);
}
public static get action(): string {
return StreamClient.ACTION;
}
public static parseParameters(params: URLSearchParams): ParamsStream {
const typedParams = super.parseParameters(params);
const { action } = typedParams;
if (action !== this.action) {
throw Error('Incorrect action');
}
return {
...typedParams,
action,
udid: Util.parseString(params, 'udid', true),
player: Util.parseString(params, 'player', true),
};
}
public createPlayer(udid: string, playerName?: string): BasePlayer {
return StreamClient.createPlayer(udid, playerName);
}
public getMaxSize(controlButtons: HTMLElement): Size | undefined {
return StreamClient.getMaxSize(controlButtons);
}
protected async runWebDriverAgent(): Promise<void> {
if (!this.waitForWda) {
this.wdaProxy.on('wda-status', this.handleWdaStatus);
this.waitForWda = this.wdaProxy.runWebDriverAgent().then(this.handleWdaStatus);
}
return this.waitForWda;
}
protected handleWdaStatus = (message: MessageRunWdaResponse): void => {
const data = message.data;
this.setWdaStatusNotification(data.status);
switch (data.status) {
case WdaStatus.STARTING:
case WdaStatus.STARTED:
case WdaStatus.STOPPED:
this.emit('wda:status', data.status);
break;
default:
throw Error(`Unknown WDA status: '${status}'`);
}
};
protected setTouchListeners(player: BasePlayer): void {
if (this.touchHandler) {
return;
}
this.touchHandler = new SimpleInteractionHandler(player, this.wdaProxy);
}
protected onInputVideoResize = (screenInfo: ScreenInfo): void => {
this.wdaProxy.setScreenInfo(screenInfo);
};
public onStop(ev?: string | Event): void {
if (ev && ev instanceof Event && ev.type === 'error') {
console.error(TAG, ev);
}
if (this.deviceView) {
const parent = this.deviceView.parentElement;
if (parent) {
parent.removeChild(this.deviceView);
}
}
if (this.moreBox) {
const parent = this.moreBox.parentElement;
if (parent) {
parent.removeChild(this.moreBox);
}
}
this.wdaProxy.stop();
this.player?.stop();
}
public setWdaStatusNotification(status: WdaStatus): void {
// TODO: use proper notification instead of `cursor: wait`
if (status === WdaStatus.STARTED || status === WdaStatus.STOPPED) {
this.videoWrapper.classList.remove(WAIT_CLASS);
} else {
this.videoWrapper.classList.add(WAIT_CLASS);
}
}
protected createMoreBox(udid: string, player: BasePlayer): ApplMoreBox {
return new ApplMoreBox(udid, player, this.wdaProxy);
}
protected startStream(inputPlayer?: BasePlayer): void {
const { udid, player: playerName } = this.params;
if (!udid) {
throw Error(`Invalid udid value: "${udid}"`);
}
let player: BasePlayer;
if (inputPlayer) {
player = inputPlayer;
} else {
player = this.createPlayer(udid, playerName);
}
this.setTouchListeners(player);
player.pause();
const deviceView = document.createElement('div');
deviceView.className = 'device-view';
const applMoreBox = this.createMoreBox(udid, player);
applMoreBox.setOnStop(this);
const moreBox: HTMLElement = applMoreBox.getHolderElement();
const applToolBox = ApplToolBox.createToolBox(udid, player, this, this.wdaProxy, moreBox);
const controlButtons = applToolBox.getHolderElement();
deviceView.appendChild(controlButtons);
deviceView.appendChild(this.videoWrapper);
deviceView.appendChild(moreBox);
player.setParent(this.videoWrapper);
player.on('input-video-resize', this.onInputVideoResize);
document.body.appendChild(deviceView);
const bounds = this.getMaxSize(controlButtons);
if (bounds) {
player.setBounds(bounds);
}
this.player = player;
}
public getDeviceName(): string {
return this.deviceName;
}
}

View File

@@ -0,0 +1,50 @@
import { ParamsStream } from '../../../types/ParamsStream';
import { ACTION } from '../../../common/Action';
import { StreamClient } from './StreamClient';
import { BasePlayer, PlayerClass } from '../../player/BasePlayer';
import { WdaStatus } from '../../../common/WdaStatus';
import { ApplMjpegMoreBox } from '../toolbox/ApplMjpegMoreBox';
const TAG = '[StreamClientMJPEG]';
export class StreamClientMJPEG extends StreamClient<ParamsStream> {
public static ACTION = ACTION.STREAM_MJPEG;
protected static players: Map<string, PlayerClass> = new Map<string, PlayerClass>();
public static start(params: ParamsStream): StreamClientMJPEG {
return new StreamClientMJPEG(params);
}
constructor(params: ParamsStream) {
super(params);
this.name = `[${TAG}:${this.udid}]`;
this.udid = this.params.udid;
this.runWebDriverAgent().then(() => {
this.startStream();
this.player?.play();
});
this.on('wda:status', (status) => {
if (status === WdaStatus.STOPPED) {
this.player?.stop();
} else if (status === WdaStatus.STARTED) {
this.player?.play();
}
});
}
public static get action(): string {
return StreamClientMJPEG.ACTION;
}
public createPlayer(udid: string, playerName?: string): BasePlayer {
return StreamClientMJPEG.createPlayer(udid, playerName);
}
public getDeviceName(): string {
return this.deviceName;
}
protected createMoreBox(udid: string, player: BasePlayer): ApplMjpegMoreBox {
return new ApplMjpegMoreBox(udid, player, this.wdaProxy);
}
}

View File

@@ -0,0 +1,72 @@
import { StreamReceiver } from '../../client/StreamReceiver';
import { BasePlayer, PlayerClass } from '../../player/BasePlayer';
import { ACTION } from '../../../common/Action';
import { StreamReceiverQVHack } from './StreamReceiverQVHack';
import { StreamClient } from './StreamClient';
import { ParamsStream } from '../../../types/ParamsStream';
const TAG = '[StreamClientQVHack]';
export class StreamClientQVHack extends StreamClient<ParamsStream> {
public static ACTION = ACTION.STREAM_WS_QVH;
protected static players: Map<string, PlayerClass> = new Map<string, PlayerClass>();
public static start(params: ParamsStream): StreamClientQVHack {
return new StreamClientQVHack(params);
}
private readonly streamReceiver: StreamReceiver<ParamsStream>;
constructor(params: ParamsStream) {
super(params);
this.name = `[${TAG}:${this.udid}]`;
this.udid = this.params.udid;
let udid = this.udid;
// Workaround for qvh v0.5-beta
if (udid.indexOf('-') !== -1) {
udid = udid.replace('-', '');
udid = udid + '\0'.repeat(16);
}
this.streamReceiver = new StreamReceiverQVHack({ ...this.params, udid });
this.startStream();
this.setTitle(`${this.udid} stream`);
this.setBodyClass('stream');
}
public static get action(): string {
return StreamClientQVHack.ACTION;
}
public createPlayer(udid: string, playerName?: string): BasePlayer {
return StreamClientQVHack.createPlayer(udid, playerName);
}
protected onViewVideoResize = (): void => {
this.runWebDriverAgent();
};
public onStop(ev?: string | Event): void {
super.onStop(ev);
this.streamReceiver.stop();
}
protected startStream(inputPlayer?: BasePlayer): void {
console.log("标识", inputPlayer);
super.startStream(inputPlayer);
const player = this.player;
if (player) {
player.on('video-view-resize', this.onViewVideoResize);
this.streamReceiver.on('video', (data) => {
const STATE = BasePlayer.STATE;
if (player.getState() === STATE.PAUSED) {
player.play();
}
if (player.getState() === STATE.PLAYING) {
player.pushFrame(new Uint8Array(data));
}
});
}
}
}

View File

@@ -0,0 +1,34 @@
import { StreamReceiver } from '../../client/StreamReceiver';
import { ACTION } from '../../../common/Action';
import Util from '../../Util';
import { ParamsStream } from '../../../types/ParamsStream';
import { ChannelCode } from '../../../common/ChannelCode';
export class StreamReceiverQVHack extends StreamReceiver<ParamsStream> {
public static parseParameters(params: URLSearchParams): ParamsStream {
const typedParams = super.parseParameters(params);
const { action } = typedParams;
if (action !== ACTION.STREAM_WS_QVH) {
throw Error('Incorrect action');
}
return {
...typedParams,
action,
player: Util.parseString(params, 'player', true),
udid: Util.parseString(params, 'udid', true),
};
}
protected supportMultiplexing(): boolean {
return true;
}
protected getChannelInitData(): Buffer {
const udid = Util.stringToUtf8ByteArray(this.params.udid);
const buffer = Buffer.alloc(4 + 4 + udid.byteLength);
buffer.write(ChannelCode.QVHS, 'ascii');
buffer.writeUInt32LE(udid.length, 4);
buffer.set(udid, 8);
return buffer;
}
}

View File

@@ -0,0 +1,309 @@
import { ManagerClient } from '../../client/ManagerClient';
import { MessageRunWdaResponse } from '../../../types/MessageRunWdaResponse';
import { Message } from '../../../types/Message';
import { ControlCenterCommand } from '../../../common/ControlCenterCommand';
import { ParamsWdaProxy } from '../../../types/ParamsWdaProxy';
import { ACTION } from '../../../common/Action';
import Util from '../../Util';
import { ChannelCode } from '../../../common/ChannelCode';
import { WDAMethod } from '../../../common/WDAMethod';
import ScreenInfo from '../../ScreenInfo';
import Position from '../../Position';
import Point from '../../Point';
import { TouchHandlerListener } from '../../interactionHandler/SimpleInteractionHandler';
export type WdaProxyClientEvents = {
'wda-status': MessageRunWdaResponse;
connected: boolean;
};
export type MjpegServerOptions = {
// The maximum count of screenshots per second taken by the MJPEG screenshots broadcaster.
// Must be in range 1..60. 10 by default
mjpegServerFramerate?: number;
// The percentage value used to apply downscaling on the screenshots generated by the MJPEG screenshots
// broadcaster. Must be in range 1..100. 100 is by default, which means that screenshots are not downscaled.
mjpegScalingFactor?: number;
// The percentage value used to apply lossy JPEG compression on the screenshots generated by the MJPEG
// screenshots broadcaster. Must be in range 1..100. 25 is by default, which means that screenshots are
// compressed to the quarter of their original quality.
mjpegServerScreenshotQuality?: number;
};
export const DefaultMjpegServerOption: MjpegServerOptions = {
mjpegServerFramerate: 10,
mjpegScalingFactor: 100,
mjpegServerScreenshotQuality: 25,
};
const TAG = '[WdaProxyClient]';
export class WdaProxyClient
extends ManagerClient<ParamsWdaProxy, WdaProxyClientEvents>
implements TouchHandlerListener {
public static calculatePhysicalPoint(
screenInfo: ScreenInfo,
screenWidth: number,
position: Position,
): Point | undefined {
// ignore the locked video orientation, the events will apply in coordinates considered in the physical device orientation
const { videoSize, deviceRotation, contentRect } = screenInfo;
const { right, left, bottom, top } = contentRect;
let shortSide: number;
if (videoSize.width >= videoSize.height) {
shortSide = bottom - top;
} else {
shortSide = right - left;
}
const scale = shortSide / screenWidth;
// reverse the video rotation to apply the events
const devicePosition = position.rotate(deviceRotation);
if (!videoSize.equals(devicePosition.screenSize)) {
// The client sends a click relative to a video with wrong dimensions,
// the device may have been rotated since the event was generated, so ignore the event
return;
}
const { point } = devicePosition;
const convertedX = contentRect.left + (point.x * contentRect.getWidth()) / videoSize.width;
const convertedY = contentRect.top + (point.y * contentRect.getHeight()) / videoSize.height;
const scaledX = Math.round(convertedX / scale);
const scaledY = Math.round(convertedY / scale);
return new Point(scaledX, scaledY);
}
private screenInfo?: ScreenInfo;
private screenWidth = 0;
private udid: string;
private stopped = false;
private commands: string[] = [];
private hasSession = false;
private messageId = 0;
private wait: Map<number, { resolve: (m: Message) => void; reject: (error: any) => void }> = new Map();
constructor(params: ParamsWdaProxy) {
super(params);
this.openNewConnection();
this.udid = params.udid;
}
public static parseParameters(params: URLSearchParams): ParamsWdaProxy {
const typedParams = super.parseParameters(params);
const { action } = typedParams;
if (action !== ACTION.PROXY_WDA) {
throw Error('Incorrect action');
}
return { ...typedParams, action, udid: Util.parseString(params, 'udid', true) };
}
protected onSocketClose(event: CloseEvent): void {
this.emit('connected', false);
console.log(TAG, `Connection closed: ${event.reason}`);
if (!this.stopped) {
setTimeout(() => {
this.openNewConnection();
}, 2000);
}
}
protected onSocketMessage(event: MessageEvent): void {
console.log("接收到的参数03", event.data)
new Response(event.data)
.text()
.then((text: string) => {
const json = JSON.parse(text) as Message;
const id = json['id'];
const p = this.wait.get(id);
if (p) {
this.wait.delete(id);
p.resolve(json);
return;
}
switch (json['type']) {
case ControlCenterCommand.RUN_WDA:
this.emit('wda-status', json as MessageRunWdaResponse);
return;
default:
throw Error('Unsupported message');
}
})
.catch((error: Error) => {
console.error(TAG, error.message);
console.log(TAG, event.data);
});
}
protected onSocketOpen(): void {
this.emit('connected', true);
while (this.commands.length) {
const str = this.commands.shift();
if (str) {
this.sendCommand(str);
}
}
}
private sendCommand(str: string): void {
if (this.ws && this.ws.readyState === this.ws.OPEN) {
console.log('发送的参数1');
this.ws.send(str);
} else {
this.commands.push(str);
}
}
private getNextId(): number {
return ++this.messageId;
}
public async sendMessage(message: Message): Promise<Message> {
this.sendCommand(JSON.stringify(message));
return new Promise<Message>((resolve, reject) => {
this.wait.set(message.id, { resolve, reject });
});
}
public setScreenInfo(screenInfo: ScreenInfo): void {
this.screenInfo = screenInfo;
}
public getScreenInfo(): ScreenInfo | undefined {
return this.screenInfo;
}
private async getScreenWidth(): Promise<number> {
if (this.screenWidth) {
return this.screenWidth;
}
const temp = await this.requestWebDriverAgent(WDAMethod.GET_SCREEN_WIDTH);
if (temp.data.success && typeof temp.data.response === 'number') {
return (this.screenWidth = temp.data.response);
}
throw Error('Invalid response');
}
public async setMjpegServerOptions(opts: MjpegServerOptions): Promise<void> {
const { mjpegServerFramerate, mjpegScalingFactor, mjpegServerScreenshotQuality } = opts;
const options: MjpegServerOptions = {
mjpegServerFramerate,
mjpegScalingFactor,
mjpegServerScreenshotQuality,
};
if (!mjpegServerFramerate || isNaN(mjpegServerFramerate)) {
options.mjpegServerFramerate = DefaultMjpegServerOption.mjpegServerFramerate;
}
if (!mjpegScalingFactor || isNaN(mjpegScalingFactor)) {
options.mjpegScalingFactor = DefaultMjpegServerOption.mjpegScalingFactor;
}
if (!mjpegServerScreenshotQuality || isNaN(mjpegServerScreenshotQuality)) {
options.mjpegServerScreenshotQuality = DefaultMjpegServerOption.mjpegServerScreenshotQuality;
}
return this.requestWebDriverAgent(WDAMethod.APPIUM_SETTINGS, { options });
}
public async sendKeys(keys: string): Promise<void> {
return this.requestWebDriverAgent(WDAMethod.SEND_KEYS, {
keys,
});
}
public async pressButton(name: string): Promise<void> {
return this.requestWebDriverAgent(WDAMethod.PRESS_BUTTON, {
name,
});
}
public async performClick(position: Position): Promise<void> {
if (!this.screenInfo) {
return;
}
const screenWidth = this.screenWidth || (await this.getScreenWidth());
const point = WdaProxyClient.calculatePhysicalPoint(this.screenInfo, screenWidth, position);
if (!point) {
return;
}
return this.requestWebDriverAgent(WDAMethod.CLICK, {
x: point.x,
y: point.y,
});
}
public async performScroll(from: Position, to: Position): Promise<void> {
if (!this.screenInfo) {
return;
}
const wdaScreen = this.screenWidth || (await this.getScreenWidth());
const fromPoint = WdaProxyClient.calculatePhysicalPoint(this.screenInfo, wdaScreen, from);
const toPoint = WdaProxyClient.calculatePhysicalPoint(this.screenInfo, wdaScreen, to);
if (!fromPoint || !toPoint) {
return;
}
return this.requestWebDriverAgent(WDAMethod.SCROLL, {
from: {
x: fromPoint.x,
y: fromPoint.y,
},
to: {
x: toPoint.x,
y: toPoint.y,
},
});
}
public async runWebDriverAgent(): Promise<MessageRunWdaResponse> {
const message: Message = {
id: this.getNextId(),
type: ControlCenterCommand.RUN_WDA,
data: {
udid: this.udid,
},
};
const response = await this.sendMessage(message);
this.hasSession = true;
return response as MessageRunWdaResponse;
}
public async requestWebDriverAgent(method: WDAMethod, args?: any): Promise<any> {
if (!this.hasSession) {
throw Error('No session');
}
const message: Message = {
id: this.getNextId(),
type: ControlCenterCommand.REQUEST_WDA,
data: {
method,
args,
},
};
return this.sendMessage(message);
}
protected supportMultiplexing(): boolean {
return true;
}
protected getChannelInitData(): Buffer {
const udid = Util.stringToUtf8ByteArray(this.params.udid);
const buffer = Buffer.alloc(4 + 4 + udid.byteLength);
buffer.write(ChannelCode.WDAP, 'ascii');
buffer.writeUInt32LE(udid.length, 4);
buffer.set(udid, 8);
return buffer;
}
public stop(): void {
if (this.stopped) {
return;
}
this.stopped = true;
if (this.ws && this.ws.readyState === this.ws.OPEN) {
this.ws.close();
}
}
}

View File

@@ -0,0 +1,81 @@
import { ApplMoreBox } from './ApplMoreBox';
import { BasePlayer } from '../../player/BasePlayer';
import { DefaultMjpegServerOption, MjpegServerOptions, WdaProxyClient } from '../client/WdaProxyClient';
export class ApplMjpegMoreBox extends ApplMoreBox {
private readonly framerateInput: HTMLInputElement;
private readonly scalingFactorInput: HTMLInputElement;
private readonly qualityInput: HTMLInputElement;
constructor(udid: string, player: BasePlayer, wdaConnection: WdaProxyClient) {
super(udid, player, wdaConnection);
const action = 'CHANGE_PARAMS';
const text = 'Change stream parameters';
const playerName = player.getName();
const spoiler = document.createElement('div');
const spoilerLabel = document.createElement('label');
const spoilerCheck = document.createElement('input');
const innerDiv = document.createElement('div');
const id = `spoiler_video_${udid}_${playerName}_${action}`;
const btn = document.createElement('button');
spoiler.className = 'spoiler';
spoilerCheck.type = 'checkbox';
spoilerCheck.id = id;
spoilerLabel.htmlFor = id;
spoilerLabel.innerText = text;
innerDiv.className = 'box';
spoiler.appendChild(spoilerCheck);
spoiler.appendChild(spoilerLabel);
spoiler.appendChild(innerDiv);
const defaultOptions = DefaultMjpegServerOption;
const framerateLabel = document.createElement('label');
framerateLabel.innerText = 'Framerate:';
const framerateInput = document.createElement('input');
framerateInput.placeholder = `1 .. 60`;
framerateInput.value = `${defaultOptions.mjpegServerFramerate}`;
ApplMjpegMoreBox.wrap('div', [framerateLabel, framerateInput], innerDiv);
this.framerateInput = framerateInput;
const scalingFactorLabel = document.createElement('label');
scalingFactorLabel.innerText = 'Scaling factor:';
const scalingFactorInput = document.createElement('input');
scalingFactorInput.placeholder = `1 .. 100`;
scalingFactorInput.value = `${defaultOptions.mjpegScalingFactor}`;
const scalingWrapper = ApplMjpegMoreBox.wrap('div', [scalingFactorLabel, scalingFactorInput], innerDiv);
// FIXME: scaling factor changes are not handled
scalingWrapper.style.display = 'none';
this.scalingFactorInput = scalingFactorInput;
const qualityLabel = document.createElement('label');
qualityLabel.innerText = 'Quality:';
const qualityInput = document.createElement('input');
qualityInput.placeholder = `1 .. 100`;
qualityInput.value = `${defaultOptions.mjpegServerScreenshotQuality}`;
ApplMjpegMoreBox.wrap('div', [qualityLabel, qualityInput], innerDiv);
this.qualityInput = qualityInput;
innerDiv.appendChild(btn);
btn.innerText = text;
btn.onclick = () => {
const mjpegServerFramerate = parseInt(this.framerateInput.value, 10);
const mjpegScalingFactor = parseInt(this.scalingFactorInput.value, 10);
const mjpegServerScreenshotQuality = parseInt(this.qualityInput.value, 10);
const options: MjpegServerOptions = {
mjpegServerFramerate,
mjpegScalingFactor,
mjpegServerScreenshotQuality,
};
wdaConnection.setMjpegServerOptions(options);
};
const holder = this.getHolderElement();
const childNodes = holder.childNodes;
if (childNodes.length > 1) {
holder.insertBefore(spoiler, childNodes[childNodes.length - 2]);
} else {
holder.appendChild(spoiler);
}
}
}

View File

@@ -0,0 +1,94 @@
import '../../../style/morebox.css';
import { BasePlayer } from '../../player/BasePlayer';
import Size from '../../Size';
import { WdaProxyClient } from '../client/WdaProxyClient';
const TAG = '[ApplMoreBox]';
interface StopListener {
onStop: () => void;
}
export class ApplMoreBox {
private stopListener?: StopListener;
private readonly holder: HTMLElement;
constructor(udid: string, player: BasePlayer, wdaConnection: WdaProxyClient) {
const playerName = player.getName();
const moreBox = document.createElement('div');
moreBox.className = 'more-box';
const nameBox = document.createElement('p');
nameBox.innerText = `${udid} (${playerName})`;
nameBox.className = 'text-with-shadow';
moreBox.appendChild(nameBox);
const input = document.createElement('textarea');
input.classList.add('text-area');
const sendButton = document.createElement('button');
sendButton.innerText = 'Send as keys';
ApplMoreBox.wrap('p', [input, sendButton], moreBox);
sendButton.onclick = () => {
if (input.value) {
wdaConnection.sendKeys(input.value);
}
};
const qualityId = `show_video_quality_${udid}_${playerName}`;
const qualityLabel = document.createElement('label');
const qualityCheck = document.createElement('input');
qualityCheck.type = 'checkbox';
qualityCheck.checked = BasePlayer.DEFAULT_SHOW_QUALITY_STATS;
qualityCheck.id = qualityId;
qualityLabel.htmlFor = qualityId;
qualityLabel.innerText = 'Show quality stats';
ApplMoreBox.wrap('p', [qualityCheck, qualityLabel], moreBox);
qualityCheck.onchange = () => {
player.setShowQualityStats(qualityCheck.checked);
};
const stop = (ev?: string | Event) => {
if (ev && ev instanceof Event && ev.type === 'error') {
console.error(TAG, ev);
}
const parent = moreBox.parentElement;
if (parent) {
parent.removeChild(moreBox);
}
player.off('video-view-resize', this.onViewVideoResize);
if (this.stopListener) {
this.stopListener.onStop();
delete this.stopListener;
}
};
const stopBtn = document.createElement('button') as HTMLButtonElement;
stopBtn.innerText = `Disconnect`;
stopBtn.onclick = stop;
ApplMoreBox.wrap('p', [stopBtn], moreBox);
player.on('video-view-resize', this.onViewVideoResize);
this.holder = moreBox;
}
private onViewVideoResize = (size: Size): void => {
// padding: 10px
this.holder.style.width = `${size.width - 2 * 10}px`;
};
protected static wrap(tagName: string, elements: HTMLElement[], parent: HTMLElement): HTMLElement {
const wrap = document.createElement(tagName);
elements.forEach((e) => {
wrap.appendChild(e);
});
parent.appendChild(wrap);
return wrap;
}
public getHolderElement(): HTMLElement {
return this.holder;
}
public setOnStop(listener: StopListener): void {
this.stopListener = listener;
}
}

View File

@@ -0,0 +1,70 @@
import { ToolBox } from '../../toolbox/ToolBox';
import SvgImage from '../../ui/SvgImage';
import { BasePlayer } from '../../player/BasePlayer';
import { ToolBoxButton } from '../../toolbox/ToolBoxButton';
import { ToolBoxElement } from '../../toolbox/ToolBoxElement';
import { ToolBoxCheckbox } from '../../toolbox/ToolBoxCheckbox';
import { WdaProxyClient } from '../client/WdaProxyClient';
const BUTTONS = [
{
title: 'Home',
name: 'home',
icon: SvgImage.Icon.HOME,
},
];
export interface StreamClient {
getDeviceName(): string;
}
export class ApplToolBox extends ToolBox {
protected constructor(list: ToolBoxElement<any>[]) {
super(list);
}
public static createToolBox(
udid: string,
player: BasePlayer,
client: StreamClient,
wdaConnection: WdaProxyClient,
moreBox?: HTMLElement,
): ApplToolBox {
const playerName = player.getName();
const list = BUTTONS.slice();
const handler = <K extends keyof HTMLElementEventMap, T extends HTMLElement>(
_: K,
element: ToolBoxElement<T>,
) => {
if (!element.optional?.name) {
return;
}
const { name } = element.optional;
wdaConnection.pressButton(name);
};
const elements: ToolBoxElement<any>[] = list.map((item) => {
const button = new ToolBoxButton(item.title, item.icon, {
name: item.name,
});
button.addEventListener('click', handler);
return button;
});
if (player.supportsScreenshot) {
const screenshot = new ToolBoxButton('Take screenshot', SvgImage.Icon.CAMERA);
screenshot.addEventListener('click', () => {
player.createScreenshot(client.getDeviceName());
});
elements.push(screenshot);
}
if (moreBox) {
const more = new ToolBoxCheckbox('More', SvgImage.Icon.MORE, `show_more_${udid}_${playerName}`);
more.addEventListener('click', (_, el) => {
const element = el.getElement();
moreBox.style.display = element.checked ? 'block' : 'none';
});
elements.unshift(more);
}
return new ApplToolBox(elements);
}
}

View File

@@ -0,0 +1,40 @@
import { EventMap, TypedEmitter } from '../../common/TypedEmitter';
import { ParamsBase } from '../../types/ParamsBase';
import Util from '../Util';
export class BaseClient<P extends ParamsBase, TE extends EventMap> extends TypedEmitter<TE> {
protected title = 'BaseClient';
protected params: P;
protected constructor(params: P) {
super();
this.params = params;
}
public static parseParameters(query: URLSearchParams): ParamsBase {
const action = Util.parseStringEnv(query.get('action'));
if (!action) {
throw TypeError('Invalid action');
}
return {
action: action,
useProxy: Util.parseBooleanEnv(query.get('useProxy')),
secure: Util.parseBooleanEnv(query.get('secure')),
hostname: Util.parseStringEnv(query.get('hostname')),
port: Util.parseIntEnv(query.get('port')),
pathname: Util.parseStringEnv(query.get('pathname')),
};
}
public setTitle(text = this.title): void {
let titleTag: HTMLTitleElement | null = document.querySelector('head > title');
if (!titleTag) {
titleTag = document.createElement('title');
}
titleTag.innerText = text;
}
public setBodyClass(text: string): void {
document.body.className = text;
}
}

View File

@@ -0,0 +1,280 @@
import { ManagerClient } from './ManagerClient';
import { Message } from '../../types/Message';
import { BaseDeviceDescriptor } from '../../types/BaseDeviceDescriptor';
import { DeviceTrackerEvent } from '../../types/DeviceTrackerEvent';
import { DeviceTrackerEventList } from '../../types/DeviceTrackerEventList';
import { html } from '../ui/HtmlTag';
import { ParamsDeviceTracker } from '../../types/ParamsDeviceTracker';
import { HostItem } from '../../types/Configuration';
import { Tool } from './Tool';
import Util from '../Util';
import { EventMap } from '../../common/TypedEmitter';
const TAG = '[BaseDeviceTracker]';
export abstract class BaseDeviceTracker<DD extends BaseDeviceDescriptor, TE extends EventMap> extends ManagerClient<
ParamsDeviceTracker,
TE
> {
public static readonly ACTION_LIST = 'devicelist';
public static readonly ACTION_DEVICE = 'device';
public static readonly HOLDER_ELEMENT_ID = 'devices';
public static readonly AttributePrefixInterfaceSelectFor = 'interface_select_for_';
public static readonly AttributePlayerFullName = 'data-player-full-name';
public static readonly AttributePlayerCodeName = 'data-player-code-name';
public static readonly AttributePrefixPlayerFor = 'player_for_';
protected static tools: Set<Tool> = new Set();
protected static instanceId = 0;
public static registerTool(tool: Tool): void {
this.tools.add(tool);
}
public static buildUrl(item: HostItem): URL {
const { secure, port, hostname } = item;
const pathname = item.pathname ?? '/';
const protocol = secure ? 'wss:' : 'ws:';
const url = new URL(`${protocol}//${hostname}${pathname}`);
if (port) {
url.port = port.toString();
}
return url;
}
public static buildUrlForTracker(params: HostItem): URL {
const wsUrl = this.buildUrl(params);
wsUrl.searchParams.set('action', this.ACTION);
return wsUrl;
}
public static buildLink(q: any, text: string, params: ParamsDeviceTracker): HTMLAnchorElement {
let { hostname } = params;
let port: string | number | undefined = params.port;
let pathname = params.pathname ?? location.pathname;
let protocol = params.secure ? 'https:' : 'http:';
if (params.useProxy) {
q.hostname = hostname;
q.port = port;
q.pathname = pathname;
q.secure = params.secure;
q.useProxy = true;
protocol = location.protocol;
hostname = location.hostname;
port = location.port;
pathname = location.pathname;
}
const hash = `#!${new URLSearchParams(q).toString()}`;
const a = document.createElement('a');
a.setAttribute('href', `${protocol}//${hostname}:${port}${pathname}${hash}`);
a.setAttribute('rel', 'noopener noreferrer');
a.setAttribute('target', '_blank');
a.classList.add(`link-${q.action}`);
a.innerText = text;
return a;
}
protected title = 'Device list';
protected tableId = 'base_device_list';
protected descriptors: DD[] = [];
protected elementId: string;
protected trackerName = '';
protected id = '';
private created = false;
private messageId = 0;
protected constructor(params: ParamsDeviceTracker, protected readonly directUrl: string) {
super(params);
this.elementId = `tracker_instance${++BaseDeviceTracker.instanceId}`;
this.trackerName = `Unavailable. Host: ${params.hostname}, type: ${params.type}`;
this.setBodyClass('list');
this.setTitle();
}
public static parseParameters(params: URLSearchParams): ParamsDeviceTracker {
const typedParams = super.parseParameters(params);
const type = Util.parseString(params, 'type', true);
if (type !== 'android' && type !== 'ios') {
throw Error('Incorrect type');
}
return { ...typedParams, type };
}
protected getNextId(): number {
return ++this.messageId;
}
protected buildDeviceTable(): void {
const data = this.descriptors;
const devices = this.getOrCreateTableHolder();
const tbody = this.getOrBuildTableBody(devices);
const block = this.getOrCreateTrackerBlock(tbody, this.trackerName);
data.forEach((item) => {
this.buildDeviceRow(block, item);
});
}
private setNameValue(parent: Element | null, name: string): void {
if (!parent) {
return;
}
const nameBlockId = `${this.elementId}_name`;
let nameEl = document.getElementById(nameBlockId);
if (!nameEl) {
nameEl = document.createElement('div');
nameEl.id = nameBlockId;
nameEl.className = 'tracker-name';
}
nameEl.innerText = name;
parent.insertBefore(nameEl, parent.firstChild);
}
private getOrCreateTrackerBlock(parent: Element, controlCenterName: string): Element {
let el = document.getElementById(this.elementId);
if (!el) {
el = document.createElement('div');
el.id = this.elementId;
parent.appendChild(el);
this.created = true;
} else {
while (el.children.length) {
el.removeChild(el.children[0]);
}
}
this.setNameValue(el, controlCenterName);
return el;
}
protected abstract buildDeviceRow(tbody: Element, device: DD): void;
protected onSocketClose(event: CloseEvent): void {
if (this.destroyed) {
return;
}
console.log(TAG, `Connection closed: ${event.reason}`);
setTimeout(() => {
this.openNewConnection();
}, 2000);
}
protected onSocketMessage(event: MessageEvent): void {
console.log("接收到的参数3", event.data)
let message: Message;
try {
message = JSON.parse(event.data);
} catch (error: any) {
console.error(TAG, error.message);
console.log(TAG, error.data);
return;
}
switch (message.type) {
case BaseDeviceTracker.ACTION_LIST: {
const event = message.data as DeviceTrackerEventList<DD>;
this.descriptors = event.list;
this.setIdAndHostName(event.id, event.name);
this.buildDeviceTable();
break;
}
case BaseDeviceTracker.ACTION_DEVICE: {
const event = message.data as DeviceTrackerEvent<DD>;
this.setIdAndHostName(event.id, event.name);
this.updateDescriptor(event.device);
this.buildDeviceTable();
break;
}
default:
console.log(TAG, `Unknown message type: ${message.type}`);
}
}
protected setIdAndHostName(id: string, trackerName: string): void {
if (this.id === id && this.trackerName === trackerName) {
return;
}
this.id = id;
this.trackerName = trackerName;
this.setNameValue(document.getElementById(this.elementId), trackerName);
}
protected getOrCreateTableHolder(): HTMLElement {
const id = BaseDeviceTracker.HOLDER_ELEMENT_ID;
let devices = document.getElementById(id);
if (!devices) {
devices = document.createElement('div');
devices.id = id;
devices.className = 'table-wrapper';
document.body.appendChild(devices);
}
return devices;
}
protected updateDescriptor(descriptor: DD): void {
const idx = this.descriptors.findIndex((item: DD) => {
return item.udid === descriptor.udid;
});
if (idx !== -1) {
this.descriptors[idx] = descriptor;
} else {
this.descriptors.push(descriptor);
}
}
protected getOrBuildTableBody(parent: HTMLElement): Element {
const className = 'device-list';
let tbody = document.querySelector(
`#${BaseDeviceTracker.HOLDER_ELEMENT_ID} #${this.tableId}.${className}`,
) as Element;
if (!tbody) {
const fragment = html`<div id="${this.tableId}" class="${className}"></div>`.content;
parent.appendChild(fragment);
const last = parent.children.item(parent.children.length - 1);
if (last) {
tbody = last;
}
}
return tbody;
}
public getDescriptorByUdid(udid: string): DD | undefined {
if (!this.descriptors.length) {
return;
}
return this.descriptors.find((descriptor: DD) => {
return descriptor.udid === udid;
});
}
public destroy(): void {
super.destroy();
if (this.created) {
const el = document.getElementById(this.elementId);
if (el) {
const { parentElement } = el;
el.remove();
if (parentElement && !parentElement.children.length) {
parentElement.remove();
}
}
}
const holder = document.getElementById(BaseDeviceTracker.HOLDER_ELEMENT_ID);
if (holder && !holder.children.length) {
holder.remove();
}
}
protected supportMultiplexing(): boolean {
return true;
}
protected getChannelCode(): string {
throw Error('Not implemented. Must override');
}
protected getChannelInitData(): Buffer {
const code = this.getChannelCode();
const buffer = Buffer.alloc(code.length);
buffer.write(code, 'ascii');
return buffer;
}
}

View File

@@ -0,0 +1,127 @@
import { ManagerClient } from './ManagerClient';
import { Message } from '../../types/Message';
import { MessageError, MessageHosts, MessageType } from '../../common/HostTrackerMessage';
import { ACTION } from '../../common/Action';
import { DeviceTracker as GoogDeviceTracker } from '../googDevice/client/DeviceTracker';
import { DeviceTracker as ApplDeviceTracker } from '../applDevice/client/DeviceTracker';
import { ParamsBase } from '../../types/ParamsBase';
import { HostItem } from '../../types/Configuration';
import { ChannelCode } from '../../common/ChannelCode';
const TAG = '[HostTracker]';
export interface HostTrackerEvents {
// hosts: HostItem[];
disconnected: CloseEvent;
error: string;
}
export class HostTracker extends ManagerClient<ParamsBase, HostTrackerEvents> {
private static instance?: HostTracker;
public static start(): void {
this.getInstance();
}
public static getInstance(): HostTracker {
if (!this.instance) {
this.instance = new HostTracker();
}
return this.instance;
}
private trackers: Array<GoogDeviceTracker | ApplDeviceTracker> = [];
constructor() {
super({ action: ACTION.LIST_HOSTS });
this.openNewConnection();
if (this.ws) {
this.ws.binaryType = 'arraybuffer';
}
}
protected onSocketClose(ev: CloseEvent): void {
console.log(TAG, 'WS closed');
this.emit('disconnected', ev);
}
protected onSocketMessage(event: MessageEvent): void {
console.log("接收到的参数4", event.data)
let message: Message;
try {
// TODO: rewrite to binary
message = JSON.parse(event.data);
} catch (error: any) {
console.error(TAG, error.message);
console.log(TAG, error.data);
return;
}
switch (message.type) {
case MessageType.ERROR: {
const msg = message as MessageError;
console.error(TAG, msg.data);
this.emit('error', msg.data);
break;
}
case MessageType.HOSTS: {
const msg = message as MessageHosts;
// this.emit('hosts', msg.data);
if (msg.data.local) {
msg.data.local.forEach(({ type }) => {
const secure = location.protocol === 'https:';
const port = location.port ? parseInt(location.port, 10) : secure ? 443 : 80;
const { hostname, pathname } = location;
if (type !== 'android' && type !== 'ios') {
console.warn(TAG, `Unsupported host type: "${type}"`);
return;
}
const hostItem: HostItem = { useProxy: false, secure, port, hostname, pathname, type };
this.startTracker(hostItem);
});
}
if (msg.data.remote) {
msg.data.remote.forEach((item) => this.startTracker(item));
}
break;
}
default:
console.log(TAG, `Unknown message type: ${message.type}`);
}
}
private startTracker(hostItem: HostItem): void {
switch (hostItem.type) {
case 'android':
this.trackers.push(GoogDeviceTracker.start(hostItem));
break;
case 'ios':
this.trackers.push(ApplDeviceTracker.start(hostItem));
break;
default:
console.warn(TAG, `Unsupported host type: "${hostItem.type}"`);
}
}
protected onSocketOpen(): void {
// do nothing
}
public destroy(): void {
super.destroy();
this.trackers.forEach((tracker) => {
tracker.destroy();
});
this.trackers.length = 0;
}
protected supportMultiplexing(): boolean {
return true;
}
protected getChannelInitData(): Buffer {
const buffer = Buffer.alloc(4);
buffer.write(ChannelCode.HSTS, 'ascii');
return buffer;
}
}

View File

@@ -0,0 +1,133 @@
import { BaseClient } from './BaseClient';
import { ACTION } from '../../common/Action';
import { ParamsBase } from '../../types/ParamsBase';
import Util from '../Util';
import { Multiplexer } from '../../packages/multiplexer/Multiplexer';
import { EventMap } from '../../common/TypedEmitter';
export abstract class ManagerClient<P extends ParamsBase, TE extends EventMap> extends BaseClient<P, TE> {
public static ACTION = 'unknown';
public static CODE = 'NONE';
public static sockets: Map<string, Multiplexer> = new Map();
protected destroyed = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
public static start(..._rest: any[]): void {
throw Error('Not implemented');
}
protected readonly action?: string;
protected url: URL;
protected ws?: Multiplexer | WebSocket;
protected constructor(params: P) {
super(params);
this.action = Util.parseStringEnv(params.action);
this.url = this.buildWebSocketUrl();
}
public hasConnection(): boolean {
return !!(this.ws && this.ws.readyState === this.ws.OPEN);
}
protected openNewConnection(): Multiplexer | WebSocket {
if (this.ws && this.ws.readyState === this.ws.OPEN) {
this.ws.close();
delete this.ws;
}
const url = this.url.toString();
console.log(`连接到的websocket链接 ${url}`);
if (this.supportMultiplexing()) {
let openedMultiplexer = ManagerClient.sockets.get(url);
if (!openedMultiplexer) {
const ws = new WebSocket(url);
ws.addEventListener('close', () => {
ManagerClient.sockets.delete(url);
});
const newMultiplexer = Multiplexer.wrap(ws);
newMultiplexer.on('empty', () => {
newMultiplexer.close();
});
ManagerClient.sockets.set(url, newMultiplexer);
openedMultiplexer = newMultiplexer;
}
const ws = openedMultiplexer.createChannel(this.getChannelInitData());
ws.addEventListener('open', this.onSocketOpen.bind(this));
ws.addEventListener('message', this.onSocketMessage.bind(this));
ws.addEventListener('close', this.onSocketClose.bind(this));
this.ws = ws;
} else {
const ws = new WebSocket(url);
ws.addEventListener('open', this.onSocketOpen.bind(this));
ws.addEventListener('message', this.onSocketMessage.bind(this));
ws.addEventListener('close', this.onSocketClose.bind(this));
this.ws = ws;
}
return this.ws;
}
public destroy(): void {
if (this.destroyed) {
console.error(new Error('Already disposed'));
return;
}
this.destroyed = true;
if (this.ws) {
if (this.ws.readyState === this.ws.OPEN) {
this.ws.close();
}
}
}
protected buildWebSocketUrl(): URL {
const directUrl = this.buildDirectWebSocketUrl();
if (this.params.useProxy && !this.supportMultiplexing()) {
return this.wrapInProxy(directUrl);
}
return directUrl;
}
protected buildDirectWebSocketUrl(): URL {
const { hostname, port, secure, action } = this.params;
const pathname = this.params.pathname ?? location.pathname;
let urlString: string;
if (typeof hostname === 'string' && typeof port === 'number') {
const protocol = secure ? 'wss:' : 'ws:';
urlString = `${protocol}//${hostname}:${port}${pathname}`;
} else {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
// location.host includes hostname and port
urlString = `${protocol}${location.host}${pathname}`;
}
const directUrl = new URL(urlString);
if (this.supportMultiplexing()) {
directUrl.searchParams.set('action', ACTION.MULTIPLEX);
} else {
if (action) {
directUrl.searchParams.set('action', action);
}
}
return directUrl;
}
protected wrapInProxy(directUrl: URL): URL {
const localProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const localUrl = new URL(`${localProtocol}//${location.host}`);
localUrl.searchParams.set('action', ACTION.PROXY_WS);
localUrl.searchParams.set('ws', directUrl.toString());
return localUrl;
}
protected supportMultiplexing(): boolean {
return false;
}
protected getChannelInitData(): Buffer {
return Buffer.from(ManagerClient.CODE);
}
protected abstract onSocketOpen(event: Event): void;
protected abstract onSocketMessage(event: MessageEvent): void;
protected abstract onSocketClose(event: CloseEvent): void;
}

View File

@@ -0,0 +1,252 @@
import { ManagerClient } from './ManagerClient';
import { ControlMessage } from '../controlMessage/ControlMessage';
import DeviceMessage from '../googDevice/DeviceMessage';
import VideoSettings from '../VideoSettings';
import ScreenInfo from '../ScreenInfo';
import Util from '../Util';
import { DisplayInfo } from '../DisplayInfo';
import { ParamsStream } from '../../types/ParamsStream';
// import WebSocket, { Server } from 'ws'; // 新增WebSocket服务端依赖
const DEVICE_NAME_FIELD_LENGTH = 64;
const MAGIC_BYTES_INITIAL = Util.stringToUtf8ByteArray('scrcpy_initial');
export type ClientsStats = {
deviceName: string;
clientId: number;
};
export type DisplayCombinedInfo = {
displayInfo: DisplayInfo;
videoSettings?: VideoSettings;
screenInfo?: ScreenInfo;
connectionCount: number;
};
interface StreamReceiverEvents {
video: ArrayBuffer;
deviceMessage: DeviceMessage;
displayInfo: DisplayCombinedInfo[];
clientsStats: ClientsStats;
encoders: string[];
connected: void;
disconnected: CloseEvent;
}
const TAG = '[StreamReceiver]';
export class StreamReceiver<P extends ParamsStream> extends ManagerClient<ParamsStream, StreamReceiverEvents> {
// 新增WebSocket服务端相关属性
// private wss: Server | null = null;
// private clientConnections: Set<WebSocket> = new Set();
private events: ControlMessage[] = [];
private encodersSet: Set<string> = new Set<string>();
private clientId = -1;
private deviceName = '';
private readonly displayInfoMap: Map<number, DisplayInfo> = new Map();
private readonly connectionCountMap: Map<number, number> = new Map();
private readonly screenInfoMap: Map<number, ScreenInfo> = new Map();
private readonly videoSettingsMap: Map<number, VideoSettings> = new Map();
private hasInitialInfo = false;
constructor(params: P) {
super(params);
// 新增WebSocket服务端初始化
// this.startWebSocketServer();
this.openNewConnection();
if (this.ws) {
this.ws.binaryType = 'arraybuffer';
}
}
// 新增方法启动WebSocket服务端
// private startWebSocketServer(): void {
// this.wss = new WebSocket.Server({ port: 8765 });
// this.wss.on('connection', (ws: WebSocket) => {
// console.log(`连接成功`);
// this.clientConnections.add(ws);
// // 设置消息处理器
// ws.on('message', (data: WebSocket.Data) => {
// console.log(data, '接收到')
// });
// // 处理连接关闭
// ws.on('close', () => {
// console.log(`${TAG} Client disconnected from server`);
// this.clientConnections.delete(ws);
// });
// // 发送初始信息给新客户端
// });
// }
private handleInitialInfo(data: ArrayBuffer): void {
let offset = MAGIC_BYTES_INITIAL.length;
let nameBytes = new Uint8Array(data, offset, DEVICE_NAME_FIELD_LENGTH);
offset += DEVICE_NAME_FIELD_LENGTH;
let rest: Buffer = Buffer.from(new Uint8Array(data, offset));
const displaysCount = rest.readInt32BE(0);
this.displayInfoMap.clear();
this.connectionCountMap.clear();
this.screenInfoMap.clear();
this.videoSettingsMap.clear();
rest = rest.slice(4);
for (let i = 0; i < displaysCount; i++) {
const displayInfoBuffer = rest.slice(0, DisplayInfo.BUFFER_LENGTH);
const displayInfo = DisplayInfo.fromBuffer(displayInfoBuffer);
const { displayId } = displayInfo;
this.displayInfoMap.set(displayId, displayInfo);
rest = rest.slice(DisplayInfo.BUFFER_LENGTH);
this.connectionCountMap.set(displayId, rest.readInt32BE(0));
rest = rest.slice(4);
const screenInfoBytesCount = rest.readInt32BE(0);
rest = rest.slice(4);
if (screenInfoBytesCount) {
this.screenInfoMap.set(displayId, ScreenInfo.fromBuffer(rest.slice(0, screenInfoBytesCount)));
rest = rest.slice(screenInfoBytesCount);
}
const videoSettingsBytesCount = rest.readInt32BE(0);
rest = rest.slice(4);
if (videoSettingsBytesCount) {
this.videoSettingsMap.set(displayId, VideoSettings.fromBuffer(rest.slice(0, videoSettingsBytesCount)));
rest = rest.slice(videoSettingsBytesCount);
}
}
this.encodersSet.clear();
const encodersCount = rest.readInt32BE(0);
rest = rest.slice(4);
for (let i = 0; i < encodersCount; i++) {
const nameLength = rest.readInt32BE(0);
rest = rest.slice(4);
const nameBytes = rest.slice(0, nameLength);
rest = rest.slice(nameLength);
const name = Util.utf8ByteArrayToString(nameBytes);
this.encodersSet.add(name);
}
this.clientId = rest.readInt32BE(0);
nameBytes = Util.filterTrailingZeroes(nameBytes);
this.deviceName = Util.utf8ByteArrayToString(nameBytes);
this.hasInitialInfo = true;
this.triggerInitialInfoEvents();
}
private static EqualArrays(a: ArrayLike<number>, b: ArrayLike<number>): boolean {
if (a.length !== b.length) {
return false;
}
for (let i = 0, l = a.length; i < l; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
protected buildDirectWebSocketUrl(): URL {
const localUrl = super.buildDirectWebSocketUrl();
if (this.supportMultiplexing()) {
return localUrl;
}
localUrl.searchParams.set('udid', this.params.udid);
return localUrl;
}
protected onSocketClose(ev: CloseEvent): void {
console.log(`${TAG}. WS closed: ${ev.reason}`);
this.emit('disconnected', ev);
}
protected onSocketMessage(event: MessageEvent): void {
// console.log("屏幕接收到的参数", event.data)
if (event.data instanceof ArrayBuffer) {
// works only because MAGIC_BYTES_INITIAL and MAGIC_BYTES_MESSAGE have same length
if (event.data.byteLength > MAGIC_BYTES_INITIAL.length) {
const magicBytes = new Uint8Array(event.data, 0, MAGIC_BYTES_INITIAL.length);
if (StreamReceiver.EqualArrays(magicBytes, MAGIC_BYTES_INITIAL)) {
this.handleInitialInfo(event.data);
return;
}
if (StreamReceiver.EqualArrays(magicBytes, DeviceMessage.MAGIC_BYTES_MESSAGE)) {
const message = DeviceMessage.fromBuffer(event.data);
console.log('屏幕接收到22', message)
this.emit('deviceMessage', message);
return;
}
}
this.emit('video', new Uint8Array(event.data));
}
}
protected onSocketOpen(): void {
this.emit('connected', void 0);
let e = this.events.shift();
while (e) {
this.sendEvent(e);
e = this.events.shift();
}
}
public sendEvent(event: ControlMessage): void {
console.log('发送消息2', event)
if (this.ws && this.ws.readyState === this.ws.OPEN) {
// console.log('发送的参数2');
this.ws.send(event.toBuffer());
} else {
// this.events.push(event);
}
}
public stop(): void {
if (this.ws && this.ws.readyState === this.ws.OPEN) {
this.ws.close();
}
this.events.length = 0;
}
public getEncoders(): string[] {
return Array.from(this.encodersSet.values());
}
public getDeviceName(): string {
return this.deviceName;
}
public triggerInitialInfoEvents(): void {
if (this.hasInitialInfo) {
const encoders = this.getEncoders();
this.emit('encoders', encoders);
const { clientId, deviceName } = this;
this.emit('clientsStats', { clientId, deviceName });
const infoArray: DisplayCombinedInfo[] = [];
this.displayInfoMap.forEach((displayInfo: DisplayInfo, displayId: number) => {
const connectionCount = this.connectionCountMap.get(displayId) || 0;
infoArray.push({
displayInfo,
videoSettings: this.videoSettingsMap.get(displayId),
screenInfo: this.screenInfoMap.get(displayId),
connectionCount,
});
});
this.emit('displayInfo', infoArray);
}
}
public getDisplayInfo(displayId: number): DisplayInfo | undefined {
return this.displayInfoMap.get(displayId);
}
}

12
src/app/client/Tool.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
import { ParamsDeviceTracker } from '../../types/ParamsDeviceTracker';
import { BaseDeviceDescriptor } from '../../types/BaseDeviceDescriptor';
type Entry = HTMLElement | DocumentFragment;
export interface Tool {
createEntryForDeviceList(
descriptor: BaseDeviceDescriptor,
blockClass: string,
params: ParamsDeviceTracker,
): Array<Entry | undefined> | Entry | undefined;
}

View File

@@ -0,0 +1,226 @@
import { ControlMessage } from './ControlMessage';
import VideoSettings from '../VideoSettings';
import Util from '../Util';
export enum FilePushState {
NEW,
START,
APPEND,
FINISH,
CANCEL,
}
type FilePushParams = {
id: number;
state: FilePushState;
chunk?: Uint8Array;
fileName?: string;
fileSize?: number;
};
export class CommandControlMessage extends ControlMessage {
public static PAYLOAD_LENGTH = 0;
public static Commands: Map<number, string> = new Map([
[ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL, 'Expand notifications'],
[ControlMessage.TYPE_EXPAND_SETTINGS_PANEL, 'Expand settings'],
[ControlMessage.TYPE_COLLAPSE_PANELS, 'Collapse panels'],
[ControlMessage.TYPE_GET_CLIPBOARD, 'Get clipboard'],
[ControlMessage.TYPE_SET_CLIPBOARD, 'Set clipboard'],
[ControlMessage.TYPE_ROTATE_DEVICE, 'Rotate device'],
[ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS, 'Change video settings'],
]);
public static createSetVideoSettingsCommand(videoSettings: VideoSettings): CommandControlMessage {
const temp = videoSettings.toBuffer();
const event = new CommandControlMessage(ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS);
const offset = CommandControlMessage.PAYLOAD_LENGTH + 1;
const buffer = Buffer.alloc(offset + temp.length);
buffer.writeUInt8(event.type, 0);
temp.forEach((byte, index) => {
buffer.writeUInt8(byte, index + offset);
});
event.buffer = buffer;
return event;
}
public static createSetClipboardCommand(text: string, paste = true): CommandControlMessage {
const event = new CommandControlMessage(ControlMessage.TYPE_SET_CLIPBOARD);
const textBytes: Uint8Array | null = text ? Util.stringToUtf8ByteArray(text) : null;
const textLength = textBytes ? textBytes.length : 0;
let offset = 0;
const buffer = Buffer.alloc(1 + 1 + 4 + textLength);
offset = buffer.writeInt8(event.type, offset);
offset = buffer.writeInt8(paste ? 1 : 0, offset);
offset = buffer.writeInt32BE(textLength, offset);
if (textBytes) {
textBytes.forEach((byte: number, index: number) => {
buffer.writeUInt8(byte, index + offset);
});
}
event.buffer = buffer;
return event;
}
public static createSetScreenPowerModeCommand(mode: boolean): CommandControlMessage {
const event = new CommandControlMessage(ControlMessage.TYPE_SET_SCREEN_POWER_MODE);
let offset = 0;
const buffer = Buffer.alloc(1 + 1);
offset = buffer.writeInt8(event.type, offset);
buffer.writeUInt8(mode ? 1 : 0, offset);
event.buffer = buffer;
return event;
}
public static createPushFileCommand(params: FilePushParams): CommandControlMessage {
const { id, fileName, fileSize, chunk, state } = params;
if (state === FilePushState.START) {
return this.createPushFileStartCommand(id, fileName as string, fileSize as number);
}
if (state === FilePushState.APPEND) {
if (!chunk) {
throw TypeError('Invalid type');
}
return this.createPushFileChunkCommand(id, chunk);
}
if (state === FilePushState.CANCEL || state === FilePushState.FINISH || state === FilePushState.NEW) {
return this.createPushFileOtherCommand(id, state);
}
throw TypeError(`Unsupported state: "${state}"`);
}
private static createPushFileStartCommand(id: number, fileName: string, fileSize: number): CommandControlMessage {
const event = new CommandControlMessage(ControlMessage.TYPE_PUSH_FILE);
const text = Util.stringToUtf8ByteArray(fileName);
const typeField = 1;
const idField = 2;
const stateField = 1;
const sizeField = 4;
const textLengthField = 2;
const textLength = text.length;
let offset = CommandControlMessage.PAYLOAD_LENGTH;
const buffer = Buffer.alloc(
offset + typeField + idField + stateField + sizeField + textLengthField + textLength,
);
buffer.writeUInt8(event.type, offset);
offset += typeField;
buffer.writeInt16BE(id, offset);
offset += idField;
buffer.writeInt8(FilePushState.START, offset);
offset += stateField;
buffer.writeUInt32BE(fileSize, offset);
offset += sizeField;
buffer.writeUInt16BE(textLength, offset);
offset += textLengthField;
text.forEach((byte, index) => {
buffer.writeUInt8(byte, index + offset);
});
event.buffer = buffer;
return event;
}
private static createPushFileChunkCommand(id: number, chunk: Uint8Array): CommandControlMessage {
const event = new CommandControlMessage(ControlMessage.TYPE_PUSH_FILE);
const typeField = 1;
const idField = 2;
const stateField = 1;
const chunkLengthField = 4;
const chunkLength = chunk.byteLength;
let offset = CommandControlMessage.PAYLOAD_LENGTH;
const buffer = Buffer.alloc(offset + typeField + idField + stateField + chunkLengthField + chunkLength);
buffer.writeUInt8(event.type, offset);
offset += typeField;
buffer.writeInt16BE(id, offset);
offset += idField;
buffer.writeInt8(FilePushState.APPEND, offset);
offset += stateField;
buffer.writeUInt32BE(chunkLength, offset);
offset += chunkLengthField;
Array.from(chunk).forEach((byte, index) => {
buffer.writeUInt8(byte, index + offset);
});
event.buffer = buffer;
return event;
}
private static createPushFileOtherCommand(id: number, state: FilePushState): CommandControlMessage {
const event = new CommandControlMessage(ControlMessage.TYPE_PUSH_FILE);
const typeField = 1;
const idField = 2;
const stateField = 1;
let offset = CommandControlMessage.PAYLOAD_LENGTH;
const buffer = Buffer.alloc(offset + typeField + idField + stateField);
buffer.writeUInt8(event.type, offset);
offset += typeField;
buffer.writeInt16BE(id, offset);
offset += idField;
buffer.writeInt8(state, offset);
event.buffer = buffer;
return event;
}
public static pushFileCommandFromBuffer(buffer: Buffer): {
id: number;
state: FilePushState;
chunk?: Buffer;
fileSize?: number;
fileName?: string;
} {
let offset = 0;
const type = buffer.readUInt8(offset);
offset += 1;
if (type !== CommandControlMessage.TYPE_PUSH_FILE) {
throw TypeError(`Incorrect type: "${type}"`);
}
const id = buffer.readInt16BE(offset);
offset += 2;
const state = buffer.readInt8(offset);
offset += 1;
let chunk: Buffer | undefined;
let fileSize: number | undefined;
let fileName: string | undefined;
if (state === FilePushState.APPEND) {
const chunkLength = buffer.readUInt32BE(offset);
offset += 4;
chunk = buffer.slice(offset, offset + chunkLength);
} else if (state === FilePushState.START) {
fileSize = buffer.readUInt32BE(offset);
offset += 4;
const textLength = buffer.readUInt16BE(offset);
offset += 2;
fileName = Util.utf8ByteArrayToString(buffer.slice(offset, offset + textLength));
}
return { id, state, chunk, fileName, fileSize };
}
private buffer?: Buffer;
constructor(readonly type: number) {
super(type);
}
/**
* @override
*/
public toBuffer(): Buffer {
console.log("this.buffer", this.buffer)
if (!this.buffer) {
console.log('CommandControlMessage.PAYLOAD_LENGTH', CommandControlMessage.PAYLOAD_LENGTH)
const buffer = Buffer.alloc(CommandControlMessage.PAYLOAD_LENGTH + 1);
buffer.writeUInt8(this.type, 0);
this.buffer = buffer;
}
console.log("转换comm", this.buffer)
return this.buffer;
}
public toString(): string {
const buffer = this.buffer ? `, buffer=[${this.buffer.join(',')}]` : '';
return `CommandControlMessage{action=${this.type}${buffer}}`;
}
}

View File

@@ -0,0 +1,36 @@
export interface ControlMessageInterface {
type: number;
}
export class ControlMessage {
public static TYPE_KEYCODE = 0;
public static TYPE_TEXT = 1;
public static TYPE_TOUCH = 2;
public static TYPE_SCROLL = 3;
public static TYPE_BACK_OR_SCREEN_ON = 4;
public static TYPE_EXPAND_NOTIFICATION_PANEL = 5;
public static TYPE_EXPAND_SETTINGS_PANEL = 6;
public static TYPE_COLLAPSE_PANELS = 7;
public static TYPE_GET_CLIPBOARD = 8;
public static TYPE_SET_CLIPBOARD = 9;
public static TYPE_SET_SCREEN_POWER_MODE = 10;
public static TYPE_ROTATE_DEVICE = 11;
public static TYPE_CHANGE_STREAM_PARAMETERS = 101;
public static TYPE_PUSH_FILE = 102;
constructor(readonly type: number) {}
public toBuffer(): Buffer {
throw Error('Not implemented');
}
public toString(): string {
return 'ControlMessage';
}
public toJSON(): ControlMessageInterface {
return {
type: this.type,
};
}
}

View File

@@ -0,0 +1,53 @@
import { Buffer } from 'buffer';
import { ControlMessage, ControlMessageInterface } from './ControlMessage';
export interface KeyCodeControlMessageInterface extends ControlMessageInterface {
action: number;
keycode: number;
repeat: number;
metaState: number;
}
export class KeyCodeControlMessage extends ControlMessage {
public static PAYLOAD_LENGTH = 13;
constructor(
readonly action: number,
readonly keycode: number,
readonly repeat: number,
readonly metaState: number,
) {
super(ControlMessage.TYPE_KEYCODE);
}
/**
* @override
*/
public toBuffer(): Buffer {
const buffer = Buffer.alloc(KeyCodeControlMessage.PAYLOAD_LENGTH + 1);
let offset = 0;
offset = buffer.writeInt8(this.type, offset);
offset = buffer.writeInt8(this.action, offset);
offset = buffer.writeInt32BE(this.keycode, offset);
offset = buffer.writeInt32BE(this.repeat, offset);
buffer.writeInt32BE(this.metaState, offset);
console.log("转换按钮", buffer)
console.log("鼠标按下的key", this.keycode)
return buffer;
}
public toString(): string {
return `KeyCodeControlMessage{action=${this.action}, keycode=${this.keycode}, metaState=${this.metaState}}`;
}
public toJSON(): KeyCodeControlMessageInterface {
return {
type: this.type,
action: this.action,
keycode: this.keycode,
metaState: this.metaState,
repeat: this.repeat,
};
}
}

View File

@@ -0,0 +1,47 @@
import { ControlMessage, ControlMessageInterface } from './ControlMessage';
import Position, { PositionInterface } from '../Position';
export interface ScrollControlMessageInterface extends ControlMessageInterface {
position: PositionInterface;
hScroll: number;
vScroll: number;
}
export class ScrollControlMessage extends ControlMessage {
public static PAYLOAD_LENGTH = 20;
constructor(readonly position: Position, readonly hScroll: number, readonly vScroll: number) {
super(ControlMessage.TYPE_SCROLL);
}
/**
* @override
*/
public toBuffer(): Buffer {
const buffer = Buffer.alloc(ScrollControlMessage.PAYLOAD_LENGTH + 1);
let offset = 0;
offset = buffer.writeUInt8(this.type, offset);
offset = buffer.writeUInt32BE(this.position.point.x, offset);
offset = buffer.writeUInt32BE(this.position.point.y, offset);
offset = buffer.writeUInt16BE(this.position.screenSize.width, offset);
offset = buffer.writeUInt16BE(this.position.screenSize.height, offset);
offset = buffer.writeInt32BE(this.hScroll, offset);
buffer.writeInt32BE(this.vScroll, offset);
console.log("转换text", buffer)
return buffer;
}
public toString(): string {
return `ScrollControlMessage{hScroll=${this.hScroll}, vScroll=${this.vScroll}, position=${this.position}}`;
}
public toJSON(): ScrollControlMessageInterface {
return {
type: this.type,
position: this.position.toJSON(),
hScroll: this.hScroll,
vScroll: this.vScroll,
};
}
}

View File

@@ -0,0 +1,43 @@
import { Buffer } from 'buffer';
import { ControlMessage, ControlMessageInterface } from './ControlMessage';
export interface TextControlMessageInterface extends ControlMessageInterface {
text: string;
}
export class TextControlMessage extends ControlMessage {
private static TEXT_SIZE_FIELD_LENGTH = 4;
constructor(readonly text: string) {
super(ControlMessage.TYPE_TEXT);
}
public getText(): string {
return this.text;
}
/**
* @override
*/
public toBuffer(): Buffer {
const length = this.text.length;
const buffer = Buffer.alloc(length + 1 + TextControlMessage.TEXT_SIZE_FIELD_LENGTH);
let offset = 0;
offset = buffer.writeUInt8(this.type, offset);
offset = buffer.writeUInt32BE(length, offset);
buffer.write(this.text, offset);
console.log("转换text", buffer)
return buffer;
}
public toString(): string {
return `TextControlMessage{text=${this.text}}`;
}
public toJSON(): TextControlMessageInterface {
return {
type: this.type,
text: this.text,
};
}
}

View File

@@ -0,0 +1,77 @@
import { ControlMessage, ControlMessageInterface } from './ControlMessage';
import Position, { PositionInterface } from '../Position';
export interface TouchControlMessageInterface extends ControlMessageInterface {
type: number;
action: number;
pointerId: number;
position: PositionInterface;
pressure: number;
buttons: number;
}
export class TouchControlMessage extends ControlMessage {
public static PAYLOAD_LENGTH = 28;
/**
* - For a touch screen or touch pad, reports the approximate pressure
* applied to the surface by a finger or other tool. The value is
* normalized to a range from 0 (no pressure at all) to 1 (normal pressure),
* although values higher than 1 may be generated depending on the
* calibration of the input device.
* - For a trackball, the value is set to 1 if the trackball button is pressed
* or 0 otherwise.
* - For a mouse, the value is set to 1 if the primary mouse button is pressed
* or 0 otherwise.
*
* - scrcpy server expects signed short (2 bytes) for a pressure value
* - in browser TouchEvent has `force` property (values in 0..1 range), we
* use it as "pressure" for scrcpy
*/
public static readonly MAX_PRESSURE_VALUE = 0xffff;
constructor(
readonly action: number,
readonly pointerId: number,
readonly position: Position,
readonly pressure: number,
readonly buttons: number,
) {
super(ControlMessage.TYPE_TOUCH);
}
/**
* @override
*/
public toBuffer(): Buffer {
const buffer: Buffer = Buffer.alloc(TouchControlMessage.PAYLOAD_LENGTH + 1);
let offset = 0;
offset = buffer.writeUInt8(this.type, offset);
offset = buffer.writeUInt8(this.action, offset);
offset = buffer.writeUInt32BE(0, offset); // pointerId is `long` (8 bytes) on java side
offset = buffer.writeUInt32BE(this.pointerId, offset);
offset = buffer.writeUInt32BE(this.position.point.x, offset);
offset = buffer.writeUInt32BE(this.position.point.y, offset);
offset = buffer.writeUInt16BE(this.position.screenSize.width, offset);
offset = buffer.writeUInt16BE(this.position.screenSize.height, offset);
offset = buffer.writeUInt16BE(this.pressure * TouchControlMessage.MAX_PRESSURE_VALUE, offset);
buffer.writeUInt32BE(this.buttons, offset);
// console.log("转换屏幕点击滑动事件", buffer)
return buffer;
}
public toString(): string {
return `TouchControlMessage{action=${this.action}, pointerId=${this.pointerId}, position=${this.position}, pressure=${this.pressure}, buttons=${this.buttons}}`;
}
public toJSON(): TouchControlMessageInterface {
return {
type: this.type,
action: this.action,
pointerId: this.pointerId,
position: this.position.toJSON(),
pressure: this.pressure,
buttons: this.buttons,
};
}
}

View File

@@ -0,0 +1,54 @@
import Util from '../Util';
export default class DeviceMessage {
public static TYPE_CLIPBOARD = 0;
public static TYPE_PUSH_RESPONSE = 101;
public static readonly MAGIC_BYTES_MESSAGE = Util.stringToUtf8ByteArray('scrcpy_message');
constructor(public readonly type: number, protected readonly buffer: Buffer) { }
public static fromBuffer(data: ArrayBuffer): DeviceMessage {
const magicSize = this.MAGIC_BYTES_MESSAGE.length;
const buffer = Buffer.from(data, magicSize, data.byteLength - magicSize);
const type = buffer.readUInt8(0);
return new DeviceMessage(type, buffer);
}
public getText(): string {
if (this.type !== DeviceMessage.TYPE_CLIPBOARD) {
throw TypeError(`Wrong message type: ${this.type}`);
}
if (!this.buffer) {
throw Error('Empty buffer');
}
let offset = 1;
const length = this.buffer.readInt32BE(offset);
offset += 4;
const textBytes = this.buffer.slice(offset, offset + length);
return Util.utf8ByteArrayToString(textBytes);
}
public getPushStats(): { id: number; code: number } {
if (this.type !== DeviceMessage.TYPE_PUSH_RESPONSE) {
throw TypeError(`Wrong message type: ${this.type}`);
}
if (!this.buffer) {
throw Error('Empty buffer');
}
console.log("从设备获取到的信息", this.buffer)
const id = this.buffer.readInt16BE(1);
const code = this.buffer.readInt8(3);
return { id, code };
}
public toString(): string {
let desc: string;
if (this.type === DeviceMessage.TYPE_CLIPBOARD && this.buffer) {
desc = `, text=[${this.getText()}]`;
} else {
desc = this.buffer ? `, buffer=[${this.buffer.join(',')}]` : '';
}
return `DeviceMessage{type=${this.type}${desc}}`;
}
}

View File

@@ -0,0 +1,102 @@
export interface DragEventListener {
onDragEnter: () => boolean;
onDragLeave: () => boolean;
onFilesDrop: (files: File[]) => boolean;
getElement: () => HTMLElement;
}
export class DragAndDropHandler {
private static readonly listeners: Set<DragEventListener> = new Set();
private static dropHandler = (ev: DragEvent): boolean => {
if (!ev.dataTransfer) {
return false;
}
const files: File[] = [];
if (ev.dataTransfer.items) {
for (let i = 0; i < ev.dataTransfer.items.length; i++) {
if (ev.dataTransfer.items[i].kind === 'file') {
const file = ev.dataTransfer.items[i].getAsFile();
if (file) {
files.push(file);
}
}
}
} else {
for (let i = 0; i < ev.dataTransfer.files.length; i++) {
files.push(ev.dataTransfer.files[i]);
}
}
let handled = false;
DragAndDropHandler.listeners.forEach((listener) => {
const element = listener.getElement();
if (element === ev.currentTarget) {
handled = handled || listener.onFilesDrop(files);
}
});
if (handled) {
ev.preventDefault();
return true;
}
return false;
};
private static dragOverHandler = (ev: DragEvent): void => {
ev.preventDefault();
};
private static dragLeaveHandler = (ev: DragEvent): boolean => {
let handled = false;
DragAndDropHandler.listeners.forEach((listener) => {
const element = listener.getElement();
if (element === ev.currentTarget) {
handled = handled || listener.onDragLeave();
}
});
if (handled) {
ev.preventDefault();
return true;
}
return false;
};
private static dragEnterHandler = (ev: DragEvent): boolean => {
let handled = false;
DragAndDropHandler.listeners.forEach((listener) => {
const element = listener.getElement();
if (element === ev.currentTarget) {
handled = handled || listener.onDragEnter();
}
});
if (handled) {
ev.preventDefault();
return true;
}
return false;
};
private static attachListeners(element: HTMLElement): void {
element.addEventListener('drop', this.dropHandler);
element.addEventListener('dragover', this.dragOverHandler);
element.addEventListener('dragleave', this.dragLeaveHandler);
element.addEventListener('dragenter', this.dragEnterHandler);
}
private static detachListeners(element: HTMLElement): void {
element.removeEventListener('drop', this.dropHandler);
element.removeEventListener('dragover', this.dragOverHandler);
element.removeEventListener('dragleave', this.dragLeaveHandler);
element.removeEventListener('dragenter', this.dragEnterHandler);
}
public static addEventListener(listener: DragEventListener): void {
if (this.listeners.has(listener)) {
return;
}
this.attachListeners(listener.getElement());
this.listeners.add(listener);
}
public static removeEventListener(listener: DragEventListener): void {
if (!this.listeners.has(listener)) {
return;
}
this.detachListeners(listener.getElement());
this.listeners.delete(listener);
}
}

View File

@@ -0,0 +1,129 @@
import FilePushHandler, { DragAndPushListener, PushUpdateParams } from './filePush/FilePushHandler';
const TAG = '[DragAndPushLogger]';
export default class DragAndPushLogger implements DragAndPushListener {
private static readonly X: number = 20;
private static readonly Y: number = 40;
private static readonly HEIGHT = 12;
private static readonly LOG_BACKGROUND: string = 'rgba(0,0,0, 0.5)';
private static readonly DEBUG_COLOR: string = 'hsl(136, 85%,50%)';
private static readonly ERROR_COLOR: string = 'hsl(336,85%,50%)';
private readonly ctx: CanvasRenderingContext2D | null = null;
private timeoutMap: Map<number, number> = new Map();
private dirtyMap: Map<number, number> = new Map();
private pushLineMap: Map<string, number> = new Map();
private linePushMap: Map<number, string> = new Map();
private dirtyLines: boolean[] = [];
constructor(element: HTMLElement) {
if (element instanceof HTMLCanvasElement) {
const canvas = element as HTMLCanvasElement;
this.ctx = canvas.getContext('2d');
}
}
cleanDirtyLine = (line: number): void => {
if (!this.ctx) {
return;
}
const { X, Y, HEIGHT } = DragAndPushLogger;
const x = X;
const y = Y + HEIGHT * line * 2;
const dirty = this.dirtyMap.get(line);
if (dirty) {
const p = DragAndPushLogger.HEIGHT / 2;
const d = p * 2;
this.ctx.clearRect(x - p, y - HEIGHT - p, dirty + d, HEIGHT + d);
}
this.dirtyLines[line] = false;
};
private logText(text: string, line: number, scheduleCleanup = false, error = false): void {
if (!this.ctx) {
error ? console.error(TAG, text) : console.log(TAG, text);
return;
}
if (error) {
console.error(TAG, text);
}
this.cleanDirtyLine(line);
const { X, Y, HEIGHT } = DragAndPushLogger;
const x = X;
const y = Y + HEIGHT * line * 2;
this.ctx.save();
this.ctx.font = `${HEIGHT}px monospace`;
const textMetrics = this.ctx.measureText(text);
const width = Math.abs(textMetrics.actualBoundingBoxLeft) + Math.abs(textMetrics.actualBoundingBoxRight);
this.dirtyMap.set(line, width);
this.ctx.fillStyle = DragAndPushLogger.LOG_BACKGROUND;
const p = DragAndPushLogger.HEIGHT / 2 - 1;
const d = p * 2;
this.ctx.fillRect(x - p, y - HEIGHT - p, width + d, HEIGHT + d);
this.ctx.fillStyle = error ? DragAndPushLogger.ERROR_COLOR : DragAndPushLogger.DEBUG_COLOR;
this.ctx.fillText(text, x, y);
this.ctx.restore();
if (scheduleCleanup) {
this.dirtyLines[line] = true;
let timeout = this.timeoutMap.get(line);
if (timeout) {
clearTimeout(timeout);
}
timeout = window.setTimeout(() => {
this.cleanDirtyLine(line);
const key = this.linePushMap.get(line);
if (typeof key === 'string') {
this.linePushMap.delete(line);
this.pushLineMap.delete(key);
}
}, 5000);
this.timeoutMap.set(line, timeout);
}
}
public onDragEnter(): boolean {
this.logText('Drop APK files here', 1);
return true;
}
public onDragLeave(): boolean {
this.cleanDirtyLine(1);
return true;
}
public onDrop(): boolean {
this.cleanDirtyLine(1);
return true;
}
public onError(error: Error | string): void {
const text = typeof error === 'string' ? error : error.message;
this.logText(text, 1, true);
}
onFilePushUpdate(data: PushUpdateParams): void {
const { pushId, message, fileName, error } = data;
const key = `${pushId}/${fileName}`;
const firstKey = `${FilePushHandler.REQUEST_NEW_PUSH_ID}/${fileName}`;
let line: number | undefined = this.pushLineMap.get(key);
let update = false;
if (typeof line === 'undefined' && key !== firstKey) {
line = this.pushLineMap.get(firstKey);
if (typeof line !== 'undefined') {
this.pushLineMap.delete(firstKey);
update = true;
}
}
if (typeof line === 'undefined') {
line = 2;
while (this.dirtyLines[line]) {
line++;
}
update = true;
}
if (update) {
this.pushLineMap.set(key, line);
this.linePushMap.set(line, key);
}
this.logText(`Upload "${fileName}": ${message}`, line, true, error);
}
}

View File

@@ -0,0 +1,11 @@
import { Stats } from './Stats';
export class Entry extends Stats {
constructor(public name: string, mode: number, size: number, mtime: number) {
super(mode, size, mtime);
}
public toString(): string {
return this.name;
}
}

View File

@@ -0,0 +1,79 @@
import { KeyCodeControlMessage } from '../controlMessage/KeyCodeControlMessage';
import KeyEvent from './android/KeyEvent';
import { KeyToCodeMap } from './KeyToCodeMap';
export interface KeyEventListener {
onKeyEvent: (event: KeyCodeControlMessage) => void;
}
export class KeyInputHandler {
private static readonly repeatCounter: Map<number, number> = new Map();
private static readonly listeners: Set<KeyEventListener> = new Set();
private static handler = (event: Event): void => {
const keyboardEvent = event as KeyboardEvent;
const keyCode = KeyToCodeMap.get(keyboardEvent.code);
if (!keyCode) {
return;
}
let action: typeof KeyEvent.ACTION_DOWN | typeof KeyEvent.ACTION_DOWN;
let repeatCount = 0;
console.log('键盘按下', keyCode)
if (keyboardEvent.type === 'keydown') {
action = KeyEvent.ACTION_DOWN;
if (keyboardEvent.repeat) {
let count = KeyInputHandler.repeatCounter.get(keyCode);
if (typeof count !== 'number') {
count = 1;
} else {
count++;
}
repeatCount = count;
KeyInputHandler.repeatCounter.set(keyCode, count);
}
} else if (keyboardEvent.type === 'keyup') {
action = KeyEvent.ACTION_UP;
KeyInputHandler.repeatCounter.delete(keyCode);
} else {
return;
}
const metaState =
(keyboardEvent.getModifierState('Alt') ? KeyEvent.META_ALT_ON : 0) |
(keyboardEvent.getModifierState('Shift') ? KeyEvent.META_SHIFT_ON : 0) |
(keyboardEvent.getModifierState('Control') ? KeyEvent.META_CTRL_ON : 0) |
(keyboardEvent.getModifierState('Meta') ? KeyEvent.META_META_ON : 0) |
(keyboardEvent.getModifierState('CapsLock') ? KeyEvent.META_CAPS_LOCK_ON : 0) |
(keyboardEvent.getModifierState('ScrollLock') ? KeyEvent.META_SCROLL_LOCK_ON : 0) |
(keyboardEvent.getModifierState('NumLock') ? KeyEvent.META_NUM_LOCK_ON : 0);
const controlMessage: KeyCodeControlMessage = new KeyCodeControlMessage(
action,
keyCode,
repeatCount,
metaState,
);
KeyInputHandler.listeners.forEach((listener) => {
listener.onKeyEvent(controlMessage);
});
event.preventDefault();
};
private static attachListeners(): void {
document.body.addEventListener('keydown', this.handler);
document.body.addEventListener('keyup', this.handler);
}
private static detachListeners(): void {
document.body.removeEventListener('keydown', this.handler);
document.body.removeEventListener('keyup', this.handler);
}
public static addEventListener(listener: KeyEventListener): void {
if (!this.listeners.size) {
this.attachListeners();
}
this.listeners.add(listener);
}
public static removeEventListener(listener: KeyEventListener): void {
this.listeners.delete(listener);
if (!this.listeners.size) {
this.detachListeners();
}
}
}

View File

@@ -0,0 +1,118 @@
import KeyEvent from './android/KeyEvent';
import UIEventsCode from '../UIEventsCode';
export const KeyToCodeMap = new Map([
[UIEventsCode.Backquote, KeyEvent.KEYCODE_GRAVE],
[UIEventsCode.Backslash, KeyEvent.KEYCODE_BACKSLASH],
[UIEventsCode.BracketLeft, KeyEvent.KEYCODE_LEFT_BRACKET],
[UIEventsCode.BracketRight, KeyEvent.KEYCODE_RIGHT_BRACKET],
[UIEventsCode.Comma, KeyEvent.KEYCODE_COMMA],
[UIEventsCode.Digit0, KeyEvent.KEYCODE_0],
[UIEventsCode.Digit1, KeyEvent.KEYCODE_1],
[UIEventsCode.Digit2, KeyEvent.KEYCODE_2],
[UIEventsCode.Digit3, KeyEvent.KEYCODE_3],
[UIEventsCode.Digit4, KeyEvent.KEYCODE_4],
[UIEventsCode.Digit5, KeyEvent.KEYCODE_5],
[UIEventsCode.Digit6, KeyEvent.KEYCODE_6],
[UIEventsCode.Digit7, KeyEvent.KEYCODE_7],
[UIEventsCode.Digit8, KeyEvent.KEYCODE_8],
[UIEventsCode.Digit9, KeyEvent.KEYCODE_9],
[UIEventsCode.Equal, KeyEvent.KEYCODE_EQUALS],
[UIEventsCode.IntlRo, KeyEvent.KEYCODE_RO],
[UIEventsCode.IntlYen, KeyEvent.KEYCODE_YEN],
[UIEventsCode.KeyA, KeyEvent.KEYCODE_A],
[UIEventsCode.KeyB, KeyEvent.KEYCODE_B],
[UIEventsCode.KeyC, KeyEvent.KEYCODE_C],
[UIEventsCode.KeyD, KeyEvent.KEYCODE_D],
[UIEventsCode.KeyE, KeyEvent.KEYCODE_E],
[UIEventsCode.KeyF, KeyEvent.KEYCODE_F],
[UIEventsCode.KeyG, KeyEvent.KEYCODE_G],
[UIEventsCode.KeyH, KeyEvent.KEYCODE_H],
[UIEventsCode.KeyI, KeyEvent.KEYCODE_I],
[UIEventsCode.KeyJ, KeyEvent.KEYCODE_J],
[UIEventsCode.KeyK, KeyEvent.KEYCODE_K],
[UIEventsCode.KeyL, KeyEvent.KEYCODE_L],
[UIEventsCode.KeyM, KeyEvent.KEYCODE_M],
[UIEventsCode.KeyN, KeyEvent.KEYCODE_N],
[UIEventsCode.KeyO, KeyEvent.KEYCODE_O],
[UIEventsCode.KeyP, KeyEvent.KEYCODE_P],
[UIEventsCode.KeyQ, KeyEvent.KEYCODE_Q],
[UIEventsCode.KeyR, KeyEvent.KEYCODE_R],
[UIEventsCode.KeyS, KeyEvent.KEYCODE_S],
[UIEventsCode.KeyT, KeyEvent.KEYCODE_T],
[UIEventsCode.KeyU, KeyEvent.KEYCODE_U],
[UIEventsCode.KeyV, KeyEvent.KEYCODE_V],
[UIEventsCode.KeyW, KeyEvent.KEYCODE_W],
[UIEventsCode.KeyX, KeyEvent.KEYCODE_X],
[UIEventsCode.KeyY, KeyEvent.KEYCODE_Y],
[UIEventsCode.KeyZ, KeyEvent.KEYCODE_Z],
[UIEventsCode.Minus, KeyEvent.KEYCODE_MINUS],
[UIEventsCode.Period, KeyEvent.KEYCODE_PERIOD],
[UIEventsCode.Quote, KeyEvent.KEYCODE_APOSTROPHE],
[UIEventsCode.Semicolon, KeyEvent.KEYCODE_SEMICOLON],
[UIEventsCode.Slash, KeyEvent.KEYCODE_SLASH],
[UIEventsCode.KanaMode, KeyEvent.KEYCODE_KANA],
[UIEventsCode.Delete, KeyEvent.KEYCODE_FORWARD_DEL],
[UIEventsCode.End, KeyEvent.KEYCODE_MOVE_END],
[UIEventsCode.Help, KeyEvent.KEYCODE_HELP],
[UIEventsCode.Home, KeyEvent.KEYCODE_MOVE_HOME],
[UIEventsCode.Insert, KeyEvent.KEYCODE_INSERT],
[UIEventsCode.PageDown, KeyEvent.KEYCODE_PAGE_DOWN],
[UIEventsCode.PageUp, KeyEvent.KEYCODE_PAGE_UP],
[UIEventsCode.AltLeft, KeyEvent.KEYCODE_ALT_LEFT],
[UIEventsCode.AltRight, KeyEvent.KEYCODE_ALT_RIGHT],
[UIEventsCode.Backspace, KeyEvent.KEYCODE_DEL],
[UIEventsCode.CapsLock, KeyEvent.KEYCODE_CAPS_LOCK],
[UIEventsCode.ControlLeft, KeyEvent.KEYCODE_CTRL_LEFT],
[UIEventsCode.ControlRight, KeyEvent.KEYCODE_CTRL_RIGHT],
[UIEventsCode.Enter, KeyEvent.KEYCODE_ENTER],
[UIEventsCode.MetaLeft, KeyEvent.KEYCODE_META_LEFT],
[UIEventsCode.MetaRight, KeyEvent.KEYCODE_META_RIGHT],
[UIEventsCode.ShiftLeft, KeyEvent.KEYCODE_SHIFT_LEFT],
[UIEventsCode.ShiftRight, KeyEvent.KEYCODE_SHIFT_RIGHT],
[UIEventsCode.Space, KeyEvent.KEYCODE_SPACE],
[UIEventsCode.Tab, KeyEvent.KEYCODE_TAB],
[UIEventsCode.ArrowLeft, KeyEvent.KEYCODE_DPAD_LEFT],
[UIEventsCode.ArrowUp, KeyEvent.KEYCODE_DPAD_UP],
[UIEventsCode.ArrowRight, KeyEvent.KEYCODE_DPAD_RIGHT],
[UIEventsCode.ArrowDown, KeyEvent.KEYCODE_DPAD_DOWN],
[UIEventsCode.NumLock, KeyEvent.KEYCODE_NUM_LOCK],
[UIEventsCode.Numpad0, KeyEvent.KEYCODE_NUMPAD_0],
[UIEventsCode.Numpad1, KeyEvent.KEYCODE_NUMPAD_1],
[UIEventsCode.Numpad2, KeyEvent.KEYCODE_NUMPAD_2],
[UIEventsCode.Numpad3, KeyEvent.KEYCODE_NUMPAD_3],
[UIEventsCode.Numpad4, KeyEvent.KEYCODE_NUMPAD_4],
[UIEventsCode.Numpad5, KeyEvent.KEYCODE_NUMPAD_5],
[UIEventsCode.Numpad6, KeyEvent.KEYCODE_NUMPAD_6],
[UIEventsCode.Numpad7, KeyEvent.KEYCODE_NUMPAD_7],
[UIEventsCode.Numpad8, KeyEvent.KEYCODE_NUMPAD_8],
[UIEventsCode.Numpad9, KeyEvent.KEYCODE_NUMPAD_9],
[UIEventsCode.NumpadAdd, KeyEvent.KEYCODE_NUMPAD_ADD],
[UIEventsCode.NumpadComma, KeyEvent.KEYCODE_NUMPAD_COMMA],
[UIEventsCode.NumpadDecimal, KeyEvent.KEYCODE_NUMPAD_DOT],
[UIEventsCode.NumpadDivide, KeyEvent.KEYCODE_NUMPAD_DIVIDE],
[UIEventsCode.NumpadEnter, KeyEvent.KEYCODE_NUMPAD_ENTER],
[UIEventsCode.NumpadEqual, KeyEvent.KEYCODE_NUMPAD_EQUALS],
[UIEventsCode.NumpadMultiply, KeyEvent.KEYCODE_NUMPAD_MULTIPLY],
[UIEventsCode.NumpadParenLeft, KeyEvent.KEYCODE_NUMPAD_LEFT_PAREN],
[UIEventsCode.NumpadParenRight, KeyEvent.KEYCODE_NUMPAD_RIGHT_PAREN],
[UIEventsCode.NumpadSubtract, KeyEvent.KEYCODE_NUMPAD_SUBTRACT],
[UIEventsCode.Escape, KeyEvent.KEYCODE_ESCAPE],
[UIEventsCode.F1, KeyEvent.KEYCODE_F1],
[UIEventsCode.F2, KeyEvent.KEYCODE_F2],
[UIEventsCode.F3, KeyEvent.KEYCODE_F3],
[UIEventsCode.F4, KeyEvent.KEYCODE_F4],
[UIEventsCode.F5, KeyEvent.KEYCODE_F5],
[UIEventsCode.F6, KeyEvent.KEYCODE_F6],
[UIEventsCode.F7, KeyEvent.KEYCODE_F7],
[UIEventsCode.F8, KeyEvent.KEYCODE_F8],
[UIEventsCode.F9, KeyEvent.KEYCODE_F9],
[UIEventsCode.F10, KeyEvent.KEYCODE_F10],
[UIEventsCode.F11, KeyEvent.KEYCODE_F11],
[UIEventsCode.F12, KeyEvent.KEYCODE_F12],
[UIEventsCode.Fn, KeyEvent.KEYCODE_FUNCTION],
[UIEventsCode.PrintScreen, KeyEvent.KEYCODE_SYSRQ],
[UIEventsCode.Pause, KeyEvent.KEYCODE_BREAK],
]);

View File

@@ -0,0 +1,67 @@
export class Stats {
// The following constant were extracted from `man 2 stat` on Ubuntu 12.10.
public static S_IFMT = 0o170000; // bit mask for the file type bit fields
public static S_IFSOCK = 0o140000; // socket
public static S_IFLNK = 0o120000; // symbolic link
public static S_IFREG = 0o100000; // regular file
public static S_IFBLK = 0o060000; // block device
public static S_IFDIR = 0o040000; // directory
public static S_IFCHR = 0o020000; // character device
public static S_IFIFO = 0o010000; // FIFO
public static S_ISUID = 0o004000; // set UID bit
public static S_ISGID = 0o002000; // set-group-ID bit (see below)
public static S_ISVTX = 0o001000; // sticky bit (see below)
public static S_IRWXU = 0o0700; // mask for file owner permissions
public static S_IRUSR = 0o0400; // owner has read permission
public static S_IWUSR = 0o0200; // owner has write permission
public static S_IXUSR = 0o0100; // owner has execute permission
public static S_IRWXG = 0o0070; // mask for group permissions
public static S_IRGRP = 0o0040; // group has read permission
public readonly mtime: Date;
constructor(public readonly mode: number, public readonly size: number, mtime: number) {
this.mtime = new Date(mtime * 1000);
}
private checkModeProperty(property: number): boolean {
return (this.mode & Stats.S_IFMT) === property;
}
public isBlockDevice(): boolean {
return this.checkModeProperty(Stats.S_IFBLK);
}
public isCharacterDevice(): boolean {
return this.checkModeProperty(Stats.S_IFCHR);
}
public isDirectory(): boolean {
return this.checkModeProperty(Stats.S_IFDIR);
}
public isFIFO(): boolean {
return this.checkModeProperty(Stats.S_IFIFO);
}
public isisSocket(): boolean {
return this.checkModeProperty(Stats.S_IFSOCK);
}
public isSymbolicLink(): boolean {
return this.checkModeProperty(Stats.S_IFLNK);
}
public isFile(): boolean {
return this.checkModeProperty(Stats.S_IFREG);
}
}

View File

@@ -0,0 +1,317 @@
export default class KeyEvent {
// 定义按键常量
public static readonly ACTION_DOWN: number = 0;
public static readonly ACTION_UP: number = 1;
public static readonly KEYCODE_0: number = 7;
public static readonly KEYCODE_1: number = 8;
public static readonly KEYCODE_11: number = 227;
public static readonly KEYCODE_12: number = 228;
public static readonly KEYCODE_2: number = 9;
public static readonly KEYCODE_3: number = 10;
public static readonly KEYCODE_3D_MODE: number = 206;
public static readonly KEYCODE_4: number = 11;
public static readonly KEYCODE_5: number = 12;
public static readonly KEYCODE_6: number = 13;
public static readonly KEYCODE_7: number = 14;
public static readonly KEYCODE_8: number = 15;
public static readonly KEYCODE_9: number = 16;
public static readonly KEYCODE_A: number = 29;
public static readonly KEYCODE_ALL_APPS: number = 284;
public static readonly KEYCODE_ALT_LEFT: number = 57;
public static readonly KEYCODE_ALT_RIGHT: number = 58;
public static readonly KEYCODE_APOSTROPHE: number = 75;
public static readonly KEYCODE_APP_SWITCH: number = 187;
public static readonly KEYCODE_ASSIST: number = 219;
public static readonly KEYCODE_AT: number = 77;
public static readonly KEYCODE_AVR_INPUT: number = 182;
public static readonly KEYCODE_AVR_POWER: number = 181;
public static readonly KEYCODE_B: number = 30;
public static readonly KEYCODE_BACK: number = 4;
public static readonly KEYCODE_BACKSLASH: number = 73;
public static readonly KEYCODE_BOOKMARK: number = 174;
public static readonly KEYCODE_BREAK: number = 121;
public static readonly KEYCODE_BRIGHTNESS_DOWN: number = 220;
public static readonly KEYCODE_BRIGHTNESS_UP: number = 221;
public static readonly KEYCODE_BUTTON_1: number = 188;
public static readonly KEYCODE_BUTTON_10: number = 197;
public static readonly KEYCODE_BUTTON_11: number = 198;
public static readonly KEYCODE_BUTTON_12: number = 199;
public static readonly KEYCODE_BUTTON_13: number = 200;
public static readonly KEYCODE_BUTTON_14: number = 201;
public static readonly KEYCODE_BUTTON_15: number = 202;
public static readonly KEYCODE_BUTTON_16: number = 203;
public static readonly KEYCODE_BUTTON_2: number = 189;
public static readonly KEYCODE_BUTTON_3: number = 190;
public static readonly KEYCODE_BUTTON_4: number = 191;
public static readonly KEYCODE_BUTTON_5: number = 192;
public static readonly KEYCODE_BUTTON_6: number = 193;
public static readonly KEYCODE_BUTTON_7: number = 194;
public static readonly KEYCODE_BUTTON_8: number = 195;
public static readonly KEYCODE_BUTTON_9: number = 196;
public static readonly KEYCODE_BUTTON_A: number = 96;
public static readonly KEYCODE_BUTTON_B: number = 97;
public static readonly KEYCODE_BUTTON_C: number = 98;
public static readonly KEYCODE_BUTTON_L1: number = 102;
public static readonly KEYCODE_BUTTON_L2: number = 104;
public static readonly KEYCODE_BUTTON_MODE: number = 110;
public static readonly KEYCODE_BUTTON_R1: number = 103;
public static readonly KEYCODE_BUTTON_R2: number = 105;
public static readonly KEYCODE_BUTTON_SELECT: number = 109;
public static readonly KEYCODE_BUTTON_START: number = 108;
public static readonly KEYCODE_BUTTON_THUMBL: number = 106;
public static readonly KEYCODE_BUTTON_THUMBR: number = 107;
public static readonly KEYCODE_BUTTON_X: number = 99;
public static readonly KEYCODE_BUTTON_Y: number = 100;
public static readonly KEYCODE_BUTTON_Z: number = 101;
public static readonly KEYCODE_C: number = 31;
public static readonly KEYCODE_CALCULATOR: number = 210;
public static readonly KEYCODE_CALENDAR: number = 208;
public static readonly KEYCODE_CALL: number = 5;
public static readonly KEYCODE_CAMERA: number = 27;
public static readonly KEYCODE_CAPS_LOCK: number = 115;
public static readonly KEYCODE_CAPTIONS: number = 175;
public static readonly KEYCODE_CHANNEL_DOWN: number = 167;
public static readonly KEYCODE_CHANNEL_UP: number = 166;
public static readonly KEYCODE_CLEAR: number = 28;
public static readonly KEYCODE_COMMA: number = 55;
public static readonly KEYCODE_CONTACTS: number = 207;
public static readonly KEYCODE_COPY: number = 278;
public static readonly KEYCODE_CTRL_LEFT: number = 113;
public static readonly KEYCODE_CTRL_RIGHT: number = 114;
public static readonly KEYCODE_CUT: number = 277;
public static readonly KEYCODE_D: number = 32;
public static readonly KEYCODE_DEL: number = 67;
public static readonly KEYCODE_DPAD_CENTER: number = 23;
public static readonly KEYCODE_DPAD_DOWN: number = 20;
public static readonly KEYCODE_DPAD_DOWN_LEFT: number = 269;
public static readonly KEYCODE_DPAD_DOWN_RIGHT: number = 271;
public static readonly KEYCODE_DPAD_LEFT: number = 21;
public static readonly KEYCODE_DPAD_RIGHT: number = 22;
public static readonly KEYCODE_DPAD_UP: number = 19;
public static readonly KEYCODE_DPAD_UP_LEFT: number = 268;
public static readonly KEYCODE_DPAD_UP_RIGHT: number = 270;
public static readonly KEYCODE_DVR: number = 173;
public static readonly KEYCODE_E: number = 33;
public static readonly KEYCODE_EISU: number = 212;
public static readonly KEYCODE_ENDCALL: number = 6;
public static readonly KEYCODE_ENTER: number = 66;
public static readonly KEYCODE_ENVELOPE: number = 65;
public static readonly KEYCODE_EQUALS: number = 70;
public static readonly KEYCODE_ESCAPE: number = 111;
public static readonly KEYCODE_EXPLORER: number = 64;
public static readonly KEYCODE_F: number = 34;
public static readonly KEYCODE_F1: number = 131;
public static readonly KEYCODE_F10: number = 140;
public static readonly KEYCODE_F11: number = 141;
public static readonly KEYCODE_F12: number = 142;
public static readonly KEYCODE_F2: number = 132;
public static readonly KEYCODE_F3: number = 133;
public static readonly KEYCODE_F4: number = 134;
public static readonly KEYCODE_F5: number = 135;
public static readonly KEYCODE_F6: number = 136;
public static readonly KEYCODE_F7: number = 137;
public static readonly KEYCODE_F8: number = 138;
public static readonly KEYCODE_F9: number = 139;
public static readonly KEYCODE_FOCUS: number = 80;
public static readonly KEYCODE_FORWARD: number = 125;
public static readonly KEYCODE_FORWARD_DEL: number = 112;
public static readonly KEYCODE_FUNCTION: number = 119;
public static readonly KEYCODE_G: number = 35;
public static readonly KEYCODE_GRAVE: number = 68;
public static readonly KEYCODE_GUIDE: number = 172;
public static readonly KEYCODE_H: number = 36;
public static readonly KEYCODE_HEADSETHOOK: number = 79;
public static readonly KEYCODE_HELP: number = 259;
public static readonly KEYCODE_HENKAN: number = 214;
public static readonly KEYCODE_HOME: number = 3;
public static readonly KEYCODE_I: number = 37;
public static readonly KEYCODE_INFO: number = 165;
public static readonly KEYCODE_INSERT: number = 124;
public static readonly KEYCODE_J: number = 38;
public static readonly KEYCODE_K: number = 39;
public static readonly KEYCODE_KANA: number = 218;
public static readonly KEYCODE_KATAKANA_HIRAGANA: number = 215;
public static readonly KEYCODE_L: number = 40;
public static readonly KEYCODE_LANGUAGE_SWITCH: number = 204;
public static readonly KEYCODE_LAST_CHANNEL: number = 229;
public static readonly KEYCODE_LEFT_BRACKET: number = 71;
public static readonly KEYCODE_M: number = 41;
public static readonly KEYCODE_MANNER_MODE: number = 205;
public static readonly KEYCODE_MEDIA_AUDIO_TRACK: number = 222;
public static readonly KEYCODE_MEDIA_CLOSE: number = 128;
public static readonly KEYCODE_MEDIA_EJECT: number = 129;
public static readonly KEYCODE_MEDIA_FAST_FORWARD: number = 90;
public static readonly KEYCODE_MEDIA_NEXT: number = 87;
public static readonly KEYCODE_MEDIA_PAUSE: number = 127;
public static readonly KEYCODE_MEDIA_PLAY: number = 126;
public static readonly KEYCODE_MEDIA_PLAY_PAUSE: number = 85;
public static readonly KEYCODE_MEDIA_PREVIOUS: number = 88;
public static readonly KEYCODE_MEDIA_RECORD: number = 130;
public static readonly KEYCODE_MEDIA_REWIND: number = 89;
public static readonly KEYCODE_MEDIA_SKIP_BACKWARD: number = 273;
public static readonly KEYCODE_MEDIA_SKIP_FORWARD: number = 272;
public static readonly KEYCODE_MEDIA_STEP_BACKWARD: number = 275;
public static readonly KEYCODE_MEDIA_STEP_FORWARD: number = 274;
public static readonly KEYCODE_MEDIA_STOP: number = 86;
public static readonly KEYCODE_MEDIA_TOP_MENU: number = 226;
public static readonly KEYCODE_MENU: number = 82;
public static readonly KEYCODE_META_LEFT: number = 117;
public static readonly KEYCODE_META_RIGHT: number = 118;
public static readonly KEYCODE_MINUS: number = 69;
public static readonly KEYCODE_MOVE_END: number = 123;
public static readonly KEYCODE_MOVE_HOME: number = 122;
public static readonly KEYCODE_MUHENKAN: number = 213;
public static readonly KEYCODE_MUSIC: number = 209;
public static readonly KEYCODE_MUTE: number = 91;
public static readonly KEYCODE_N: number = 42;
public static readonly KEYCODE_NAVIGATE_IN: number = 262;
public static readonly KEYCODE_NAVIGATE_NEXT: number = 261;
public static readonly KEYCODE_NAVIGATE_OUT: number = 263;
public static readonly KEYCODE_NAVIGATE_PREVIOUS: number = 260;
public static readonly KEYCODE_NOTIFICATION: number = 83;
public static readonly KEYCODE_NUM: number = 78;
public static readonly KEYCODE_NUMPAD_0: number = 144;
public static readonly KEYCODE_NUMPAD_1: number = 145;
public static readonly KEYCODE_NUMPAD_2: number = 146;
public static readonly KEYCODE_NUMPAD_3: number = 147;
public static readonly KEYCODE_NUMPAD_4: number = 148;
public static readonly KEYCODE_NUMPAD_5: number = 149;
public static readonly KEYCODE_NUMPAD_6: number = 150;
public static readonly KEYCODE_NUMPAD_7: number = 151;
public static readonly KEYCODE_NUMPAD_8: number = 152;
public static readonly KEYCODE_NUMPAD_9: number = 153;
public static readonly KEYCODE_NUMPAD_ADD: number = 157;
public static readonly KEYCODE_NUMPAD_COMMA: number = 159;
public static readonly KEYCODE_NUMPAD_DIVIDE: number = 154;
public static readonly KEYCODE_NUMPAD_DOT: number = 158;
public static readonly KEYCODE_NUMPAD_ENTER: number = 160;
public static readonly KEYCODE_NUMPAD_EQUALS: number = 161;
public static readonly KEYCODE_NUMPAD_LEFT_PAREN: number = 162;
public static readonly KEYCODE_NUMPAD_MULTIPLY: number = 155;
public static readonly KEYCODE_NUMPAD_RIGHT_PAREN: number = 163;
public static readonly KEYCODE_NUMPAD_SUBTRACT: number = 156;
public static readonly KEYCODE_NUM_LOCK: number = 143;
public static readonly KEYCODE_O: number = 43;
public static readonly KEYCODE_P: number = 44;
public static readonly KEYCODE_PAGE_DOWN: number = 93;
public static readonly KEYCODE_PAGE_UP: number = 92;
public static readonly KEYCODE_PAIRING: number = 225;
public static readonly KEYCODE_PASTE: number = 279;
public static readonly KEYCODE_PERIOD: number = 56;
public static readonly KEYCODE_PICTSYMBOLS: number = 94;
public static readonly KEYCODE_PLUS: number = 81;
public static readonly KEYCODE_POUND: number = 18;
public static readonly KEYCODE_POWER: number = 26;
public static readonly KEYCODE_PROFILE_SWITCH: number = 288;
public static readonly KEYCODE_PROG_BLUE: number = 186;
public static readonly KEYCODE_PROG_GREEN: number = 184;
public static readonly KEYCODE_PROG_RED: number = 183;
public static readonly KEYCODE_PROG_YELLOW: number = 185;
public static readonly KEYCODE_Q: number = 45;
public static readonly KEYCODE_R: number = 46;
public static readonly KEYCODE_REFRESH: number = 285;
public static readonly KEYCODE_RIGHT_BRACKET: number = 72;
public static readonly KEYCODE_RO: number = 217;
public static readonly KEYCODE_S: number = 47;
public static readonly KEYCODE_SCROLL_LOCK: number = 116;
public static readonly KEYCODE_SEARCH: number = 84;
public static readonly KEYCODE_SEMICOLON: number = 74;
public static readonly KEYCODE_SETTINGS: number = 176;
public static readonly KEYCODE_SHIFT_LEFT: number = 59;
public static readonly KEYCODE_SHIFT_RIGHT: number = 60;
public static readonly KEYCODE_SLASH: number = 76;
public static readonly KEYCODE_SLEEP: number = 223;
public static readonly KEYCODE_SOFT_LEFT: number = 1;
public static readonly KEYCODE_SOFT_RIGHT: number = 2;
public static readonly KEYCODE_SOFT_SLEEP: number = 276;
public static readonly KEYCODE_SPACE: number = 62;
public static readonly KEYCODE_STAR: number = 17;
public static readonly KEYCODE_STB_INPUT: number = 180;
public static readonly KEYCODE_STB_POWER: number = 179;
public static readonly KEYCODE_STEM_1: number = 265;
public static readonly KEYCODE_STEM_2: number = 266;
public static readonly KEYCODE_STEM_3: number = 267;
public static readonly KEYCODE_STEM_PRIMARY: number = 264;
public static readonly KEYCODE_SWITCH_CHARSET: number = 95;
public static readonly KEYCODE_SYM: number = 63;
public static readonly KEYCODE_SYSRQ: number = 120;
public static readonly KEYCODE_SYSTEM_NAVIGATION_DOWN: number = 281;
public static readonly KEYCODE_SYSTEM_NAVIGATION_LEFT: number = 282;
public static readonly KEYCODE_SYSTEM_NAVIGATION_RIGHT: number = 283;
public static readonly KEYCODE_SYSTEM_NAVIGATION_UP: number = 280;
public static readonly KEYCODE_T: number = 48;
public static readonly KEYCODE_TAB: number = 61;
public static readonly KEYCODE_THUMBS_DOWN: number = 287;
public static readonly KEYCODE_THUMBS_UP: number = 286;
public static readonly KEYCODE_TV: number = 170;
public static readonly KEYCODE_TV_ANTENNA_CABLE: number = 242;
public static readonly KEYCODE_TV_AUDIO_DESCRIPTION: number = 252;
public static readonly KEYCODE_TV_AUDIO_DESCRIPTION_MIX_DOWN: number = 254;
public static readonly KEYCODE_TV_AUDIO_DESCRIPTION_MIX_UP: number = 253;
public static readonly KEYCODE_TV_CONTENTS_MENU: number = 256;
public static readonly KEYCODE_TV_DATA_SERVICE: number = 230;
public static readonly KEYCODE_TV_INPUT: number = 178;
public static readonly KEYCODE_TV_INPUT_COMPONENT_1: number = 249;
public static readonly KEYCODE_TV_INPUT_COMPONENT_2: number = 250;
public static readonly KEYCODE_TV_INPUT_COMPOSITE_1: number = 247;
public static readonly KEYCODE_TV_INPUT_COMPOSITE_2: number = 248;
public static readonly KEYCODE_TV_INPUT_HDMI_1: number = 243;
public static readonly KEYCODE_TV_INPUT_HDMI_2: number = 244;
public static readonly KEYCODE_TV_INPUT_HDMI_3: number = 245;
public static readonly KEYCODE_TV_INPUT_HDMI_4: number = 246;
public static readonly KEYCODE_TV_INPUT_VGA_1: number = 251;
public static readonly KEYCODE_TV_MEDIA_CONTEXT_MENU: number = 257;
public static readonly KEYCODE_TV_NETWORK: number = 241;
public static readonly KEYCODE_TV_NUMBER_ENTRY: number = 234;
public static readonly KEYCODE_TV_POWER: number = 177;
public static readonly KEYCODE_TV_RADIO_SERVICE: number = 232;
public static readonly KEYCODE_TV_SATELLITE: number = 237;
public static readonly KEYCODE_TV_SATELLITE_BS: number = 238;
public static readonly KEYCODE_TV_SATELLITE_CS: number = 239;
public static readonly KEYCODE_TV_SATELLITE_SERVICE: number = 240;
public static readonly KEYCODE_TV_TELETEXT: number = 233;
public static readonly KEYCODE_TV_TERRESTRIAL_ANALOG: number = 235;
public static readonly KEYCODE_TV_TERRESTRIAL_DIGITAL: number = 236;
public static readonly KEYCODE_TV_TIMER_PROGRAMMING: number = 258;
public static readonly KEYCODE_TV_ZOOM_MODE: number = 255;
public static readonly KEYCODE_U: number = 49;
public static readonly KEYCODE_UNKNOWN: number = 0;
public static readonly KEYCODE_V: number = 50;
public static readonly KEYCODE_VOICE_ASSIST: number = 231;
public static readonly KEYCODE_VOLUME_DOWN: number = 25;
public static readonly KEYCODE_VOLUME_MUTE: number = 164;
public static readonly KEYCODE_VOLUME_UP: number = 24;
public static readonly KEYCODE_W: number = 51;
public static readonly KEYCODE_WAKEUP: number = 224;
public static readonly KEYCODE_WINDOW: number = 171;
public static readonly KEYCODE_X: number = 52;
public static readonly KEYCODE_Y: number = 53;
public static readonly KEYCODE_YEN: number = 216;
public static readonly KEYCODE_Z: number = 54;
public static readonly KEYCODE_ZENKAKU_HANKAKU: number = 211;
public static readonly KEYCODE_ZOOM_IN: number = 168;
public static readonly KEYCODE_ZOOM_OUT: number = 169;
public static readonly META_ALT_LEFT_ON: number = 16;
public static readonly META_ALT_MASK: number = 50;
public static readonly META_ALT_ON: number = 2;
public static readonly META_ALT_RIGHT_ON: number = 32;
public static readonly META_CAPS_LOCK_ON: number = 1048576;
public static readonly META_CTRL_LEFT_ON: number = 8192;
public static readonly META_CTRL_MASK: number = 28672;
public static readonly META_CTRL_ON: number = 4096;
public static readonly META_CTRL_RIGHT_ON: number = 16384;
public static readonly META_FUNCTION_ON: number = 8;
public static readonly META_META_LEFT_ON: number = 131072;
public static readonly META_META_MASK: number = 458752;
public static readonly META_META_ON: number = 65536;
public static readonly META_META_RIGHT_ON: number = 262144;
public static readonly META_NUM_LOCK_ON: number = 2097152;
public static readonly META_SCROLL_LOCK_ON: number = 4194304;
public static readonly META_SHIFT_LEFT_ON: number = 64;
public static readonly META_SHIFT_MASK: number = 193;
public static readonly META_SHIFT_ON: number = 1;
public static readonly META_SHIFT_RIGHT_ON: number = 128;
public static readonly META_SYM_ON: number = 4;
}

View File

@@ -0,0 +1,935 @@
export default class MediaFormat {
public static readonly MIMETYPE_VIDEO_VP8: string = 'video/x-vnd.on2.vp8';
public static readonly MIMETYPE_VIDEO_VP9: string = 'video/x-vnd.on2.vp9';
public static readonly MIMETYPE_VIDEO_AV1: string = 'video/av01';
public static readonly MIMETYPE_VIDEO_AVC: string = 'video/avc';
public static readonly MIMETYPE_VIDEO_HEVC: string = 'video/hevc';
public static readonly MIMETYPE_VIDEO_MPEG4: string = 'video/mp4v-es';
public static readonly MIMETYPE_VIDEO_H263: string = 'video/3gpp';
public static readonly MIMETYPE_VIDEO_MPEG2: string = 'video/mpeg2';
public static readonly MIMETYPE_VIDEO_RAW: string = 'video/raw';
public static readonly MIMETYPE_VIDEO_DOLBY_VISION: string = 'video/dolby-vision';
public static readonly MIMETYPE_VIDEO_SCRAMBLED: string = 'video/scrambled';
public static readonly MIMETYPE_AUDIO_AMR_NB: string = 'audio/3gpp';
public static readonly MIMETYPE_AUDIO_AMR_WB: string = 'audio/amr-wb';
public static readonly MIMETYPE_AUDIO_MPEG: string = 'audio/mpeg';
public static readonly MIMETYPE_AUDIO_AAC: string = 'audio/mp4a-latm';
public static readonly MIMETYPE_AUDIO_QCELP: string = 'audio/qcelp';
public static readonly MIMETYPE_AUDIO_VORBIS: string = 'audio/vorbis';
public static readonly MIMETYPE_AUDIO_OPUS: string = 'audio/opus';
public static readonly MIMETYPE_AUDIO_G711_ALAW: string = 'audio/g711-alaw';
public static readonly MIMETYPE_AUDIO_G711_MLAW: string = 'audio/g711-mlaw';
public static readonly MIMETYPE_AUDIO_RAW: string = 'audio/raw';
public static readonly MIMETYPE_AUDIO_FLAC: string = 'audio/flac';
public static readonly MIMETYPE_AUDIO_MSGSM: string = 'audio/gsm';
public static readonly MIMETYPE_AUDIO_AC3: string = 'audio/ac3';
public static readonly MIMETYPE_AUDIO_EAC3: string = 'audio/eac3';
public static readonly MIMETYPE_AUDIO_EAC3_JOC: string = 'audio/eac3-joc';
public static readonly MIMETYPE_AUDIO_AC4: string = 'audio/ac4';
public static readonly MIMETYPE_AUDIO_SCRAMBLED: string = 'audio/scrambled';
/**
* MIME type for HEIF still image data encoded in HEVC.
*
* To decode such an image, {@link MediaCodec} decoder for
* {@link #MIMETYPE_VIDEO_HEVC} shall be used. The client needs to form
* the correct {@link #MediaFormat} based on additional information in
* the track format, and send it to {@link MediaCodec#configure}.
*
* The track's MediaFormat will come with {@link #KEY_WIDTH} and
* {@link #KEY_HEIGHT} keys, which describes the width and height
* of the image. If the image doesn't contain grid (i.e. none of
* {@link #KEY_TILE_WIDTH}, {@link #KEY_TILE_HEIGHT},
* {@link #KEY_GRID_ROWS}, {@link #KEY_GRID_COLUMNS} are present}), the
* track will contain a single sample of coded data for the entire image,
* and the image width and height should be used to set up the decoder.
*
* If the image does come with grid, each sample from the track will
* contain one tile in the grid, of which the size is described by
* {@link #KEY_TILE_WIDTH} and {@link #KEY_TILE_HEIGHT}. This size
* (instead of {@link #KEY_WIDTH} and {@link #KEY_HEIGHT}) should be
* used to set up the decoder. The track contains {@link #KEY_GRID_ROWS}
* by {@link #KEY_GRID_COLUMNS} samples in row-major, top-row first,
* left-to-right order. The output image should be reconstructed by
* first tiling the decoding results of the tiles in the correct order,
* then trimming (before rotation is applied) on the bottom and right
* side, if the tiled area is larger than the image width and height.
*/
public static readonly MIMETYPE_IMAGE_ANDROID_HEIC: string = 'image/vnd.android.heic';
/**
* MIME type for WebVTT subtitle data.
*/
public static readonly MIMETYPE_TEXT_VTT: string = 'text/vtt';
/**
* MIME type for SubRip (SRT) container.
*/
public static readonly MIMETYPE_TEXT_SUBRIP: string = 'application/x-subrip';
/**
* MIME type for CEA-608 closed caption data.
*/
public static readonly MIMETYPE_TEXT_CEA_608: string = 'text/cea-608';
/**
* MIME type for CEA-708 closed caption data.
*/
public static readonly MIMETYPE_TEXT_CEA_708: string = 'text/cea-708';
// private mMap: Map<String, Object> = new Map();
/**
* A key describing the mime type of the MediaFormat.
* The associated value is a string.
*/
public static readonly KEY_MIME: string = 'mime';
/**
* A key describing the language of the content, using either ISO 639-1
* or 639-2/T codes. The associated value is a string.
*/
public static readonly KEY_LANGUAGE: string = 'language';
/**
* A key describing the sample rate of an audio format.
* The associated value is an integer
*/
public static readonly KEY_SAMPLE_RATE: string = 'sample-rate';
/**
* A key describing the number of channels in an audio format.
* The associated value is an integer
*/
public static readonly KEY_CHANNEL_COUNT: string = 'channel-count';
/**
* A key describing the width of the content in a video format.
* The associated value is an integer
*/
public static readonly KEY_WIDTH: string = 'width';
/**
* A key describing the height of the content in a video format.
* The associated value is an integer
*/
public static readonly KEY_HEIGHT: string = 'height';
/**
* A key describing the maximum expected width of the content in a video
* decoder format, in case there are resolution changes in the video content.
* The associated value is an integer
*/
public static readonly KEY_MAX_WIDTH: string = 'max-width';
/**
* A key describing the maximum expected height of the content in a video
* decoder format, in case there are resolution changes in the video content.
* The associated value is an integer
*/
public static readonly KEY_MAX_HEIGHT: string = 'max-height';
/** A key describing the maximum size in bytes of a buffer of data
* described by this MediaFormat.
* The associated value is an integer
*/
public static readonly KEY_MAX_INPUT_SIZE: string = 'max-input-size';
/**
* A key describing the average bitrate in bits/sec.
* The associated value is an integer
*/
public static readonly KEY_BIT_RATE: string = 'bitrate';
/**
* A key describing the max bitrate in bits/sec.
* This is usually over a one-second sliding window (e.g. over any window of one second).
* The associated value is an integer
* @hide
*/
public static readonly KEY_MAX_BIT_RATE: string = 'max-bitrate';
/**
* A key describing the color format of the content in a video format.
* Constants are declared in {@link android.media.MediaCodecInfo.CodecCapabilities}.
*/
public static readonly KEY_COLOR_FORMAT: string = 'color-format';
/**
* A key describing the frame rate of a video format in frames/sec.
* The associated value is normally an integer when the value is used by the platform,
* but video codecs also accept float configuration values.
* Specifically, {@link MediaExtractor#getTrackFormat MediaExtractor} provides an integer
* value corresponding to the frame rate information of the track if specified and non-zero.
* Otherwise, this key is not present. {@link MediaCodec#configure MediaCodec} accepts both
* float and integer values. This represents the desired operating frame rate if the
* {@link #KEY_OPERATING_RATE} is not present and {@link #KEY_PRIORITY} is {@code 0}
* (realtime). For video encoders this value corresponds to the intended frame rate,
* although encoders are expected
* to support variable frame rate based on {@link MediaCodec.BufferInfo#presentationTimeUs
* buffer timestamp}. This key is not used in the {@code MediaCodec}
* {@link MediaCodec#getInputFormat input}/{@link MediaCodec#getOutputFormat output} formats,
* nor by {@link MediaMuxer#addTrack MediaMuxer}.
*/
public static readonly KEY_FRAME_RATE: string = 'frame-rate';
/**
* A key describing the width (in pixels) of each tile of the content in a
* {@link #MIMETYPE_IMAGE_ANDROID_HEIC} track. The associated value is an integer.
*
* Refer to {@link #MIMETYPE_IMAGE_ANDROID_HEIC} on decoding instructions of such tracks.
*
* @see #KEY_TILE_HEIGHT
* @see #KEY_GRID_ROWS
* @see #KEY_GRID_COLUMNS
*/
public static readonly KEY_TILE_WIDTH: string = 'tile-width';
/**
* A key describing the height (in pixels) of each tile of the content in a
* {@link #MIMETYPE_IMAGE_ANDROID_HEIC} track. The associated value is an integer.
*
* Refer to {@link #MIMETYPE_IMAGE_ANDROID_HEIC} on decoding instructions of such tracks.
*
* @see #KEY_TILE_WIDTH
* @see #KEY_GRID_ROWS
* @see #KEY_GRID_COLUMNS
*/
public static readonly KEY_TILE_HEIGHT: string = 'tile-height';
/**
* A key describing the number of grid rows in the content in a
* {@link #MIMETYPE_IMAGE_ANDROID_HEIC} track. The associated value is an integer.
*
* Refer to {@link #MIMETYPE_IMAGE_ANDROID_HEIC} on decoding instructions of such tracks.
*
* @see #KEY_TILE_WIDTH
* @see #KEY_TILE_HEIGHT
* @see #KEY_GRID_COLUMNS
*/
public static readonly KEY_GRID_ROWS: string = 'grid-rows';
/**
* A key describing the number of grid columns in the content in a
* {@link #MIMETYPE_IMAGE_ANDROID_HEIC} track. The associated value is an integer.
*
* Refer to {@link #MIMETYPE_IMAGE_ANDROID_HEIC} on decoding instructions of such tracks.
*
* @see #KEY_TILE_WIDTH
* @see #KEY_TILE_HEIGHT
* @see #KEY_GRID_ROWS
*/
public static readonly KEY_GRID_COLUMNS: string = 'grid-cols';
/**
* A key describing the raw audio sample encoding/format.
*
* <p>The associated value is an integer, using one of the
* {@link AudioFormat}.ENCODING_PCM_ values.</p>
*
* <p>This is an optional key for audio decoders and encoders specifying the
* desired raw audio sample format during {@link MediaCodec#configure
* MediaCodec.configure(&hellip;)} call. Use {@link MediaCodec#getInputFormat
* MediaCodec.getInput}/{@link MediaCodec#getOutputFormat OutputFormat(&hellip;)}
* to confirm the actual format. For the PCM decoder this key specifies both
* input and output sample encodings.</p>
*
* <p>This key is also used by {@link MediaExtractor} to specify the sample
* format of audio data, if it is specified.</p>
*
* <p>If this key is missing, the raw audio sample format is signed 16-bit short.</p>
*/
public static readonly KEY_PCM_ENCODING: string = 'pcm-encoding';
/**
* A key describing the capture rate of a video format in frames/sec.
* <p>
* When capture rate is different than the frame rate, it means that the
* video is acquired at a different rate than the playback, which produces
* slow motion or timelapse effect during playback. Application can use the
* value of this key to tell the relative speed ratio between capture and
* playback rates when the video was recorded.
* </p>
* <p>
* The associated value is an integer or a float.
* </p>
*/
public static readonly KEY_CAPTURE_RATE: string = 'capture-rate';
/**
* A key describing the frequency of key frames expressed in seconds between key frames.
* <p>
* This key is used by video encoders.
* A negative value means no key frames are requested after the first frame.
* A zero value means a stream containing all key frames is requested.
* <p class=note>
* Most video encoders will convert this value of the number of non-key-frames between
* key-frames, using the {@linkplain #KEY_FRAME_RATE frame rate} information; therefore,
* if the actual frame rate differs (e.g. input frames are dropped or the frame rate
* changes), the <strong>time interval</strong> between key frames will not be the
* configured value.
* <p>
* The associated value is an integer (or float since
* {@link android.os.Build.VERSION_CODES#N_MR1}).
*/
public static readonly KEY_I_FRAME_INTERVAL: string = 'i-frame-interval';
/**
* An optional key describing the period of intra refresh in frames. This is an
* optional parameter that applies only to video encoders. If encoder supports it
* ({@link MediaCodecInfo.CodecCapabilities#FEATURE_IntraRefresh}), the whole
* frame is completely refreshed after the specified period. Also for each frame,
* a fix subset of macroblocks must be intra coded which leads to more constant bitrate
* than inserting a key frame. This key is recommended for video streaming applications
* as it provides low-delay and good error-resilience. This key is ignored if the
* video encoder does not support the intra refresh feature. Use the output format to
* verify that this feature was enabled.
* The associated value is an integer.
*/
public static readonly KEY_INTRA_REFRESH_PERIOD: string = 'intra-refresh-period';
/**
* An optional key describing whether encoders prepend headers to sync frames (e.g.
* SPS and PPS to IDR frames for H.264). This is an optional parameter that applies only
* to video encoders. A video encoder may not support this feature; the component will fail
* to configure in that case. For other components, this key is ignored.
*
* The value is an integer, with 1 indicating to prepend headers to every sync frames,
* or 0 otherwise. The default value is 0.
*/
public static readonly KEY_PREPEND_HEADER_TO_SYNC_FRAMES: string = 'prepend-sps-pps-to-idr-frames';
/**
* A key describing the temporal layering schema. This is an optional parameter
* that applies only to video encoders. Use {@link MediaCodec#getOutputFormat}
* after {@link MediaCodec#configure configure} to query if the encoder supports
* the desired schema. Supported values are {@code webrtc.vp8.N-layer},
* {@code android.generic.N}, {@code android.generic.N+M} and {@code none}, where
* {@code N} denotes the total number of non-bidirectional layers (which must be at least 1)
* and {@code M} denotes the total number of bidirectional layers (which must be non-negative).
* <p class=note>{@code android.generic.*} schemas have been added in {@link
* android.os.Build.VERSION_CODES#N_MR1}.
* <p>
* The encoder may support fewer temporal layers, in which case the output format
* will contain the configured schema. If the encoder does not support temporal
* layering, the output format will not have an entry with this key.
* The associated value is a string.
*/
public static readonly KEY_TEMPORAL_LAYERING: string = 'ts-schema';
/**
* A key describing the stride of the video bytebuffer layout.
* Stride (or row increment) is the difference between the index of a pixel
* and that of the pixel directly underneath. For YUV 420 formats, the
* stride corresponds to the Y plane; the stride of the U and V planes can
* be calculated based on the color format, though it is generally undefined
* and depends on the device and release.
* The associated value is an integer, representing number of bytes.
*/
public static readonly KEY_STRIDE: string = 'stride';
/**
* A key describing the plane height of a multi-planar (YUV) video bytebuffer layout.
* Slice height (or plane height/vertical stride) is the number of rows that must be skipped
* to get from the top of the Y plane to the top of the U plane in the bytebuffer. In essence
* the offset of the U plane is sliceHeight * stride. The height of the U/V planes
* can be calculated based on the color format, though it is generally undefined
* and depends on the device and release.
* The associated value is an integer, representing number of rows.
*/
public static readonly KEY_SLICE_HEIGHT: string = 'slice-height';
/**
* Applies only when configuring a video encoder in "surface-input" mode.
* The associated value is a long and gives the time in microseconds
* after which the frame previously submitted to the encoder will be
* repeated (once) if no new frame became available since.
*/
public static readonly KEY_REPEAT_PREVIOUS_FRAME_AFTER: string = 'repeat-previous-frame-after';
/**
* Instruct the video encoder in "surface-input" mode to drop excessive
* frames from the source, so that the input frame rate to the encoder
* does not exceed the specified fps.
*
* The associated value is a float, representing the max frame rate to
* feed the encoder at.
*
*/
public static readonly KEY_MAX_FPS_TO_ENCODER: string = 'max-fps-to-encoder';
/**
* Instruct the video encoder in "surface-input" mode to limit the gap of
* timestamp between any two adjacent frames fed to the encoder to the
* specified amount (in micro-second).
*
* The associated value is a long int. When positive, it represents the max
* timestamp gap between two adjacent frames fed to the encoder. When negative,
* the absolute value represents a fixed timestamp gap between any two adjacent
* frames fed to the encoder. Note that this will also apply even when the
* original timestamp goes backward in time. Under normal conditions, such frames
* would be dropped and not sent to the encoder.
*
* The output timestamp will be restored to the original timestamp and will
* not be affected.
*
* This is used in some special scenarios where input frames arrive sparingly
* but it's undesirable to allocate more bits to any single frame, or when it's
* important to ensure all frames are captured (rather than captured in the
* correct order).
*
*/
public static readonly KEY_MAX_PTS_GAP_TO_ENCODER: string = 'max-pts-gap-to-encoder';
/**
* If specified when configuring a video encoder that's in "surface-input"
* mode, it will instruct the encoder to put the surface source in suspended
* state when it's connected. No video frames will be accepted until a resume
* operation (see {@link MediaCodec#PARAMETER_KEY_SUSPEND}), optionally with
* timestamp specified via {@link MediaCodec#PARAMETER_KEY_SUSPEND_TIME}, is
* received.
*
* The value is an integer, with 1 indicating to create with the surface
* source suspended, or 0 otherwise. The default value is 0.
*
* If this key is not set or set to 0, the surface source will accept buffers
* as soon as it's connected to the encoder (although they may not be encoded
* immediately). This key can be used when the client wants to prepare the
* encoder session in advance, but do not want to accept buffers immediately.
*/
public static readonly KEY_CREATE_INPUT_SURFACE_SUSPENDED: string = 'create-input-buffers-suspended';
/**
* If specified when configuring a video decoder rendering to a surface,
* causes the decoder to output "blank", i.e. black frames to the surface
* when stopped to clear out any previously displayed contents.
* The associated value is an integer of value 1.
*/
public static readonly KEY_PUSH_BLANK_BUFFERS_ON_STOP: string = 'push-blank-buffers-on-shutdown';
/**
* A key describing the duration (in microseconds) of the content.
* The associated value is a long.
*/
public static readonly KEY_DURATION: string = 'durationUs';
/**
* A key mapping to a value of 1 if the content is AAC audio and
* audio frames are prefixed with an ADTS header.
* The associated value is an integer (0 or 1).
* This key is only supported when _decoding_ content, it cannot
* be used to configure an encoder to emit ADTS output.
*/
public static readonly KEY_IS_ADTS: string = 'is-adts';
/**
* A key describing the channel composition of audio content. This mask
* is composed of bits drawn from channel mask definitions in {@link android.media.AudioFormat}.
* The associated value is an integer.
*/
public static readonly KEY_CHANNEL_MASK: string = 'channel-mask';
/**
* A key describing the AAC profile to be used (AAC audio formats only).
* Constants are declared in {@link android.media.MediaCodecInfo.CodecProfileLevel}.
*/
public static readonly KEY_AAC_PROFILE: string = 'aac-profile';
/**
* A key describing the AAC SBR mode to be used (AAC audio formats only).
* The associated value is an integer and can be set to following values:
* <ul>
* <li>0 - no SBR should be applied</li>
* <li>1 - single rate SBR</li>
* <li>2 - double rate SBR</li>
* </ul>
* Note: If this key is not defined the default SRB mode for the desired AAC profile will
* be used.
* <p>This key is only used during encoding.
*/
public static readonly KEY_AAC_SBR_MODE: string = 'aac-sbr-mode';
/**
* A key describing the maximum number of channels that can be output by the AAC decoder.
* By default, the decoder will output the same number of channels as present in the encoded
* stream, if supported. Set this value to limit the number of output channels, and use
* the downmix information in the stream, if available.
* <p>Values larger than the number of channels in the content to decode are ignored.
* <p>This key is only used during decoding.
*/
public static readonly KEY_AAC_MAX_OUTPUT_CHANNEL_COUNT: string = 'aac-max-output-channel_count';
/**
* A key describing a gain to be applied so that the output loudness matches the
* Target Reference Level. This is typically used to normalize loudness across program items.
* The gain is derived as the difference between the Target Reference Level and the
* Program Reference Level. The latter can be given in the bitstream and indicates the actual
* loudness value of the program item.
* <p>The Target Reference Level controls loudness normalization for both MPEG-4 DRC and
* MPEG-D DRC.
* <p>The value is given as an integer value between
* 40 and 127, and is calculated as -4 * Target Reference Level in LKFS.
* Therefore, it represents the range of -10 to -31.75 LKFS.
* <p>The default value on mobile devices is 64 (-16 LKFS).
* <p>This key is only used during decoding.
*/
public static readonly KEY_AAC_DRC_TARGET_REFERENCE_LEVEL: string = 'aac-target-ref-level';
/**
* A key describing for selecting the DRC effect type for MPEG-D DRC.
* The supported values are defined in ISO/IEC 23003-4:2015 and are described as follows:
* <table>
* <tr><th>Value</th><th>Effect</th></tr>
* <tr><th>-1</th><th>Off</th></tr>
* <tr><th>0</th><th>None</th></tr>
* <tr><th>1</th><th>Late night</th></tr>
* <tr><th>2</th><th>Noisy environment</th></tr>
* <tr><th>3</th><th>Limited playback range</th></tr>
* <tr><th>4</th><th>Low playback level</th></tr>
* <tr><th>5</th><th>Dialog enhancement</th></tr>
* <tr><th>6</th><th>General compression</th></tr>
* </table>
* <p>The value -1 (Off) disables DRC processing, while loudness normalization may still be
* active and dependent on KEY_AAC_DRC_TARGET_REFERENCE_LEVEL.<br>
* The value 0 (None) automatically enables DRC processing if necessary to prevent signal
* clipping<br>
* The value 6 (General compression) can be used for enabling MPEG-D DRC without particular
* DRC effect type request.<br>
* The default DRC effect type is 3 ("Limited playback range") on mobile devices.
* <p>This key is only used during decoding.
*/
public static readonly KEY_AAC_DRC_EFFECT_TYPE: string = 'aac-drc-effect-type';
/**
* A key describing the target reference level that was assumed at the encoder for
* calculation of attenuation gains for clipping prevention.
* <p>If it is known, this information can be provided as an integer value between
* 0 and 127, which is calculated as -4 * Encoded Target Level in LKFS.
* If the Encoded Target Level is unknown, the value can be set to -1.
* <p>The default value is -1 (unknown).
* <p>The value is ignored when heavy compression is used (see
* {@link #KEY_AAC_DRC_HEAVY_COMPRESSION}).
* <p>This key is only used during decoding.
*/
public static readonly KEY_AAC_ENCODED_TARGET_LEVEL: string = 'aac-encoded-target-level';
/**
* A key describing the boost factor allowing to adapt the dynamics of the output to the
* actual listening requirements. This relies on DRC gain sequences that can be transmitted in
* the encoded bitstream to be able to reduce the dynamics of the output signal upon request.
* This factor enables the user to select how much of the gains are applied.
* <p>Positive gains (boost) and negative gains (attenuation, see
* {@link #KEY_AAC_DRC_ATTENUATION_FACTOR}) can be controlled separately for a better match
* to different use-cases.
* <p>Typically, attenuation gains are sent for loud signal segments, and boost gains are sent
* for soft signal segments. If the output is listened to in a noisy environment, for example,
* the boost factor is used to enable the positive gains, i.e. to amplify soft signal segments
* beyond the noise floor. But for listening late at night, the attenuation
* factor is used to enable the negative gains, to prevent loud signal from surprising
* the listener. In applications which generally need a low dynamic range, both the boost factor
* and the attenuation factor are used in order to enable all DRC gains.
* <p>In order to prevent clipping, it is also recommended to apply the attenuation gains
* in case of a downmix and/or loudness normalization to high target reference levels.
* <p>Both the boost and the attenuation factor parameters are given as integer values
* between 0 and 127, representing the range of the factor of 0 (i.e. don't apply)
* to 1 (i.e. fully apply boost/attenuation gains respectively).
* <p>The default value is 127 (fully apply boost DRC gains).
* <p>This key is only used during decoding.
*/
public static readonly KEY_AAC_DRC_BOOST_FACTOR: string = 'aac-drc-boost-level';
/**
* A key describing the attenuation factor allowing to adapt the dynamics of the output to the
* actual listening requirements.
* See {@link #KEY_AAC_DRC_BOOST_FACTOR} for a description of the role of this attenuation
* factor and the value range.
* <p>The default value is 127 (fully apply attenuation DRC gains).
* <p>This key is only used during decoding.
*/
public static readonly KEY_AAC_DRC_ATTENUATION_FACTOR: string = 'aac-drc-cut-level';
/**
* A key describing the selection of the heavy compression profile for DRC.
* Two separate DRC gain sequences can be transmitted in one bitstream: MPEG-4 DRC light
* compression, and DVB-specific heavy compression. When selecting the application of the heavy
* compression, one of the sequences is selected:
* <ul>
* <li>0 enables light compression,</li>
* <li>1 enables heavy compression instead.
* </ul>
* Note that only light compression offers the features of scaling of DRC gains
* (see {@link #KEY_AAC_DRC_BOOST_FACTOR} and {@link #KEY_AAC_DRC_ATTENUATION_FACTOR} for the
* boost and attenuation factors, and frequency-selective (multiband) DRC.
* Light compression usually contains clipping prevention for stereo downmixing while heavy
* compression, if additionally provided in the bitstream, is usually stronger, and contains
* clipping prevention for stereo and mono downmixing.
* <p>The default is 1 (heavy compression).
* <p>This key is only used during decoding.
*/
public static readonly KEY_AAC_DRC_HEAVY_COMPRESSION: string = 'aac-drc-heavy-compression';
/**
* A key describing the FLAC compression level to be used (FLAC audio format only).
* The associated value is an integer ranging from 0 (fastest, least compression)
* to 8 (slowest, most compression).
*/
public static readonly KEY_FLAC_COMPRESSION_LEVEL: string = 'flac-compression-level';
/**
* A key describing the encoding complexity.
* The associated value is an integer. These values are device and codec specific,
* but lower values generally result in faster and/or less power-hungry encoding.
*
* @see MediaCodecInfo.EncoderCapabilities#getComplexityRange()
*/
public static readonly KEY_COMPLEXITY: string = 'complexity';
/**
* A key describing the desired encoding quality.
* The associated value is an integer. This key is only supported for encoders
* that are configured in constant-quality mode. These values are device and
* codec specific, but lower values generally result in more efficient
* (smaller-sized) encoding.
*
* @see MediaCodecInfo.EncoderCapabilities#getQualityRange()
*/
public static readonly KEY_QUALITY: string = 'quality';
/**
* A key describing the desired codec priority.
* <p>
* The associated value is an integer. Higher value means lower priority.
* <p>
* Currently, only two levels are supported:<br>
* 0: realtime priority - meaning that the codec shall support the given
* performance configuration (e.g. framerate) at realtime. This should
* only be used by media playback, capture, and possibly by realtime
* communication scenarios if best effort performance is not suitable.<br>
* 1: non-realtime priority (best effort).
* <p>
* This is a hint used at codec configuration and resource planning - to understand
* the realtime requirements of the application; however, due to the nature of
* media components, performance is not guaranteed.
*
*/
public static readonly KEY_PRIORITY: string = 'priority';
/**
* A key describing the desired operating frame rate for video or sample rate for audio
* that the codec will need to operate at.
* <p>
* The associated value is an integer or a float representing frames-per-second or
* samples-per-second
* <p>
* This is used for cases like high-speed/slow-motion video capture, where the video encoder
* format contains the target playback rate (e.g. 30fps), but the component must be able to
* handle the high operating capture rate (e.g. 240fps).
* <p>
* This rate will be used by codec for resource planning and setting the operating points.
*
*/
public static readonly KEY_OPERATING_RATE: string = 'operating-rate';
/**
* A key describing the desired profile to be used by an encoder.
* The associated value is an integer.
* Constants are declared in {@link MediaCodecInfo.CodecProfileLevel}.
* This key is used as a hint, and is only supported for codecs
* that specify a profile. Note: Codecs are free to use all the available
* coding tools at the specified profile.
*
* @see MediaCodecInfo.CodecCapabilities#profileLevels
*/
public static readonly KEY_PROFILE: string = 'profile';
/**
* A key describing the desired profile to be used by an encoder.
* The associated value is an integer.
* Constants are declared in {@link MediaCodecInfo.CodecProfileLevel}.
* This key is used as a further hint when specifying a desired profile,
* and is only supported for codecs that specify a level.
* <p>
* This key is ignored if the {@link #KEY_PROFILE profile} is not specified.
*
* @see MediaCodecInfo.CodecCapabilities#profileLevels
*/
public static readonly KEY_LEVEL: string = 'level';
/**
* An optional key describing the desired encoder latency in frames. This is an optional
* parameter that applies only to video encoders. If encoder supports it, it should ouput
* at least one output frame after being queued the specified number of frames. This key
* is ignored if the video encoder does not support the latency feature. Use the output
* format to verify that this feature was enabled and the actual value used by the encoder.
* <p>
* If the key is not specified, the default latency will be implenmentation specific.
* The associated value is an integer.
*/
public static readonly KEY_LATENCY: string = 'latency';
/**
* An optional key describing the maximum number of non-display-order coded frames.
* This is an optional parameter that applies only to video encoders. Application should
* check the value for this key in the output format to see if codec will produce
* non-display-order coded frames. If encoder supports it, the output frames' order will be
* different from the display order and each frame's display order could be retrived from
* {@link MediaCodec.BufferInfo#presentationTimeUs}. Before API level 27, application may
* receive non-display-order coded frames even though the application did not request it.
* Note: Application should not rearrange the frames to display order before feeding them
* to {@link MediaMuxer#writeSampleData}.
* <p>
* The default value is 0.
*/
public static readonly KEY_OUTPUT_REORDER_DEPTH: string = 'output-reorder-depth';
/**
* A key describing the desired clockwise rotation on an output surface.
* This key is only used when the codec is configured using an output surface.
* The associated value is an integer, representing degrees. Supported values
* are 0, 90, 180 or 270. This is an optional field; if not specified, rotation
* defaults to 0.
*
* @see MediaCodecInfo.CodecCapabilities#profileLevels
*/
public static readonly KEY_ROTATION: string = 'rotation-degrees';
/**
* A key describing the desired bitrate mode to be used by an encoder.
* Constants are declared in {@link MediaCodecInfo.CodecCapabilities}.
*
* @see MediaCodecInfo.EncoderCapabilities#isBitrateModeSupported(int)
*/
public static readonly KEY_BITRATE_MODE: string = 'bitrate-mode';
/**
* A key describing the audio session ID of the AudioTrack associated
* to a tunneled video codec.
* The associated value is an integer.
*
* @see MediaCodecInfo.CodecCapabilities#FEATURE_TunneledPlayback
*/
public static readonly KEY_AUDIO_SESSION_ID: string = 'audio-session-id';
/**
* A key for boolean AUTOSELECT behavior for the track. Tracks with AUTOSELECT=true
* are considered when automatically selecting a track without specific user
* choice, based on the current locale.
* This is currently only used for subtitle tracks, when the user selected
* 'Default' for the captioning locale.
* The associated value is an integer, where non-0 means TRUE. This is an optional
* field; if not specified, AUTOSELECT defaults to TRUE.
*/
public static readonly KEY_IS_AUTOSELECT: string = 'is-autoselect';
/**
* A key for boolean DEFAULT behavior for the track. The track with DEFAULT=true is
* selected in the absence of a specific user choice.
* This is currently used in two scenarios:
* 1) for subtitle tracks, when the user selected 'Default' for the captioning locale.
* 2) for a {@link #MIMETYPE_IMAGE_ANDROID_HEIC} track, indicating the image is the
* primary item in the file.
* The associated value is an integer, where non-0 means TRUE. This is an optional
* field; if not specified, DEFAULT is considered to be FALSE.
*/
public static readonly KEY_IS_DEFAULT: string = 'is-default';
/**
* A key for the FORCED field for subtitle tracks. True if it is a
* forced subtitle track. Forced subtitle tracks are essential for the
* content and are shown even when the user turns off Captions. They
* are used for example to translate foreign/alien dialogs or signs.
* The associated value is an integer, where non-0 means TRUE. This is an
* optional field; if not specified, FORCED defaults to FALSE.
*/
public static readonly KEY_IS_FORCED_SUBTITLE: string = 'is-forced-subtitle';
/**
* A key describing the number of haptic channels in an audio format.
* The associated value is an integer.
*/
public static readonly KEY_HAPTIC_CHANNEL_COUNT: string = 'haptic-channel-count';
/** @hide */
public static readonly KEY_IS_TIMED_TEXT: string = 'is-timed-text';
// The following color aspect values must be in sync with the ones in HardwareAPI.h.
/**
* An optional key describing the color primaries, white point and
* luminance factors for video content.
*
* The associated value is an integer: 0 if unspecified, or one of the
* COLOR_STANDARD_ values.
*/
public static readonly KEY_COLOR_STANDARD: string = 'color-standard';
/** BT.709 color chromacity coordinates with KR = 0.2126, KB = 0.0722. */
public static readonly COLOR_STANDARD_BT709: number = 1;
/** BT.601 625 color chromacity coordinates with KR = 0.299, KB = 0.114. */
public static readonly COLOR_STANDARD_BT601_PAL: number = 2;
/** BT.601 525 color chromacity coordinates with KR = 0.299, KB = 0.114. */
public static readonly COLOR_STANDARD_BT601_NTSC: number = 4;
/** BT.2020 color chromacity coordinates with KR = 0.2627, KB = 0.0593. */
public static readonly COLOR_STANDARD_BT2020: number = 6;
// /** @hide */
// @IntDef({
// COLOR_STANDARD_BT709,
// COLOR_STANDARD_BT601_PAL,
// COLOR_STANDARD_BT601_NTSC,
// COLOR_STANDARD_BT2020,
// })
// @Retention(RetentionPolicy.SOURCE)
// public @interface ColorStandard {}
/**
* An optional key describing the opto-electronic transfer function used
* for the video content.
*
* The associated value is an integer: 0 if unspecified, or one of the
* COLOR_TRANSFER_ values.
*/
public static readonly KEY_COLOR_TRANSFER: string = 'color-transfer';
/** Linear transfer characteristic curve. */
public static readonly COLOR_TRANSFER_LINEAR: number = 1;
/** SMPTE 170M transfer characteristic curve used by BT.601/BT.709/BT.2020. This is the curve
* used by most non-HDR video content. */
public static readonly COLOR_TRANSFER_SDR_VIDEO: number = 3;
/** SMPTE ST 2084 transfer function. This is used by some HDR video content. */
public static readonly COLOR_TRANSFER_ST2084: number = 6;
/** ARIB STD-B67 hybrid-log-gamma transfer function. This is used by some HDR video content. */
public static readonly COLOR_TRANSFER_HLG: number = 7;
/** @hide */
// @IntDef({
// COLOR_TRANSFER_LINEAR,
// COLOR_TRANSFER_SDR_VIDEO,
// COLOR_TRANSFER_ST2084,
// COLOR_TRANSFER_HLG,
// })
// @Retention(RetentionPolicy.SOURCE)
// public @interface ColorTransfer {}
/**
* An optional key describing the range of the component values of the video content.
*
* The associated value is an integer: 0 if unspecified, or one of the
* COLOR_RANGE_ values.
*/
public static readonly KEY_COLOR_RANGE: string = 'color-range';
/** Limited range. Y component values range from 16 to 235 for 8-bit content.
* Cr, Cy values range from 16 to 240 for 8-bit content.
* This is the default for video content. */
public static readonly COLOR_RANGE_LIMITED: number = 2;
/** Full range. Y, Cr and Cb component values range from 0 to 255 for 8-bit content. */
public static readonly COLOR_RANGE_FULL: number = 1;
// /** @hide */
// @IntDef({
// COLOR_RANGE_LIMITED,
// COLOR_RANGE_FULL,
// })
// @Retention(RetentionPolicy.SOURCE)
// public @interface ColorRange {}
/**
* An optional key describing the static metadata of HDR (high-dynamic-range) video content.
*
* The associated value is a ByteBuffer. This buffer contains the raw contents of the
* Static Metadata Descriptor (including the descriptor ID) of an HDMI Dynamic Range and
* Mastering InfoFrame as defined by CTA-861.3. This key must be provided to video decoders
* for HDR video content unless this information is contained in the bitstream and the video
* decoder supports an HDR-capable profile. This key must be provided to video encoders for
* HDR video content.
*/
public static readonly KEY_HDR_STATIC_INFO: string = 'hdr-static-info';
/**
* An optional key describing the HDR10+ metadata of the video content.
*
* The associated value is a ByteBuffer containing HDR10+ metadata conforming to the
* user_data_registered_itu_t_t35() syntax of SEI message for ST 2094-40. This key will
* be present on:
*<p>
* - The formats of output buffers of a decoder configured for HDR10+ profiles (such as
* {@link MediaCodecInfo.CodecProfileLevel#VP9Profile2HDR10Plus}, {@link
* MediaCodecInfo.CodecProfileLevel#VP9Profile3HDR10Plus} or {@link
* MediaCodecInfo.CodecProfileLevel#HEVCProfileMain10HDR10Plus}), or
*<p>
* - The formats of output buffers of an encoder configured for an HDR10+ profiles that
* uses out-of-band metadata (such as {@link
* MediaCodecInfo.CodecProfileLevel#VP9Profile2HDR10Plus} or {@link
* MediaCodecInfo.CodecProfileLevel#VP9Profile3HDR10Plus}).
*
* @see MediaCodec#PARAMETER_KEY_HDR10_PLUS_INFO
*/
public static readonly KEY_HDR10_PLUS_INFO: string = 'hdr10-plus-info';
/**
* A key describing a unique ID for the content of a media track.
*
* <p>This key is used by {@link MediaExtractor}. Some extractors provide multiple encodings
* of the same track (e.g. float audio tracks for FLAC and WAV may be expressed as two
* tracks via MediaExtractor: a normal PCM track for backward compatibility, and a float PCM
* track for added fidelity. Similarly, Dolby Vision extractor may provide a baseline SDR
* version of a DV track.) This key can be used to identify which MediaExtractor tracks refer
* to the same underlying content.
* </p>
*
* The associated value is an integer.
*/
public static readonly KEY_TRACK_ID: string = 'track-id';
/**
* A key describing the system id of the conditional access system used to scramble
* a media track.
* <p>
* This key is set by {@link MediaExtractor} if the track is scrambled with a conditional
* access system, regardless of the presence of a valid {@link MediaCas} object.
* <p>
* The associated value is an integer.
* @hide
*/
public static readonly KEY_CA_SYSTEM_ID: string = 'ca-system-id';
/**
* A key describing the {@link MediaCas.Session} object associated with a media track.
* <p>
* This key is set by {@link MediaExtractor} if the track is scrambled with a conditional
* access system, after it receives a valid {@link MediaCas} object.
* <p>
* The associated value is a ByteBuffer.
* @hide
*/
public static readonly KEY_CA_SESSION_ID: string = 'ca-session-id';
/**
* A key describing the private data in the CA_descriptor associated with a media track.
* <p>
* This key is set by {@link MediaExtractor} if the track is scrambled with a conditional
* access system, before it receives a valid {@link MediaCas} object.
* <p>
* The associated value is a ByteBuffer.
* @hide
*/
public static readonly KEY_CA_PRIVATE_DATA: string = 'ca-private-data';
/**
* A key describing the maximum number of B frames between I or P frames,
* to be used by a video encoder.
* The associated value is an integer. The default value is 0, which means
* that no B frames are allowed. Note that non-zero value does not guarantee
* B frames; it's up to the encoder to decide.
*/
public static readonly KEY_MAX_B_FRAMES: string = 'max-bframes';
}

View File

@@ -0,0 +1,697 @@
import '../../../style/dialog.css';
import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
import { DisplayCombinedInfo } from '../../client/StreamReceiver';
import VideoSettings from '../../VideoSettings';
import { StreamClientScrcpy } from './StreamClientScrcpy';
import Size from '../../Size';
import Util from '../../Util';
import { DisplayInfo } from '../../DisplayInfo';
import { ToolBoxButton } from '../../toolbox/ToolBoxButton';
import SvgImage from '../../ui/SvgImage';
import { PlayerClass } from '../../player/BasePlayer';
import { ToolBoxCheckbox } from '../../toolbox/ToolBoxCheckbox';
import { DeviceTracker } from './DeviceTracker';
import { Attribute } from '../../Attribute';
import { StreamReceiverScrcpy } from './StreamReceiverScrcpy';
import { ParamsStreamScrcpy } from '../../../types/ParamsStreamScrcpy';
import { BaseClient } from '../../client/BaseClient';
interface ConfigureScrcpyEvents {
closed: { dialog: ConfigureScrcpy; result: boolean };
}
type Range = {
max: number;
min: number;
step: number;
formatter?: (value: number) => string;
};
export class ConfigureScrcpy extends BaseClient<ParamsStreamScrcpy, ConfigureScrcpyEvents> {
private readonly TAG: string;
private readonly udid: string;
private readonly escapedUdid: string;
private readonly playerStorageKey: string;
private deviceName: string;
private streamReceiver?: StreamReceiverScrcpy;
private playerName?: string;
private displayInfo?: DisplayInfo;
private background: HTMLElement;
private dialogBody?: HTMLElement;
private okButton?: HTMLButtonElement;
private fitToScreenCheckbox?: HTMLInputElement;
private resetSettingsButton?: HTMLButtonElement;
private loadSettingsButton?: HTMLButtonElement;
private saveSettingsButton?: HTMLButtonElement;
private playerSelectElement?: HTMLSelectElement;
private displayIdSelectElement?: HTMLSelectElement;
private encoderSelectElement?: HTMLSelectElement;
private connectionStatusElement?: HTMLElement;
private dialogContainer?: HTMLElement;
private statusText = '';
private connectionCount = 0;
constructor(private readonly tracker: DeviceTracker, descriptor: GoogDeviceDescriptor, params: ParamsStreamScrcpy) {
super(params);
this.udid = descriptor.udid;
this.escapedUdid = Util.escapeUdid(this.udid);
this.playerStorageKey = `configure_stream::${this.escapedUdid}::player`;
this.deviceName = descriptor['ro.product.model'];
this.TAG = `ConfigureScrcpy[${this.udid}]`;
this.createStreamReceiver(params);
this.setTitle(`${this.deviceName}. Configure stream`);
this.background = this.createUI();
}
public getTracker(): DeviceTracker {
return this.tracker;
}
// 创建流接收器
private createStreamReceiver(params: ParamsStreamScrcpy): void {
// 如果已经存在流接收器,则先解绑事件监听器,并停止流接收器
if (this.streamReceiver) {
this.detachEventsListeners(this.streamReceiver);
this.streamReceiver.stop();
}
// 创建新的流接收器
this.streamReceiver = new StreamReceiverScrcpy(params);
// 绑定事件监听器
this.attachEventsListeners(this.streamReceiver);
}
// 事件监听器用于监听StreamReceiverScrcpy对象的事件
private attachEventsListeners(streamReceiver: StreamReceiverScrcpy): void {
// 监听encoders事件当有新的编码器连接时触发
streamReceiver.on('encoders', this.onEncoders);
// 监听displayInfo事件当显示信息发生变化时触发
streamReceiver.on('displayInfo', this.onDisplayInfo);
// 监听connected事件当连接成功时触发
streamReceiver.on('connected', this.onConnected);
// 监听disconnected事件当连接断开时触发
streamReceiver.on('disconnected', this.onDisconnected);
}
// 移除事件监听器
private detachEventsListeners(streamReceiver: StreamReceiverScrcpy): void {
// 移除encoders事件监听器
streamReceiver.off('encoders', this.onEncoders);
// 移除displayInfo事件监听器
streamReceiver.off('displayInfo', this.onDisplayInfo);
// 移除connected事件监听器
streamReceiver.off('connected', this.onConnected);
// 移除disconnected事件监听器
streamReceiver.off('disconnected', this.onDisconnected);
}
// 更新连接状态
private updateStatus(): void {
// 如果没有连接状态元素,则返回
if (!this.connectionStatusElement) {
return;
}
// 获取连接状态文本
let text = this.statusText;
// 如果有其他客户端连接,则添加其他客户端连接数
if (this.connectionCount) {
text = `${text}. Other clients: ${this.connectionCount}.`;
}
// 将连接状态文本设置到连接状态元素中
this.connectionStatusElement.innerText = text;
}
private onEncoders = (encoders: string[]): void => {
// console.log(this.TAG, 'Encoders', encoders);
const select = this.encoderSelectElement || document.createElement('select');
let child;
while ((child = select.firstChild)) {
select.removeChild(child);
}
encoders.unshift('');
encoders.forEach((value) => {
const optionElement = document.createElement('option');
optionElement.setAttribute('value', value);
optionElement.innerText = value;
select.appendChild(optionElement);
});
this.encoderSelectElement = select;
};
private onDisplayInfo = (infoArray: DisplayCombinedInfo[]): void => {
// console.log(this.TAG, 'Received info');
this.statusText = 'Ready';
this.updateStatus();
this.dialogContainer?.classList.add('ready');
const select = this.displayIdSelectElement || document.createElement('select');
let child;
while ((child = select.firstChild)) {
select.removeChild(child);
}
let selectedOptionIdx = -1;
infoArray.forEach((value: DisplayCombinedInfo, idx: number) => {
const { displayInfo } = value;
const { displayId, size } = displayInfo;
const optionElement = document.createElement('option');
optionElement.setAttribute('value', displayId.toString());
optionElement.innerText = `ID: ${displayId}; ${size.width}x${size.height}`;
select.appendChild(optionElement);
if (
(this.displayInfo && this.displayInfo.displayId === displayId) ||
(!this.displayInfo && displayId === DisplayInfo.DEFAULT_DISPLAY)
) {
selectedOptionIdx = idx;
}
});
if (selectedOptionIdx > -1) {
select.selectedIndex = selectedOptionIdx;
const { videoSettings, connectionCount, displayInfo } = infoArray[selectedOptionIdx];
this.displayInfo = displayInfo;
if (connectionCount > 0 && videoSettings) {
// console.log(this.TAG, 'Apply other clients settings');
this.fillInputsFromVideoSettings(videoSettings, false);
} else {
// console.log(this.TAG, 'Apply settings for current player');
this.updateVideoSettingsForPlayer();
}
this.connectionCount = connectionCount;
this.updateStatus();
}
this.displayIdSelectElement = select;
if (this.dialogBody) {
this.dialogBody.classList.remove('hidden');
this.dialogBody.classList.add('visible');
}
};
private onConnected = (): void => {
// console.log(this.TAG, 'Connected');
this.statusText = 'Waiting for info...';
this.updateStatus();
if (this.okButton) {
this.okButton.disabled = false;
}
};
private onDisconnected = (): void => {
// console.log(this.TAG, 'Disconnected');
this.statusText = 'Disconnected';
this.updateStatus();
if (this.okButton) {
this.okButton.disabled = true;
}
if (this.dialogBody) {
this.dialogBody.classList.remove('visible');
this.dialogBody.classList.add('hidden');
}
};
private onPlayerChange = (): void => {
this.updateVideoSettingsForPlayer();
};
private onDisplayIdChange = (): void => {
const select = this.displayIdSelectElement;
if (!select || !this.streamReceiver) {
return;
}
const value = select.options[select.selectedIndex].value;
const displayId = parseInt(value, 10);
if (!isNaN(displayId)) {
this.displayInfo = this.streamReceiver.getDisplayInfo(displayId);
}
this.updateVideoSettingsForPlayer();
};
// 获取玩家
private getPlayer(): PlayerClass | undefined {
// 如果没有选择玩家则返回undefined
if (!this.playerSelectElement) {
return;
}
// 获取选择的玩家名称
const playerName = this.playerSelectElement.options[this.playerSelectElement.selectedIndex].value;
// 在StreamClientScrcpy中查找玩家
return StreamClientScrcpy.getPlayers().find((playerClass) => {
// 如果玩家名称匹配,则返回该玩家
return playerClass.playerFullName === playerName;
});
}
// 更新播放器的视频设置
private updateVideoSettingsForPlayer(): void {
// 获取播放器
const player = this.getPlayer();
// 如果播放器存在
if (player) {
// 设置播放器名称
this.playerName = player.playerFullName;
// 获取存储或首选的视频设置
const storedOrPreferred = player.loadVideoSettings(this.udid, this.displayInfo);
// 获取屏幕适应状态
const fitToScreen = player.getFitToScreenStatus(this.udid, this.displayInfo);
// 从视频设置中填充输入
this.fillInputsFromVideoSettings(storedOrPreferred, fitToScreen);
}
}
// 根据id获取基本的输入元素
private getBasicInput(id: string): HTMLInputElement | null {
// 根据id和udid获取元素
const element = document.getElementById(`${id}_${this.escapedUdid}`);
// 如果元素不存在返回null
if (!element) {
return null;
}
// 返回元素
return element as HTMLInputElement;
}
// 根据视频设置填充输入框
private fillInputsFromVideoSettings(videoSettings: VideoSettings, fitToScreen: boolean): void {
// 如果显示信息和视频设置中的显示id不匹配则输出错误信息
if (this.displayInfo && this.displayInfo.displayId !== videoSettings.displayId) {
console.error(this.TAG, `Display id from VideoSettings and DisplayInfo don't match`);
}
// 根据视频设置填充基本输入框
this.fillBasicInput({ id: 'bitrate' }, videoSettings);
this.fillBasicInput({ id: 'maxFps' }, videoSettings);
this.fillBasicInput({ id: 'iFrameInterval' }, videoSettings);
// this.fillBasicInput({ id: 'displayId' }, videoSettings);
this.fillBasicInput({ id: 'codecOptions' }, videoSettings);
// 如果视频设置中有边界,则填充最大宽度和最大高度输入框
if (videoSettings.bounds) {
const { width, height } = videoSettings.bounds;
const widthInput = this.getBasicInput('maxWidth');
if (widthInput) {
widthInput.value = width.toString(10);
}
const heightInput = this.getBasicInput('maxHeight');
if (heightInput) {
heightInput.value = height.toString(10);
}
}
// 如果有编码器选择框,则根据视频设置填充编码器选择框
if (this.encoderSelectElement) {
const encoderName = videoSettings.encoderName || '';
const option = Array.from(this.encoderSelectElement.options).find((element) => {
return element.value === encoderName;
});
if (option) {
this.encoderSelectElement.selectedIndex = option.index;
}
}
// 如果有适应屏幕复选框则根据fitToScreen参数填充复选框并调用onFitToScreenChanged方法
if (this.fitToScreenCheckbox) {
this.fitToScreenCheckbox.checked = fitToScreen;
this.onFitToScreenChanged(fitToScreen);
}
}
// 当fitToScreenCheckbox的值改变时调用
private onFitToScreenChanged(checked: boolean) {
// 获取maxHeight和maxWidth的输入框
const heightInput = this.getBasicInput('maxHeight');
const widthInput = this.getBasicInput('maxWidth');
// 如果fitToScreenCheckbox、heightInput或widthInput不存在则返回
if (!this.fitToScreenCheckbox || !heightInput || !widthInput) {
return;
}
// 如果checked为true则禁用heightInput和widthInput
heightInput.disabled = widthInput.disabled = checked;
// 如果checked为true则将heightInput和widthInput的值清空并保存原来的值
if (checked) {
heightInput.setAttribute(Attribute.VALUE, heightInput.value);
heightInput.value = '';
widthInput.setAttribute(Attribute.VALUE, widthInput.value);
widthInput.value = '';
} else {
// 如果checked为false则将heightInput和widthInput的值恢复为原来的值
const storedHeight = heightInput.getAttribute(Attribute.VALUE);
if (typeof storedHeight === 'string') {
heightInput.value = storedHeight;
heightInput.removeAttribute(Attribute.VALUE);
}
const storedWidth = widthInput.getAttribute(Attribute.VALUE);
if (typeof storedWidth === 'string') {
widthInput.value = storedWidth;
widthInput.removeAttribute(Attribute.VALUE);
}
}
}
// 根据传入的参数,填充视频设置的基本输入框
private fillBasicInput(opts: { id: keyof VideoSettings }, videoSettings: VideoSettings): void {
// 获取基本输入框
const input = this.getBasicInput(opts.id);
// 获取视频设置中对应id的值
const value = videoSettings[opts.id];
// 如果输入框存在
if (input) {
// 如果值存在且不为'-'、0、null
if (typeof value !== 'undefined' && value !== '-' && value !== 0 && value !== null) {
// 将值转换为字符串并赋值给输入框
input.value = value.toString(10);
// 如果输入框的类型为range触发input事件
if (input.getAttribute('type') === 'range') {
input.dispatchEvent(new Event('input'));
}
} else {
// 否则将输入框的值置为空
input.value = '';
}
}
}
private appendBasicInput(
parent: HTMLElement,
opts: { label: string; id: string; range?: Range },
): HTMLInputElement {
const label = document.createElement('label');
label.classList.add('label');
label.innerText = `${opts.label}:`;
label.id = `label_${opts.id}_${this.escapedUdid}`;
parent.appendChild(label);
const input = document.createElement('input');
input.classList.add('input');
input.id = label.htmlFor = `${opts.id}_${this.escapedUdid}`;
const { range } = opts;
if (range) {
label.setAttribute('title', opts.label);
input.oninput = () => {
const value = range.formatter ? range.formatter(parseInt(input.value, 10)) : input.value;
label.innerText = `${opts.label} (${value}):`;
};
input.setAttribute('type', 'range');
input.setAttribute('max', range.max.toString());
input.setAttribute('min', range.min.toString());
input.setAttribute('step', range.step.toString());
}
parent.appendChild(input);
return input;
}
private getNumberValueFromInput(name: string): number {
const value = (document.getElementById(`${name}_${this.escapedUdid}`) as HTMLInputElement).value;
return parseInt(value, 10);
}
private getStringValueFromInput(name: string): string {
return (document.getElementById(`${name}_${this.escapedUdid}`) as HTMLInputElement).value;
}
private getValueFromSelect(name: string): string {
const select = document.getElementById(`${name}_${this.escapedUdid}`) as HTMLSelectElement;
return select.options[select.selectedIndex].value;
}
private buildVideoSettings(): VideoSettings | null {
try {
const bitrate = this.getNumberValueFromInput('bitrate');
const maxFps = this.getNumberValueFromInput('maxFps');
const iFrameInterval = this.getNumberValueFromInput('iFrameInterval');
const maxWidth = this.getNumberValueFromInput('maxWidth');
const maxHeight = this.getNumberValueFromInput('maxHeight');
const displayId = this.getNumberValueFromInput('displayId');
const codecOptions = this.getStringValueFromInput('codecOptions') || undefined;
let bounds: Size | undefined;
if (!isNaN(maxWidth) && !isNaN(maxHeight) && maxWidth && maxHeight) {
bounds = new Size(maxWidth, maxHeight);
}
const encoderName = this.getValueFromSelect('encoderName') || undefined;
return new VideoSettings({
bitrate,
bounds,
maxFps,
iFrameInterval,
displayId,
codecOptions,
encoderName,
});
} catch (error: any) {
console.error(this.TAG, error.message);
return null;
}
}
private getFitToScreenValue(): boolean {
if (!this.fitToScreenCheckbox) {
return false;
}
return this.fitToScreenCheckbox.checked;
}
private getPreviouslyUsedPlayer(): string {
if (!window.localStorage) {
return '';
}
const result = window.localStorage.getItem(this.playerStorageKey);
if (result) {
return result;
} else {
return '';
}
}
private setPreviouslyUsedPlayer(playerName: string): void {
if (!window.localStorage) {
return;
}
window.localStorage.setItem(this.playerStorageKey, playerName);
}
private createUI(): HTMLElement {
const dialogName = 'configureDialog';
const blockClass = 'dialog-block';
const background = document.createElement('div');
background.classList.add('dialog-background', dialogName);
const dialogContainer = (this.dialogContainer = document.createElement('div'));
dialogContainer.classList.add('dialog-container', dialogName);
const dialogHeader = document.createElement('div');
dialogHeader.classList.add('dialog-header', dialogName, 'control-wrapper');
const backButton = new ToolBoxButton('Back', SvgImage.Icon.ARROW_BACK);
backButton.addEventListener('click', () => {
this.cancel();
});
backButton.getAllElements().forEach((el) => {
dialogHeader.appendChild(el);
});
const deviceName = document.createElement('span');
deviceName.classList.add('dialog-title', 'main-title');
deviceName.innerText = this.deviceName;
dialogHeader.appendChild(deviceName);
const dialogBody = (this.dialogBody = document.createElement('div'));
dialogBody.classList.add('dialog-body', blockClass, dialogName, 'hidden');
const playerWrapper = document.createElement('div');
playerWrapper.classList.add('controls');
const playerLabel = document.createElement('label');
playerLabel.classList.add('label');
playerLabel.innerText = 'Player:';
playerWrapper.appendChild(playerLabel);
const playerSelect = (this.playerSelectElement = document.createElement('select'));
playerSelect.classList.add('input');
playerSelect.id = playerLabel.htmlFor = `player_${this.escapedUdid}`;
playerWrapper.appendChild(playerSelect);
dialogBody.appendChild(playerWrapper);
const previouslyUsedPlayer = this.getPreviouslyUsedPlayer();
StreamClientScrcpy.getPlayers().forEach((playerClass, index) => {
const { playerFullName } = playerClass;
const optionElement = document.createElement('option');
optionElement.setAttribute('value', playerFullName);
optionElement.innerText = playerFullName;
playerSelect.appendChild(optionElement);
if (playerFullName === previouslyUsedPlayer) {
playerSelect.selectedIndex = index;
}
});
playerSelect.onchange = this.onPlayerChange;
this.updateVideoSettingsForPlayer();
const controls = document.createElement('div');
controls.classList.add('controls', 'control-wrapper');
const displayIdLabel = document.createElement('label');
displayIdLabel.classList.add('label');
displayIdLabel.innerText = 'Display:';
controls.appendChild(displayIdLabel);
if (!this.displayIdSelectElement) {
this.displayIdSelectElement = document.createElement('select');
}
controls.appendChild(this.displayIdSelectElement);
this.displayIdSelectElement.classList.add('input');
this.displayIdSelectElement.id = displayIdLabel.htmlFor = `displayId_${this.escapedUdid}`;
this.displayIdSelectElement.onchange = this.onDisplayIdChange;
this.appendBasicInput(controls, {
label: 'Bitrate',
id: 'bitrate',
range: { min: 524288, max: 8388608, step: 524288, formatter: Util.prettyBytes },
});
this.appendBasicInput(controls, {
label: 'Max FPS',
id: 'maxFps',
range: { min: 1, max: 60, step: 1 },
});
this.appendBasicInput(controls, { label: 'I-Frame interval', id: 'iFrameInterval' });
const fitLabel = document.createElement('label');
fitLabel.innerText = 'Fit to screen';
fitLabel.classList.add('label');
controls.appendChild(fitLabel);
const fitToggle = new ToolBoxCheckbox(
'Fit to screen',
{ off: SvgImage.Icon.TOGGLE_OFF, on: SvgImage.Icon.TOGGLE_ON },
'fit_to_screen',
);
fitToggle.getAllElements().forEach((el) => {
controls.appendChild(el);
if (el instanceof HTMLLabelElement) {
fitLabel.htmlFor = el.htmlFor;
el.classList.add('input');
}
if (el instanceof HTMLInputElement) {
this.fitToScreenCheckbox = el;
}
});
fitToggle.addEventListener('click', (_, el) => {
const element = el.getElement();
this.onFitToScreenChanged(element.checked);
});
this.appendBasicInput(controls, { label: 'Max width', id: 'maxWidth' });
this.appendBasicInput(controls, { label: 'Max height', id: 'maxHeight' });
this.appendBasicInput(controls, { label: 'Codec options', id: 'codecOptions' });
const encoderLabel = document.createElement('label');
encoderLabel.classList.add('label');
encoderLabel.innerText = 'Encoder:';
controls.appendChild(encoderLabel);
if (!this.encoderSelectElement) {
this.encoderSelectElement = document.createElement('select');
}
controls.appendChild(this.encoderSelectElement);
this.encoderSelectElement.classList.add('input');
this.encoderSelectElement.id = encoderLabel.htmlFor = `encoderName_${this.escapedUdid}`;
dialogBody.appendChild(controls);
const buttonsWrapper = document.createElement('div');
buttonsWrapper.classList.add('controls');
const resetSettingsButton = (this.resetSettingsButton = document.createElement('button'));
resetSettingsButton.classList.add('button');
resetSettingsButton.innerText = 'Reset settings';
resetSettingsButton.addEventListener('click', this.resetSettings);
buttonsWrapper.appendChild(resetSettingsButton);
const loadSettingsButton = (this.loadSettingsButton = document.createElement('button'));
loadSettingsButton.classList.add('button');
loadSettingsButton.innerText = 'Load settings';
loadSettingsButton.addEventListener('click', this.loadSettings);
buttonsWrapper.appendChild(loadSettingsButton);
const saveSettingsButton = (this.saveSettingsButton = document.createElement('button'));
saveSettingsButton.classList.add('button');
saveSettingsButton.innerText = 'Save settings';
saveSettingsButton.addEventListener('click', this.saveSettings);
buttonsWrapper.appendChild(saveSettingsButton);
dialogBody.appendChild(buttonsWrapper);
const dialogFooter = document.createElement('div');
dialogFooter.classList.add('dialog-footer', blockClass, dialogName);
const statusElement = document.createElement('span');
statusElement.classList.add('subtitle');
this.connectionStatusElement = statusElement;
dialogFooter.appendChild(statusElement);
this.statusText = `Connecting...`;
this.updateStatus();
// const cancelButton = (this.cancelButton = document.createElement('button'));
// cancelButton.innerText = 'Cancel';
// cancelButton.addEventListener('click', this.cancel);
const okButton = (this.okButton = document.createElement('button'));
okButton.innerText = 'Open';
okButton.disabled = true;
okButton.addEventListener('click', this.openStream);
dialogFooter.appendChild(okButton);
// dialogFooter.appendChild(cancelButton);
dialogBody.appendChild(dialogFooter);
dialogContainer.appendChild(dialogHeader);
dialogContainer.appendChild(dialogBody);
dialogContainer.appendChild(dialogFooter);
background.appendChild(dialogContainer);
background.addEventListener('click', this.onBackgroundClick);
document.body.appendChild(background);
return background;
}
private removeUI(): void {
document.body.removeChild(this.background);
this.okButton?.removeEventListener('click', this.openStream);
// this.cancelButton?.removeEventListener('click', this.cancel);
this.resetSettingsButton?.removeEventListener('click', this.resetSettings);
this.loadSettingsButton?.removeEventListener('click', this.loadSettings);
this.saveSettingsButton?.removeEventListener('click', this.saveSettings);
this.background.removeEventListener('click', this.onBackgroundClick);
}
private onBackgroundClick = (event: MouseEvent): void => {
if (event.target !== event.currentTarget) {
return;
}
this.cancel();
};
private cancel = (): void => {
if (this.streamReceiver) {
this.detachEventsListeners(this.streamReceiver);
this.streamReceiver.stop();
}
this.emit('closed', { dialog: this, result: false });
this.removeUI();
};
private resetSettings = (): void => {
const player = this.getPlayer();
if (player) {
this.fillInputsFromVideoSettings(player.getPreferredVideoSetting(), false);
}
};
private loadSettings = (): void => {
this.updateVideoSettingsForPlayer();
};
private saveSettings = (): void => {
const videoSettings = this.buildVideoSettings();
const player = this.getPlayer();
if (videoSettings && player) {
const fitToScreen = this.getFitToScreenValue();
player.saveVideoSettings(this.udid, videoSettings, fitToScreen, this.displayInfo);
}
};
private openStream = (): void => {
const videoSettings = this.buildVideoSettings();
if (!videoSettings || !this.streamReceiver || !this.playerName) {
return;
}
const fitToScreen = this.getFitToScreenValue();
this.detachEventsListeners(this.streamReceiver);
this.emit('closed', { dialog: this, result: true });
this.removeUI();
const player = StreamClientScrcpy.createPlayer(this.playerName, this.udid, this.displayInfo);
if (!player) {
return;
}
this.setPreviouslyUsedPlayer(this.playerName);
// return;
player.setVideoSettings(videoSettings, fitToScreen, false);
const params: ParamsStreamScrcpy = {
...this.params,
udid: this.udid,
fitToScreen,
};
StreamClientScrcpy.start(params, this.streamReceiver, player, fitToScreen, videoSettings);
this.streamReceiver.triggerInitialInfoEvents();
};
}

View File

@@ -0,0 +1,364 @@
import '../../../style/devicelist.css';
import { BaseDeviceTracker } from '../../client/BaseDeviceTracker';
import { SERVER_PORT } from '../../../common/Constants';
import { ACTION } from '../../../common/Action';
import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
import { ControlCenterCommand } from '../../../common/ControlCenterCommand';
import { StreamClientScrcpy } from './StreamClientScrcpy';
import SvgImage from '../../ui/SvgImage';
import { html } from '../../ui/HtmlTag';
import Util from '../../Util';
import { Attribute } from '../../Attribute';
import { DeviceState } from '../../../common/DeviceState';
import { Message } from '../../../types/Message';
import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
import { HostItem } from '../../../types/Configuration';
import { ChannelCode } from '../../../common/ChannelCode';
import { Tool } from '../../client/Tool';
type Field = keyof GoogDeviceDescriptor | ((descriptor: GoogDeviceDescriptor) => string);
type DescriptionColumn = { title: string; field: Field };
const DESC_COLUMNS: DescriptionColumn[] = [
{
title: 'Net Interface',
field: 'interfaces',
},
{
title: 'Server PID',
field: 'pid',
},
];
export class DeviceTracker extends BaseDeviceTracker<GoogDeviceDescriptor, never> {
public static readonly ACTION = ACTION.GOOG_DEVICE_LIST;
public static readonly CREATE_DIRECT_LINKS = true;
private static instancesByUrl: Map<string, DeviceTracker> = new Map();
protected static tools: Set<Tool> = new Set();
protected tableId = 'goog_device_list';
public static start(hostItem: HostItem): DeviceTracker {
const url = this.buildUrlForTracker(hostItem).toString();
let instance = this.instancesByUrl.get(url);
if (!instance) {
instance = new DeviceTracker(hostItem, url);
}
return instance;
}
public static getInstance(hostItem: HostItem): DeviceTracker {
return this.start(hostItem);
}
protected constructor(params: HostItem, directUrl: string) {
super({ ...params, action: DeviceTracker.ACTION }, directUrl);
DeviceTracker.instancesByUrl.set(directUrl, this);
this.buildDeviceTable();
this.openNewConnection();
}
protected onSocketOpen(): void {
// nothing here;
}
protected setIdAndHostName(id: string, hostName: string): void {
super.setIdAndHostName(id, hostName);
for (const value of DeviceTracker.instancesByUrl.values()) {
if (value.id === id && value !== this) {
console.warn(
`Tracker with url: "${this.url}" has the same id(${this.id}) as tracker with url "${value.url}"`,
);
console.warn(`This tracker will shut down`);
this.destroy();
}
}
}
onInterfaceSelected = (event: Event): void => {
const selectElement = event.currentTarget as HTMLSelectElement;
const option = selectElement.selectedOptions[0];
const url = decodeURI(option.getAttribute(Attribute.URL) || '');
const name = option.getAttribute(Attribute.NAME) || '';
const fullName = decodeURIComponent(selectElement.getAttribute(Attribute.FULL_NAME) || '');
const udid = selectElement.getAttribute(Attribute.UDID) || '';
this.updateLink({ url, name, fullName, udid, store: true });
};
private updateLink(params: { url: string; name: string; fullName: string; udid: string; store: boolean }): void {
const { url, name, fullName, udid, store } = params;
const playerTds = document.getElementsByName(
encodeURIComponent(`${DeviceTracker.AttributePrefixPlayerFor}${fullName}`),
);
if (typeof udid !== 'string') {
return;
}
if (store) {
const localStorageKey = DeviceTracker.getLocalStorageKey(fullName || '');
if (localStorage && name) {
localStorage.setItem(localStorageKey, name);
}
}
const action = ACTION.STREAM_SCRCPY;
playerTds.forEach((item) => {
item.innerHTML = '';
const playerFullName = item.getAttribute(DeviceTracker.AttributePlayerFullName);
const playerCodeName = item.getAttribute(DeviceTracker.AttributePlayerCodeName);
if (!playerFullName || !playerCodeName) {
return;
}
const link = DeviceTracker.buildLink(
{
action,
udid,
player: decodeURIComponent(playerCodeName),
ws: url,
},
decodeURIComponent(playerFullName),
this.params,
);
item.appendChild(link);
});
}
onActionButtonClick = (event: MouseEvent): void => {
const button = event.currentTarget as HTMLButtonElement;
const udid = button.getAttribute(Attribute.UDID);
const pidString = button.getAttribute(Attribute.PID) || '';
const command = button.getAttribute(Attribute.COMMAND) as string;
const pid = parseInt(pidString, 10);
const data: Message = {
id: this.getNextId(),
type: command,
data: {
udid: typeof udid === 'string' ? udid : undefined,
pid: isNaN(pid) ? undefined : pid,
},
};
if (this.ws && this.ws.readyState === this.ws.OPEN) {
console.log('发送的参数3');
this.ws.send(JSON.stringify(data));
}
};
private static getLocalStorageKey(udid: string): string {
return `device_list::${udid}::interface`;
}
protected static createUrl(params: ParamsDeviceTracker, udid = ''): URL {
const secure = !!params.secure;
const hostname = params.hostname || location.hostname;
const port = typeof params.port === 'number' ? params.port : secure ? 443 : 80;
const pathname = params.pathname || location.pathname;
const urlObject = this.buildUrl({ ...params, secure, hostname, port, pathname });
if (udid) {
urlObject.searchParams.set('action', ACTION.PROXY_ADB);
urlObject.searchParams.set('remote', `tcp:${SERVER_PORT.toString(10)}`);
urlObject.searchParams.set('udid', udid);
}
return urlObject;
}
protected static createInterfaceOption(name: string, url: string): HTMLOptionElement {
const optionElement = document.createElement('option');
optionElement.setAttribute(Attribute.URL, url);
optionElement.setAttribute(Attribute.NAME, name);
optionElement.innerText = `proxy over adb`;
return optionElement;
}
private static titleToClassName(title: string): string {
return title.toLowerCase().replace(/\s/g, '_');
}
protected buildDeviceRow(tbody: Element, device: GoogDeviceDescriptor): void {
let selectedInterfaceUrl = '';
let selectedInterfaceName = '';
const blockClass = 'desc-block';
const fullName = `${this.id}_${Util.escapeUdid(device.udid)}`;
const isActive = device.state === DeviceState.DEVICE;
let hasPid = false;
const servicesId = `device_services_${fullName}`;
const row = html`<div class="device ${isActive ? 'active' : 'not-active'}">
<div class="device-header">
<div class="device-name">${device['ro.product.manufacturer']} ${device['ro.product.model']}</div>
<div class="device-serial">${device.udid}</div>
<div class="device-version">
<div class="release-version">${device['ro.build.version.release']}</div>
<div class="sdk-version">${device['ro.build.version.sdk']}</div>
</div>
<div class="device-state" title="State: ${device.state}"></div>
</div>
<div id="${servicesId}" class="services"></div>
</div>`.content;
const services = row.getElementById(servicesId);
if (!services) {
return;
}
DeviceTracker.tools.forEach((tool) => {
const entry = tool.createEntryForDeviceList(device, blockClass, this.params);
if (entry) {
if (Array.isArray(entry)) {
entry.forEach((item) => {
item && services.appendChild(item);
});
} else {
services.appendChild(entry);
}
}
});
const streamEntry = StreamClientScrcpy.createEntryForDeviceList(device, blockClass, fullName, this.params);
streamEntry && services.appendChild(streamEntry);
DESC_COLUMNS.forEach((item) => {
const { title } = item;
const fieldName = item.field;
let value: string;
if (typeof item.field === 'string') {
value = '' + device[item.field];
} else {
value = item.field(device);
}
const td = document.createElement('div');
td.classList.add(DeviceTracker.titleToClassName(title), blockClass);
services.appendChild(td);
if (fieldName === 'pid') {
hasPid = value !== '-1';
const actionButton = document.createElement('button');
actionButton.className = 'action-button kill-server-button';
actionButton.setAttribute(Attribute.UDID, device.udid);
actionButton.setAttribute(Attribute.PID, value);
let command: string;
if (isActive) {
actionButton.classList.add('active');
actionButton.onclick = this.onActionButtonClick;
if (hasPid) {
command = ControlCenterCommand.KILL_SERVER;
actionButton.title = 'Kill server';
actionButton.appendChild(SvgImage.create(SvgImage.Icon.CANCEL));
} else {
command = ControlCenterCommand.START_SERVER;
actionButton.title = 'Start server';
actionButton.appendChild(SvgImage.create(SvgImage.Icon.REFRESH));
}
actionButton.setAttribute(Attribute.COMMAND, command);
} else {
const timestamp = device['last.update.timestamp'];
if (timestamp) {
const date = new Date(timestamp);
actionButton.title = `Last update on ${date.toLocaleDateString()} at ${date.toLocaleTimeString()}`;
} else {
actionButton.title = `Not active`;
}
actionButton.appendChild(SvgImage.create(SvgImage.Icon.OFFLINE));
}
const span = document.createElement('span');
span.innerText = value;
actionButton.appendChild(span);
td.appendChild(actionButton);
} else if (fieldName === 'interfaces') {
const proxyInterfaceUrl = DeviceTracker.createUrl(this.params, device.udid).toString();
const proxyInterfaceName = 'proxy';
const localStorageKey = DeviceTracker.getLocalStorageKey(fullName);
const lastSelected = localStorage && localStorage.getItem(localStorageKey);
const selectElement = document.createElement('select');
selectElement.setAttribute(Attribute.UDID, device.udid);
selectElement.setAttribute(Attribute.FULL_NAME, fullName);
selectElement.setAttribute(
'name',
encodeURIComponent(`${DeviceTracker.AttributePrefixInterfaceSelectFor}${fullName}`),
);
/// #if SCRCPY_LISTENS_ON_ALL_INTERFACES
device.interfaces.forEach((value) => {
const params = {
...this.params,
secure: false,
hostname: value.ipv4,
port: SERVER_PORT,
};
const url = DeviceTracker.createUrl(params).toString();
const optionElement = DeviceTracker.createInterfaceOption(value.name, url);
optionElement.innerText = `${value.name}: ${value.ipv4}`;
selectElement.appendChild(optionElement);
if (lastSelected) {
if (lastSelected === value.name || !selectedInterfaceName) {
optionElement.selected = true;
selectedInterfaceUrl = url;
selectedInterfaceName = value.name;
}
} else if (device['wifi.interface'] === value.name) {
optionElement.selected = true;
}
});
/// #else
selectedInterfaceUrl = proxyInterfaceUrl;
selectedInterfaceName = proxyInterfaceName;
td.classList.add('hidden');
/// #endif
if (isActive) {
const adbProxyOption = DeviceTracker.createInterfaceOption(proxyInterfaceName, proxyInterfaceUrl);
if (lastSelected === proxyInterfaceName || !selectedInterfaceName) {
adbProxyOption.selected = true;
selectedInterfaceUrl = proxyInterfaceUrl;
selectedInterfaceName = proxyInterfaceName;
}
selectElement.appendChild(adbProxyOption);
const actionButton = document.createElement('button');
actionButton.className = 'action-button update-interfaces-button active';
actionButton.title = `Update information`;
actionButton.appendChild(SvgImage.create(SvgImage.Icon.REFRESH));
actionButton.setAttribute(Attribute.UDID, device.udid);
actionButton.setAttribute(Attribute.COMMAND, ControlCenterCommand.UPDATE_INTERFACES);
actionButton.onclick = this.onActionButtonClick;
td.appendChild(actionButton);
}
selectElement.onchange = this.onInterfaceSelected;
td.appendChild(selectElement);
} else {
td.innerText = value;
}
});
if (DeviceTracker.CREATE_DIRECT_LINKS) {
const name = `${DeviceTracker.AttributePrefixPlayerFor}${fullName}`;
StreamClientScrcpy.getPlayers().forEach((playerClass) => {
const { playerCodeName, playerFullName } = playerClass;
const playerTd = document.createElement('div');
playerTd.classList.add(blockClass);
playerTd.setAttribute('name', encodeURIComponent(name));
playerTd.setAttribute(DeviceTracker.AttributePlayerFullName, encodeURIComponent(playerFullName));
playerTd.setAttribute(DeviceTracker.AttributePlayerCodeName, encodeURIComponent(playerCodeName));
services.appendChild(playerTd);
});
}
tbody.appendChild(row);
if (DeviceTracker.CREATE_DIRECT_LINKS && hasPid && selectedInterfaceUrl) {
this.updateLink({
url: selectedInterfaceUrl,
name: selectedInterfaceName,
fullName,
udid: device.udid,
store: false,
});
}
}
protected getChannelCode(): string {
return ChannelCode.GTRC;
}
public destroy(): void {
super.destroy();
DeviceTracker.instancesByUrl.delete(this.url.toString());
if (!DeviceTracker.instancesByUrl.size) {
const holder = document.getElementById(BaseDeviceTracker.HOLDER_ELEMENT_ID);
if (holder && holder.parentElement) {
holder.parentElement.removeChild(holder);
}
}
}
}

View File

@@ -0,0 +1,381 @@
import '../../../style/devtools.css';
import { ManagerClient } from '../../client/ManagerClient';
import { ACTION } from '../../../common/Action';
import { ParamsDevtools } from '../../../types/ParamsDevtools';
import { RemoteDevtoolsCommand } from '../../../types/RemoteDevtoolsCommand';
import { Message } from '../../../types/Message';
import { DevtoolsInfo, RemoteBrowserInfo, RemoteTarget, TargetDescription } from '../../../types/RemoteDevtools';
import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
import { BaseDeviceTracker } from '../../client/BaseDeviceTracker';
import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
import Util from '../../Util';
const FRONTEND_RE = /^https?:\/\/chrome-devtools-frontend\.appspot\.com\/serve_rev\/(@.*)/;
const TAG = '[DevtoolsClient]';
export class DevtoolsClient extends ManagerClient<ParamsDevtools, never> {
public static readonly ACTION = ACTION.DEVTOOLS;
public static readonly TIMEOUT = 1000;
public static start(params: ParamsDevtools): DevtoolsClient {
return new DevtoolsClient(params);
}
private timeout?: number;
private readonly hiddenInput: HTMLInputElement;
private readonly tooltip: HTMLSpanElement;
private hideTimeout?: number;
private readonly udid: string;
constructor(params: ParamsDevtools) {
super(params);
this.udid = this.params.udid;
this.openNewConnection();
this.setTitle(`Devtools ${this.udid}`);
this.setBodyClass('devtools');
this.hiddenInput = document.createElement('input');
this.hiddenInput.className = 'hidden';
this.hiddenInput.setAttribute('hidden', 'hidden');
document.body.appendChild(this.hiddenInput);
this.tooltip = document.createElement('span');
this.tooltip.innerText = 'Copied!';
this.tooltip.className = 'tooltip';
this.tooltip.style.display = 'none';
document.body.appendChild(this.tooltip);
}
public static parseParameters(params: URLSearchParams): ParamsDevtools {
const typedParams = super.parseParameters(params);
const { action } = typedParams;
if (action !== ACTION.DEVTOOLS) {
throw Error('Incorrect action');
}
return { ...typedParams, action, udid: Util.parseString(params, 'udid', true) };
}
private static compareBrowsers = (a: RemoteBrowserInfo, b: RemoteBrowserInfo): number => {
const aBrowser = a.version.Browser;
const bBrowser = b.version.Browser;
if (aBrowser > bBrowser) {
return 1;
} else if (aBrowser < bBrowser) {
return -1;
}
return 0;
};
protected buildDirectWebSocketUrl(): URL {
const localUrl = super.buildDirectWebSocketUrl();
if (typeof this.params.udid === 'string') {
localUrl.searchParams.set('udid', this.params.udid);
}
return localUrl;
}
protected onSocketClose(event: CloseEvent): void {
console.error(TAG, `Socket closed. Code: ${event.code}.${event.reason ? ' Reason: ' + event.reason : ''}`);
setTimeout(() => {
this.openNewConnection();
}, 2000);
}
protected onSocketMessage(event: MessageEvent): void {
console.log("接收到的参数234", event.data)
let message: Message;
try {
message = JSON.parse(event.data);
} catch (error: any) {
console.error(TAG, error.message);
console.log(TAG, error.data);
return;
}
if (message.type !== DevtoolsClient.ACTION) {
console.log(TAG, `Unknown message type: ${message.type}`);
return;
}
const list = message.data as DevtoolsInfo;
this.buildList(list);
if (!this.timeout) {
this.timeout = window.setTimeout(this.requestListUpdate, DevtoolsClient.TIMEOUT);
}
}
protected onSocketOpen(): void {
this.requestListUpdate();
}
private requestListUpdate = (): void => {
this.timeout = undefined;
if (!this.ws || this.ws.readyState !== this.ws.OPEN) {
return;
}
console.log('发送的参数4');
this.ws.send(
JSON.stringify({
command: RemoteDevtoolsCommand.LIST_DEVTOOLS,
}),
);
};
private createDeviceBlock(info: DevtoolsInfo): HTMLDivElement {
const d = document.createElement('div');
d.className = 'device';
d.id = `device:${info.deviceSerial}`;
return d;
}
private createDeviceHeader(info: DevtoolsInfo): HTMLDivElement {
const h = document.createElement('div');
h.className = 'device-header';
const n = document.createElement('div');
n.className = 'device-name';
n.innerText = info.deviceName;
const s = document.createElement('div');
s.className = 'device-serial';
s.innerText = `#${info.deviceSerial.toUpperCase()}`;
const p = document.createElement('div');
p.className = 'device-ports';
h.appendChild(n);
h.appendChild(s);
h.appendChild(p);
return h;
}
private createBrowsersBlock(info: DevtoolsInfo): HTMLDivElement {
const { deviceSerial } = info;
const bs = document.createElement('div');
bs.className = 'browsers';
info.browsers.sort(DevtoolsClient.compareBrowsers).forEach((browser) => {
const b = this.createBrowserBlock(deviceSerial, browser);
bs.appendChild(b);
});
return bs;
}
private createBrowserBlock(serial: string, info: RemoteBrowserInfo): HTMLDivElement {
const { socket } = info;
const b = document.createElement('div');
b.id = `${serial}:${socket}`;
b.className = 'browser';
const h = document.createElement('div');
h.className = 'browser-header';
b.appendChild(h);
const n = document.createElement('div');
n.className = 'browser-name';
h.appendChild(n);
const pkg = info.version['Android-Package'];
const browser = info.version.Browser;
let version: string;
const temp = browser.split('/');
if (temp.length > 1) {
version = temp[1];
} else {
version = browser;
}
const prefix = socket.indexOf('webview') === 0 ? 'WebView in ' : '';
n.innerText = `${prefix}${pkg}(${version})`;
const s = document.createElement('span');
s.setAttribute('tabIndex', '1');
s.className = 'action';
s.innerText = 'trace';
s.setAttribute('hidden', 'hidden');
h.appendChild(s);
const pages = document.createElement('div');
pages.className = 'list pages';
info.targets.forEach((page) => {
pages.appendChild(this.createPageBlock(page, version));
});
b.appendChild(pages);
return b;
}
private createPageBlock(page: RemoteTarget, version?: string): HTMLDivElement {
const row = document.createElement('div');
row.className = 'row';
const props = document.createElement('div');
props.className = 'properties-box';
row.appendChild(props);
if (page.faviconUrl) {
const img = document.createElement('img');
img.src = page.faviconUrl;
props.appendChild(img);
}
const subrow = document.createElement('div');
subrow.className = 'subrow-box';
props.appendChild(subrow);
const sub1 = document.createElement('div');
sub1.className = 'subrow';
subrow.appendChild(sub1);
const n = document.createElement('div');
n.className = 'name';
if (page.title) {
n.innerText = page.title;
}
sub1.appendChild(n);
const u = document.createElement('div');
u.className = 'url';
if (page.url) {
u.innerText = page.url;
}
sub1.appendChild(u);
const sub2 = document.createElement('div');
sub2.className = 'subrow webview';
subrow.appendChild(sub2);
if (page.description) {
try {
const desc = JSON.parse(page.description) as TargetDescription;
const position = document.createElement('div');
position.className = 'position';
position.innerText = `at (${desc.screenX}, ${desc.screenY})`;
sub2.appendChild(position);
const size = document.createElement('div');
size.className = 'size';
size.innerText = `size ${desc.width} × ${desc.height}`;
sub2.appendChild(size);
} catch (error: any) { }
}
const absoluteAddress = page.devtoolsFrontendUrl && page.devtoolsFrontendUrl.startsWith('http');
const actions = document.createElement('div');
actions.className = 'actions';
subrow.appendChild(actions);
const inspect = document.createElement('a');
inspect.setAttribute('tabIndex', '1');
inspect.className = 'action';
inspect.innerText = 'inspect';
actions.appendChild(inspect);
if (page.devtoolsFrontendUrl) {
inspect.setAttribute('href', page.devtoolsFrontendUrl);
inspect.setAttribute('rel', 'noopener noreferrer');
inspect.setAttribute('target', '_blank');
} else {
inspect.classList.add('disabled');
}
if (!absoluteAddress) {
inspect.classList.add('disabled');
}
if (page.webSocketDebuggerUrl) {
const bundled = document.createElement('a');
bundled.setAttribute('tabIndex', '1');
bundled.className = 'action copy';
bundled.innerText = 'bundled';
bundled.title = 'Copy link and open manually';
actions.appendChild(bundled);
const base = 'devtools://devtools/bundled/inspector.html?experiments=true&ws=';
bundled.setAttribute('href', `${base}${page.webSocketDebuggerUrl}`);
bundled.setAttribute('rel', 'noopener noreferrer');
bundled.setAttribute('target', '_blank');
bundled.onclick = this.onDevtoolsLinkClick;
}
if (page.devtoolsFrontendUrl && page.webSocketDebuggerUrl && absoluteAddress) {
const ur = new URL(page.devtoolsFrontendUrl);
ur.searchParams.delete('ws');
const urStr = ur.toString();
const match = urStr.match(FRONTEND_RE);
if (match) {
const str = match[1];
const temp = str.split('/');
const revision = temp.shift();
const rest = temp.join('/');
const remoteVersion = version ? `remoteVersion=${version}&` : '';
const opts = `remoteFrontend=true&dockSide=undocked&`;
const ws = `ws=${page.webSocketDebuggerUrl}`;
const url = `devtools://devtools/remote/serve_rev/${revision}/${rest}?${remoteVersion}${opts}${ws}`;
const remote = document.createElement('a');
remote.setAttribute('tabIndex', '1');
remote.className = 'action copy';
remote.innerText = 'remote';
remote.title = 'Copy link and open manually';
actions.appendChild(remote);
remote.setAttribute('href', url);
remote.setAttribute('rel', 'noopener noreferrer');
remote.setAttribute('target', '_blank');
remote.onclick = this.onDevtoolsLinkClick;
}
}
const pause = document.createElement('span');
pause.setAttribute('hidden', 'hidden');
pause.setAttribute('tabIndex', '1');
pause.className = 'action';
pause.innerText = 'pause';
actions.appendChild(pause);
return row;
}
private onDevtoolsLinkClick = (event: MouseEvent): void => {
const a = event.target as HTMLAnchorElement;
const url = a.getAttribute('href');
if (!url) {
return;
}
this.hiddenInput.value = url;
this.hiddenInput.removeAttribute('hidden');
this.hiddenInput.select();
this.hiddenInput.setSelectionRange(0, url.length);
document.execCommand('copy');
this.hiddenInput.setAttribute('hidden', 'hidden');
this.tooltip.style.left = `${event.clientX}px`;
this.tooltip.style.top = `${event.clientY}px`;
this.tooltip.style.display = 'block';
this.hideTooltip();
event.preventDefault();
};
private hideTooltip() {
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
}
this.hideTimeout = window.setTimeout(() => {
this.hideTimeout = undefined;
this.tooltip.style.display = 'none';
}, 1000);
}
public buildList(info: DevtoolsInfo): void {
// console.log(info);
const block = this.createDeviceBlock(info);
const header = this.createDeviceHeader(info);
const browsers = this.createBrowsersBlock(info);
block.appendChild(header);
block.appendChild(browsers);
const old = document.getElementById(block.id);
if (old) {
old.parentElement?.replaceChild(block, old);
} else {
document.body.appendChild(block);
}
}
public static createEntryForDeviceList(
descriptor: GoogDeviceDescriptor,
blockClass: string,
params: ParamsDeviceTracker,
): HTMLElement | DocumentFragment | undefined {
if (descriptor.state !== 'device') {
return;
}
const entry = document.createElement('div');
entry.classList.add('devtools', blockClass);
entry.appendChild(
BaseDeviceTracker.buildLink(
{
action: ACTION.DEVTOOLS,
udid: descriptor.udid,
},
'devtools',
params,
),
);
return entry;
}
}

View File

@@ -0,0 +1,596 @@
import '../../../style/filelisting.css';
import { ParamsFileListing } from '../../../types/ParamsFileListing';
import { ManagerClient } from '../../client/ManagerClient';
import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
import { BaseDeviceTracker } from '../../client/BaseDeviceTracker';
import { ACTION } from '../../../common/Action';
import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
import Util from '../../Util';
import Protocol from '@dead50f7/adbkit/lib/adb/protocol';
import { Entry } from '../Entry';
import { html } from '../../ui/HtmlTag';
import * as path from 'path';
import { ChannelCode } from '../../../common/ChannelCode';
import { Multiplexer } from '../../../packages/multiplexer/Multiplexer';
import FilePushHandler, { DragAndPushListener, PushUpdateParams } from '../filePush/FilePushHandler';
import { AdbkitFilePushStream } from '../filePush/AdbkitFilePushStream';
const TAG = '[FileListing]';
const parentDirLinkBox = 'parentDirLinkBox';
const rootDirLinkBox = 'rootDirLinkBox';
const tempDirLinkBox = 'tempDirLinkBox';
const storageDirLinkBox = 'storageDirLinkBox';
const rootPath = '/';
const tempPath = '/data/local/tmp';
const storagePath = '/storage';
type Download = {
receivedBytes: number;
entry?: Entry;
progressEl?: HTMLElement;
anchor?: HTMLElement;
chunks: Uint8Array[];
path: string;
pathToLoadAfter: string;
};
type Upload = { row: HTMLElement; progressEl: HTMLElement; anchor: HTMLElement; timeout: number | null };
enum Foreground {
Drop = 'drop-target',
Connect = 'connect',
}
const Message: Record<Foreground, string> = {
[Foreground.Drop]: 'Drop files here',
[Foreground.Connect]: 'Connection lost',
};
export class FileListingClient extends ManagerClient<ParamsFileListing, never> implements DragAndPushListener {
public static readonly ACTION = ACTION.FILE_LISTING;
public static readonly PARENT_DIR = '..';
public static readonly PROPERTY_NAME = 'data-name';
public static readonly PROPERTY_ENTRY_ID = 'data-entry-id';
public static REMOVE_ROW_TIMEOUT = 2000;
public static start(params: ParamsFileListing): FileListingClient {
return new FileListingClient(params);
}
public static createEntryForDeviceList(
descriptor: GoogDeviceDescriptor,
blockClass: string,
params: ParamsDeviceTracker,
): HTMLElement | DocumentFragment | undefined {
if (descriptor.state !== 'device') {
return;
}
const entry = document.createElement('div');
entry.classList.add('file-listing', blockClass);
entry.appendChild(
BaseDeviceTracker.buildLink(
{
action: ACTION.FILE_LISTING,
udid: descriptor.udid,
path: `${tempPath}/`,
},
'list files',
params,
),
);
return entry;
}
private readonly serial: string;
private readonly name: string;
private readonly tableBodyId: string;
private readonly wrapperId: string;
private readonly filePushHandler?: FilePushHandler;
private readonly parent: HTMLElement;
private enterCount = 0;
private entries: Entry[] = [];
private path: string;
private requireClean = false;
private requestedPath = '';
private downloads: Map<Multiplexer, Download> = new Map();
private uploads: Map<string, Upload> = new Map();
private tableBody: HTMLElement;
private channels: Set<Multiplexer> = new Set();
constructor(params: ParamsFileListing) {
super(params);
this.parent = document.body;
this.serial = this.params.udid;
this.path = this.params.path;
this.openNewConnection();
this.setTitle(`Listing ${this.serial}`);
this.setBodyClass('file-listing');
this.name = `${TAG} [${this.serial}]`;
this.tableBodyId = `${Util.escapeUdid(this.serial)}_list`;
this.wrapperId = `wrapper_${this.tableBodyId}`;
const fragment = html`<div id="${this.wrapperId}" class="listing">
<h1 id="header">Contents ${this.path}</h1>
<div id="${parentDirLinkBox}" class="quick-link-box">
<a class="icon up" href="#!" ${FileListingClient.PROPERTY_NAME}=".."> [parent] </a>
</div>
<div id="${rootDirLinkBox}" class="quick-link-box">
<a class="icon dir" href="#!" ${FileListingClient.PROPERTY_NAME}="${rootPath}"> [root] </a>
</div>
<div id="${storageDirLinkBox}" class="quick-link-box">
<a class="icon dir" href="#!" ${FileListingClient.PROPERTY_NAME}="${storagePath}/"> [storage] </a>
</div>
<div id="${tempDirLinkBox}" class="quick-link-box">
<a class="icon dir" href="#!" ${FileListingClient.PROPERTY_NAME}="${tempPath}/"> [temp] </a>
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>MTime</th>
</tr>
</thead>
<tbody id="${this.tableBodyId}"></tbody>
</table>
</div>`.content;
this.tableBody = fragment.getElementById(this.tableBodyId) as HTMLElement;
const wrapper = fragment.getElementById(this.wrapperId);
if (wrapper) {
wrapper.addEventListener('click', (e) => {
if (!e.target || !(e.target instanceof HTMLElement)) {
return;
}
const name = e.target.getAttribute(FileListingClient.PROPERTY_NAME);
if (!name) {
return;
}
e.preventDefault();
e.cancelBubble = true;
const newPath = path.resolve(this.path, name);
if (newPath !== this.path) {
const entryIdString = e.target.getAttribute(FileListingClient.PROPERTY_ENTRY_ID);
let entry: Entry | undefined;
let anchor: HTMLElement | undefined;
if (entryIdString) {
const entryId = parseInt(entryIdString, 10);
if (!isNaN(entryId) && this.entries[entryId]) {
entry = this.entries[entryId];
anchor = e.target;
}
}
this.loadContent(newPath, entry, anchor);
}
});
if (this.ws instanceof Multiplexer) {
this.filePushHandler = new FilePushHandler(this.parent, new AdbkitFilePushStream(this.ws, this));
this.filePushHandler.addEventListener(this);
}
}
this.parent.appendChild(fragment);
}
public onDragEnter(): boolean {
if (this.enterCount === 0) {
this.addForeground(Foreground.Drop);
}
this.enterCount++;
return true;
}
public onDragLeave(): boolean {
this.enterCount--;
if (this.enterCount < 0) {
this.enterCount = 0;
}
if (this.enterCount === 0) {
this.removeForeground(Foreground.Drop);
}
return true;
}
public onDrop(): boolean {
this.enterCount = 0;
this.removeForeground(Foreground.Drop);
return true;
}
private findOrCreateEntryRow(fileName: string): HTMLElement {
const row = document.getElementById(`entry-${fileName}`);
if (row) {
return row;
}
return this.addRow(true, fileName, 'file');
}
public onFilePushUpdate(data: PushUpdateParams): void {
const { fileName, progress, error, message, finished } = data;
let upload = this.uploads.get(fileName);
if (!upload || document.getElementById(upload.anchor.id) !== upload.anchor) {
const row = this.findOrCreateEntryRow(fileName);
const anchor = row.getElementsByTagName('a')[0];
if (!anchor.id) {
anchor.id = `upload_${fileName}`;
}
const progressEl = this.appendProgressElement(anchor);
upload = { row, progressEl, anchor, timeout: null };
this.uploads.set(fileName, upload);
}
const { row, progressEl, anchor } = upload;
if (error) {
this.uploads.delete(fileName);
progressEl.style.width = `100%`;
progressEl.classList.add('error');
if (!anchor.classList.contains('error')) {
anchor.classList.add('error');
anchor.innerText = `${fileName}. ${message}`;
}
if (!upload.timeout) {
upload.timeout = window.setTimeout(() => {
const parent = row.parentElement;
if (parent) {
parent.removeChild(row);
this.reload();
}
}, FileListingClient.REMOVE_ROW_TIMEOUT);
}
} else {
anchor.innerText = `${fileName}. ${message}`;
progressEl.style.width = `${progress}%`;
}
if (finished && !error) {
this.uploads.delete(fileName);
this.reload();
}
}
public onError(error: string | Error): void {
console.error(this.name, 'FIXME: implement', error);
}
private addForeground(type: Foreground): void {
const fragment = html`<div class="foreground ${type}">
<div class="foreground-message ${type}-message">${Message[type]}</div>
</div>`.content;
this.parent.appendChild(fragment);
}
private removeForeground(type: Foreground): void {
const els = this.parent.getElementsByClassName(type);
Array.from(els).forEach((el) => {
this.parent.removeChild(el);
});
}
public static parseParameters(params: URLSearchParams): ParamsFileListing {
const typedParams = super.parseParameters(params);
const { action } = typedParams;
if (action !== ACTION.FILE_LISTING) {
throw Error('Incorrect action');
}
const pathParam = params.get('path');
const path = pathParam || '/data/local/tmp';
return { ...typedParams, action, udid: Util.parseString(params, 'udid', true), path };
}
protected buildDirectWebSocketUrl(): URL {
const localUrl = super.buildDirectWebSocketUrl();
localUrl.searchParams.set('action', ACTION.MULTIPLEX);
return localUrl;
}
protected onSocketClose(event: CloseEvent): void {
if (this.filePushHandler) {
this.filePushHandler.release();
}
console.error(this.name, 'socket closed', event.reason);
this.addForeground(Foreground.Connect);
}
protected onSocketMessage(_e: MessageEvent): void {
// We create separate channel for each request
// Don't expect any messages on this level
console.log("接收到的参数234", _e.data)
}
protected onSocketOpen(): void {
this.loadContent(this.path);
}
protected loadContent(path: string, entry?: Entry, anchor?: HTMLElement, pathToLoadAfter = ''): void {
if (!this.ws || this.ws.readyState !== this.ws.OPEN || !(this.ws instanceof Multiplexer)) {
return;
}
if (!entry && (this.channels.size || this.uploads.size)) {
return;
}
this.requireClean = true;
this.requestedPath = path;
let cmd: string;
if (!entry) {
cmd = Protocol.STAT;
} else if (entry.isFile()) {
cmd = Protocol.RECV;
} else {
cmd = Protocol.LIST;
}
const len = Buffer.byteLength(path, 'utf-8');
const payload = Buffer.alloc(cmd.length + 4 + len);
let pos = payload.write(cmd, 0);
pos = payload.writeUInt32LE(len, pos);
payload.write(path, pos);
const channel = this.ws.createChannel(payload);
this.channels.add(channel);
const download: Download = {
receivedBytes: 0,
path,
entry,
anchor,
chunks: [],
pathToLoadAfter,
};
this.downloads.set(channel, download);
const onMessage = (event: MessageEvent): void => {
this.handleReply(channel, event);
};
const onClose = (): void => {
this.channels.delete(channel);
this.downloads.delete(channel);
channel.removeEventListener('message', onMessage);
channel.removeEventListener('close', onClose);
};
channel.addEventListener('message', onMessage);
channel.addEventListener('close', onClose);
}
protected clean(): void {
this.tableBody.innerHTML = '';
const header = document.getElementById('header');
if (header) {
header.innerText = `Content ${this.path}`;
}
this.toggleQuickLinks(this.path);
// FIXME: should do over way around: load content on hash change
const hash = location.hash.replace(/#!/, '');
const params = new URLSearchParams(hash);
if (params.get('action') === ACTION.FILE_LISTING) {
params.set('path', this.path);
location.hash = `#!${params.toString()}`;
}
}
protected toggleQuickLinks(path: string): void {
const isRoot = path === rootPath;
const parentEl = document.getElementById(parentDirLinkBox);
if (parentEl) {
parentEl.classList.toggle('hidden', isRoot);
}
const rootEl = document.getElementById(rootDirLinkBox);
if (rootEl) {
rootEl.classList.toggle('hidden', isRoot);
}
const isTemp = path === tempPath;
const tempEl = document.getElementById(tempDirLinkBox);
if (tempEl) {
tempEl.classList.toggle('hidden', isTemp);
}
const isStorage = path === storagePath;
const storageEl = document.getElementById(storageDirLinkBox);
if (storageEl) {
storageEl.classList.toggle('hidden', isStorage);
}
}
protected handleReply(channel: Multiplexer, e: MessageEvent): void {
const data = Buffer.from(e.data);
const reply = data.slice(0, 4).toString('ascii');
switch (reply) {
case Protocol.DENT:
const stat = data.slice(4);
const mode = stat.readUInt32LE(0);
const size = stat.readUInt32LE(4);
const mtime = stat.readUInt32LE(8);
const namelen = stat.readUInt32LE(12);
const name = Util.utf8ByteArrayToString(stat.slice(16, 16 + namelen));
this.addEntry(new Entry(name, mode, size, mtime));
return;
case Protocol.DONE:
this.finishDownload(channel);
return;
case Protocol.STAT: {
const download = this.downloads.get(channel);
if (!download) {
return;
}
const stat = data.slice(4);
const mode = stat.readUInt32LE(0);
const size = stat.readUInt32LE(4);
const mtime = stat.readUInt32LE(8);
const nameString = path.basename(download.path);
if (mode === 0) {
console.error('FIXME: show error in UI');
console.error(`Error: no entity "${download.path}"`);
this.channels.delete(channel);
this.loadContent(tempPath);
return;
}
const entry = new Entry(nameString, mode, size, mtime);
let anchor: HTMLElement | undefined;
let nextPath = '';
if (!entry.isDirectory()) {
nextPath = this.requestedPath = path.dirname(download.path);
const row = this.addEntry(entry);
anchor = row ? row.getElementsByTagName('a')[0] : undefined;
}
this.loadContent(download.path, entry, anchor, nextPath);
break;
}
case Protocol.FAIL:
const length = data.readUInt32LE(4);
const message = Util.utf8ByteArrayToString(data.slice(8, 8 + length));
console.error(TAG, `FAIL: ${message}`);
return;
case Protocol.DATA:
const download = this.downloads.get(channel);
if (!download) {
return;
}
download.chunks.push(data.slice(4));
download.receivedBytes += data.length - 4;
if (download.anchor) {
let progressElement = download.progressEl;
if (!progressElement) {
progressElement = this.appendProgressElement(download.anchor);
download.progressEl = progressElement;
}
if (download.entry) {
const { size } = download.entry;
const percent = (download.receivedBytes * 100) / size;
progressElement.style.width = `${percent}%`;
}
}
return;
default:
console.error(`Unexpected "${reply}"`);
}
}
protected appendProgressElement(anchor: HTMLElement): HTMLElement {
const progressElement = document.createElement('span');
progressElement.className = 'background-progress';
const parent = anchor.parentElement;
if (parent) {
parent.appendChild(progressElement);
}
return progressElement;
}
protected addEntry(entry: Entry): HTMLElement | undefined {
if (this.requireClean) {
this.path = this.requestedPath;
this.requestedPath = '';
this.clean();
this.requireClean = false;
this.entries.length = 0;
}
this.entries.push(entry);
const entryId = (this.entries.length - 1).toString();
if (entry.name === '.') {
return;
}
if (entry.name === FileListingClient.PARENT_DIR) {
const el = document.getElementById(parentDirLinkBox);
if (el) {
const a = el.children[0];
if (a) {
a.setAttribute(FileListingClient.PROPERTY_ENTRY_ID, entryId);
}
}
return;
}
const type = entry.isDirectory() ? 'dir' : entry.isSymbolicLink() ? 'link' : entry.isFile() ? 'file' : 'else';
const date = entry.mtime.toLocaleString();
return this.addRow(false, entry.name, type, entry.size.toString(), date, entryId);
}
protected addRow(push: boolean, name: string, typeClass: string, size = '', date = '', entryId = ''): HTMLElement {
const row = document.createElement('tr');
row.id = `entry-${name}`;
row.classList.add('entry-row');
const nameTd = document.createElement('td');
nameTd.classList.add('entry-name');
const link = document.createElement('a');
link.classList.add('icon', typeClass);
link.setAttribute(FileListingClient.PROPERTY_NAME, name);
if (entryId) {
link.setAttribute(FileListingClient.PROPERTY_ENTRY_ID, entryId);
}
link.innerText = name;
nameTd.appendChild(link);
row.appendChild(nameTd);
if (push) {
nameTd.colSpan = 3;
link.classList.add('push');
} else {
const href = new URL(location.href);
const hash = new URLSearchParams(href.hash.replace(/^#!/, ''));
hash.set('path', path.join(this.path, name));
href.hash = `#!${hash.toString()}`;
link.href = href.toString();
const sizeTd = document.createElement('td');
sizeTd.classList.add('entry-size');
sizeTd.innerText = size;
row.appendChild(sizeTd);
const mtimeTd = document.createElement('td');
mtimeTd.classList.add('entry-time');
mtimeTd.innerText = date;
row.appendChild(mtimeTd);
}
if (push || !this.tableBody.children.length) {
this.tableBody.insertBefore(row, this.tableBody.firstChild);
} else {
this.tableBody.appendChild(row);
}
return row;
}
protected finishDownload(channel: Multiplexer): void {
const download = this.downloads.get(channel);
if (!download) {
return;
}
this.downloads.delete(channel);
const el = download.progressEl;
if (el) {
this.cleanProgress(el);
}
let name: string;
if (download.entry && download.entry.isFile()) {
name = download.entry.name;
} else {
// we always should have `download.entry` and never be here
name = path.basename(this.path);
}
if (download.pathToLoadAfter) {
this.channels.delete(channel);
this.loadContent(download.pathToLoadAfter);
}
const file = new File(download.chunks, name, { type: 'application/octet-stream' });
const a = document.createElement('a');
a.href = URL.createObjectURL(file);
a.download = `${name}`;
a.click();
}
protected cleanProgress(el: HTMLElement): void {
el.classList.add('finished');
setTimeout(() => {
const parent = el.parentElement;
if (parent) {
parent.removeChild(el);
}
});
}
public getPath(): string {
return this.path;
}
public reload(): void {
this.loadContent(this.path);
}
protected supportMultiplexing(): boolean {
return true;
}
protected getChannelInitData(): Buffer {
const serial = Util.stringToUtf8ByteArray(this.serial);
const buffer = Buffer.alloc(4 + 4 + serial.byteLength);
buffer.write(ChannelCode.FSLS, 'ascii');
buffer.writeUInt32LE(serial.length, 4);
buffer.set(serial, 8);
return buffer;
}
}

View File

@@ -0,0 +1,147 @@
import 'xterm/css/xterm.css';
import { ManagerClient } from '../../client/ManagerClient';
import { Terminal } from 'xterm';
import { AttachAddon } from 'xterm-addon-attach';
import { FitAddon } from 'xterm-addon-fit';
import { MessageXtermClient } from '../../../types/MessageXtermClient';
import { ACTION } from '../../../common/Action';
import { ParamsShell } from '../../../types/ParamsShell';
import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
import { BaseDeviceTracker } from '../../client/BaseDeviceTracker';
import Util from '../../Util';
import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
import { ChannelCode } from '../../../common/ChannelCode';
const TAG = '[ShellClient]';
export class ShellClient extends ManagerClient<ParamsShell, never> {
public static ACTION = ACTION.SHELL;
public static start(params: ParamsShell): ShellClient {
return new ShellClient(params);
}
private readonly term: Terminal;
private readonly fitAddon: FitAddon;
private readonly escapedUdid: string;
private readonly udid: string;
constructor(params: ParamsShell) {
super(params);
this.udid = params.udid;
this.openNewConnection();
this.setTitle(`Shell ${this.udid}`);
this.setBodyClass('shell');
if (!this.ws) {
throw Error('No WebSocket');
}
this.term = new Terminal();
this.term.loadAddon(new AttachAddon(this.ws));
this.fitAddon = new FitAddon();
this.term.loadAddon(this.fitAddon);
this.escapedUdid = Util.escapeUdid(this.udid);
this.term.open(ShellClient.getOrCreateContainer(this.escapedUdid));
this.updateTerminalSize();
this.term.focus();
}
protected supportMultiplexing(): boolean {
return true;
}
public static parseParameters(params: URLSearchParams): ParamsShell {
const typedParams = super.parseParameters(params);
const { action } = typedParams;
if (action !== ACTION.SHELL) {
throw Error('Incorrect action');
}
return { ...typedParams, action, udid: Util.parseString(params, 'udid', true) };
}
protected onSocketOpen = (): void => {
this.startShell(this.udid);
};
protected onSocketClose(event: CloseEvent): void {
console.log(TAG, `Connection closed: ${event.reason}`);
this.term.dispose();
}
protected onSocketMessage(): void {
// messages are processed by Attach Addon
}
public startShell(udid: string): void {
if (!udid || !this.ws || this.ws.readyState !== this.ws.OPEN) {
return;
}
const { rows, cols } = this.fitAddon.proposeDimensions();
const message: MessageXtermClient = {
id: 1,
type: 'shell',
data: {
type: 'start',
rows,
cols,
udid,
},
};
console.log('发送的参数5');
this.ws.send(JSON.stringify(message));
}
private static getOrCreateContainer(udid: string): HTMLElement {
let container = document.getElementById(udid);
if (!container) {
container = document.createElement('div');
container.className = 'terminal-container';
container.id = udid;
document.body.appendChild(container);
}
return container;
}
private updateTerminalSize(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const term: any = this.term;
const terminalContainer: HTMLElement = ShellClient.getOrCreateContainer(this.escapedUdid);
const { rows, cols } = this.fitAddon.proposeDimensions();
const width =
(cols * term._core._renderService.dimensions.actualCellWidth + term._core.viewport.scrollBarWidth).toFixed(
2,
) + 'px';
const height = (rows * term._core._renderService.dimensions.actualCellHeight).toFixed(2) + 'px';
terminalContainer.style.width = width;
terminalContainer.style.height = height;
this.fitAddon.fit();
}
public static createEntryForDeviceList(
descriptor: GoogDeviceDescriptor,
blockClass: string,
params: ParamsDeviceTracker,
): HTMLElement | DocumentFragment | undefined {
if (descriptor.state !== 'device') {
return;
}
const entry = document.createElement('div');
entry.classList.add('shell', blockClass);
entry.appendChild(
BaseDeviceTracker.buildLink(
{
action: ACTION.SHELL,
udid: descriptor.udid,
},
'shell',
params,
),
);
return entry;
}
protected getChannelInitData(): Buffer {
const buffer = Buffer.alloc(4);
buffer.write(ChannelCode.SHEL, 'ascii');
return buffer;
}
}

View File

@@ -0,0 +1,506 @@
import { BaseClient } from '../../client/BaseClient';
import { ParamsStreamScrcpy } from '../../../types/ParamsStreamScrcpy';
import { GoogMoreBox } from '../toolbox/GoogMoreBox';
import { GoogToolBox } from '../toolbox/GoogToolBox';
import VideoSettings from '../../VideoSettings';
import Size from '../../Size';
import { ControlMessage } from '../../controlMessage/ControlMessage';
import { ClientsStats, DisplayCombinedInfo } from '../../client/StreamReceiver';
import { CommandControlMessage } from '../../controlMessage/CommandControlMessage';
import Util from '../../Util';
import FilePushHandler from '../filePush/FilePushHandler';
import DragAndPushLogger from '../DragAndPushLogger';
import { KeyEventListener, KeyInputHandler } from '../KeyInputHandler';
import { KeyCodeControlMessage } from '../../controlMessage/KeyCodeControlMessage';
import { BasePlayer, PlayerClass } from '../../player/BasePlayer';
import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
import { ConfigureScrcpy } from './ConfigureScrcpy';
import { DeviceTracker } from './DeviceTracker';
import { ControlCenterCommand } from '../../../common/ControlCenterCommand';
import { html } from '../../ui/HtmlTag';
import {
FeaturedInteractionHandler,
InteractionHandlerListener,
} from '../../interactionHandler/FeaturedInteractionHandler';
import DeviceMessage from '../DeviceMessage';
import { DisplayInfo } from '../../DisplayInfo';
import { Attribute } from '../../Attribute';
import { HostTracker } from '../../client/HostTracker';
import { ACTION } from '../../../common/Action';
import { StreamReceiverScrcpy } from './StreamReceiverScrcpy';
import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
import { ScrcpyFilePushStream } from '../filePush/ScrcpyFilePushStream';
type StartParams = {
udid: string;
playerName?: string;
player?: BasePlayer;
fitToScreen?: boolean;
videoSettings?: VideoSettings;
};
const TAG = '[StreamClientScrcpy]';
export class StreamClientScrcpy
extends BaseClient<ParamsStreamScrcpy, never>
implements KeyEventListener, InteractionHandlerListener {
public static ACTION = 'stream';
private static players: Map<string, PlayerClass> = new Map<string, PlayerClass>();
private controlButtons?: HTMLElement;
private deviceName = '';
private clientId = -1;
private clientsCount = -1;
private joinedStream = false;
private requestedVideoSettings?: VideoSettings;
private touchHandler?: FeaturedInteractionHandler;
private moreBox?: GoogMoreBox;
private player?: BasePlayer;
private filePushHandler?: FilePushHandler;
private fitToScreen?: boolean;
private readonly streamReceiver: StreamReceiverScrcpy;
public static registerPlayer(playerClass: PlayerClass): void {
if (playerClass.isSupported()) {
this.players.set(playerClass.playerFullName, playerClass);
}
}
public static getPlayers(): PlayerClass[] {
return Array.from(this.players.values());
}
private static getPlayerClass(playerName: string): PlayerClass | undefined {
let playerClass: PlayerClass | undefined;
for (const value of StreamClientScrcpy.players.values()) {
if (value.playerFullName === playerName || value.playerCodeName === playerName) {
playerClass = value;
}
}
return playerClass;
}
public static createPlayer(playerName: string, udid: string, displayInfo?: DisplayInfo): BasePlayer | undefined {
const playerClass = this.getPlayerClass(playerName);
if (!playerClass) {
return;
}
return new playerClass(udid, displayInfo);
}
public static getFitToScreen(playerName: string, udid: string, displayInfo?: DisplayInfo): boolean {
const playerClass = this.getPlayerClass(playerName);
if (!playerClass) {
return false;
}
return playerClass.getFitToScreenStatus(udid, displayInfo);
}
public static start(
query: URLSearchParams | ParamsStreamScrcpy,
streamReceiver?: StreamReceiverScrcpy,
player?: BasePlayer,
fitToScreen?: boolean,
videoSettings?: VideoSettings,
): StreamClientScrcpy {
if (query instanceof URLSearchParams) {
const params = StreamClientScrcpy.parseParameters(query);
return new StreamClientScrcpy(params, streamReceiver, player, fitToScreen, videoSettings);
} else {
return new StreamClientScrcpy(query, streamReceiver, player, fitToScreen, videoSettings);
}
}
private static createVideoSettingsWithBounds(old: VideoSettings, newBounds: Size): VideoSettings {
return new VideoSettings({
crop: old.crop,
bitrate: old.bitrate,
bounds: newBounds,
maxFps: old.maxFps,
iFrameInterval: old.iFrameInterval,
sendFrameMeta: old.sendFrameMeta,
lockedVideoOrientation: old.lockedVideoOrientation,
displayId: old.displayId,
codecOptions: old.codecOptions,
encoderName: old.encoderName,
});
}
protected constructor(
params: ParamsStreamScrcpy,
streamReceiver?: StreamReceiverScrcpy,
player?: BasePlayer,
fitToScreen?: boolean,
videoSettings?: VideoSettings,
) {
super(params);
if (streamReceiver) {
this.streamReceiver = streamReceiver;
} else {
this.streamReceiver = new StreamReceiverScrcpy(this.params);
}
const { udid, player: playerName } = this.params;
this.startStream({ udid, player, playerName, fitToScreen, videoSettings });
this.setBodyClass('stream');
}
public static parseParameters(params: URLSearchParams): ParamsStreamScrcpy {
const typedParams = super.parseParameters(params);
const { action } = typedParams;
if (action !== ACTION.STREAM_SCRCPY) {
throw Error('Incorrect action');
}
return {
...typedParams,
action,
player: Util.parseString(params, 'player', true),
udid: Util.parseString(params, 'udid', true),
ws: Util.parseString(params, 'ws', true),
};
}
public OnDeviceMessage = (message: DeviceMessage): void => {
if (this.moreBox) {
console.log('message', message)
this.moreBox.OnDeviceMessage(message);
}
};
public onVideo = (data: ArrayBuffer): void => {
if (!this.player) {
return;
}
const STATE = BasePlayer.STATE;
if (this.player.getState() === STATE.PAUSED) {
this.player.play();
}
if (this.player.getState() === STATE.PLAYING) {
this.player.pushFrame(new Uint8Array(data));
}
};
public onClientsStats = (stats: ClientsStats): void => {
this.deviceName = stats.deviceName;
this.clientId = stats.clientId;
this.setTitle(`Stream ${this.deviceName}`);
};
public onDisplayInfo = (infoArray: DisplayCombinedInfo[]): void => {
if (!this.player) {
return;
}
let currentSettings = this.player.getVideoSettings();
const displayId = currentSettings.displayId;
const info = infoArray.find((value) => {
return value.displayInfo.displayId === displayId;
});
if (!info) {
return;
}
if (this.player.getState() === BasePlayer.STATE.PAUSED) {
this.player.play();
}
const { videoSettings, screenInfo } = info;
this.player.setDisplayInfo(info.displayInfo);
if (typeof this.fitToScreen !== 'boolean') {
this.fitToScreen = this.player.getFitToScreenStatus();
}
if (this.fitToScreen) {
const newBounds = this.getMaxSize();
if (newBounds) {
currentSettings = StreamClientScrcpy.createVideoSettingsWithBounds(currentSettings, newBounds);
this.player.setVideoSettings(currentSettings, this.fitToScreen, false);
}
}
if (!videoSettings || !screenInfo) {
this.joinedStream = true;
this.sendMessage(CommandControlMessage.createSetVideoSettingsCommand(currentSettings));
return;
}
this.clientsCount = info.connectionCount;
let min = VideoSettings.copy(videoSettings);
const oldInfo = this.player.getScreenInfo();
if (!screenInfo.equals(oldInfo)) {
this.player.setScreenInfo(screenInfo);
}
if (!videoSettings.equals(currentSettings)) {
this.applyNewVideoSettings(videoSettings, videoSettings.equals(this.requestedVideoSettings));
}
if (!oldInfo) {
const bounds = currentSettings.bounds;
const videoSize: Size = screenInfo.videoSize;
const onlyOneClient = this.clientsCount === 0;
const smallerThenCurrent = bounds && (bounds.width < videoSize.width || bounds.height < videoSize.height);
if (onlyOneClient || smallerThenCurrent) {
min = currentSettings;
}
const minBounds = currentSettings.bounds?.intersect(min.bounds);
if (minBounds && !minBounds.equals(min.bounds)) {
min = StreamClientScrcpy.createVideoSettingsWithBounds(min, minBounds);
}
}
if (!min.equals(videoSettings) || !this.joinedStream) {
this.joinedStream = true;
this.sendMessage(CommandControlMessage.createSetVideoSettingsCommand(min));
}
};
public onDisconnected = (): void => {
this.streamReceiver.off('deviceMessage', this.OnDeviceMessage);
this.streamReceiver.off('video', this.onVideo);
this.streamReceiver.off('clientsStats', this.onClientsStats);
this.streamReceiver.off('displayInfo', this.onDisplayInfo);
this.streamReceiver.off('disconnected', this.onDisconnected);
this.filePushHandler?.release();
this.filePushHandler = undefined;
this.touchHandler?.release();
this.touchHandler = undefined;
};
public startStream({ udid, player, playerName, videoSettings, fitToScreen }: StartParams): void {
if (!udid) {
throw Error(`Invalid udid value: "${udid}"`);
}
this.fitToScreen = fitToScreen;
if (!player) {
if (typeof playerName !== 'string') {
throw Error('Must provide BasePlayer instance or playerName');
}
let displayInfo: DisplayInfo | undefined;
if (this.streamReceiver && videoSettings) {
displayInfo = this.streamReceiver.getDisplayInfo(videoSettings.displayId);
}
const p = StreamClientScrcpy.createPlayer(playerName, udid, displayInfo);
if (!p) {
throw Error(`Unsupported player: "${playerName}"`);
}
if (typeof fitToScreen !== 'boolean') {
fitToScreen = StreamClientScrcpy.getFitToScreen(playerName, udid, displayInfo);
}
player = p;
}
this.player = player;
this.setTouchListeners(player);
if (!videoSettings) {
videoSettings = player.getVideoSettings();
}
const deviceView = document.createElement('div');
deviceView.className = 'device-view';
const stop = (ev?: string | Event) => {
if (ev && ev instanceof Event && ev.type === 'error') {
console.error(TAG, ev);
}
let parent;
parent = deviceView.parentElement;
if (parent) {
parent.removeChild(deviceView);
}
parent = moreBox.parentElement;
if (parent) {
parent.removeChild(moreBox);
}
this.streamReceiver.stop();
if (this.player) {
this.player.stop();
}
};
const googMoreBox = (this.moreBox = new GoogMoreBox(udid, player, this));
const moreBox = googMoreBox.getHolderElement();
googMoreBox.setOnStop(stop);
const googToolBox = GoogToolBox.createToolBox(udid, player, this, moreBox);
this.controlButtons = googToolBox.getHolderElement();
deviceView.appendChild(this.controlButtons);
const video = document.createElement('div');
video.className = 'video';
deviceView.appendChild(video);
deviceView.appendChild(moreBox);
player.setParent(video);
player.pause();
document.body.appendChild(deviceView);
if (fitToScreen) {
const newBounds = this.getMaxSize();
if (newBounds) {
videoSettings = StreamClientScrcpy.createVideoSettingsWithBounds(videoSettings, newBounds);
}
}
this.applyNewVideoSettings(videoSettings, false);
const element = player.getTouchableElement();
const logger = new DragAndPushLogger(element);
this.filePushHandler = new FilePushHandler(element, new ScrcpyFilePushStream(this.streamReceiver));
this.filePushHandler.addEventListener(logger);
const streamReceiver = this.streamReceiver;
streamReceiver.on('deviceMessage', this.OnDeviceMessage);
streamReceiver.on('video', this.onVideo);
streamReceiver.on('clientsStats', this.onClientsStats);
streamReceiver.on('displayInfo', this.onDisplayInfo);
streamReceiver.on('disconnected', this.onDisconnected);
console.log(TAG, player.getName(), udid);
}
public sendMessage(message: ControlMessage): void {
console.log("发送消息", message)
this.streamReceiver.sendEvent(message);
}
public getDeviceName(): string {
return this.deviceName;
}
public setHandleKeyboardEvents(enabled: boolean): void {
if (enabled) {
KeyInputHandler.addEventListener(this);
} else {
KeyInputHandler.removeEventListener(this);
}
}
public onKeyEvent(event: KeyCodeControlMessage): void {
this.sendMessage(event);
}
public sendNewVideoSetting(videoSettings: VideoSettings): void {
this.requestedVideoSettings = videoSettings;
this.sendMessage(CommandControlMessage.createSetVideoSettingsCommand(videoSettings));
}
public getClientId(): number {
return this.clientId;
}
public getClientsCount(): number {
return this.clientsCount;
}
public getMaxSize(): Size | undefined {
if (!this.controlButtons) {
return;
}
const body = document.body;
const width = (body.clientWidth - this.controlButtons.clientWidth) & ~15;
const height = body.clientHeight & ~15;
return new Size(width, height);
}
private setTouchListeners(player: BasePlayer): void {
if (this.touchHandler) {
return;
}
this.touchHandler = new FeaturedInteractionHandler(player, this);
}
private applyNewVideoSettings(videoSettings: VideoSettings, saveToStorage: boolean): void {
let fitToScreen = false;
// TODO: create control (switch/checkbox) instead
if (videoSettings.bounds && videoSettings.bounds.equals(this.getMaxSize())) {
fitToScreen = true;
}
if (this.player) {
this.player.setVideoSettings(videoSettings, fitToScreen, saveToStorage);
}
}
public static createEntryForDeviceList(
descriptor: GoogDeviceDescriptor,
blockClass: string,
fullName: string,
params: ParamsDeviceTracker,
): HTMLElement | DocumentFragment | undefined {
const hasPid = descriptor.pid !== -1;
if (hasPid) {
const configureButtonId = `configure_${Util.escapeUdid(descriptor.udid)}`;
const e = html`<div class="stream ${blockClass}">
<button
${Attribute.UDID}="${descriptor.udid}"
${Attribute.COMMAND}="${ControlCenterCommand.CONFIGURE_STREAM}"
${Attribute.FULL_NAME}="${fullName}"
${Attribute.SECURE}="${params.secure}"
${Attribute.HOSTNAME}="${params.hostname}"
${Attribute.PORT}="${params.port}"
${Attribute.PATHNAME}="${params.pathname}"
${Attribute.USE_PROXY}="${params.useProxy}"
id="${configureButtonId}"
class="active action-button"
>
Configure stream
</button>
</div>`;
const a = e.content.getElementById(configureButtonId);
a && (a.onclick = this.onConfigureStreamClick);
return e.content;
}
return;
}
private static onConfigureStreamClick = (event: MouseEvent): void => {
const button = event.currentTarget as HTMLAnchorElement;
const udid = Util.parseStringEnv(button.getAttribute(Attribute.UDID) || '');
const fullName = button.getAttribute(Attribute.FULL_NAME);
const secure = Util.parseBooleanEnv(button.getAttribute(Attribute.SECURE) || undefined) || false;
const hostname = Util.parseStringEnv(button.getAttribute(Attribute.HOSTNAME) || undefined) || '';
const port = Util.parseIntEnv(button.getAttribute(Attribute.PORT) || undefined);
const pathname = Util.parseStringEnv(button.getAttribute(Attribute.PATHNAME) || undefined) || '';
const useProxy = Util.parseBooleanEnv(button.getAttribute(Attribute.USE_PROXY) || undefined);
if (!udid) {
throw Error(`Invalid udid value: "${udid}"`);
}
if (typeof port !== 'number') {
throw Error(`Invalid port type: ${typeof port}`);
}
const tracker = DeviceTracker.getInstance({
type: 'android',
secure,
hostname,
port,
pathname,
useProxy,
});
const descriptor = tracker.getDescriptorByUdid(udid);
if (!descriptor) {
return;
}
event.preventDefault();
const elements = document.getElementsByName(`${DeviceTracker.AttributePrefixInterfaceSelectFor}${fullName}`);
if (!elements || !elements.length) {
return;
}
const select = elements[0] as HTMLSelectElement;
const optionElement = select.options[select.selectedIndex];
const ws = optionElement.getAttribute(Attribute.URL);
const name = optionElement.getAttribute(Attribute.NAME);
if (!ws || !name) {
return;
}
const options: ParamsStreamScrcpy = {
udid,
ws,
player: '',
action: ACTION.STREAM_SCRCPY,
secure,
hostname,
port,
pathname,
useProxy,
};
const dialog = new ConfigureScrcpy(tracker, descriptor, options);
dialog.on('closed', StreamClientScrcpy.onConfigureDialogClosed);
};
private static onConfigureDialogClosed = (event: { dialog: ConfigureScrcpy; result: boolean }): void => {
event.dialog.off('closed', StreamClientScrcpy.onConfigureDialogClosed);
if (event.result) {
HostTracker.getInstance().destroy();
}
};
}

View File

@@ -0,0 +1,24 @@
import { StreamReceiver } from '../../client/StreamReceiver';
import { ParamsStreamScrcpy } from '../../../types/ParamsStreamScrcpy';
import { ACTION } from '../../../common/Action';
import Util from '../../Util';
export class StreamReceiverScrcpy extends StreamReceiver<ParamsStreamScrcpy> {
public static parseParameters(params: URLSearchParams): ParamsStreamScrcpy {
const typedParams = super.parseParameters(params);
const { action } = typedParams;
if (action !== ACTION.STREAM_SCRCPY) {
throw Error('Incorrect action');
}
return {
...typedParams,
action,
udid: Util.parseString(params, 'udid', true),
ws: Util.parseString(params, 'ws', true),
player: Util.parseString(params, 'player', true),
};
}
protected buildDirectWebSocketUrl(): URL {
return new URL((this.params as ParamsStreamScrcpy).ws);
}
}

View File

@@ -0,0 +1,110 @@
import { FilePushStream } from './FilePushStream';
import { CommandControlMessage, FilePushState } from '../../controlMessage/CommandControlMessage';
import { Multiplexer } from '../../../packages/multiplexer/Multiplexer';
import { FilePushResponseStatus } from './FilePushResponseStatus';
import Protocol from '@dead50f7/adbkit/lib/adb/protocol';
import { FileListingClient } from '../client/FileListingClient';
import * as path from 'path';
import FilePushHandler from './FilePushHandler';
export class AdbkitFilePushStream extends FilePushStream {
private channels: Map<number, Multiplexer> = new Map();
constructor(private readonly socket: Multiplexer, private readonly fileListingClient: FileListingClient) {
super();
}
public hasConnection(): boolean {
return this.socket.readyState == this.socket.OPEN;
}
public isAllowedFile(): boolean {
return true;
}
public getChannel(id: number): Multiplexer | undefined {
const channel = this.channels.get(id);
let code: FilePushResponseStatus = FilePushResponseStatus.NO_ERROR;
if (!channel) {
code = FilePushResponseStatus.ERROR_UNKNOWN_ID;
}
if (code) {
this.emit('response', { id, code });
return;
}
return channel;
}
public sendEventAppend({ id, chunk }: { id: number; chunk: Uint8Array }): void {
const appendParams = { id, chunk, state: FilePushState.APPEND };
const channel = this.getChannel(id);
if (!channel) {
return;
}
console.log('发送的参数6');
channel.send(CommandControlMessage.createPushFileCommand(appendParams).toBuffer());
}
public sendEventFinish({ id }: { id: number }): void {
const finishParams = { id, state: FilePushState.FINISH };
const channel = this.getChannel(id);
if (!channel) {
return;
}
console.log('发送的参数7');
channel.send(CommandControlMessage.createPushFileCommand(finishParams).toBuffer());
}
public sendEventNew({ id }: { id: number }): void {
let pushId = id;
const newParams = { id, state: FilePushState.NEW };
const channel = this.socket.createChannel(Buffer.from(Protocol.SEND));
const onMessage = (event: MessageEvent): void => {
let offset = 0;
const buffer = Buffer.from(event.data);
const id = buffer.readInt16BE(offset);
offset += 2;
const code = buffer.readInt8(offset);
if (code === FilePushResponseStatus.NEW_PUSH_ID) {
this.channels.set(id, channel);
pushId = id;
}
this.emit('response', { id, code });
};
const onClose = (event: CloseEvent): void => {
if (!event.wasClean) {
const code = 4000 - event.code;
// this.emit('response', { id: pushId, code });
this.emit('error', {
id: pushId,
error: new Error(FilePushHandler.getErrorMessage(code, event.reason)),
});
}
channel.removeEventListener('message', onMessage);
channel.removeEventListener('close', onClose);
};
channel.addEventListener('message', onMessage);
channel.addEventListener('close', onClose);
console.log('发送的参数7');
channel.send(CommandControlMessage.createPushFileCommand(newParams).toBuffer());
}
public sendEventStart({ id, fileName, fileSize }: { id: number; fileName: string; fileSize: number }): void {
const filePath = path.join(this.fileListingClient.getPath(), fileName);
const startParams = { id, fileName: filePath, fileSize, state: FilePushState.START };
const channel = this.getChannel(id);
if (!channel) {
return;
}
console.log('发送的参数8');
channel.send(CommandControlMessage.createPushFileCommand(startParams).toBuffer());
}
public release(): void {
this.channels.forEach((channel) => {
channel.close();
});
}
}

View File

@@ -0,0 +1,256 @@
import { DragAndDropHandler, DragEventListener } from '../DragAndDropHandler';
import { FilePushStream, PushResponse } from './FilePushStream';
import { FilePushResponseStatus } from './FilePushResponseStatus';
type Resolve = (response: PushResponse) => void;
export type PushUpdateParams = {
pushId: number;
fileName: string;
message: string;
progress: number;
error: boolean;
finished: boolean;
};
export interface DragAndPushListener {
onDragEnter: () => boolean;
onDragLeave: () => boolean;
onDrop: () => boolean;
onFilePushUpdate: (data: PushUpdateParams) => void;
onError: (error: Error | string) => void;
}
const TAG = '[FilePushHandler]';
export default class FilePushHandler implements DragEventListener {
public static readonly REQUEST_NEW_PUSH_ID = 0; // ignored on server, when state is `NEW_PUSH_ID`
private responseWaiter: Map<number, Resolve | Resolve[]> = new Map();
private listeners: Set<DragAndPushListener> = new Set();
private pushIdFileNameMap: Map<number, string> = new Map();
constructor(private readonly element: HTMLElement, private readonly filePushStream: FilePushStream) {
DragAndDropHandler.addEventListener(this);
filePushStream.on('response', this.onStreamResponse);
filePushStream.on('error', this.onStreamError);
}
private sendUpdate(params: PushUpdateParams): void {
if (params.error) {
this.pushIdFileNameMap.delete(params.pushId);
}
this.listeners.forEach((listener) => {
listener.onFilePushUpdate(params);
});
}
private logError(pushId: number, fileName: string, code: number): void {
const msg = RESPONSE_CODES.get(code) || `Unknown error (${code})`;
this.sendUpdate({ pushId, fileName, message: `error: "${msg}"`, progress: -1, error: true, finished: true });
}
private static async getStreamReader(file: File): Promise<{
reader: ReadableStreamDefaultReader<Uint8Array>;
result: ReadableStreamReadResult<Uint8Array>;
}> {
const blob = await new Response(file).blob();
const reader = blob.stream().getReader() as ReadableStreamDefaultReader<Uint8Array>;
const result = await reader.read();
return { reader, result };
}
private async pushFile(file: File): Promise<void> {
const start = Date.now();
const { name: fileName, size: fileSize } = file;
if (!this.filePushStream.hasConnection()) {
this.listeners.forEach((listener) => {
listener.onError('WebSocket is not ready');
});
return;
}
const id = FilePushHandler.REQUEST_NEW_PUSH_ID;
this.sendUpdate({ pushId: id, fileName, message: 'begins...', progress: 0, error: false, finished: false });
this.filePushStream.sendEventNew({ id });
const { code: pushId } = await this.waitForResponse(id);
if (pushId <= 0) {
return this.logError(pushId, fileName, pushId);
}
this.pushIdFileNameMap.set(pushId, fileName);
const waitPromise = this.waitForResponse(pushId);
this.filePushStream.sendEventStart({ id: pushId, fileName, fileSize });
const [{ code: startResponseCode }, { reader, result }] = await Promise.all([
waitPromise,
FilePushHandler.getStreamReader(file),
]);
if (startResponseCode !== FilePushResponseStatus.NO_ERROR) {
this.logError(pushId, fileName, startResponseCode);
return;
}
let receivedBytes = 0;
const processData = async ({ done, value }: { done: boolean; value?: Uint8Array }): Promise<void> => {
if (done || !value) {
this.filePushStream.sendEventFinish({ id: pushId });
const { code: finishResponseCode } = await this.waitForResponse(pushId);
if (finishResponseCode !== 0) {
this.logError(pushId, fileName, finishResponseCode);
} else {
this.sendUpdate({
pushId,
fileName,
message: 'success!',
progress: 100,
error: false,
finished: true,
});
}
console.log(TAG, `File "${fileName}" uploaded in ${Date.now() - start}ms`);
return;
}
receivedBytes += value.length;
this.filePushStream.sendEventAppend({ id: pushId, chunk: value });
const [{ code: appendResponseCode }, result] = await Promise.all([
this.waitForResponse(pushId),
reader.read(),
]);
if (appendResponseCode !== 0) {
this.logError(pushId, fileName, appendResponseCode);
return;
}
const progress = (receivedBytes * 100) / fileSize;
const message = `${progress.toFixed(2)}%`;
this.sendUpdate({ pushId, fileName, message, progress, error: false, finished: false });
return processData(result);
};
return processData(result);
}
private waitForResponse(pushId: number): Promise<PushResponse> {
return new Promise((resolve) => {
const stored = this.responseWaiter.get(pushId);
if (Array.isArray(stored)) {
stored.push(resolve);
} else if (stored) {
const arr: Resolve[] = [stored];
arr.push(resolve);
this.responseWaiter.set(pushId, arr);
} else {
this.responseWaiter.set(pushId, resolve);
}
});
}
onStreamError = ({ id: pushId, error }: { id: number; error: Error }): void => {
const fileName = this.pushIdFileNameMap.get(pushId) || 'Unknown file';
this.sendUpdate({ pushId, fileName, message: error.message, progress: -1, error: true, finished: true });
};
onStreamResponse = (response: PushResponse): void => {
let func: Resolve;
let value: PushResponse;
const { code, id: idInResponse } = response;
const id = code === FilePushResponseStatus.NEW_PUSH_ID ? FilePushHandler.REQUEST_NEW_PUSH_ID : response.id;
const resolve = this.responseWaiter.get(id);
if (!resolve) {
console.warn(TAG, `Unexpected push id: "${id}", ${JSON.stringify(response)}`);
return;
}
if (Array.isArray(resolve)) {
func = resolve.shift() as Resolve;
if (!resolve.length) {
this.responseWaiter.delete(id);
}
} else {
func = resolve;
this.responseWaiter.delete(id);
}
if (code === FilePushResponseStatus.NEW_PUSH_ID) {
value = { id, code: idInResponse };
} else {
value = { id, code: code };
}
func(value);
};
public onFilesDrop(files: File[]): boolean {
this.listeners.forEach((listener) => {
listener.onDrop();
});
files.forEach((file: File) => {
const { type, name } = file;
if (this.filePushStream.isAllowedFile(file)) {
this.pushFile(file);
} else {
const errorParams: PushUpdateParams = {
pushId: FilePushHandler.REQUEST_NEW_PUSH_ID,
fileName: name,
message: `Unsupported type "${type}"`,
progress: -1,
error: true,
finished: true,
};
this.sendUpdate(errorParams);
}
});
return true;
}
public static getErrorMessage(code: number, message?: string): string {
return message || RESPONSE_CODES.get(code) || 'Unknown error';
}
public onDragEnter(): boolean {
let handled = false;
this.listeners.forEach((listener) => {
handled = handled || listener.onDragEnter();
});
return handled;
}
public onDragLeave(): boolean {
let handled = false;
this.listeners.forEach((listener) => {
handled = handled || listener.onDragLeave();
});
return handled;
}
public getElement(): HTMLElement {
return this.element;
}
public release(): void {
this.filePushStream.off('response', this.onStreamResponse);
this.filePushStream.off('error', this.onStreamError);
this.filePushStream.release();
DragAndDropHandler.removeEventListener(this);
this.listeners.clear();
}
public addEventListener(listener: DragAndPushListener): void {
this.listeners.add(listener);
}
public removeEventListener(listener: DragAndPushListener): void {
this.listeners.delete(listener);
}
}
const RESPONSE_CODES = new Map([
[FilePushResponseStatus.NEW_PUSH_ID, 'New push id'],
[FilePushResponseStatus.NO_ERROR, 'No error'],
[FilePushResponseStatus.ERROR_INVALID_NAME, 'Invalid name'],
[FilePushResponseStatus.ERROR_NO_SPACE, 'No space'],
[FilePushResponseStatus.ERROR_FAILED_TO_DELETE, 'Failed to delete existing'],
[FilePushResponseStatus.ERROR_FAILED_TO_CREATE, 'Failed to create new file'],
[FilePushResponseStatus.ERROR_FILE_NOT_FOUND, 'File not found'],
[FilePushResponseStatus.ERROR_FAILED_TO_WRITE, 'Failed to write to file'],
[FilePushResponseStatus.ERROR_FILE_IS_BUSY, 'File is busy'],
[FilePushResponseStatus.ERROR_INVALID_STATE, 'Invalid state'],
[FilePushResponseStatus.ERROR_UNKNOWN_ID, 'Unknown id'],
[FilePushResponseStatus.ERROR_NO_FREE_ID, 'No free id'],
[FilePushResponseStatus.ERROR_INCORRECT_SIZE, 'Incorrect size'],
]);

View File

@@ -0,0 +1,16 @@
export enum FilePushResponseStatus {
NEW_PUSH_ID = 1,
NO_ERROR = 0,
ERROR_INVALID_NAME = -1,
ERROR_NO_SPACE = -2,
ERROR_FAILED_TO_DELETE = -3,
ERROR_FAILED_TO_CREATE = -4,
ERROR_FILE_NOT_FOUND = -5,
ERROR_FAILED_TO_WRITE = -6,
ERROR_FILE_IS_BUSY = -7,
ERROR_INVALID_STATE = -8,
ERROR_UNKNOWN_ID = -9,
ERROR_NO_FREE_ID = -10,
ERROR_INCORRECT_SIZE = -11,
ERROR_OTHER = -12,
}

View File

@@ -0,0 +1,18 @@
import { TypedEmitter } from '../../../common/TypedEmitter';
export type PushResponse = { id: number; code: number };
interface FilePushStreamEvents {
response: PushResponse;
error: { id: number; error: Error };
}
export abstract class FilePushStream extends TypedEmitter<FilePushStreamEvents> {
public abstract hasConnection(): boolean;
public abstract isAllowedFile(file: File): boolean;
public abstract sendEventNew(params: { id: number }): void;
public abstract sendEventStart(params: { id: number; fileName: string; fileSize: number }): void;
public abstract sendEventFinish(params: { id: number }): void;
public abstract sendEventAppend(params: { id: number; chunk: Uint8Array }): void;
public abstract release(): void;
}

View File

@@ -0,0 +1,54 @@
import { FilePushStream } from './FilePushStream';
import { StreamReceiverScrcpy } from '../client/StreamReceiverScrcpy';
import DeviceMessage from '../DeviceMessage';
import { CommandControlMessage, FilePushState } from '../../controlMessage/CommandControlMessage';
const ALLOWED_TYPES = ['application/vnd.android.package-archive'];
const ALLOWED_NAME_RE = /\.apk$/i;
export class ScrcpyFilePushStream extends FilePushStream {
constructor(private readonly streamReceiver: StreamReceiverScrcpy) {
super();
streamReceiver.on('deviceMessage', this.onDeviceMessage);
}
public hasConnection(): boolean {
return this.streamReceiver.hasConnection();
}
public isAllowedFile(file: File): boolean {
const { type, name } = file;
return (type && ALLOWED_TYPES.includes(type)) || (!type && ALLOWED_NAME_RE.test(name));
}
public sendEventAppend({ id, chunk }: { id: number; chunk: Uint8Array }): void {
const appendParams = { id, chunk, state: FilePushState.APPEND };
this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(appendParams));
}
public sendEventFinish({ id }: { id: number }): void {
const finishParams = { id, state: FilePushState.FINISH };
this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(finishParams));
}
public sendEventNew({ id }: { id: number }): void {
const newParams = { id, state: FilePushState.NEW };
this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(newParams));
}
public sendEventStart({ id, fileName, fileSize }: { id: number; fileName: string; fileSize: number }): void {
const startParams = { id, fileName, fileSize, state: FilePushState.START };
this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(startParams));
}
public release(): void {
this.streamReceiver.off('deviceMessage', this.onDeviceMessage);
}
onDeviceMessage = (ev: DeviceMessage): void => {
if (ev.type !== DeviceMessage.TYPE_PUSH_RESPONSE) {
return;
}
const stats = ev.getPushStats();
this.emit('response', stats);
};
}

View File

@@ -0,0 +1,319 @@
import '../../../style/morebox.css';
import { BasePlayer } from '../../player/BasePlayer';
import { TextControlMessage } from '../../controlMessage/TextControlMessage';
import { CommandControlMessage } from '../../controlMessage/CommandControlMessage';
import { ControlMessage } from '../../controlMessage/ControlMessage';
import Size from '../../Size';
import DeviceMessage from '../DeviceMessage';
import VideoSettings from '../../VideoSettings';
import { StreamClientScrcpy } from '../client/StreamClientScrcpy';
const TAG = '[GoogMoreBox]';
export class GoogMoreBox {
private static defaultSize = new Size(480, 480);
private onStop?: () => void;
private readonly holder: HTMLElement;
private readonly input: HTMLTextAreaElement;
private readonly bitrateInput?: HTMLInputElement;
private readonly maxFpsInput?: HTMLInputElement;
private readonly iFrameIntervalInput?: HTMLInputElement;
private readonly maxWidthInput?: HTMLInputElement;
private readonly maxHeightInput?: HTMLInputElement;
constructor(udid: string, private player: BasePlayer, private client: StreamClientScrcpy) {
const playerName = player.getName();
const videoSettings = player.getVideoSettings();
const { displayId } = videoSettings;
const preferredSettings = player.getPreferredVideoSetting();
const moreBox = document.createElement('div');
moreBox.className = 'more-box';
const nameBox = document.createElement('p');
nameBox.innerText = `${udid} (${playerName})`;
nameBox.className = 'text-with-shadow';
moreBox.appendChild(nameBox);
const input = (this.input = document.createElement('textarea'));
input.classList.add('text-area');
const sendButton = document.createElement('button');
sendButton.innerText = 'Send as keys';
const inputWrapper = GoogMoreBox.wrap('p', [input, sendButton], moreBox);
sendButton.onclick = () => {
if (input.value) {
client.sendMessage(new TextControlMessage(input.value));
}
};
const commands: HTMLElement[] = [];
const codes = CommandControlMessage.Commands;
for (const [action, command] of codes.entries()) {
const btn = document.createElement('button');
let bitrateInput: HTMLInputElement;
let maxFpsInput: HTMLInputElement;
let iFrameIntervalInput: HTMLInputElement;
let maxWidthInput: HTMLInputElement;
let maxHeightInput: HTMLInputElement;
if (action === ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS) {
const spoiler = document.createElement('div');
const spoilerLabel = document.createElement('label');
const spoilerCheck = document.createElement('input');
const innerDiv = document.createElement('div');
const id = `spoiler_video_${udid}_${playerName}_${displayId}_${action}`;
spoiler.className = 'spoiler';
spoilerCheck.type = 'checkbox';
spoilerCheck.id = id;
spoilerLabel.htmlFor = id;
spoilerLabel.innerText = command;
innerDiv.className = 'box';
spoiler.appendChild(spoilerCheck);
spoiler.appendChild(spoilerLabel);
spoiler.appendChild(innerDiv);
const bitrateLabel = document.createElement('label');
bitrateLabel.innerText = 'Bitrate:';
bitrateInput = document.createElement('input');
bitrateInput.placeholder = `${preferredSettings.bitrate} bps`;
bitrateInput.value = videoSettings.bitrate.toString();
GoogMoreBox.wrap('div', [bitrateLabel, bitrateInput], innerDiv);
this.bitrateInput = bitrateInput;
const maxFpsLabel = document.createElement('label');
maxFpsLabel.innerText = 'Max fps:';
maxFpsInput = document.createElement('input');
maxFpsInput.placeholder = `${preferredSettings.maxFps} fps`;
maxFpsInput.value = videoSettings.maxFps.toString();
GoogMoreBox.wrap('div', [maxFpsLabel, maxFpsInput], innerDiv);
this.maxFpsInput = maxFpsInput;
const iFrameIntervalLabel = document.createElement('label');
iFrameIntervalLabel.innerText = 'I-Frame Interval:';
iFrameIntervalInput = document.createElement('input');
iFrameIntervalInput.placeholder = `${preferredSettings.iFrameInterval} seconds`;
iFrameIntervalInput.value = videoSettings.iFrameInterval.toString();
GoogMoreBox.wrap('div', [iFrameIntervalLabel, iFrameIntervalInput], innerDiv);
this.iFrameIntervalInput = iFrameIntervalInput;
const { width, height } = videoSettings.bounds || client.getMaxSize() || GoogMoreBox.defaultSize;
const pWidth = preferredSettings.bounds?.width || width;
const pHeight = preferredSettings.bounds?.height || height;
const maxWidthLabel = document.createElement('label');
maxWidthLabel.innerText = 'Max width:';
maxWidthInput = document.createElement('input');
maxWidthInput.placeholder = `${pWidth} px`;
maxWidthInput.value = width.toString();
GoogMoreBox.wrap('div', [maxWidthLabel, maxWidthInput], innerDiv);
this.maxWidthInput = maxWidthInput;
const maxHeightLabel = document.createElement('label');
maxHeightLabel.innerText = 'Max height:';
maxHeightInput = document.createElement('input');
maxHeightInput.placeholder = `${pHeight} px`;
maxHeightInput.value = height.toString();
GoogMoreBox.wrap('div', [maxHeightLabel, maxHeightInput], innerDiv);
this.maxHeightInput = maxHeightInput;
innerDiv.appendChild(btn);
const fitButton = document.createElement('button');
fitButton.innerText = 'Fit';
fitButton.onclick = this.fit;
innerDiv.insertBefore(fitButton, innerDiv.firstChild);
const resetButton = document.createElement('button');
resetButton.innerText = 'Reset';
resetButton.onclick = this.reset;
innerDiv.insertBefore(resetButton, innerDiv.firstChild);
commands.push(spoiler);
} else {
if (
action === CommandControlMessage.TYPE_SET_CLIPBOARD ||
action === CommandControlMessage.TYPE_GET_CLIPBOARD
) {
inputWrapper.appendChild(btn);
} else {
commands.push(btn);
}
}
btn.innerText = command;
if (action === ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS) {
btn.onclick = () => {
const bitrate = parseInt(bitrateInput.value, 10);
const maxFps = parseInt(maxFpsInput.value, 10);
const iFrameInterval = parseInt(iFrameIntervalInput.value, 10);
if (isNaN(bitrate) || isNaN(maxFps)) {
return;
}
const width = parseInt(maxWidthInput.value, 10) & ~15;
const height = parseInt(maxHeightInput.value, 10) & ~15;
const bounds = new Size(width, height);
const current = player.getVideoSettings();
const { lockedVideoOrientation, sendFrameMeta, displayId, codecOptions, encoderName } = current;
const videoSettings = new VideoSettings({
bounds,
bitrate,
maxFps,
iFrameInterval,
lockedVideoOrientation,
sendFrameMeta,
displayId,
codecOptions,
encoderName,
});
client.sendNewVideoSetting(videoSettings);
};
} else if (action === CommandControlMessage.TYPE_SET_CLIPBOARD) {
btn.onclick = () => {
const text = input.value;
if (text) {
console.log(`粘贴的内容${text}`);
client.sendMessage(CommandControlMessage.createSetClipboardCommand(text));
}
};
} else {
btn.onclick = () => {
client.sendMessage(new CommandControlMessage(action));
};
}
}
GoogMoreBox.wrap('p', commands, moreBox);
const screenPowerModeId = `screen_power_mode_${udid}_${playerName}_${displayId}`;
const screenPowerModeLabel = document.createElement('label');
screenPowerModeLabel.style.display = 'none';
const labelTextPrefix = 'Mode';
const buttonTextPrefix = 'Set screen power mode';
const screenPowerModeCheck = document.createElement('input');
screenPowerModeCheck.type = 'checkbox';
let mode = (screenPowerModeCheck.checked = false) ? 'ON' : 'OFF';
screenPowerModeCheck.id = screenPowerModeLabel.htmlFor = screenPowerModeId;
screenPowerModeLabel.innerText = `${labelTextPrefix} ${mode}`;
screenPowerModeCheck.onchange = () => {
mode = screenPowerModeCheck.checked ? 'ON' : 'OFF';
screenPowerModeLabel.innerText = `${labelTextPrefix} ${mode}`;
sendScreenPowerModeButton.innerText = `${buttonTextPrefix} ${mode}`;
};
const sendScreenPowerModeButton = document.createElement('button');
sendScreenPowerModeButton.innerText = `${buttonTextPrefix} ${mode}`;
sendScreenPowerModeButton.onclick = () => {
const message = CommandControlMessage.createSetScreenPowerModeCommand(screenPowerModeCheck.checked);
client.sendMessage(message);
};
GoogMoreBox.wrap('p', [screenPowerModeCheck, screenPowerModeLabel, sendScreenPowerModeButton], moreBox, [
'flex-center',
]);
const qualityId = `show_video_quality_${udid}_${playerName}_${displayId}`;
const qualityLabel = document.createElement('label');
const qualityCheck = document.createElement('input');
qualityCheck.type = 'checkbox';
qualityCheck.checked = BasePlayer.DEFAULT_SHOW_QUALITY_STATS;
qualityCheck.id = qualityId;
qualityLabel.htmlFor = qualityId;
qualityLabel.innerText = 'Show quality stats';
GoogMoreBox.wrap('p', [qualityCheck, qualityLabel], moreBox, ['flex-center']);
qualityCheck.onchange = () => {
player.setShowQualityStats(qualityCheck.checked);
};
const stop = (ev?: string | Event) => {
if (ev && ev instanceof Event && ev.type === 'error') {
console.error(TAG, ev);
}
const parent = moreBox.parentElement;
if (parent) {
parent.removeChild(moreBox);
}
player.off('video-view-resize', this.onViewVideoResize);
if (this.onStop) {
this.onStop();
delete this.onStop;
}
};
const stopBtn = document.createElement('button') as HTMLButtonElement;
stopBtn.innerText = `Disconnect`;
stopBtn.onclick = stop;
GoogMoreBox.wrap('p', [stopBtn], moreBox);
player.on('video-view-resize', this.onViewVideoResize);
player.on('video-settings', this.onVideoSettings);
this.holder = moreBox;
}
private onViewVideoResize = (size: Size): void => {
// padding: 10px
this.holder.style.width = `${size.width - 2 * 10}px`;
};
private onVideoSettings = (videoSettings: VideoSettings): void => {
if (this.bitrateInput) {
this.bitrateInput.value = videoSettings.bitrate.toString();
}
if (this.maxFpsInput) {
this.maxFpsInput.value = videoSettings.maxFps.toString();
}
if (this.iFrameIntervalInput) {
this.iFrameIntervalInput.value = videoSettings.iFrameInterval.toString();
}
if (videoSettings.bounds) {
const { width, height } = videoSettings.bounds;
if (this.maxWidthInput) {
this.maxWidthInput.value = width.toString();
}
if (this.maxHeightInput) {
this.maxHeightInput.value = height.toString();
}
}
};
private fit = (): void => {
const { width, height } = this.client.getMaxSize() || GoogMoreBox.defaultSize;
if (this.maxWidthInput) {
this.maxWidthInput.value = width.toString();
}
if (this.maxHeightInput) {
this.maxHeightInput.value = height.toString();
}
};
private reset = (): void => {
const preferredSettings = this.player.getPreferredVideoSetting();
this.onVideoSettings(preferredSettings);
};
public OnDeviceMessage(ev: DeviceMessage): void {
if (ev.type !== DeviceMessage.TYPE_CLIPBOARD) {
return;
}
this.input.value = ev.getText();
console.log("this.input.value", this.input.value)
this.input.select();
document.execCommand('copy');
}
private static wrap(
tagName: string,
elements: HTMLElement[],
parent: HTMLElement,
opt_classes?: string[],
): HTMLElement {
const wrap = document.createElement(tagName);
if (opt_classes) {
wrap.classList.add(...opt_classes);
}
elements.forEach((e) => {
wrap.appendChild(e);
});
parent.appendChild(wrap);
return wrap;
}
public getHolderElement(): HTMLElement {
return this.holder;
}
public setOnStop(listener: () => void): void {
this.onStop = listener;
}
}

View File

@@ -0,0 +1,122 @@
import { ToolBox } from '../../toolbox/ToolBox';
import KeyEvent from '../android/KeyEvent';
import SvgImage from '../../ui/SvgImage';
import { KeyCodeControlMessage } from '../../controlMessage/KeyCodeControlMessage';
import { ToolBoxButton } from '../../toolbox/ToolBoxButton';
import { ToolBoxElement } from '../../toolbox/ToolBoxElement';
import { ToolBoxCheckbox } from '../../toolbox/ToolBoxCheckbox';
import { StreamClientScrcpy } from '../client/StreamClientScrcpy';
import { BasePlayer } from '../../player/BasePlayer';
const BUTTONS = [
{
title: 'Power',
code: KeyEvent.KEYCODE_POWER,
icon: SvgImage.Icon.POWER,
},
{
title: 'Volume up',
code: KeyEvent.KEYCODE_VOLUME_UP,
icon: SvgImage.Icon.VOLUME_UP,
},
{
title: 'Volume down',
code: KeyEvent.KEYCODE_VOLUME_DOWN,
icon: SvgImage.Icon.VOLUME_DOWN,
},
{
title: 'Back',
code: KeyEvent.KEYCODE_BACK,
icon: SvgImage.Icon.BACK,
},
{
title: 'Home',
code: KeyEvent.KEYCODE_HOME,
icon: SvgImage.Icon.HOME,
},
{
title: 'Overview',
code: KeyEvent.KEYCODE_APP_SWITCH,
icon: SvgImage.Icon.OVERVIEW,
},
{
title: '打开抖音',
code: -1, // 用特殊值标记,后续处理
icon: SvgImage.Icon.OVERVIEW,
action: 'launch_app', // 自定义字段,标记特殊动作
packageName: 'com.ss.android.ugc.aweme', // 抖音包名
},
];
export class GoogToolBox extends ToolBox {
protected constructor(list: ToolBoxElement<any>[]) {
super(list);
}
public static createToolBox(
udid: string,
player: BasePlayer,
client: StreamClientScrcpy,
moreBox?: HTMLElement,
): GoogToolBox {
const playerName = player.getName();
const list = BUTTONS.slice();
console.log("list", list)
const handler = <K extends keyof HTMLElementEventMap, T extends HTMLElement>(
type: K,
element: ToolBoxElement<T>,
) => {
const { code } = element.optional || {};
// 处理普通按键事件
if (code && code !== -1) {
const actionType = type === 'mousedown' ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP;
const event = new KeyCodeControlMessage(actionType, code, 0, 0);
client.sendMessage(event);
// 处理启动抖音应用
} else if (code == -1) {
console.log("启动抖音")
}
};
const elements: ToolBoxElement<any>[] = list.map((item) => {
const button = new ToolBoxButton(item.title, item.icon, {
code: item.code,
});
button.addEventListener('mousedown', handler);
button.addEventListener('mouseup', handler);
return button;
});
if (player.supportsScreenshot) {
const screenshot = new ToolBoxButton('Take screenshot', SvgImage.Icon.CAMERA);
screenshot.addEventListener('click', () => {
player.createScreenshot(client.getDeviceName());
});
elements.push(screenshot);
}
const keyboard = new ToolBoxCheckbox(
'Capture keyboard',
SvgImage.Icon.KEYBOARD,
`capture_keyboard_${udid}_${playerName}`,
);
keyboard.addEventListener('click', (_, el) => {
const element = el.getElement();
client.setHandleKeyboardEvents(element.checked);
});
elements.push(keyboard);
if (moreBox) {
const displayId = player.getVideoSettings().displayId;
const id = `show_more_${udid}_${playerName}_${displayId}`;
const more = new ToolBoxCheckbox('More', SvgImage.Icon.MORE, id);
more.addEventListener('click', (_, el) => {
const element = el.getElement();
moreBox.style.display = element.checked ? 'block' : 'none';
});
elements.unshift(more);
}
return new GoogToolBox(elements);
}
}

112
src/app/index.ts Normal file
View File

@@ -0,0 +1,112 @@
import '../style/app.css';
import { StreamClientScrcpy } from './googDevice/client/StreamClientScrcpy';
import { HostTracker } from './client/HostTracker';
import { Tool } from './client/Tool';
window.onload = async function (): Promise<void> {
const hash = location.hash.replace(/^#!/, '');
const parsedQuery = new URLSearchParams(hash);
const action = parsedQuery.get('action');
/// #if USE_BROADWAY
const { BroadwayPlayer } = await import('./player/BroadwayPlayer');
StreamClientScrcpy.registerPlayer(BroadwayPlayer);
/// #endif
/// #if USE_H264_CONVERTER
const { MsePlayer } = await import('./player/MsePlayer');
StreamClientScrcpy.registerPlayer(MsePlayer);
/// #endif
/// #if USE_TINY_H264
const { TinyH264Player } = await import('./player/TinyH264Player');
StreamClientScrcpy.registerPlayer(TinyH264Player);
/// #endif
/// #if USE_WEBCODECS
const { WebCodecsPlayer } = await import('./player/WebCodecsPlayer');
StreamClientScrcpy.registerPlayer(WebCodecsPlayer);
/// #endif
if (action === StreamClientScrcpy.ACTION && typeof parsedQuery.get('udid') === 'string') {
StreamClientScrcpy.start(parsedQuery);
return;
}
/// #if INCLUDE_APPL
{
const { DeviceTracker } = await import('./applDevice/client/DeviceTracker');
/// #if USE_QVH_SERVER
const { StreamClientQVHack } = await import('./applDevice/client/StreamClientQVHack');
DeviceTracker.registerTool(StreamClientQVHack);
/// #if USE_WEBCODECS
const { WebCodecsPlayer } = await import('./player/WebCodecsPlayer');
StreamClientQVHack.registerPlayer(WebCodecsPlayer);
/// #endif
/// #if USE_H264_CONVERTER
const { MsePlayerForQVHack } = await import('./player/MsePlayerForQVHack');
StreamClientQVHack.registerPlayer(MsePlayerForQVHack);
/// #endif
if (action === StreamClientQVHack.ACTION && typeof parsedQuery.get('udid') === 'string') {
StreamClientQVHack.start(StreamClientQVHack.parseParameters(parsedQuery));
return;
}
/// #endif
/// #if USE_WDA_MJPEG_SERVER
const { StreamClientMJPEG } = await import('./applDevice/client/StreamClientMJPEG');
DeviceTracker.registerTool(StreamClientMJPEG);
const { MjpegPlayer } = await import('./player/MjpegPlayer');
StreamClientMJPEG.registerPlayer(MjpegPlayer);
if (action === StreamClientMJPEG.ACTION && typeof parsedQuery.get('udid') === 'string') {
StreamClientMJPEG.start(StreamClientMJPEG.parseParameters(parsedQuery));
return;
}
/// #endif
}
/// #endif
const tools: Tool[] = [];
/// #if INCLUDE_ADB_SHELL
const { ShellClient } = await import('./googDevice/client/ShellClient');
if (action === ShellClient.ACTION && typeof parsedQuery.get('udid') === 'string') {
ShellClient.start(ShellClient.parseParameters(parsedQuery));
return;
}
tools.push(ShellClient);
/// #endif
/// #if INCLUDE_DEV_TOOLS
const { DevtoolsClient } = await import('./googDevice/client/DevtoolsClient');
if (action === DevtoolsClient.ACTION) {
DevtoolsClient.start(DevtoolsClient.parseParameters(parsedQuery));
return;
}
tools.push(DevtoolsClient);
/// #endif
/// #if INCLUDE_FILE_LISTING
const { FileListingClient } = await import('./googDevice/client/FileListingClient');
if (action === FileListingClient.ACTION) {
FileListingClient.start(FileListingClient.parseParameters(parsedQuery));
return;
}
tools.push(FileListingClient);
/// #endif
if (tools.length) {
const { DeviceTracker } = await import('./googDevice/client/DeviceTracker');
tools.forEach((tool) => {
DeviceTracker.registerTool(tool);
});
}
HostTracker.start();
};

View File

@@ -0,0 +1,132 @@
import { InteractionEvents, KeyEventNames, InteractionHandler } from './InteractionHandler';
import { BasePlayer } from '../player/BasePlayer';
import { ControlMessage } from '../controlMessage/ControlMessage';
import { TouchControlMessage } from '../controlMessage/TouchControlMessage';
import MotionEvent from '../MotionEvent';
import ScreenInfo from '../ScreenInfo';
import { ScrollControlMessage } from '../controlMessage/ScrollControlMessage';
const TAG = '[FeaturedTouchHandler]';
export interface InteractionHandlerListener {
sendMessage: (message: ControlMessage) => void;
}
export class FeaturedInteractionHandler extends InteractionHandler {
private static readonly touchEventsNames: InteractionEvents[] = [
'touchstart',
'touchend',
'touchmove',
'touchcancel',
'mousedown',
'mouseup',
'mousemove',
'wheel',
];
private static readonly keyEventsNames: KeyEventNames[] = ['keydown', 'keyup'];
public static SCROLL_EVENT_THROTTLING_TIME = 30; // one event per 50ms
private readonly storedFromMouseEvent = new Map<number, TouchControlMessage>();
private readonly storedFromTouchEvent = new Map<number, TouchControlMessage>();
private lastScrollEvent?: { time: number; hScroll: number; vScroll: number };
constructor(player: BasePlayer, public readonly listener: InteractionHandlerListener) {
super(player, FeaturedInteractionHandler.touchEventsNames, FeaturedInteractionHandler.keyEventsNames);
this.tag.addEventListener('mouseleave', this.onMouseLeave);
this.tag.addEventListener('mouseenter', this.onMouseEnter);
}
public buildScrollEvent(event: WheelEvent, screenInfo: ScreenInfo): ScrollControlMessage[] {
const messages: ScrollControlMessage[] = [];
const touchOnClient = InteractionHandler.buildTouchOnClient(event, screenInfo);
if (touchOnClient) {
const hScroll = event.deltaX > 0 ? -1 : event.deltaX < -0 ? 1 : 0;
const vScroll = event.deltaY > 0 ? -1 : event.deltaY < -0 ? 1 : 0;
const time = Date.now();
if (
!this.lastScrollEvent ||
time - this.lastScrollEvent.time > FeaturedInteractionHandler.SCROLL_EVENT_THROTTLING_TIME ||
this.lastScrollEvent.vScroll !== vScroll ||
this.lastScrollEvent.hScroll !== hScroll
) {
this.lastScrollEvent = { time, hScroll, vScroll };
messages.push(new ScrollControlMessage(touchOnClient.touch.position, hScroll, vScroll));
}
}
return messages;
}
protected onInteraction(event: MouseEvent | TouchEvent): void {
const screenInfo = this.player.getScreenInfo();
if (!screenInfo) {
return;
}
let messages: ControlMessage[];
let storage: Map<number, TouchControlMessage>;
if (event instanceof MouseEvent) {
if (event.target !== this.tag) {
return;
}
if (window['WheelEvent'] && event instanceof WheelEvent) {
messages = this.buildScrollEvent(event, screenInfo);
} else {
storage = this.storedFromMouseEvent;
messages = this.buildTouchEvent(event, screenInfo, storage);
}
if (this.over) {
this.lastPosition = event;
}
} else if (window['TouchEvent'] && event instanceof TouchEvent) {
// TODO: Research drag from out of the target inside it
if (event.target !== this.tag) {
return;
}
storage = this.storedFromTouchEvent;
messages = this.formatTouchEvent(event, screenInfo, storage);
} else {
console.error(TAG, 'Unsupported event', event);
return;
}
if (event.cancelable) {
event.preventDefault();
}
event.stopPropagation();
messages.forEach((message) => {
this.listener.sendMessage(message);
});
}
protected onKey(event: KeyboardEvent): void {
if (!this.lastPosition) {
return;
}
const screenInfo = this.player.getScreenInfo();
if (!screenInfo) {
return;
}
const { ctrlKey, shiftKey } = event;
const { target, button, buttons, clientY, clientX } = this.lastPosition;
const type = InteractionHandler.SIMULATE_MULTI_TOUCH;
const props = { ctrlKey, shiftKey, type, target, button, buttons, clientX, clientY };
this.buildTouchEvent(props, screenInfo, new Map());
}
private onMouseEnter = (): void => {
this.over = true;
};
private onMouseLeave = (): void => {
this.lastPosition = undefined;
this.over = false;
this.storedFromMouseEvent.forEach((message) => {
this.listener.sendMessage(InteractionHandler.createEmulatedMessage(MotionEvent.ACTION_UP, message));
});
this.storedFromMouseEvent.clear();
this.clearCanvas();
};
public release(): void {
super.release();
this.tag.removeEventListener('mouseleave', this.onMouseLeave);
this.tag.removeEventListener('mouseenter', this.onMouseEnter);
this.storedFromMouseEvent.clear();
}
}

View File

@@ -0,0 +1,562 @@
import MotionEvent from '../MotionEvent';
import ScreenInfo from '../ScreenInfo';
import { TouchControlMessage } from '../controlMessage/TouchControlMessage';
import Size from '../Size';
import Point from '../Point';
import Position from '../Position';
import TouchPointPNG from '../../public/images/multitouch/touch_point.png';
import CenterPointPNG from '../../public/images/multitouch/center_point.png';
import Util from '../Util';
import { BasePlayer } from '../player/BasePlayer';
interface Touch {
action: number;
position: Position;
buttons: number;
invalid: boolean;
}
interface TouchOnClient {
client: {
width: number;
height: number;
};
touch: Touch;
}
interface CommonTouchAndMouse {
clientX: number;
clientY: number;
type: string;
target: EventTarget | null;
buttons: number;
}
interface MiniMouseEvent extends CommonTouchAndMouse {
ctrlKey: boolean;
shiftKey: boolean;
buttons: number;
}
const TAG = '[TouchHandler]';
export type TouchEventNames =
| 'touchstart'
| 'touchend'
| 'touchmove'
| 'touchcancel'
| 'mousedown'
| 'mouseup'
| 'mousemove';
export type WheelEventNames = 'wheel';
export type InteractionEvents = TouchEventNames | WheelEventNames;
export type KeyEventNames = 'keydown' | 'keyup';
export abstract class InteractionHandler {
protected static readonly SIMULATE_MULTI_TOUCH = 'SIMULATE_MULTI_TOUCH';
protected static readonly STROKE_STYLE: string = '#00BEA4';
protected static EVENT_ACTION_MAP: Record<string, number> = {
touchstart: MotionEvent.ACTION_DOWN,
touchend: MotionEvent.ACTION_UP,
touchmove: MotionEvent.ACTION_MOVE,
touchcancel: MotionEvent.ACTION_UP,
mousedown: MotionEvent.ACTION_DOWN,
mousemove: MotionEvent.ACTION_MOVE,
mouseup: MotionEvent.ACTION_UP,
[InteractionHandler.SIMULATE_MULTI_TOUCH]: -1,
};
private static options = Util.supportsPassive() ? { passive: false } : false;
private static idToPointerMap: Map<number, number> = new Map();
private static pointerToIdMap: Map<number, number> = new Map();
private static touchPointRadius = 10;
private static centerPointRadius = 5;
private static touchPointImage?: HTMLImageElement;
private static centerPointImage?: HTMLImageElement;
private static pointImagesLoaded = false;
private static eventListeners: Map<string, Set<InteractionHandler>> = new Map();
private multiTouchActive = false;
private multiTouchCenter?: Point;
private multiTouchShift = false;
private dirtyPlace: Point[] = [];
protected readonly ctx: CanvasRenderingContext2D | null;
protected readonly tag: HTMLCanvasElement;
protected over = false;
protected lastPosition?: MouseEvent;
protected constructor(
public readonly player: BasePlayer,
public readonly touchEventsNames: InteractionEvents[],
public readonly keyEventsNames: KeyEventNames[],
) {
this.tag = player.getTouchableElement();
this.ctx = this.tag.getContext('2d');
InteractionHandler.loadImages();
InteractionHandler.bindGlobalListeners(this);
}
protected abstract onInteraction(event: MouseEvent | TouchEvent): void;
protected abstract onKey(event: KeyboardEvent): void;
protected static bindGlobalListeners(interactionHandler: InteractionHandler): void {
interactionHandler.touchEventsNames.forEach((eventName) => {
let set: Set<InteractionHandler> | undefined = InteractionHandler.eventListeners.get(eventName);
if (!set) {
set = new Set();
document.body.addEventListener(eventName, this.onInteractionEvent, InteractionHandler.options);
this.eventListeners.set(eventName, set);
}
set.add(interactionHandler);
});
interactionHandler.keyEventsNames.forEach((eventName) => {
let set = InteractionHandler.eventListeners.get(eventName);
if (!set) {
set = new Set();
document.body.addEventListener(eventName, this.onKeyEvent);
this.eventListeners.set(eventName, set);
}
set.add(interactionHandler);
});
}
protected static unbindListeners(touchHandler: InteractionHandler): void {
touchHandler.touchEventsNames.forEach((eventName) => {
const set = InteractionHandler.eventListeners.get(eventName);
if (!set) {
return;
}
set.delete(touchHandler);
if (set.size <= 0) {
this.eventListeners.delete(eventName);
document.body.removeEventListener(eventName, this.onInteractionEvent);
}
});
touchHandler.keyEventsNames.forEach((eventName) => {
const set = InteractionHandler.eventListeners.get(eventName);
if (!set) {
return;
}
set.delete(touchHandler);
if (set.size <= 0) {
this.eventListeners.delete(eventName);
document.body.removeEventListener(eventName, this.onKeyEvent);
}
});
}
protected static onInteractionEvent = (event: MouseEvent | TouchEvent): void => {
const set = InteractionHandler.eventListeners.get(event.type as TouchEventNames);
if (!set) {
return;
}
set.forEach((instance) => {
instance.onInteraction(event);
});
};
protected static onKeyEvent = (event: KeyboardEvent): void => {
const set = InteractionHandler.eventListeners.get(event.type as KeyEventNames);
if (!set) {
return;
}
set.forEach((instance) => {
instance.onKey(event);
});
};
protected static loadImages(): void {
if (this.pointImagesLoaded) {
return;
}
const total = 2;
let current = 0;
const onload = (event: Event) => {
if (++current === total) {
this.pointImagesLoaded = true;
}
if (event.target === this.touchPointImage) {
this.touchPointRadius = this.touchPointImage.width / 2;
} else if (event.target === this.centerPointImage) {
this.centerPointRadius = this.centerPointImage.width / 2;
}
};
const touch = (this.touchPointImage = new Image());
touch.src = TouchPointPNG;
touch.onload = onload;
const center = (this.centerPointImage = new Image());
center.src = CenterPointPNG;
center.onload = onload;
}
protected static getPointerId(type: string, identifier: number): number {
if (this.idToPointerMap.has(identifier)) {
const pointerId = this.idToPointerMap.get(identifier) as number;
if (type === 'touchend' || type === 'touchcancel') {
this.idToPointerMap.delete(identifier);
this.pointerToIdMap.delete(pointerId);
}
return pointerId;
}
let pointerId = 0;
while (this.pointerToIdMap.has(pointerId)) {
pointerId++;
}
this.idToPointerMap.set(identifier, pointerId);
this.pointerToIdMap.set(pointerId, identifier);
return pointerId;
}
protected static buildTouchOnClient(event: CommonTouchAndMouse, screenInfo: ScreenInfo): TouchOnClient | null {
const action = this.mapTypeToAction(event.type);
const { width, height } = screenInfo.videoSize;
const target: HTMLElement = event.target as HTMLElement;
const rect = target.getBoundingClientRect();
let { clientWidth, clientHeight } = target;
let touchX = event.clientX - rect.left;
let touchY = event.clientY - rect.top;
let invalid = false;
if (touchX < 0 || touchX > clientWidth || touchY < 0 || touchY > clientHeight) {
invalid = true;
}
const eps = 1e5;
const ratio = width / height;
const shouldBe = Math.round(eps * ratio);
const haveNow = Math.round((eps * clientWidth) / clientHeight);
if (shouldBe > haveNow) {
const realHeight = Math.ceil(clientWidth / ratio);
const top = (clientHeight - realHeight) / 2;
if (touchY < top || touchY > top + realHeight) {
invalid = true;
}
touchY -= top;
clientHeight = realHeight;
} else if (shouldBe < haveNow) {
const realWidth = Math.ceil(clientHeight * ratio);
const left = (clientWidth - realWidth) / 2;
if (touchX < left || touchX > left + realWidth) {
invalid = true;
}
touchX -= left;
clientWidth = realWidth;
}
const x = (touchX * width) / clientWidth;
const y = (touchY * height) / clientHeight;
const size = new Size(width, height);
const point = new Point(x, y);
const position = new Position(point, size);
if (x < 0 || y < 0 || x > width || y > height) {
invalid = true;
}
return {
client: {
width: clientWidth,
height: clientHeight,
},
touch: {
invalid,
action,
position,
buttons: event.buttons,
},
};
}
private static validateMessage(
originalEvent: MiniMouseEvent | TouchEvent,
message: TouchControlMessage,
storage: Map<number, TouchControlMessage>,
logPrefix: string,
): TouchControlMessage[] {
const messages: TouchControlMessage[] = [];
const { action, pointerId } = message;
const previous = storage.get(pointerId);
if (action === MotionEvent.ACTION_UP) {
if (!previous) {
console.warn(logPrefix, 'Received ACTION_UP while there are no DOWN stored');
} else {
storage.delete(pointerId);
messages.push(message);
}
} else if (action === MotionEvent.ACTION_DOWN) {
if (previous) {
console.warn(logPrefix, 'Received ACTION_DOWN while already has one stored');
} else {
storage.set(pointerId, message);
messages.push(message);
}
} else if (action === MotionEvent.ACTION_MOVE) {
if (!previous) {
if (
(originalEvent instanceof MouseEvent && originalEvent.buttons) ||
(window['TouchEvent'] && originalEvent instanceof TouchEvent)
) {
console.warn(logPrefix, 'Received ACTION_MOVE while there are no DOWN stored');
const emulated = InteractionHandler.createEmulatedMessage(MotionEvent.ACTION_DOWN, message);
messages.push(emulated);
storage.set(pointerId, emulated);
}
} else {
messages.push(message);
storage.set(pointerId, message);
}
}
return messages;
}
protected static createEmulatedMessage(action: number, event: TouchControlMessage): TouchControlMessage {
const { pointerId, position, buttons } = event;
let pressure = event.pressure;
if (action === MotionEvent.ACTION_UP) {
pressure = 0;
}
return new TouchControlMessage(action, pointerId, position, pressure, buttons);
}
public static mapTypeToAction(type: string): number {
return this.EVENT_ACTION_MAP[type];
}
protected getTouch(
e: CommonTouchAndMouse,
screenInfo: ScreenInfo,
ctrlKey: boolean,
shiftKey: boolean,
): Touch[] | null {
const touchOnClient = InteractionHandler.buildTouchOnClient(e, screenInfo);
if (!touchOnClient) {
return null;
}
const { client, touch } = touchOnClient;
const result: Touch[] = [touch];
if (!ctrlKey) {
this.multiTouchActive = false;
this.multiTouchCenter = undefined;
this.multiTouchShift = false;
this.clearCanvas();
return result;
}
const { position, action, buttons } = touch;
const { point, screenSize } = position;
const { width, height } = screenSize;
const { x, y } = point;
if (!this.multiTouchActive) {
if (shiftKey) {
this.multiTouchCenter = point;
this.multiTouchShift = true;
} else {
this.multiTouchCenter = new Point(client.width / 2, client.height / 2);
}
}
this.multiTouchActive = true;
let opposite: Point | undefined;
let invalid = false;
if (this.multiTouchShift && this.multiTouchCenter) {
const oppoX = 2 * this.multiTouchCenter.x - x;
const oppoY = 2 * this.multiTouchCenter.y - y;
opposite = new Point(oppoX, oppoY);
if (!(oppoX <= width && oppoX >= 0 && oppoY <= height && oppoY >= 0)) {
invalid = true;
}
} else {
opposite = new Point(client.width - x, client.height - y);
invalid = touch.invalid;
}
if (opposite) {
result.push({
invalid,
action,
buttons,
position: new Position(opposite, screenSize),
});
}
return result;
}
protected drawCircle(ctx: CanvasRenderingContext2D, point: Point, radius: number): void {
ctx.beginPath();
ctx.arc(point.x, point.y, radius, 0, Math.PI * 2, true);
ctx.stroke();
}
public drawLine(point1: Point, point2: Point): void {
if (!this.ctx) {
return;
}
this.ctx.save();
this.ctx.strokeStyle = InteractionHandler.STROKE_STYLE;
this.ctx.beginPath();
this.ctx.moveTo(point1.x, point1.y);
this.ctx.lineTo(point2.x, point2.y);
this.ctx.stroke();
this.ctx.restore();
}
protected drawPoint(point: Point, radius: number, image?: HTMLImageElement): void {
if (!this.ctx) {
return;
}
let { lineWidth } = this.ctx;
if (InteractionHandler.pointImagesLoaded && image) {
radius = image.width / 2;
lineWidth = 0;
this.ctx.drawImage(image, point.x - radius, point.y - radius);
} else {
this.drawCircle(this.ctx, point, radius);
}
const topLeft = new Point(point.x - radius - lineWidth, point.y - radius - lineWidth);
const bottomRight = new Point(point.x + radius + lineWidth, point.y + radius + lineWidth);
this.updateDirty(topLeft, bottomRight);
}
public drawPointer(point: Point): void {
this.drawPoint(point, InteractionHandler.touchPointRadius, InteractionHandler.touchPointImage);
if (this.multiTouchCenter) {
this.drawLine(this.multiTouchCenter, point);
}
}
public drawCenter(point: Point): void {
this.drawPoint(point, InteractionHandler.centerPointRadius, InteractionHandler.centerPointImage);
}
protected updateDirty(topLeft: Point, bottomRight: Point): void {
if (!this.dirtyPlace.length) {
this.dirtyPlace.push(topLeft, bottomRight);
return;
}
const currentTopLeft = this.dirtyPlace[0];
const currentBottomRight = this.dirtyPlace[1];
const newTopLeft = new Point(Math.min(currentTopLeft.x, topLeft.x), Math.min(currentTopLeft.y, topLeft.y));
const newBottomRight = new Point(
Math.max(currentBottomRight.x, bottomRight.x),
Math.max(currentBottomRight.y, bottomRight.y),
);
this.dirtyPlace.length = 0;
this.dirtyPlace.push(newTopLeft, newBottomRight);
}
public clearCanvas(): void {
const { clientWidth, clientHeight } = this.tag;
const ctx = this.ctx;
if (ctx && this.dirtyPlace.length) {
const topLeft = this.dirtyPlace[0];
const bottomRight = this.dirtyPlace[1];
this.dirtyPlace.length = 0;
const x = Math.max(topLeft.x, 0);
const y = Math.max(topLeft.y, 0);
const w = Math.min(clientWidth, bottomRight.x - x);
const h = Math.min(clientHeight, bottomRight.y - y);
ctx.clearRect(x, y, w, h);
ctx.strokeStyle = InteractionHandler.STROKE_STYLE;
}
}
public formatTouchEvent(
e: TouchEvent,
screenInfo: ScreenInfo,
storage: Map<number, TouchControlMessage>,
): TouchControlMessage[] {
const logPrefix = `${TAG}[formatTouchEvent]`;
const messages: TouchControlMessage[] = [];
const touches = e.changedTouches;
if (touches && touches.length) {
for (let i = 0, l = touches.length; i < l; i++) {
const touch = touches[i];
const pointerId = InteractionHandler.getPointerId(e.type, touch.identifier);
if (touch.target !== this.tag) {
continue;
}
const previous = storage.get(pointerId);
const item: CommonTouchAndMouse = {
clientX: touch.clientX,
clientY: touch.clientY,
type: e.type,
buttons: MotionEvent.BUTTON_PRIMARY,
target: e.target,
};
const event = InteractionHandler.buildTouchOnClient(item, screenInfo);
if (event) {
const { action, buttons, position, invalid } = event.touch;
let pressure = 1;
if (action === MotionEvent.ACTION_UP) {
pressure = 0;
} else if (typeof touch.force === 'number') {
pressure = touch.force;
}
if (!invalid) {
const message = new TouchControlMessage(action, pointerId, position, pressure, buttons);
messages.push(
...InteractionHandler.validateMessage(e, message, storage, `${logPrefix}[validate]`),
);
} else {
if (previous) {
messages.push(InteractionHandler.createEmulatedMessage(MotionEvent.ACTION_UP, previous));
storage.delete(pointerId);
}
}
} else {
console.error(logPrefix, `Failed to format touch`, touch);
}
}
} else {
console.error(logPrefix, 'No "touches"', e);
}
return messages;
}
public buildTouchEvent(
e: MiniMouseEvent,
screenInfo: ScreenInfo,
storage: Map<number, TouchControlMessage>,
): TouchControlMessage[] {
const logPrefix = `${TAG}[buildTouchEvent]`;
const touches = this.getTouch(e, screenInfo, e.ctrlKey, e.shiftKey);
if (!touches) {
return [];
}
const messages: TouchControlMessage[] = [];
const points: Point[] = [];
this.clearCanvas();
touches.forEach((touch: Touch, pointerId: number) => {
const { action, buttons, position } = touch;
const previous = storage.get(pointerId);
if (!touch.invalid) {
let pressure = 1.0;
if (action === MotionEvent.ACTION_UP) {
pressure = 0;
}
const message = new TouchControlMessage(action, pointerId, position, pressure, buttons);
messages.push(...InteractionHandler.validateMessage(e, message, storage, `${logPrefix}[validate]`));
points.push(touch.position.point);
} else {
if (previous) {
points.push(previous.position.point);
}
}
});
if (this.multiTouchActive) {
if (this.multiTouchCenter) {
this.drawCenter(this.multiTouchCenter);
}
points.forEach((point) => {
this.drawPointer(point);
});
}
const hasActionUp = messages.find((message) => {
return message.action === MotionEvent.ACTION_UP;
});
if (hasActionUp && storage.size) {
console.warn(logPrefix, 'Looks like one of Multi-touch pointers was not raised up');
storage.forEach((message) => {
messages.push(InteractionHandler.createEmulatedMessage(MotionEvent.ACTION_UP, message));
});
storage.clear();
}
return messages;
}
public release(): void {
InteractionHandler.unbindListeners(this);
}
}

View File

@@ -0,0 +1,86 @@
import { InteractionEvents, InteractionHandler } from './InteractionHandler';
import { BasePlayer } from '../player/BasePlayer';
import ScreenInfo from '../ScreenInfo';
import Position from '../Position';
export interface TouchHandlerListener {
performClick: (position: Position) => void;
performScroll: (from: Position, to: Position) => void;
}
const TAG = '[SimpleTouchHandler]';
export class SimpleInteractionHandler extends InteractionHandler {
private startPosition?: Position;
private endPosition?: Position;
private static readonly touchEventsNames: InteractionEvents[] = ['mousedown', 'mouseup', 'mousemove'];
private storage = new Map();
constructor(player: BasePlayer, private readonly listener: TouchHandlerListener) {
super(player, SimpleInteractionHandler.touchEventsNames, []);
}
protected onInteraction(event: MouseEvent | TouchEvent): void {
let handled = false;
if (!(event instanceof MouseEvent)) {
return;
}
if (event.target === this.tag) {
const screenInfo: ScreenInfo = this.player.getScreenInfo() as ScreenInfo;
if (!screenInfo) {
return;
}
const events = this.buildTouchEvent(event, screenInfo, this.storage);
if (events.length > 1) {
console.warn(TAG, 'Too many events', events);
return;
}
const downEventName = 'mousedown';
if (events.length === 1) {
handled = true;
if (event.type === downEventName) {
this.startPosition = events[0].position;
} else {
if (this.startPosition) {
this.endPosition = events[0].position;
} else {
console.warn(TAG, `Received "${event.type}" before "${downEventName}"`);
}
}
if (this.startPosition) {
this.drawPointer(this.startPosition.point);
}
if (this.endPosition) {
this.drawPointer(this.endPosition.point);
if (this.startPosition) {
this.drawLine(this.startPosition.point, this.endPosition.point);
}
}
if (event.type === 'mouseup') {
if (this.startPosition && this.endPosition) {
this.clearCanvas();
if (this.startPosition.point.distance(this.endPosition.point) < 10) {
this.listener.performClick(this.endPosition);
} else {
this.listener.performScroll(this.startPosition, this.endPosition);
}
}
}
}
if (handled) {
if (event.cancelable) {
event.preventDefault();
}
event.stopPropagation();
}
}
if (event.type === 'mouseup') {
this.startPosition = undefined;
this.endPosition = undefined;
}
}
protected onKey(): void {
throw Error(`${TAG} Unsupported`);
}
}

View File

@@ -0,0 +1,236 @@
import { BasePlayer, PlaybackQuality } from './BasePlayer';
import ScreenInfo from '../ScreenInfo';
import VideoSettings from '../VideoSettings';
import { DisplayInfo } from '../DisplayInfo';
type DecodedFrame = {
width: number;
height: number;
frame: any;
};
interface CanvasDecoder {
decode(buffer: Uint8Array, width: number, height: number): void;
}
export abstract class BaseCanvasBasedPlayer extends BasePlayer {
protected framesList: Uint8Array[] = [];
protected decodedFrames: DecodedFrame[] = [];
protected videoStats: PlaybackQuality[] = [];
protected animationFrameId?: number;
protected canvas?: CanvasDecoder;
public static hasWebGLSupport(): boolean {
// For some reason if I use here `this.tag` image on canvas will be flattened
const testCanvas: HTMLCanvasElement = document.createElement('canvas');
const validContextNames = ['webgl', 'experimental-webgl', 'moz-webgl', 'webkit-3d'];
let index = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let gl: any = null;
while (!gl && index++ < validContextNames.length) {
try {
gl = testCanvas.getContext(validContextNames[index]);
} catch (error: any) {
gl = null;
}
}
return !!gl;
}
public static createElement(id?: string): HTMLCanvasElement {
const tag = document.createElement('canvas') as HTMLCanvasElement;
if (typeof id === 'string') {
tag.id = id;
}
tag.className = 'video-layer';
return tag;
}
constructor(
udid: string,
displayInfo?: DisplayInfo,
name = 'Canvas',
storageKeyPrefix = 'DummyCanvas',
protected tag: HTMLCanvasElement = BaseCanvasBasedPlayer.createElement(),
) {
super(udid, displayInfo, name, storageKeyPrefix, tag);
}
protected abstract decode(data: Uint8Array): void;
public abstract getPreferredVideoSetting(): VideoSettings;
protected drawDecoded = (): void => {
if (!this.canvas) {
return;
}
if (this.receivedFirstFrame) {
const data = this.decodedFrames.shift();
if (data) {
const { frame, width, height } = data;
this.canvas.decode(frame, width, height);
}
}
if (this.decodedFrames.length) {
this.animationFrameId = requestAnimationFrame(this.drawDecoded);
} else {
this.animationFrameId = undefined;
}
};
protected onFrameDecoded(width: number, height: number, frame: any): void {
if (!this.receivedFirstFrame) {
// decoded frame with previous video settings
return;
}
let dropped = 0;
const maxStored = this.videoSettings.maxFps / 10; // for 100ms
while (this.decodedFrames.length > maxStored) {
const data = this.decodedFrames.shift();
if (data) {
this.dropFrame(data.frame);
dropped++;
}
}
this.decodedFrames.push({ width, height, frame });
this.videoStats.push({
decodedFrames: 1,
droppedFrames: dropped,
inputBytes: 0,
inputFrames: 0,
timestamp: Date.now(),
});
if (!this.animationFrameId) {
this.animationFrameId = requestAnimationFrame(this.drawDecoded);
}
}
protected dropFrame(_frame: any): void {
// dispose frame if required
}
private shiftFrame(): void {
if (this.getState() !== BasePlayer.STATE.PLAYING) {
return;
}
const first = this.framesList.shift();
if (first) {
this.decode(first);
}
}
protected calculateMomentumStats(): void {
const timestamp = Date.now();
const oneSecondBefore = timestamp - 1000;
while (this.videoStats.length && this.videoStats[0].timestamp < oneSecondBefore) {
this.videoStats.shift();
}
while (this.inputBytes.length && this.inputBytes[0].timestamp < oneSecondBefore) {
this.inputBytes.shift();
}
let decodedFrames = 0;
let droppedFrames = 0;
let inputBytes = 0;
this.videoStats.forEach((item) => {
decodedFrames += item.decodedFrames;
droppedFrames += item.droppedFrames;
});
this.inputBytes.forEach((item) => {
inputBytes += item.bytes;
});
this.momentumQualityStats = {
decodedFrames,
droppedFrames,
inputFrames: this.inputBytes.length,
inputBytes,
timestamp,
};
}
protected resetStats(): void {
super.resetStats();
this.videoStats = [];
}
public getImageDataURL(): string {
return this.tag.toDataURL();
}
protected initCanvas(width: number, height: number): void {
if (this.canvas) {
const parent = this.tag.parentNode;
if (parent) {
const tag = BaseCanvasBasedPlayer.createElement(this.tag.id);
tag.className = this.tag.className;
parent.replaceChild(tag, this.tag);
parent.appendChild(this.touchableCanvas);
this.tag = tag;
}
}
this.tag.onerror = (event: Event | string): void => {
console.error(`[${this.name}]`, event);
};
this.tag.oncontextmenu = (event: MouseEvent): void => {
event.preventDefault();
};
this.tag.width = Math.round(width);
this.tag.height = Math.round(height);
}
public play(): void {
super.play();
if (this.getState() !== BasePlayer.STATE.PLAYING || !this.screenInfo) {
return;
}
if (!this.canvas) {
const { width, height } = this.screenInfo.videoSize;
this.initCanvas(width, height);
this.resetStats();
}
this.shiftFrame();
}
public stop(): void {
super.stop();
this.clearState();
}
public setScreenInfo(screenInfo: ScreenInfo): void {
super.setScreenInfo(screenInfo);
this.clearState();
const { width, height } = screenInfo.videoSize;
this.initCanvas(width, height);
this.framesList = [];
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = undefined;
}
}
public pushFrame(frame: Uint8Array): void {
super.pushFrame(frame);
if (BasePlayer.isIFrame(frame)) {
if (this.videoSettings) {
const { maxFps } = this.videoSettings;
if (this.framesList.length > maxFps / 2) {
const dropped = this.framesList.length;
this.framesList = [];
this.videoStats.push({
decodedFrames: 0,
droppedFrames: dropped,
inputBytes: 0,
inputFrames: 0,
timestamp: Date.now(),
});
}
}
}
this.framesList.push(frame);
this.shiftFrame();
}
protected clearState(): void {
this.framesList = [];
}
}

View File

@@ -0,0 +1,577 @@
import VideoSettings from '../VideoSettings';
import ScreenInfo from '../ScreenInfo';
import Rect from '../Rect';
import Size from '../Size';
import Util from '../Util';
import { TypedEmitter } from '../../common/TypedEmitter';
import { DisplayInfo } from '../DisplayInfo';
interface BitrateStat {
timestamp: number;
bytes: number;
}
interface FramesPerSecondStats {
avgInput: number;
avgDecoded: number;
avgDropped: number;
avgSize: number;
}
export interface PlaybackQuality {
decodedFrames: number;
droppedFrames: number;
inputFrames: number;
inputBytes: number;
timestamp: number;
}
export interface PlayerEvents {
'video-view-resize': Size;
'input-video-resize': ScreenInfo;
'video-settings': VideoSettings;
}
export interface PlayerClass {
playerFullName: string;
playerCodeName: string;
storageKeyPrefix: string;
isSupported(): boolean;
getPreferredVideoSetting(): VideoSettings;
getFitToScreenStatus(deviceName: string, displayInfo?: DisplayInfo): boolean;
loadVideoSettings(deviceName: string, displayInfo?: DisplayInfo): VideoSettings;
saveVideoSettings(
deviceName: string,
videoSettings: VideoSettings,
fitToScreen: boolean,
displayInfo?: DisplayInfo,
): void;
new(udid: string, displayInfo?: DisplayInfo): BasePlayer;
}
export abstract class BasePlayer extends TypedEmitter<PlayerEvents> {
private static readonly STAT_BACKGROUND: string = 'rgba(0, 0, 0, 0.5)';
private static readonly STAT_TEXT_COLOR: string = 'hsl(24, 85%, 50%)';
public static readonly DEFAULT_SHOW_QUALITY_STATS = false;
public static STATE: Record<string, number> = {
PLAYING: 1,
PAUSED: 2,
STOPPED: 3,
};
private static STATS_HEIGHT = 12;
protected screenInfo?: ScreenInfo;
protected videoSettings: VideoSettings;
protected parentElement?: HTMLElement;
protected touchableCanvas: HTMLCanvasElement;
protected inputBytes: BitrateStat[] = [];
protected perSecondQualityStats?: FramesPerSecondStats;
protected momentumQualityStats?: PlaybackQuality;
protected bounds: Size | null = null;
private totalStats: PlaybackQuality = {
decodedFrames: 0,
droppedFrames: 0,
inputFrames: 0,
inputBytes: 0,
timestamp: 0,
};
private totalStatsCounter = 0;
private dirtyStatsWidth = 0;
private state: number = BasePlayer.STATE.STOPPED;
private qualityAnimationId?: number;
private showQualityStats = BasePlayer.DEFAULT_SHOW_QUALITY_STATS;
protected receivedFirstFrame = false;
private statLines: string[] = [];
public readonly supportsScreenshot: boolean = false;
public readonly resizeVideoToBounds: boolean = false;
protected videoHeight = -1;
protected videoWidth = -1;
public static storageKeyPrefix = 'BaseDecoder';
public static playerFullName = 'BasePlayer';
public static playerCodeName = 'baseplayer';
public static preferredVideoSettings: VideoSettings = new VideoSettings({
lockedVideoOrientation: -1,
bitrate: 524288,
maxFps: 24,
iFrameInterval: 5,
bounds: new Size(480, 480),
sendFrameMeta: false,
});
public static isSupported(): boolean {
// Implement the check in a child class
return false;
}
constructor(
public readonly udid: string,
protected displayInfo?: DisplayInfo,
protected name: string = 'BasePlayer',
protected storageKeyPrefix: string = 'Dummy',
protected tag: HTMLElement = document.createElement('div'),
) {
super();
this.touchableCanvas = document.createElement('canvas');
this.touchableCanvas.className = 'touch-layer';
this.touchableCanvas.oncontextmenu = function (event: MouseEvent): void {
event.preventDefault();
};
const preferred = this.getPreferredVideoSetting();
this.videoSettings = BasePlayer.getVideoSettingFromStorage(preferred, this.storageKeyPrefix, udid, displayInfo);
}
protected calculateScreenInfoForBounds(videoWidth: number, videoHeight: number): void {
this.videoWidth = videoWidth;
this.videoHeight = videoHeight;
if (this.resizeVideoToBounds) {
let w = videoWidth;
let h = videoHeight;
if (this.bounds) {
let { w: boundsWidth, h: boundsHeight } = this.bounds;
if (w > boundsWidth || h > boundsHeight) {
let scaledHeight;
let scaledWidth;
if (boundsWidth > w) {
scaledHeight = h;
} else {
scaledHeight = (boundsWidth * h) / w;
}
if (boundsHeight > scaledHeight) {
boundsHeight = scaledHeight;
}
if (boundsHeight == h) {
scaledWidth = w;
} else {
scaledWidth = (boundsHeight * w) / h;
}
if (boundsWidth > scaledWidth) {
boundsWidth = scaledWidth;
}
w = boundsWidth | 0;
h = boundsHeight | 0;
this.tag.style.maxWidth = `${w}px`;
this.tag.style.maxHeight = `${h}px`;
}
}
const realScreen = new ScreenInfo(new Rect(0, 0, videoWidth, videoHeight), new Size(w, h), 0);
this.emit('input-video-resize', realScreen);
this.setScreenInfo(new ScreenInfo(new Rect(0, 0, w, h), new Size(w, h), 0));
}
}
protected static isIFrame(frame: Uint8Array): boolean {
// last 5 bits === 5: Coded slice of an IDR picture
// https://www.ietf.org/rfc/rfc3984.txt
// 1.3. Network Abstraction Layer Unit Types
// https://www.itu.int/rec/T-REC-H.264-201906-I/en
// Table 7-1 NAL unit type codes, syntax element categories, and NAL unit type classes
return frame && frame.length > 4 && (frame[4] & 31) === 5;
}
private static getStorageKey(storageKeyPrefix: string, udid: string): string {
const { innerHeight, innerWidth } = window;
return `${storageKeyPrefix}:${udid}:${innerWidth}x${innerHeight}`;
}
private static getFullStorageKey(storageKeyPrefix: string, udid: string, displayInfo?: DisplayInfo): string {
const { innerHeight, innerWidth } = window;
let base = `${storageKeyPrefix}:${udid}:${innerWidth}x${innerHeight}`;
if (displayInfo) {
const { displayId, size } = displayInfo;
base = `${base}:${displayId}:${size.width}x${size.height}`;
}
return base;
}
public static getFromStorageCompat(prefix: string, udid: string, displayInfo?: DisplayInfo): string | null {
const shortKey = this.getStorageKey(prefix, udid);
const savedInShort = window.localStorage.getItem(shortKey);
if (!displayInfo) {
return savedInShort;
}
const isDefaultDisplay = displayInfo.displayId === DisplayInfo.DEFAULT_DISPLAY;
const fullKey = this.getFullStorageKey(prefix, udid, displayInfo);
const savedInFull = window.localStorage.getItem(fullKey);
if (savedInFull) {
if (savedInShort && isDefaultDisplay) {
window.localStorage.removeItem(shortKey);
}
return savedInFull;
}
if (isDefaultDisplay) {
return savedInShort;
}
return null;
}
public static getFitToScreenFromStorage(
storageKeyPrefix: string,
udid: string,
displayInfo?: DisplayInfo,
): boolean {
if (!window.localStorage) {
return false;
}
let parsedValue = false;
const key = `${this.getFullStorageKey(storageKeyPrefix, udid, displayInfo)}:fit`;
const saved = window.localStorage.getItem(key);
if (!saved) {
return false;
}
try {
parsedValue = JSON.parse(saved);
} catch (error: any) {
console.error(`[${this.name}]`, 'Failed to parse', saved);
}
return parsedValue;
}
public static getVideoSettingFromStorage(
preferred: VideoSettings,
storageKeyPrefix: string,
udid: string,
displayInfo?: DisplayInfo,
): VideoSettings {
if (!window.localStorage) {
return preferred;
}
const saved = this.getFromStorageCompat(storageKeyPrefix, udid, displayInfo);
if (!saved) {
return preferred;
}
const parsed = JSON.parse(saved);
const {
displayId,
crop,
bitrate,
iFrameInterval,
sendFrameMeta,
lockedVideoOrientation,
codecOptions,
encoderName,
} = parsed;
// REMOVE `frameRate`
const maxFps = isNaN(parsed.maxFps) ? parsed.frameRate : parsed.maxFps;
// REMOVE `maxSize`
let bounds: Size | null = null;
if (typeof parsed.bounds !== 'object' || isNaN(parsed.bounds.width) || isNaN(parsed.bounds.height)) {
if (!isNaN(parsed.maxSize)) {
bounds = new Size(parsed.maxSize, parsed.maxSize);
}
} else {
bounds = new Size(parsed.bounds.width, parsed.bounds.height);
}
return new VideoSettings({
displayId: typeof displayId === 'number' ? displayId : 0,
crop: crop ? new Rect(crop.left, crop.top, crop.right, crop.bottom) : preferred.crop,
bitrate: !isNaN(bitrate) ? bitrate : preferred.bitrate,
bounds: bounds !== null ? bounds : preferred.bounds,
maxFps: !isNaN(maxFps) ? maxFps : preferred.maxFps,
iFrameInterval: !isNaN(iFrameInterval) ? iFrameInterval : preferred.iFrameInterval,
sendFrameMeta: typeof sendFrameMeta === 'boolean' ? sendFrameMeta : preferred.sendFrameMeta,
lockedVideoOrientation: !isNaN(lockedVideoOrientation)
? lockedVideoOrientation
: preferred.lockedVideoOrientation,
codecOptions,
encoderName,
});
}
protected static putVideoSettingsToStorage(
storageKeyPrefix: string,
udid: string,
videoSettings: VideoSettings,
fitToScreen: boolean,
displayInfo?: DisplayInfo,
): void {
if (!window.localStorage) {
return;
}
const key = this.getFullStorageKey(storageKeyPrefix, udid, displayInfo);
window.localStorage.setItem(key, JSON.stringify(videoSettings));
const fitKey = `${key}:fit`;
window.localStorage.setItem(fitKey, JSON.stringify(fitToScreen));
}
public abstract getImageDataURL(): string;
public createScreenshot(deviceName: string): void {
const a = document.createElement('a');
a.href = this.getImageDataURL();
a.download = `${deviceName} ${new Date().toLocaleString()}.png`;
a.click();
}
public play(): void {
if (this.needScreenInfoBeforePlay() && !this.screenInfo) {
return;
}
this.state = BasePlayer.STATE.PLAYING;
}
public pause(): void {
this.state = BasePlayer.STATE.PAUSED;
}
public stop(): void {
this.state = BasePlayer.STATE.STOPPED;
}
public getState(): number {
return this.state;
}
public pushFrame(frame: Uint8Array): void {
if (!this.receivedFirstFrame) {
this.receivedFirstFrame = true;
if (typeof this.qualityAnimationId !== 'number') {
this.qualityAnimationId = requestAnimationFrame(this.updateQualityStats);
}
}
this.inputBytes.push({
timestamp: Date.now(),
bytes: frame.byteLength,
});
}
public abstract getPreferredVideoSetting(): VideoSettings;
protected abstract calculateMomentumStats(): void;
public getTouchableElement(): HTMLCanvasElement {
return this.touchableCanvas;
}
public setParent(parent: HTMLElement): void {
this.parentElement = parent;
parent.appendChild(this.tag);
parent.appendChild(this.touchableCanvas);
}
protected needScreenInfoBeforePlay(): boolean {
return true;
}
public getVideoSettings(): VideoSettings {
return this.videoSettings;
}
public setVideoSettings(videoSettings: VideoSettings, fitToScreen: boolean, saveToStorage: boolean): void {
this.videoSettings = videoSettings;
if (saveToStorage) {
BasePlayer.putVideoSettingsToStorage(
this.storageKeyPrefix,
this.udid,
videoSettings,
fitToScreen,
this.displayInfo,
);
}
this.resetStats();
this.emit('video-settings', VideoSettings.copy(videoSettings));
}
public getScreenInfo(): ScreenInfo | undefined {
return this.screenInfo;
}
public setScreenInfo(screenInfo: ScreenInfo): void {
if (this.needScreenInfoBeforePlay()) {
this.pause();
}
this.receivedFirstFrame = false;
this.screenInfo = screenInfo;
const { width, height } = screenInfo.videoSize;
this.touchableCanvas.width = width;
this.touchableCanvas.height = height;
if (this.parentElement) {
this.parentElement.style.height = `${height}px`;
this.parentElement.style.width = `${width}px`;
}
const size = new Size(width, height);
this.emit('video-view-resize', size);
}
public getName(): string {
return this.name;
}
protected resetStats(): void {
this.receivedFirstFrame = false;
this.totalStatsCounter = 0;
this.totalStats = {
droppedFrames: 0,
decodedFrames: 0,
inputFrames: 0,
inputBytes: 0,
timestamp: 0,
};
this.perSecondQualityStats = {
avgDecoded: 0,
avgDropped: 0,
avgInput: 0,
avgSize: 0,
};
}
private updateQualityStats = (): void => {
const now = Date.now();
const oneSecondBefore = now - 1000;
this.calculateMomentumStats();
if (!this.momentumQualityStats) {
return;
}
if (this.totalStats.timestamp < oneSecondBefore) {
this.totalStats = {
timestamp: now,
decodedFrames: this.totalStats.decodedFrames + this.momentumQualityStats.decodedFrames,
droppedFrames: this.totalStats.droppedFrames + this.momentumQualityStats.droppedFrames,
inputFrames: this.totalStats.inputFrames + this.momentumQualityStats.inputFrames,
inputBytes: this.totalStats.inputBytes + this.momentumQualityStats.inputBytes,
};
if (this.totalStatsCounter !== 0) {
this.perSecondQualityStats = {
avgDecoded: this.totalStats.decodedFrames / this.totalStatsCounter,
avgDropped: this.totalStats.droppedFrames / this.totalStatsCounter,
avgInput: this.totalStats.inputFrames / this.totalStatsCounter,
avgSize: this.totalStats.inputBytes / this.totalStatsCounter,
};
}
this.totalStatsCounter++;
}
this.drawStats();
if (this.state !== BasePlayer.STATE.STOPPED) {
this.qualityAnimationId = requestAnimationFrame(this.updateQualityStats);
}
};
private drawStats(): void {
if (!this.showQualityStats) {
return;
}
const ctx = this.touchableCanvas.getContext('2d');
if (!ctx) {
return;
}
const newStats = [];
if (this.perSecondQualityStats && this.momentumQualityStats) {
const { decodedFrames, droppedFrames, inputBytes, inputFrames } = this.momentumQualityStats;
const { avgDecoded, avgDropped, avgSize, avgInput } = this.perSecondQualityStats;
const padInput = inputFrames.toString().padStart(3, ' ');
const padDecoded = decodedFrames.toString().padStart(3, ' ');
const padDropped = droppedFrames.toString().padStart(3, ' ');
const padAvgDecoded = avgDecoded.toFixed(1).padStart(5, ' ');
const padAvgDropped = avgDropped.toFixed(1).padStart(5, ' ');
const padAvgInput = avgInput.toFixed(1).padStart(5, ' ');
const prettyBytes = Util.prettyBytes(inputBytes).padStart(8, ' ');
const prettyAvgBytes = Util.prettyBytes(avgSize).padStart(8, ' ');
newStats.push(`Input bytes: ${prettyBytes} (avg: ${prettyAvgBytes}/s)`);
newStats.push(`Input FPS: ${padInput} (avg: ${padAvgInput})`);
newStats.push(`Dropped FPS: ${padDropped} (avg: ${padAvgDropped})`);
newStats.push(`Decoded FPS: ${padDecoded} (avg: ${padAvgDecoded})`);
} else {
newStats.push(`Not supported`);
}
let changed = this.statLines.length !== newStats.length;
let i = 0;
while (!changed && i++ < newStats.length) {
if (newStats[i] !== this.statLines[i]) {
changed = true;
}
}
if (changed) {
this.statLines = newStats;
this.updateCanvas(false);
}
}
private updateCanvas(onlyClear: boolean): void {
const ctx = this.touchableCanvas.getContext('2d');
if (!ctx) {
return;
}
console.log("123")
const y = this.touchableCanvas.height;
const height = BasePlayer.STATS_HEIGHT;
const lines = this.statLines.length;
const spaces = lines + 1;
const p = height / 2;
const d = p * 2;
const totalHeight = height * lines + p * spaces;
ctx.clearRect(0, y - totalHeight, this.dirtyStatsWidth + d, totalHeight);
this.dirtyStatsWidth = 0;
if (onlyClear) {
return;
}
ctx.save();
ctx.font = `${height}px monospace`;
this.statLines.forEach((text) => {
const textMetrics = ctx.measureText(text);
const dirty = Math.abs(textMetrics.actualBoundingBoxLeft) + Math.abs(textMetrics.actualBoundingBoxRight);
this.dirtyStatsWidth = Math.max(dirty, this.dirtyStatsWidth);
});
ctx.fillStyle = BasePlayer.STAT_BACKGROUND;
ctx.fillRect(0, y - totalHeight, this.dirtyStatsWidth + d, totalHeight);
ctx.fillStyle = BasePlayer.STAT_TEXT_COLOR;
this.statLines.forEach((text, line) => {
ctx.fillText(text, p, y - p - line * (height + p));
});
ctx.restore();
}
public setShowQualityStats(value: boolean): void {
this.showQualityStats = value;
if (!value) {
this.updateCanvas(true);
} else {
this.drawStats();
}
}
public getShowQualityStats(): boolean {
return this.showQualityStats;
}
public setBounds(bounds: Size): void {
this.bounds = Size.copy(bounds);
}
public getDisplayInfo(): DisplayInfo | undefined {
return this.displayInfo;
}
public setDisplayInfo(displayInfo: DisplayInfo): void {
this.displayInfo = displayInfo;
}
public abstract getFitToScreenStatus(): boolean;
public abstract loadVideoSettings(): VideoSettings;
public static loadVideoSettings(udid: string, displayInfo?: DisplayInfo): VideoSettings {
return this.getVideoSettingFromStorage(this.preferredVideoSettings, this.storageKeyPrefix, udid, displayInfo);
}
public static getFitToScreenStatus(udid: string, displayInfo?: DisplayInfo): boolean {
return this.getFitToScreenFromStorage(this.storageKeyPrefix, udid, displayInfo);
}
public static getPreferredVideoSetting(): VideoSettings {
return this.preferredVideoSettings;
}
public static saveVideoSettings(
udid: string,
videoSettings: VideoSettings,
fitToScreen: boolean,
displayInfo?: DisplayInfo,
): void {
this.putVideoSettingsToStorage(this.storageKeyPrefix, udid, videoSettings, fitToScreen, displayInfo);
}
}

View File

@@ -0,0 +1,69 @@
import '../../../vendor/Broadway/avc.wasm.asset';
import { BaseCanvasBasedPlayer } from './BaseCanvasBasedPlayer';
import Size from '../Size';
import YUVCanvas from '../../../vendor/h264-live-player/YUVCanvas';
import YUVWebGLCanvas from '../../../vendor/h264-live-player/YUVWebGLCanvas';
import Avc from '../../../vendor/Broadway/Decoder';
import VideoSettings from '../VideoSettings';
import Canvas from '../../../vendor/h264-live-player/Canvas';
import { DisplayInfo } from '../DisplayInfo';
export class BroadwayPlayer extends BaseCanvasBasedPlayer {
public static readonly storageKeyPrefix = 'BroadwayDecoder';
public static readonly playerFullName = 'Broadway.js';
public static readonly playerCodeName = 'broadway';
public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
lockedVideoOrientation: -1,
bitrate: 524288,
maxFps: 24,
iFrameInterval: 5,
bounds: new Size(480, 480),
sendFrameMeta: false,
});
protected canvas?: Canvas;
private avc?: Avc;
public readonly supportsScreenshot: boolean = true;
public static isSupported(): boolean {
return typeof WebAssembly === 'object' && typeof WebAssembly.instantiate === 'function';
}
constructor(udid: string, displayInfo?: DisplayInfo, name = BroadwayPlayer.playerFullName) {
super(udid, displayInfo, name, BroadwayPlayer.storageKeyPrefix);
}
protected initCanvas(width: number, height: number): void {
super.initCanvas(width, height);
if (BaseCanvasBasedPlayer.hasWebGLSupport()) {
this.canvas = new YUVWebGLCanvas(this.tag, new Size(width, height));
} else {
this.canvas = new YUVCanvas(this.tag, new Size(width, height));
}
if (!this.avc) {
this.avc = new Avc();
}
this.avc.onPictureDecoded = (buffer: Uint8Array, width: number, height: number) => {
this.onFrameDecoded(width, height, buffer);
};
}
protected decode(data: Uint8Array): void {
if (!this.avc) {
return;
}
this.avc.decode(data);
}
public getPreferredVideoSetting(): VideoSettings {
return BroadwayPlayer.preferredVideoSettings;
}
public getFitToScreenStatus(): boolean {
return BroadwayPlayer.getFitToScreenStatus(this.udid, this.displayInfo);
}
public loadVideoSettings(): VideoSettings {
return BroadwayPlayer.loadVideoSettings(this.udid, this.displayInfo);
}
}

View File

@@ -0,0 +1,93 @@
import { BasePlayer } from './BasePlayer';
import VideoSettings from '../VideoSettings';
import { DisplayInfo } from '../DisplayInfo';
export class MjpegPlayer extends BasePlayer {
private static dummyVideoSettings = new VideoSettings();
public static storageKeyPrefix = 'MjpegDecoder';
public static playerFullName = 'Mjpeg Http Player';
public static playerCodeName = 'mjpeghttp';
public static createElement(id?: string): HTMLImageElement {
const tag = document.createElement('img') as HTMLImageElement;
if (typeof id === 'string') {
tag.id = id;
}
tag.className = 'video-layer';
return tag;
}
public static isSupported(): boolean {
// I guess everything supports MJPEG?
return true;
}
public readonly supportsScreenshot = true;
public readonly resizeVideoToBounds: boolean = true;
constructor(
udid: string,
displayInfo?: DisplayInfo,
name = 'MJPEG_Player',
storageKeyPrefix = 'MJPEG',
protected tag: HTMLImageElement = MjpegPlayer.createElement(),
) {
super(udid, displayInfo, name, storageKeyPrefix, tag);
this.tag.onload = () => {
this.checkVideoResize();
};
}
public play(): void {
super.play();
this.tag.setAttribute('src', `${location.protocol}//${location.host}/mjpeg/${this.udid}`);
}
public pause(): void {
super.pause();
this.tag.removeAttribute('src');
}
public stop(): void {
super.stop();
this.tag.removeAttribute('src');
}
protected needScreenInfoBeforePlay(): boolean {
return false;
}
protected calculateMomentumStats(): void {
// not implemented
}
getFitToScreenStatus(): boolean {
return false;
}
getImageDataURL(): string {
const canvas = document.createElement('canvas');
canvas.width = this.videoWidth;
canvas.height = this.videoHeight;
canvas.getContext('2d')?.drawImage(this.tag, 0, 0);
return canvas.toDataURL('image/png');
}
getPreferredVideoSetting(): VideoSettings {
return MjpegPlayer.dummyVideoSettings;
}
loadVideoSettings(): VideoSettings {
return MjpegPlayer.dummyVideoSettings;
}
checkVideoResize = (): void => {
if (!this.tag) {
return;
}
const { height, width } = this.tag;
if (this.videoHeight !== height || this.videoWidth !== width) {
this.calculateScreenInfoForBounds(width, height);
}
};
}

450
src/app/player/MsePlayer.ts Normal file
View File

@@ -0,0 +1,450 @@
import { BasePlayer } from './BasePlayer';
import VideoConverter, { setLogger, mimeType } from 'h264-converter';
import VideoSettings from '../VideoSettings';
import Size from '../Size';
import { DisplayInfo } from '../DisplayInfo';
interface QualityStats {
timestamp: number;
decodedFrames: number;
droppedFrames: number;
}
type Block = {
start: number;
end: number;
};
export class MsePlayer extends BasePlayer {
public static readonly storageKeyPrefix = 'MseDecoder';
public static readonly playerFullName = 'H264 Converter';
public static readonly playerCodeName = 'mse';
public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
lockedVideoOrientation: -1,
bitrate: 7340032,
maxFps: 60,
iFrameInterval: 10,
bounds: new Size(720, 720),
sendFrameMeta: false,
});
private static DEFAULT_FRAMES_PER_FRAGMENT = 1;
private static DEFAULT_FRAMES_PER_SECOND = 60;
public static createElement(id?: string): HTMLVideoElement {
const tag = document.createElement('video') as HTMLVideoElement;
tag.muted = true;
tag.autoplay = true;
tag.setAttribute('muted', 'muted');
tag.setAttribute('autoplay', 'autoplay');
if (typeof id === 'string') {
tag.id = id;
}
tag.className = 'video-layer';
return tag;
}
private converter?: VideoConverter;
private videoStats: QualityStats[] = [];
private noDecodedFramesSince = -1;
private currentTimeNotChangedSince = -1;
private bigBufferSince = -1;
private aheadOfBufferSince = -1;
public fpf: number = MsePlayer.DEFAULT_FRAMES_PER_FRAGMENT;
public readonly supportsScreenshot = true;
private sourceBuffer?: SourceBuffer;
private waitUntilSegmentRemoved = false;
private blocks: Block[] = [];
private frames: Uint8Array[] = [];
private jumpEnd = -1;
private lastTime = -1;
protected canPlay = false;
private seekingSince = -1;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected readonly isSafari = !!(window as unknown as any)['safari'];
protected readonly isChrome = navigator.userAgent.includes('Chrome');
protected readonly isMac = navigator.platform.startsWith('Mac');
private MAX_TIME_TO_RECOVER = 200; // ms
private MAX_BUFFER = this.isSafari ? 2 : this.isChrome && this.isMac ? 0.9 : 0.2;
private MAX_AHEAD = -0.2;
public static isSupported(): boolean {
return typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported(mimeType);
}
constructor(
udid: string,
displayInfo?: DisplayInfo,
name = MsePlayer.playerFullName,
protected tag: HTMLVideoElement = MsePlayer.createElement(),
) {
super(udid, displayInfo, name, MsePlayer.storageKeyPrefix, tag);
tag.oncontextmenu = function (event: MouseEvent): boolean {
event.preventDefault();
return false;
};
tag.addEventListener('error', this.onVideoError);
tag.addEventListener('canplay', this.onVideoCanPlay);
// eslint-disable-next-line @typescript-eslint/no-empty-function
setLogger(() => {}, console.error);
}
onVideoError = (event: Event): void => {
console.error(`[${this.name}]`, event);
};
onVideoCanPlay = (): void => {
this.onCanPlayHandler();
};
private static createConverter(
tag: HTMLVideoElement,
fps: number = MsePlayer.DEFAULT_FRAMES_PER_SECOND,
fpf: number = MsePlayer.DEFAULT_FRAMES_PER_FRAGMENT,
): VideoConverter {
return new VideoConverter(tag, fps, fpf);
}
private getVideoPlaybackQuality(): QualityStats | null {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const video = this.tag as any;
if (typeof video.mozDecodedFrames !== 'undefined') {
return null;
}
const now = Date.now();
if (typeof this.tag.getVideoPlaybackQuality == 'function') {
const temp = this.tag.getVideoPlaybackQuality();
return {
timestamp: now,
decodedFrames: temp.totalVideoFrames,
droppedFrames: temp.droppedVideoFrames,
};
}
// Webkit-specific properties
if (typeof video.webkitDecodedFrameCount !== 'undefined') {
return {
timestamp: now,
decodedFrames: video.webkitDecodedFrameCount,
droppedFrames: video.webkitDroppedFrameCount,
};
}
return null;
}
protected onCanPlayHandler(): void {
this.canPlay = true;
this.tag.play();
this.tag.removeEventListener('canplay', this.onVideoCanPlay);
this.checkVideoResize();
}
protected calculateMomentumStats(): void {
const stat = this.getVideoPlaybackQuality();
if (!stat) {
return;
}
const timestamp = Date.now();
const oneSecondBefore = timestamp - 1000;
this.videoStats.push(stat);
while (this.videoStats.length && this.videoStats[0].timestamp < oneSecondBefore) {
this.videoStats.shift();
}
while (this.inputBytes.length && this.inputBytes[0].timestamp < oneSecondBefore) {
this.inputBytes.shift();
}
let inputBytes = 0;
this.inputBytes.forEach((item) => {
inputBytes += item.bytes;
});
const inputFrames = this.inputBytes.length;
if (this.videoStats.length) {
const oldest = this.videoStats[0];
const decodedFrames = stat.decodedFrames - oldest.decodedFrames;
const droppedFrames = stat.droppedFrames - oldest.droppedFrames;
// const droppedFrames = inputFrames - decodedFrames;
this.momentumQualityStats = {
decodedFrames,
droppedFrames,
inputBytes,
inputFrames,
timestamp,
};
}
}
protected resetStats(): void {
super.resetStats();
this.videoStats = [];
}
public getImageDataURL(): string {
const canvas = document.createElement('canvas');
canvas.width = this.tag.clientWidth;
canvas.height = this.tag.clientHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(this.tag, 0, 0, canvas.width, canvas.height);
}
return canvas.toDataURL();
}
public play(): void {
super.play();
if (this.getState() !== BasePlayer.STATE.PLAYING) {
return;
}
if (!this.converter) {
let fps = MsePlayer.DEFAULT_FRAMES_PER_SECOND;
if (this.videoSettings) {
fps = this.videoSettings.maxFps;
}
this.converter = MsePlayer.createConverter(this.tag, fps, this.fpf);
this.canPlay = false;
this.resetStats();
}
this.converter.play();
}
public pause(): void {
super.pause();
this.stopConverter();
}
public stop(): void {
super.stop();
this.stopConverter();
}
public setVideoSettings(videoSettings: VideoSettings, fitToScreen: boolean, saveToStorage: boolean): void {
if (this.videoSettings && this.videoSettings.maxFps !== videoSettings.maxFps) {
const state = this.getState();
if (this.converter) {
this.stop();
this.converter = MsePlayer.createConverter(this.tag, videoSettings.maxFps, this.fpf);
this.canPlay = false;
}
if (state === BasePlayer.STATE.PLAYING) {
this.play();
}
}
super.setVideoSettings(videoSettings, fitToScreen, saveToStorage);
}
public getPreferredVideoSetting(): VideoSettings {
return MsePlayer.preferredVideoSettings;
}
checkVideoResize = (): void => {
if (!this.tag) {
return;
}
const { videoHeight, videoWidth } = this.tag;
if (this.videoHeight !== videoHeight || this.videoWidth !== videoWidth) {
this.calculateScreenInfoForBounds(videoWidth, videoHeight);
}
};
cleanSourceBuffer = (): void => {
if (!this.sourceBuffer) {
return;
}
if (this.sourceBuffer.updating) {
return;
}
if (this.blocks.length < 10) {
return;
}
try {
this.sourceBuffer.removeEventListener('updateend', this.cleanSourceBuffer);
this.waitUntilSegmentRemoved = false;
const removeStart = this.blocks[0].start;
const removeEnd = this.blocks[4].end;
this.blocks = this.blocks.slice(5);
this.sourceBuffer.remove(removeStart, removeEnd);
let frame = this.frames.shift();
while (frame) {
if (!this.checkForIFrame(frame)) {
this.frames.unshift(frame);
break;
}
frame = this.frames.shift();
}
} catch (error: any) {
console.error(`[${this.name}]`, 'Failed to clean source buffer');
}
};
jumpToEnd = (): void => {
if (!this.sourceBuffer) {
return;
}
if (this.sourceBuffer.updating) {
return;
}
if (!this.tag.buffered.length) {
return;
}
const end = this.tag.buffered.end(this.tag.seekable.length - 1);
console.log(`[${this.name}]`, `Jumping to the end (${this.jumpEnd}, ${end - this.jumpEnd}).`);
this.tag.currentTime = end;
this.jumpEnd = -1;
this.sourceBuffer.removeEventListener('updateend', this.jumpToEnd);
};
public pushFrame(frame: Uint8Array): void {
super.pushFrame(frame);
if (!this.checkForIFrame(frame)) {
this.frames.push(frame);
} else {
this.checkForBadState();
}
}
protected checkForBadState(): void {
// Workaround for stalled playback (`stalled` event is not fired, but the image freezes)
const { currentTime } = this.tag;
const now = Date.now();
// let reasonToJump = '';
let hasReasonToJump = false;
if (this.momentumQualityStats) {
if (this.momentumQualityStats.decodedFrames === 0 && this.momentumQualityStats.inputFrames > 0) {
if (this.noDecodedFramesSince === -1) {
this.noDecodedFramesSince = now;
} else {
const time = now - this.noDecodedFramesSince;
if (time > this.MAX_TIME_TO_RECOVER) {
// reasonToJump = `No frames decoded for ${time} ms.`;
hasReasonToJump = true;
}
}
} else {
this.noDecodedFramesSince = -1;
}
}
if (currentTime === this.lastTime && this.currentTimeNotChangedSince === -1) {
this.currentTimeNotChangedSince = now;
} else {
this.currentTimeNotChangedSince = -1;
}
this.lastTime = currentTime;
if (this.tag.buffered.length) {
const end = this.tag.buffered.end(0);
const buffered = end - currentTime;
if ((end | 0) - currentTime > this.MAX_BUFFER) {
if (this.bigBufferSince === -1) {
this.bigBufferSince = now;
} else {
const time = now - this.bigBufferSince;
if (time > this.MAX_TIME_TO_RECOVER) {
// reasonToJump = `Buffer is bigger then ${this.MAX_BUFFER} (${buffered.toFixed(
// 3,
// )}) for ${time} ms.`;
hasReasonToJump = true;
}
}
} else {
this.bigBufferSince = -1;
}
if (buffered < this.MAX_AHEAD) {
if (this.aheadOfBufferSince === -1) {
this.aheadOfBufferSince = now;
} else {
const time = now - this.aheadOfBufferSince;
if (time > this.MAX_TIME_TO_RECOVER) {
// reasonToJump = `Current time is ahead of end (${buffered}) for ${time} ms.`;
hasReasonToJump = true;
}
}
} else {
this.aheadOfBufferSince = -1;
}
if (this.currentTimeNotChangedSince !== -1) {
const time = now - this.currentTimeNotChangedSince;
if (time > this.MAX_TIME_TO_RECOVER) {
// reasonToJump = `Current time not changed for ${time} ms.`;
hasReasonToJump = true;
}
}
if (!hasReasonToJump) {
return;
}
let waitingForSeekEnd = 0;
if (this.seekingSince !== -1) {
waitingForSeekEnd = now - this.seekingSince;
if (waitingForSeekEnd < 1500) {
return;
}
}
// console.info(`${reasonToJump} Jumping to the end. ${waitingForSeekEnd}`);
const onSeekEnd = () => {
this.seekingSince = -1;
this.tag.removeEventListener('seeked', onSeekEnd);
this.tag.play();
};
if (this.seekingSince !== -1) {
console.warn(`[${this.name}]`, `Attempt to seek while already seeking! ${waitingForSeekEnd}`);
}
this.seekingSince = now;
this.tag.addEventListener('seeked', onSeekEnd);
this.tag.currentTime = this.tag.buffered.end(0);
}
}
protected checkForIFrame(frame: Uint8Array): boolean {
if (!this.converter) {
return false;
}
this.sourceBuffer = this.converter.sourceBuffer;
if (BasePlayer.isIFrame(frame)) {
let start = 0;
let end = 0;
if (this.tag.buffered && this.tag.buffered.length) {
start = this.tag.buffered.start(0);
end = this.tag.buffered.end(0);
}
if (end !== 0 && start < end) {
const block: Block = {
start,
end,
};
this.blocks.push(block);
if (this.blocks.length > 10) {
this.waitUntilSegmentRemoved = true;
this.sourceBuffer.addEventListener('updateend', this.cleanSourceBuffer);
this.converter.appendRawData(frame);
return true;
}
}
if (this.sourceBuffer) {
this.sourceBuffer.onupdateend = this.checkVideoResize;
}
}
if (this.waitUntilSegmentRemoved) {
return false;
}
this.converter.appendRawData(frame);
return true;
}
private stopConverter(): void {
if (this.converter) {
this.converter.appendRawData(new Uint8Array([]));
this.converter.pause();
delete this.converter;
}
}
public getFitToScreenStatus(): boolean {
return MsePlayer.getFitToScreenStatus(this.udid, this.displayInfo);
}
public loadVideoSettings(): VideoSettings {
return MsePlayer.loadVideoSettings(this.udid, this.displayInfo);
}
}

View File

@@ -0,0 +1,37 @@
import { MsePlayer } from './MsePlayer';
import Size from '../Size';
import VideoSettings from '../VideoSettings';
import { DisplayInfo } from '../DisplayInfo';
export class MsePlayerForQVHack extends MsePlayer {
public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
lockedVideoOrientation: -1,
bitrate: 8000000,
maxFps: 30,
iFrameInterval: 10,
bounds: new Size(720, 720),
sendFrameMeta: false,
});
public readonly resizeVideoToBounds: boolean = true;
constructor(
udid: string,
displayInfo?: DisplayInfo,
name = 'MSE_Player_For_QVHack',
tag = MsePlayerForQVHack.createElement(),
) {
super(udid, displayInfo, name, tag);
}
protected needScreenInfoBeforePlay(): boolean {
return false;
}
public getPreferredVideoSetting(): VideoSettings {
return MsePlayerForQVHack.preferredVideoSettings;
}
public setVideoSettings(): void {
return;
}
}

View File

@@ -0,0 +1,126 @@
import { BaseCanvasBasedPlayer } from './BaseCanvasBasedPlayer';
import TinyH264Worker from 'worker-loader!./../../../vendor/tinyh264/H264NALDecoder.worker';
import VideoSettings from '../VideoSettings';
import YUVWebGLCanvas from '../../../vendor/tinyh264/YUVWebGLCanvas';
import YUVCanvas from '../../../vendor/tinyh264/YUVCanvas';
import Size from '../Size';
import { DisplayInfo } from '../DisplayInfo';
type WorkerMessage = {
type: string;
width: number;
height: number;
data: ArrayBuffer;
renderStateId: number;
};
export class TinyH264Player extends BaseCanvasBasedPlayer {
public static readonly storageKeyPrefix = 'Tinyh264Decoder';
public static readonly playerFullName = 'Tiny H264';
public static readonly playerCodeName = 'tinyh264';
private static videoStreamId = 1;
public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
lockedVideoOrientation: -1,
bitrate: 524288,
maxFps: 24,
iFrameInterval: 5,
bounds: new Size(480, 480),
sendFrameMeta: false,
});
private worker?: TinyH264Worker;
private isDecoderReady = false;
protected canvas?: YUVWebGLCanvas | YUVCanvas;
public readonly supportsScreenshot: boolean = true;
public static isSupported(): boolean {
return typeof WebAssembly === 'object' && typeof WebAssembly.instantiate === 'function';
}
constructor(udid: string, displayInfo?: DisplayInfo, name = TinyH264Player.playerFullName) {
super(udid, displayInfo, name, TinyH264Player.storageKeyPrefix);
}
private onWorkerMessage = (event: MessageEvent): void => {
const message: WorkerMessage = event.data;
switch (message.type) {
case 'pictureReady':
const { width, height, data } = message;
this.onFrameDecoded(width, height, new Uint8Array(data));
break;
case 'decoderReady':
this.isDecoderReady = true;
break;
default:
console.error(`[${this.name}]`, Error(`Wrong message type "${message.type}"`));
}
};
private initWorker(): void {
this.worker = new TinyH264Worker();
this.worker.addEventListener('message', this.onWorkerMessage);
}
protected initCanvas(width: number, height: number): void {
super.initCanvas(width, height);
if (BaseCanvasBasedPlayer.hasWebGLSupport()) {
this.canvas = new YUVWebGLCanvas(this.tag);
} else {
this.canvas = new YUVCanvas(this.tag);
}
}
protected decode(data: Uint8Array): void {
if (!this.worker || !this.isDecoderReady) {
return;
}
this.worker.postMessage(
{
type: 'decode',
data: data.buffer,
offset: data.byteOffset,
length: data.byteLength,
renderStateId: TinyH264Player.videoStreamId,
},
[data.buffer],
);
}
public play(): void {
super.play();
if (!this.worker) {
this.initWorker();
}
}
public stop(): void {
super.stop();
if (this.worker) {
this.worker.removeEventListener('message', this.onWorkerMessage);
this.worker.postMessage({ type: 'release', renderStateId: TinyH264Player.videoStreamId });
delete this.worker;
}
}
public getPreferredVideoSetting(): VideoSettings {
return TinyH264Player.preferredVideoSettings;
}
protected clearState(): void {
super.clearState();
if (this.worker) {
this.worker.postMessage({ type: 'release', renderStateId: TinyH264Player.videoStreamId });
TinyH264Player.videoStreamId++;
}
}
public getFitToScreenStatus(): boolean {
return TinyH264Player.getFitToScreenStatus(this.udid, this.displayInfo);
}
public loadVideoSettings(): VideoSettings {
return TinyH264Player.loadVideoSettings(this.udid, this.displayInfo);
}
}

View File

@@ -0,0 +1,223 @@
import { BaseCanvasBasedPlayer } from './BaseCanvasBasedPlayer';
import VideoSettings from '../VideoSettings';
import Size from '../Size';
import { DisplayInfo } from '../DisplayInfo';
import H264Parser from 'h264-converter/dist/h264-parser';
import NALU from 'h264-converter/dist/util/NALU';
import ScreenInfo from '../ScreenInfo';
import Rect from '../Rect';
type ParametersSubSet = {
codec: string;
width: number;
height: number;
};
function toHex(value: number) {
return value.toString(16).padStart(2, '0').toUpperCase();
}
export class WebCodecsPlayer extends BaseCanvasBasedPlayer {
public static readonly storageKeyPrefix = 'WebCodecsPlayer';
public static readonly playerFullName = 'WebCodecs';
public static readonly playerCodeName = 'webcodecs';
public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
lockedVideoOrientation: -1,
bitrate: 524288,
maxFps: 24,
iFrameInterval: 5,
bounds: new Size(480, 480),
sendFrameMeta: false,
});
public static isSupported(): boolean {
if (typeof VideoDecoder !== 'function' || typeof VideoDecoder.isConfigSupported !== 'function') {
return false;
}
// FIXME: verify support
// const result = await VideoDecoder.isConfigSupported();
return true;
}
private static parseSPS(data: Uint8Array): ParametersSubSet {
const {
profile_idc,
constraint_set_flags,
level_idc,
pic_width_in_mbs_minus1,
frame_crop_left_offset,
frame_crop_right_offset,
frame_mbs_only_flag,
pic_height_in_map_units_minus1,
frame_crop_top_offset,
frame_crop_bottom_offset,
sar,
} = H264Parser.parseSPS(data);
const sarScale = sar[0] / sar[1];
const codec = `avc1.${[profile_idc, constraint_set_flags, level_idc].map(toHex).join('')}`;
const width = Math.ceil(
((pic_width_in_mbs_minus1 + 1) * 16 - frame_crop_left_offset * 2 - frame_crop_right_offset * 2) * sarScale,
);
const height =
(2 - frame_mbs_only_flag) * (pic_height_in_map_units_minus1 + 1) * 16 -
(frame_mbs_only_flag ? 2 : 4) * (frame_crop_top_offset + frame_crop_bottom_offset);
return { codec, width, height };
}
public readonly supportsScreenshot = true;
private context: CanvasRenderingContext2D;
private decoder: VideoDecoder;
private buffer: ArrayBuffer | undefined;
private hadIDR = false;
private bufferedSPS = false;
private bufferedPPS = false;
constructor(udid: string, displayInfo?: DisplayInfo, name = WebCodecsPlayer.playerFullName) {
super(udid, displayInfo, name, WebCodecsPlayer.storageKeyPrefix);
const context = this.tag.getContext('2d');
if (!context) {
throw Error('Failed to get 2d context from canvas');
}
this.context = context;
this.decoder = this.createDecoder();
}
private createDecoder(): VideoDecoder {
return new VideoDecoder({
output: (frame) => {
this.onFrameDecoded(0, 0, frame);
},
error: (error: DOMException) => {
console.error(error, `code: ${error.code}`);
this.stop();
},
});
}
protected addToBuffer(data: Uint8Array): Uint8Array {
let array: Uint8Array;
if (this.buffer) {
array = new Uint8Array(this.buffer.byteLength + data.byteLength);
array.set(new Uint8Array(this.buffer));
array.set(new Uint8Array(data), this.buffer.byteLength);
} else {
array = data;
}
this.buffer = array.buffer;
return array;
}
protected scaleCanvas(width: number, height: number): void {
const videoSize = new Size(width, height);
let scale = 1;
if (this.bounds && !this.bounds.intersect(videoSize).equals(videoSize)) {
scale = Math.min(this.bounds.w / width, this.bounds.h / height);
}
const w = width * scale;
const h = height * scale;
const screenInfo = new ScreenInfo(new Rect(0, 0, width, height), new Size(w, h), 0);
this.emit('input-video-resize', screenInfo);
this.setScreenInfo(screenInfo);
// FIXME: canvas was initialized from `.setScreenInfo()` call above, but with wrong values
this.initCanvas(width, height);
if (scale !== 1) {
this.tag.style.transform = `scale(${scale.toFixed(4)})`;
} else {
this.tag.style.transform = ``;
}
this.tag.style.transformOrigin = 'top left';
}
protected decode(data: Uint8Array): void {
if (!data || data.length < 4) {
return;
}
const type = data[4] & 31;
const isIDR = type === NALU.IDR;
if (type === NALU.SPS) {
const { codec, width, height } = WebCodecsPlayer.parseSPS(data.subarray(4));
this.scaleCanvas(width, height);
const config: VideoDecoderConfig = {
codec,
optimizeForLatency: true,
} as VideoDecoderConfig;
this.decoder.configure(config);
this.bufferedSPS = true;
this.addToBuffer(data);
this.hadIDR = false;
return;
} else if (type === NALU.PPS) {
this.bufferedPPS = true;
this.addToBuffer(data);
return;
} else if (type === NALU.SEI) {
// Workaround for lonely SEI from ws-qvh
if (!this.bufferedSPS || !this.bufferedPPS) {
return;
}
}
const array = this.addToBuffer(data);
this.hadIDR = this.hadIDR || isIDR;
if (array && this.decoder.state === 'configured' && this.hadIDR) {
this.buffer = undefined;
this.bufferedPPS = false;
this.bufferedSPS = false;
this.decoder.decode(
new EncodedVideoChunk({
type: 'key',
timestamp: 0,
data: array.buffer,
}),
);
return;
}
}
protected drawDecoded = (): void => {
if (this.receivedFirstFrame) {
const data = this.decodedFrames.shift();
if (data) {
const frame: VideoFrame = data.frame;
this.context.drawImage(frame, 0, 0);
frame.close();
}
}
if (this.decodedFrames.length) {
this.animationFrameId = requestAnimationFrame(this.drawDecoded);
} else {
this.animationFrameId = undefined;
}
};
protected dropFrame(frame: VideoFrame): void {
frame.close();
}
public getFitToScreenStatus(): boolean {
return false;
}
public getPreferredVideoSetting(): VideoSettings {
return WebCodecsPlayer.preferredVideoSettings;
}
public loadVideoSettings(): VideoSettings {
return WebCodecsPlayer.loadVideoSettings(this.udid, this.displayInfo);
}
protected needScreenInfoBeforePlay(): boolean {
return false;
}
public stop(): void {
super.stop();
if (this.decoder.state === 'configured') {
this.decoder.close();
}
}
}

View File

@@ -0,0 +1,19 @@
import { ToolBoxElement } from './ToolBoxElement';
export class ToolBox {
private readonly holder: HTMLElement;
constructor(list: ToolBoxElement<any>[]) {
this.holder = document.createElement('div');
this.holder.classList.add('control-buttons-list', 'control-wrapper');
list.forEach((item) => {
item.getAllElements().forEach((el) => {
this.holder.appendChild(el);
});
});
}
public getHolderElement(): HTMLElement {
return this.holder;
}
}

View File

@@ -0,0 +1,21 @@
import { Optional, ToolBoxElement } from './ToolBoxElement';
import SvgImage, { Icon } from '../ui/SvgImage';
export class ToolBoxButton extends ToolBoxElement<HTMLButtonElement> {
private readonly btn: HTMLButtonElement;
constructor(title: string, icon: Icon, optional?: Optional) {
super(title, optional);
const btn = document.createElement('button');
btn.classList.add('control-button');
btn.title = title;
btn.appendChild(SvgImage.create(icon));
this.btn = btn;
}
public getElement(): HTMLButtonElement {
return this.btn;
}
public getAllElements(): HTMLElement[] {
return [this.btn];
}
}

View File

@@ -0,0 +1,51 @@
import { Optional, ToolBoxElement } from './ToolBoxElement';
import SvgImage, { Icon } from '../ui/SvgImage';
type Icons = {
on?: Icon;
off: Icon;
};
export class ToolBoxCheckbox extends ToolBoxElement<HTMLInputElement> {
private readonly input: HTMLInputElement;
private readonly label: HTMLLabelElement;
private readonly imageOn?: Element;
private readonly imageOff: Element;
constructor(title: string, icons: Icons | Icon, opt_id?: string, optional?: Optional) {
super(title, optional);
const input = document.createElement('input');
input.type = 'checkbox';
const label = document.createElement('label');
label.title = title;
label.classList.add('control-button');
let iconOff: Icon;
let iconOn: Icon | undefined;
if (typeof icons !== 'number') {
iconOff = icons.off;
iconOn = icons.on;
} else {
iconOff = icons;
}
this.imageOff = SvgImage.create(iconOff);
this.imageOff.classList.add('image', 'image-off');
label.appendChild(this.imageOff);
if (iconOn) {
this.imageOn = SvgImage.create(iconOn);
this.imageOn.classList.add('image', 'image-on');
label.appendChild(this.imageOn);
input.classList.add('two-images');
}
const id = opt_id || title.toLowerCase().replace(' ', '_');
label.htmlFor = input.id = `input_${id}`;
this.input = input;
this.label = label;
}
public getElement(): HTMLInputElement {
return this.input;
}
public getAllElements(): HTMLElement[] {
return [this.input, this.label];
}
}

View File

@@ -0,0 +1,53 @@
export type Optional = {
[index: string]: any;
};
// type Listener = <K extends keyof HTMLElementEventMap, T extends HTMLElement>(type: K, el: ToolBoxElement<T>) => any;
export abstract class ToolBoxElement<T extends HTMLElement> {
private listeners: Map<string, Set<<K extends keyof HTMLElementEventMap>(type: K, el: ToolBoxElement<T>) => any>> =
new Map();
protected constructor(public readonly title: string, public readonly optional?: Optional) {}
public abstract getElement(): T;
public abstract getAllElements(): HTMLElement[];
public addEventListener<K extends keyof HTMLElementEventMap>(
type: K,
listener: <K extends keyof HTMLElementEventMap>(type: K, el: ToolBoxElement<T>) => any,
options?: boolean | AddEventListenerOptions,
): void {
const set = this.listeners.get(type) || new Set();
if (!set.size) {
const element = this.getElement();
element.addEventListener(type, this.onEvent, options);
}
set.add(listener);
this.listeners.set(type, set);
}
public removeEventListener<K extends keyof HTMLElementEventMap>(
type: K,
listener: <K extends keyof HTMLElementEventMap>(type: K, el: ToolBoxElement<T>) => any,
): void {
const set = this.listeners.get(type);
if (!set) {
return;
}
set.delete(listener);
if (!set.size) {
this.listeners.delete(type);
const element = this.getElement();
element.removeEventListener(type, this.onEvent);
}
}
onEvent = <K extends keyof HTMLElementEventMap>(ev: HTMLElementEventMap[K]): void => {
const set = this.listeners.get(ev.type);
if (!set) {
return;
}
const type = ev.type as K;
set.forEach((listener) => {
listener(type, this);
});
};
}

23
src/app/ui/HtmlTag.ts Normal file
View File

@@ -0,0 +1,23 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Value = any;
function htmlValue(value: Value): string {
if (value instanceof HTMLTemplateElement) {
return value.innerHTML;
}
if (typeof value === 'undefined') {
return 'undefined';
}
if (value === null) {
return 'null';
}
const e = document.createElement('dummy');
e.innerText = value.toString();
return e.innerHTML;
}
export const html = function html(strings: TemplateStringsArray, ...values: ReadonlyArray<Value>): HTMLTemplateElement {
const template = document.createElement('template') as HTMLTemplateElement;
template.innerHTML = values.reduce((acc, v, idx) => acc + htmlValue(v) + strings[idx + 1], strings[0]).toString();
return template;
};

91
src/app/ui/SvgImage.ts Normal file
View File

@@ -0,0 +1,91 @@
import KeyboardSVG from '../../public/images/skin-light/ic_keyboard_678_48dp.svg';
import MoreSVG from '../../public/images/skin-light/ic_more_horiz_678_48dp.svg';
import CameraSVG from '../../public/images/skin-light/ic_photo_camera_678_48dp.svg';
import PowerSVG from '../../public/images/skin-light/ic_power_settings_new_678_48px.svg';
import VolumeDownSVG from '../../public/images/skin-light/ic_volume_down_678_48px.svg';
import VolumeUpSVG from '../../public/images/skin-light/ic_volume_up_678_48px.svg';
import BackSVG from '../../public/images/skin-light/System_Back_678.svg';
import HomeSVG from '../../public/images/skin-light/System_Home_678.svg';
import OverviewSVG from '../../public/images/skin-light/System_Overview_678.svg';
import CancelSVG from '../../public/images/buttons/cancel.svg';
import OfflineSVG from '../../public/images/buttons/offline.svg';
import RefreshSVG from '../../public/images/buttons/refresh.svg';
import SettingsSVG from '../../public/images/buttons/settings.svg';
import MenuSVG from '../../public/images/buttons/menu.svg';
import ArrowBackSVG from '../../public/images/buttons/arrow_back.svg';
import ToggleOnSVG from '../../public/images/buttons/toggle_on.svg';
import ToggleOffSVG from '../../public/images/buttons/toggle_off.svg';
export enum Icon {
BACK,
HOME,
OVERVIEW,
POWER,
VOLUME_UP,
VOLUME_DOWN,
MORE,
CAMERA,
KEYBOARD,
CANCEL,
OFFLINE,
REFRESH,
SETTINGS,
MENU,
ARROW_BACK,
TOGGLE_ON,
TOGGLE_OFF,
}
export default class SvgImage {
static Icon = Icon;
private static getSvgString(type: Icon): string {
switch (type) {
case Icon.KEYBOARD:
return KeyboardSVG;
case Icon.MORE:
return MoreSVG;
case Icon.CAMERA:
return CameraSVG;
case Icon.POWER:
return PowerSVG;
case Icon.VOLUME_DOWN:
return VolumeDownSVG;
case Icon.VOLUME_UP:
return VolumeUpSVG;
case Icon.BACK:
return BackSVG;
case Icon.HOME:
return HomeSVG;
case Icon.OVERVIEW:
return OverviewSVG;
case Icon.CANCEL:
return CancelSVG;
case Icon.OFFLINE:
return OfflineSVG;
case Icon.REFRESH:
return RefreshSVG;
case Icon.SETTINGS:
return SettingsSVG;
case Icon.MENU:
return MenuSVG;
case Icon.ARROW_BACK:
return ArrowBackSVG;
case Icon.TOGGLE_ON:
return ToggleOnSVG;
case Icon.TOGGLE_OFF:
return ToggleOffSVG;
default:
return '';
}
}
public static create(type: Icon): Element {
const dummy = document.createElement('div');
dummy.innerHTML = this.getSvgString(type);
const svg = dummy.children[0];
const titles = svg.getElementsByTagName('title');
for (let i = 0, l = titles.length; i < l; i++) {
svg.removeChild(titles[i]);
}
return svg;
}
}

15
src/common/Action.ts Normal file
View File

@@ -0,0 +1,15 @@
export enum ACTION {
LIST_HOSTS = 'list-hosts',
APPL_DEVICE_LIST = 'appl-device-list',
GOOG_DEVICE_LIST = 'goog-device-list',
MULTIPLEX = 'multiplex',
SHELL = 'shell',
PROXY_WS = 'proxy-ws',
PROXY_ADB = 'proxy-adb',
DEVTOOLS = 'devtools',
STREAM_SCRCPY = 'stream',
STREAM_WS_QVH = 'stream-qvh',
STREAM_MJPEG = 'stream-mjpeg',
PROXY_WDA = 'proxy-wda',
FILE_LISTING = 'list-files',
}

View File

@@ -0,0 +1,9 @@
export enum ChannelCode {
FSLS = 'FSLS', // File System LiSt
HSTS = 'HSTS', // HoSTS List
SHEL = 'SHEL', // SHELl
GTRC = 'GTRC', // Goog device TRaCer
ATRC = 'ATRC', // Appl device TRaCer
WDAP = 'WDAP', // WebDriverAgent Proxy
QVHS = 'QVHS', // Quicktime_Video_Hack Stream
}

20
src/common/Constants.ts Normal file
View File

@@ -0,0 +1,20 @@
export const SERVER_PACKAGE = 'com.genymobile.scrcpy.Server';
export const SERVER_PORT = 8886;
export const SERVER_VERSION = '1.19-ws6';
export const SERVER_TYPE = 'web';
export const LOG_LEVEL = 'ERROR';
let SCRCPY_LISTENS_ON_ALL_INTERFACES;
/// #if SCRCPY_LISTENS_ON_ALL_INTERFACES
SCRCPY_LISTENS_ON_ALL_INTERFACES = true;
/// #else
SCRCPY_LISTENS_ON_ALL_INTERFACES = false;
/// #endif
const ARGUMENTS = [SERVER_VERSION, SERVER_TYPE, LOG_LEVEL, SERVER_PORT, SCRCPY_LISTENS_ON_ALL_INTERFACES];
export const SERVER_PROCESS_NAME = 'app_process';
export const ARGS_STRING = `/ ${SERVER_PACKAGE} ${ARGUMENTS.join(' ')} 2>&1 > /dev/null`;

View File

@@ -0,0 +1,77 @@
import { WDAMethod } from './WDAMethod';
export class ControlCenterCommand {
public static KILL_SERVER = 'kill_server';
public static START_SERVER = 'start_server';
public static UPDATE_INTERFACES = 'update_interfaces';
public static CONFIGURE_STREAM = 'configure_stream';
public static RUN_WDA = 'run-wda';
public static REQUEST_WDA = 'request-wda';
private id = -1;
private type = '';
private pid = 0;
private udid = '';
private method = '';
private args?: any;
private data?: any;
public static fromJSON(json: string): ControlCenterCommand {
const body = JSON.parse(json);
if (!body) {
throw new Error('Invalid input');
}
const command = new ControlCenterCommand();
const data = (command.data = body.data);
command.id = body.id;
command.type = body.type;
if (typeof data.udid === 'string') {
command.udid = data.udid;
}
switch (body.type) {
case this.KILL_SERVER:
if (typeof data.pid !== 'number' && data.pid <= 0) {
throw new Error('Invalid "pid" value');
}
command.pid = data.pid;
return command;
case this.REQUEST_WDA:
if (typeof data.method !== 'string') {
throw new Error('Invalid "method" value');
}
command.method = data.method;
command.args = data.args;
return command;
case this.START_SERVER:
case this.UPDATE_INTERFACES:
case this.CONFIGURE_STREAM:
case this.RUN_WDA:
return command;
default:
throw new Error(`Unknown command "${body.command}"`);
}
}
public getType(): string {
return this.type;
}
public getPid(): number {
return this.pid;
}
public getUdid(): string {
return this.udid;
}
public getId(): number {
return this.id;
}
public getMethod(): WDAMethod | string {
return this.method;
}
public getData(): any {
return this.data;
}
public getArgs(): any {
return this.args;
}
}

View File

@@ -0,0 +1,6 @@
export enum DeviceState {
DEVICE = 'device',
DISCONNECTED = 'disconnected',
CONNECTED = 'Connected',
}

View File

@@ -0,0 +1,20 @@
import { Message } from '../types/Message';
import { HostItem } from '../types/Configuration';
export enum MessageType {
HOSTS = 'hosts',
ERROR = 'error',
}
export interface MessageHosts extends Message {
type: 'hosts';
data: {
local?: { type: string }[];
remote?: HostItem[];
};
}
export interface MessageError extends Message {
type: 'error';
data: string;
}

147
src/common/ProductType.ts Normal file
View File

@@ -0,0 +1,147 @@
export class ProductType {
// from https://gist.github.com/adamawolf/3048717
private static type: Record<string, string> = {
i386: 'iPhone Simulator',
x86_64: 'iPhone Simulator',
arm64: 'iPhone Simulator',
'iPhone1,1': 'iPhone',
'iPhone1,2': 'iPhone 3G',
'iPhone2,1': 'iPhone 3GS',
'iPhone3,1': 'iPhone 4',
'iPhone3,2': 'iPhone 4 GSM Rev A',
'iPhone3,3': 'iPhone 4 CDMA',
'iPhone4,1': 'iPhone 4S',
'iPhone5,1': 'iPhone 5 (GSM)',
'iPhone5,2': 'iPhone 5 (GSM+CDMA)',
'iPhone5,3': 'iPhone 5C (GSM)',
'iPhone5,4': 'iPhone 5C (Global)',
'iPhone6,1': 'iPhone 5S (GSM)',
'iPhone6,2': 'iPhone 5S (Global)',
'iPhone7,1': 'iPhone 6 Plus',
'iPhone7,2': 'iPhone 6',
'iPhone8,1': 'iPhone 6s',
'iPhone8,2': 'iPhone 6s Plus',
'iPhone8,4': 'iPhone SE (GSM)',
'iPhone9,1': 'iPhone 7',
'iPhone9,2': 'iPhone 7 Plus',
'iPhone9,3': 'iPhone 7',
'iPhone9,4': 'iPhone 7 Plus',
'iPhone10,1': 'iPhone 8',
'iPhone10,2': 'iPhone 8 Plus',
'iPhone10,3': 'iPhone X Global',
'iPhone10,4': 'iPhone 8',
'iPhone10,5': 'iPhone 8 Plus',
'iPhone10,6': 'iPhone X GSM',
'iPhone11,2': 'iPhone XS',
'iPhone11,4': 'iPhone XS Max',
'iPhone11,6': 'iPhone XS Max Global',
'iPhone11,8': 'iPhone XR',
'iPhone12,1': 'iPhone 11',
'iPhone12,3': 'iPhone 11 Pro',
'iPhone12,5': 'iPhone 11 Pro Max',
'iPhone12,8': 'iPhone SE 2nd Gen',
'iPhone13,1': 'iPhone 12 Mini',
'iPhone13,2': 'iPhone 12',
'iPhone13,3': 'iPhone 12 Pro',
'iPhone13,4': 'iPhone 12 Pro Max',
'iPod1,1': '1st Gen iPod',
'iPod2,1': '2nd Gen iPod',
'iPod3,1': '3rd Gen iPod',
'iPod4,1': '4th Gen iPod',
'iPod5,1': '5th Gen iPod',
'iPod7,1': '6th Gen iPod',
'iPod9,1': '7th Gen iPod',
'iPad1,1': 'iPad',
'iPad1,2': 'iPad 3G',
'iPad2,1': '2nd Gen iPad',
'iPad2,2': '2nd Gen iPad GSM',
'iPad2,3': '2nd Gen iPad CDMA',
'iPad2,4': '2nd Gen iPad New Revision',
'iPad3,1': '3rd Gen iPad',
'iPad3,2': '3rd Gen iPad CDMA',
'iPad3,3': '3rd Gen iPad GSM',
'iPad2,5': 'iPad mini',
'iPad2,6': 'iPad mini GSM+LTE',
'iPad2,7': 'iPad mini CDMA+LTE',
'iPad3,4': '4th Gen iPad',
'iPad3,5': '4th Gen iPad GSM+LTE',
'iPad3,6': '4th Gen iPad CDMA+LTE',
'iPad4,1': 'iPad Air (WiFi)',
'iPad4,2': 'iPad Air (GSM+CDMA)',
'iPad4,3': '1st Gen iPad Air (China)',
'iPad4,4': 'iPad mini Retina (WiFi)',
'iPad4,5': 'iPad mini Retina (GSM+CDMA)',
'iPad4,6': 'iPad mini Retina (China)',
'iPad4,7': 'iPad mini 3 (WiFi)',
'iPad4,8': 'iPad mini 3 (GSM+CDMA)',
'iPad4,9': 'iPad Mini 3 (China)',
'iPad5,1': 'iPad mini 4 (WiFi)',
'iPad5,2': '4th Gen iPad mini (WiFi+Cellular)',
'iPad5,3': 'iPad Air 2 (WiFi)',
'iPad5,4': 'iPad Air 2 (Cellular)',
'iPad6,3': 'iPad Pro (9.7 inch, WiFi)',
'iPad6,4': 'iPad Pro (9.7 inch, WiFi+LTE)',
'iPad6,7': 'iPad Pro (12.9 inch, WiFi)',
'iPad6,8': 'iPad Pro (12.9 inch, WiFi+LTE)',
'iPad6,11': 'iPad (2017)',
'iPad6,12': 'iPad (2017)',
'iPad7,1': 'iPad Pro 2nd Gen (WiFi)',
'iPad7,2': 'iPad Pro 2nd Gen (WiFi+Cellular)',
'iPad7,3': 'iPad Pro 10.5-inch',
'iPad7,4': 'iPad Pro 10.5-inch',
'iPad7,5': 'iPad 6th Gen (WiFi)',
'iPad7,6': 'iPad 6th Gen (WiFi+Cellular)',
'iPad7,11': 'iPad 7th Gen 10.2-inch (WiFi)',
'iPad7,12': 'iPad 7th Gen 10.2-inch (WiFi+Cellular)',
'iPad8,1': 'iPad Pro 11 inch 3rd Gen (WiFi)',
'iPad8,2': 'iPad Pro 11 inch 3rd Gen (1TB, WiFi)',
'iPad8,3': 'iPad Pro 11 inch 3rd Gen (WiFi+Cellular)',
'iPad8,4': 'iPad Pro 11 inch 3rd Gen (1TB, WiFi+Cellular)',
'iPad8,5': 'iPad Pro 12.9 inch 3rd Gen (WiFi)',
'iPad8,6': 'iPad Pro 12.9 inch 3rd Gen (1TB, WiFi)',
'iPad8,7': 'iPad Pro 12.9 inch 3rd Gen (WiFi+Cellular)',
'iPad8,8': 'iPad Pro 12.9 inch 3rd Gen (1TB, WiFi+Cellular)',
'iPad8,9': 'iPad Pro 11 inch 4th Gen (WiFi)',
'iPad8,10': 'iPad Pro 11 inch 4th Gen (WiFi+Cellular)',
'iPad8,11': 'iPad Pro 12.9 inch 4th Gen (WiFi)',
'iPad8,12': 'iPad Pro 12.9 inch 4th Gen (WiFi+Cellular)',
'iPad11,1': 'iPad mini 5th Gen (WiFi)',
'iPad11,2': 'iPad mini 5th Gen',
'iPad11,3': 'iPad Air 3rd Gen (WiFi)',
'iPad11,4': 'iPad Air 3rd Gen',
'iPad11,6': 'iPad 8th Gen (WiFi)',
'iPad11,7': 'iPad 8th Gen (WiFi+Cellular)',
'iPad13,1': 'iPad air 4th Gen (WiFi)',
'iPad13,2': 'iPad air 4th Gen (WiFi+Cellular)',
'Watch1,1': 'Apple Watch 38mm case',
'Watch1,2': 'Apple Watch 42mm case',
'Watch2,6': 'Apple Watch Series 1 38mm case',
'Watch2,7': 'Apple Watch Series 1 42mm case',
'Watch2,3': 'Apple Watch Series 2 38mm case',
'Watch2,4': 'Apple Watch Series 2 42mm case',
'Watch3,1': 'Apple Watch Series 3 38mm case (GPS+Cellular)',
'Watch3,2': 'Apple Watch Series 3 42mm case (GPS+Cellular)',
'Watch3,3': 'Apple Watch Series 3 38mm case (GPS)',
'Watch3,4': 'Apple Watch Series 3 42mm case (GPS)',
'Watch4,1': 'Apple Watch Series 4 40mm case (GPS)',
'Watch4,2': 'Apple Watch Series 4 44mm case (GPS)',
'Watch4,3': 'Apple Watch Series 4 40mm case (GPS+Cellular)',
'Watch4,4': 'Apple Watch Series 4 44mm case (GPS+Cellular)',
'Watch5,1': 'Apple Watch Series 5 40mm case (GPS)',
'Watch5,2': 'Apple Watch Series 5 44mm case (GPS)',
'Watch5,3': 'Apple Watch Series 5 40mm case (GPS+Cellular)',
'Watch5,4': 'Apple Watch Series 5 44mm case (GPS+Cellular)',
'Watch5,9': 'Apple Watch SE 40mm case (GPS)',
'Watch5,10': 'Apple Watch SE 44mm case (GPS)',
'Watch5,11': 'Apple Watch SE 40mm case (GPS+Cellular)',
'Watch5,12': 'Apple Watch SE 44mm case (GPS+Cellular)',
'Watch6,1': 'Apple Watch Series 6 40mm case (GPS)',
'Watch6,2': 'Apple Watch Series 6 44mm case (GPS)',
'Watch6,3': 'Apple Watch Series 6 40mm case (GPS+Cellular)',
'Watch6,4': 'Apple Watch Series 6 44mm case (GPS+Cellular)',
};
public static getModel(productType: string): string {
return this.type[productType] || productType;
}
}

View File

@@ -0,0 +1,43 @@
import { EventEmitter } from 'events';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type EventMap = Record<string, any>;
export type EventKey<T extends EventMap> = string & keyof T;
export type EventReceiver<T> = (params: T) => void;
interface Emitter<T extends EventMap> {
on<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void;
off<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void;
emit<K extends EventKey<T>>(eventName: K, params: T[K]): void;
}
export class TypedEmitter<T extends EventMap> implements Emitter<T> {
private emitter = new EventEmitter();
addEventListener<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void {
this.emitter.on(eventName, fn);
}
removeEventListener<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void {
this.emitter.off(eventName, fn);
}
dispatchEvent(event: Event): boolean {
return this.emitter.emit(event.type, event);
}
on<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void {
this.emitter.on(eventName, fn);
}
once<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void {
this.emitter.once(eventName, fn);
}
off<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void {
this.emitter.off(eventName, fn);
}
emit<K extends EventKey<T>>(eventName: K, params: T[K]): boolean {
return this.emitter.emit(eventName, params);
}
}

8
src/common/WDAMethod.ts Normal file
View File

@@ -0,0 +1,8 @@
export enum WDAMethod {
CLICK = 'CLICK',
SCROLL = 'SCROLL',
PRESS_BUTTON = 'PRESS_BUTTON',
GET_SCREEN_WIDTH = 'GET_SCREEN_WIDTH',
APPIUM_SETTINGS = 'APPIUM_SETTINGS',
SEND_KEYS = 'SEND_KEYS',
}

5
src/common/WdaStatus.ts Normal file
View File

@@ -0,0 +1,5 @@
export enum WdaStatus {
STARTING = 'STARTING',
STARTED = 'STARTED',
STOPPED = 'STOPPED',
}

View File

@@ -0,0 +1,15 @@
import { Event2 } from './Event';
export class CloseEvent2 extends Event2 implements CloseEvent {
readonly code: number;
readonly reason: string;
readonly wasClean: boolean;
constructor(type: string, { code, reason }: CloseEventInit = {}) {
super(type);
this.code = code || 0;
this.reason = reason || '';
this.wasClean = this.code === 0;
}
}
export const CloseEventClass = typeof CloseEvent !== 'undefined' ? CloseEvent : CloseEvent2;

Some files were not shown because too many files have changed in this diff Show More