diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 00000000..fecff4a6
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,4 @@
+src/public/**/*.js
+src/vendor/**/*.js
+src/Util.ts
+src/h264-live-player/**/*.ts
diff --git a/.eslintrc b/.eslintrc
new file mode 100644
index 00000000..3276a710
--- /dev/null
+++ b/.eslintrc
@@ -0,0 +1,23 @@
+{
+ "extends": [
+ "plugin:@typescript-eslint/recommended",
+ "plugin:prettier/recommended",
+ "prettier",
+ "prettier/@typescript-eslint"
+ ],
+ "plugins": [
+ "progress",
+ "@typescript-eslint",
+ "prettier"
+ ],
+ "parserOptions": {
+ "ecmaVersion": 2020,
+ "sourceType": "module"
+ },
+ "rules": {
+ "progress/activate": 1,
+ "import/no-absolute-path": "off"
+ },
+ "overrides": [
+ ]
+}
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 00000000..15ed5a6f
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "semi": true,
+ "trailingComma": "all",
+ "singleQuote": true,
+ "printWidth": 120,
+ "tabWidth": 4
+}
diff --git a/README.md b/README.md
index eea23811..41b63436 100644
--- a/README.md
+++ b/README.md
@@ -4,12 +4,11 @@ Web client prototype for [scrcpy](https://github.com/Genymobile/scrcpy).
## Requirements
-You'll need a web browser with these technologies support:
+You'll need a web browser that supports the following technologies:
* WebSockets
* Media Source Extensions and h264 decoding ([NativeDecoder](/src/decoder/NativeDecoder.ts))
-* WebGL ([Broadway.js](/src/decoder/BroadwayDecoder.ts))
-* WebWorkers ([h264bsd](/src/decoder/H264bsdDecoder.ts))
-* WebAssembly (both [Broadway.js](/src/decoder/BroadwayDecoder.ts) and [h264bsd](/src/decoder/H264bsdDecoder.ts))
+* WebWorkers ([h264bsd](/src/decoder/H264bsdDecoder.ts), [tinyh264](/src/decoder/Tinyh264Decoder.ts))
+* WebAssembly ([Broadway.js](/src/decoder/BroadwayDecoder.ts) and [h264bsd](/src/decoder/H264bsdDecoder.ts), [tinyh264](/src/decoder/Tinyh264Decoder.ts))
## Build and Start
@@ -21,18 +20,34 @@ npm start
```
## Supported features
-* Screen casting
+
+### Screen casting
+The modified [version](https://github.com/NetrisTV/scrcpy/tree/feature/websocket-v1.15.x) of [Genymobile/scrcpy](https://github.com/Genymobile/scrcpy) used to stream H264 video, which then decoded by one of included decoders.
+
+### Remote control
* Touch events (including multi-touch)
-* Input events
-* Clipboard events
+* Multi-touch emulation: CTRL to start with center at the center of the screen, SHIFT + CTRL to start with center at the current point
+* Capturing keyboard events
+* Injecting text (ASCII only)
+* Copy to/from device clipboard
* Device "rotation"
-* Video settings changing
+
+### File push
+Drag & drop an APK file to push it to the `/data/local/tmp` directory. You can install it manually from the included [xterm.js](https://github.com/xtermjs/xterm.js) terminal emulator.
## Known issues
-* The server on the Android Emulator listens on internal interface and not available from the outside (as workaround you can do `adb forward tcp:8886 tcp:8886` and use `127.0.0.1` instead of emulator IP address)
+* The server on the Android Emulator listens on the internal interface and not available from the outside (as workaround you can do `adb forward tcp:8886 tcp:8886` and use `127.0.0.1` instead of emulator IP address)
* H264bsdDecoder may fail to start
-* Version `0.2.0` is incompatible with previous. Reboot device or manually kill `app_process`.
+* Version `0.3.0` is incompatible with previous. Reboot device or manually kill `app_process`.
+
+## Security warning
+Be advised and keep in mind:
+* There is no encryption between browser and node.js server (plain HTTP).
+* There is no encryption between browser and WebSocket server (plain WS).
+* There is no authorization on any level.
+* The modified version of scrcpy with integrated WebSocket server is listening for connections on all network interfaces.
+* The modified version of scrcpy will keep running after the last client disconnected.
## Related projects
* [Genymobile/scrcpy](https://github.com/Genymobile/scrcpy)
@@ -43,9 +58,10 @@ npm start
* [openstf/adbkit](https://github.com/openstf/adbkit)
* [openstf/adbkit-logcat](https://github.com/openstf/adbkit-logcat)
* [xtermjs/xterm.js](https://github.com/xtermjs/xterm.js)
+* [udevbe/tinyh264](https://github.com/udevbe/tinyh264)
## scrcpy websocket fork
-Currently support of WebSocket protocol added to v1.13 of scrcpy
-* [Prebuilt package](https://github.com/NetrisTV/scrcpy/releases/download/v1.13-ws/scrcpy-server.jar)
-* [Source code](https://github.com/NetrisTV/scrcpy/tree/feature/websocket-v1.13)
+Currently, support of WebSocket protocol added to v1.15.1 of scrcpy
+* [Prebuilt package](/src/public/scrcpy-server.jar)
+* [Source code](https://github.com/NetrisTV/scrcpy/tree/feature/websocket-v1.15.x)
diff --git a/images/multitouch/SOURCE b/images/multitouch/SOURCE
new file mode 100644
index 00000000..8a9bdf5e
--- /dev/null
+++ b/images/multitouch/SOURCE
@@ -0,0 +1 @@
+https://android.googlesource.com/platform/external/qemu/+/emu-2.0-release/android/skin/qt/images/multitouch/
diff --git a/images/multitouch/center_point.png b/images/multitouch/center_point.png
new file mode 100644
index 00000000..bbbae582
Binary files /dev/null and b/images/multitouch/center_point.png differ
diff --git a/images/multitouch/center_point_2x.png b/images/multitouch/center_point_2x.png
new file mode 100644
index 00000000..22bf59e4
Binary files /dev/null and b/images/multitouch/center_point_2x.png differ
diff --git a/images/multitouch/touch_point.png b/images/multitouch/touch_point.png
new file mode 100644
index 00000000..33880891
Binary files /dev/null and b/images/multitouch/touch_point.png differ
diff --git a/images/multitouch/touch_point_2x.png b/images/multitouch/touch_point_2x.png
new file mode 100644
index 00000000..c6d4c54f
Binary files /dev/null and b/images/multitouch/touch_point_2x.png differ
diff --git a/images/skin-light/SOURCE b/images/skin-light/SOURCE
new file mode 100644
index 00000000..9f97be4e
--- /dev/null
+++ b/images/skin-light/SOURCE
@@ -0,0 +1 @@
+https://android.googlesource.com/platform/external/qemu/+/emu-2.0-release/android/skin/qt/images/light/
diff --git a/images/skin-light/System_Back_678.svg b/images/skin-light/System_Back_678.svg
new file mode 100644
index 00000000..4637e6b2
--- /dev/null
+++ b/images/skin-light/System_Back_678.svg
@@ -0,0 +1,21 @@
+
+
diff --git a/images/skin-light/System_Home_678.svg b/images/skin-light/System_Home_678.svg
new file mode 100644
index 00000000..5d6d0a22
--- /dev/null
+++ b/images/skin-light/System_Home_678.svg
@@ -0,0 +1,15 @@
+
+
diff --git a/images/skin-light/System_Overview_678.svg b/images/skin-light/System_Overview_678.svg
new file mode 100644
index 00000000..2b19b984
--- /dev/null
+++ b/images/skin-light/System_Overview_678.svg
@@ -0,0 +1,15 @@
+
+
diff --git a/images/skin-light/ic_keyboard_678_48dp.svg b/images/skin-light/ic_keyboard_678_48dp.svg
new file mode 100644
index 00000000..ae09f969
--- /dev/null
+++ b/images/skin-light/ic_keyboard_678_48dp.svg
@@ -0,0 +1,4 @@
+
diff --git a/images/skin-light/ic_more_horiz_678_48dp.svg b/images/skin-light/ic_more_horiz_678_48dp.svg
new file mode 100644
index 00000000..22cf9fee
--- /dev/null
+++ b/images/skin-light/ic_more_horiz_678_48dp.svg
@@ -0,0 +1,4 @@
+
diff --git a/images/skin-light/ic_photo_camera_678_48dp.svg b/images/skin-light/ic_photo_camera_678_48dp.svg
new file mode 100644
index 00000000..e58a0f36
--- /dev/null
+++ b/images/skin-light/ic_photo_camera_678_48dp.svg
@@ -0,0 +1,5 @@
+
diff --git a/images/skin-light/ic_power_settings_new_678_48px.svg b/images/skin-light/ic_power_settings_new_678_48px.svg
new file mode 100644
index 00000000..c0e4ade2
--- /dev/null
+++ b/images/skin-light/ic_power_settings_new_678_48px.svg
@@ -0,0 +1,4 @@
+
diff --git a/images/skin-light/ic_volume_down_678_48px.svg b/images/skin-light/ic_volume_down_678_48px.svg
new file mode 100644
index 00000000..f4e6282d
--- /dev/null
+++ b/images/skin-light/ic_volume_down_678_48px.svg
@@ -0,0 +1,4 @@
+
diff --git a/images/skin-light/ic_volume_up_678_48px.svg b/images/skin-light/ic_volume_up_678_48px.svg
new file mode 100644
index 00000000..dcfaa3a3
--- /dev/null
+++ b/images/skin-light/ic_volume_up_678_48px.svg
@@ -0,0 +1,4 @@
+
diff --git a/package.json b/package.json
index aca682ea..265a3368 100644
--- a/package.json
+++ b/package.json
@@ -1,25 +1,27 @@
{
"name": "ws-scrcpy",
- "version": "0.2.0",
+ "version": "0.3.0",
"description": "ws client for scrcpy",
"scripts": {
- "build": "npm run compile && npm run copy:vendor && npx browserify build/index.js -o build/bundle.js",
+ "build": "npm run compile && npm run copy:vendor && npm run build:webpack",
"clean": "npm run clean:build && npm run clean:dist",
"clean:build": "npx rimraf build",
"clean:dist": "npx rimraf dist",
+ "build:webpack": "webpack --config webpack.config.js",
"compile": "npx tsc -p .",
"copy:bundle": "node -e \"fs.copyFile('build/bundle.js','dist/public/bundle.js',function(e){if(e)process.exit(1);process.exit(0);})\"",
"copy:public": "node -e \"require('recursive-copy')('src/public','dist/public', {overwrite: true, debug: true} ,function(e){if(e)process.exit(1);process.exit(0);})\"",
"copy:server": "node -e \"require('recursive-copy')('build/server','dist/server', {overwrite: true, debug: true} ,function(e){if(e)process.exit(1);process.exit(0);})\"",
"copy:vendor": "node -e \"require('recursive-copy')('src/vendor','build', {overwrite: true, debug: true} ,function(e){if(e)process.exit(1);process.exit(0);})\"",
"copy:xterm.css": "node -e \"require('recursive-copy')('node_modules/xterm/css','dist/public', {overwrite: true, debug: true} ,function(e){if(e)process.exit(1);process.exit(0);})\"",
- "dist": "npm run build && npm run mkdirs && npm run copy:package.json && npm run copy:public && npm run copy:xterm.css && npm run copy:bundle && npm run copy:server",
+ "dist": "npm run build && npm run mkdirs && npm run copy:package.json && npm run copy:public && npm run copy:xterm.css && npm run copy:server",
"copy:package.json": "node -e \"const j=require('./package.json');const {name,version,description,author,license,dependencies,scripts}=j; const p={name, version, description,author,license,dependencies}; p.scripts={start: scripts['dist:start']};fs.writeFileSync('./dist/package.json', JSON.stringify(p, null, ' '))\"",
"mkdirs": "npx mkdirp dist/public",
"start": "npm run dist && npm run start:dist",
"start:dist": "cd dist && npm start",
"dist:start": "cd server && node index.js",
- "lint": "npx tslint --project .",
+ "lint": "eslint src/ --ext .ts",
+ "format": "eslint src/ --fix --ext .ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Sergey Volkov ",
@@ -34,16 +36,27 @@
"devDependencies": {
"@types/node": "^12.0.2",
"@types/ws": "^6.0.4",
- "browserify": "^16.2.3",
+ "@typescript-eslint/eslint-plugin": "^3.8.0",
+ "@typescript-eslint/parser": "^3.8.0",
+ "babel-runtime": "^6.26.0",
"buffer": "^5.2.1",
+ "eslint": "^7.6.0",
+ "eslint-config-prettier": "^6.11.0",
+ "eslint-plugin-prettier": "^3.1.4",
+ "eslint-plugin-progress": "0.0.1",
+ "file-loader": "^6.0.0",
"h264-converter": "^0.1.1",
"mkdirp": "^0.5.1",
+ "prettier": "^2.0.5",
"recursive-copy": "^2.0.10",
"rimraf": "^3.0.0",
+ "svg-inline-loader": "^0.8.2",
"sylvester.js": "^0.1.1",
- "tslint": "^5.16.0",
- "tslint-react": "^4.0.0",
+ "tinyh264": "0.0.5",
"typescript": "^3.4.5",
+ "webpack": "^4.43.0",
+ "webpack-cli": "^3.3.12",
+ "worker-loader": "^2.0.0",
"xterm": "^4.5.0",
"xterm-addon-attach": "^0.5.0",
"xterm-addon-fit": "^0.3.0"
diff --git a/src/DeviceConnection.ts b/src/DeviceConnection.ts
index 5cd30eb6..a19fab00 100644
--- a/src/DeviceConnection.ts
+++ b/src/DeviceConnection.ts
@@ -1,20 +1,29 @@
import VideoSettings from './VideoSettings';
import ControlEvent from './controlEvent/ControlEvent';
-import MotionEvent from './MotionEvent';
-import Position from './Position';
import Size from './Size';
-import Point from './Point';
import Decoder from './decoder/Decoder';
import Util from './Util';
import TouchControlEvent from './controlEvent/TouchControlEvent';
import CommandControlEvent from './controlEvent/CommandControlEvent';
import ScreenInfo from './ScreenInfo';
import DeviceMessage from './DeviceMessage';
+import TouchHandler from './TouchHandler';
+import { KeyEventListener, KeyInputHandler } from './KeyInputHandler';
+import KeyCodeControlEvent from './controlEvent/KeyCodeControlEvent';
+import FilePushHandler from './FilePushHandler';
+import DragAndPushLogger from './DragAndPushLogger';
-const CURSOR_RADIUS = 10;
const DEVICE_NAME_FIELD_LENGTH = 64;
const MAGIC = 'scrcpy';
-const DEVICE_INFO_LENGTH = MAGIC.length + DEVICE_NAME_FIELD_LENGTH + ScreenInfo.BUFFER_LENGTH + VideoSettings.BUFFER_LENGTH;
+const CLIENT_ID_LENGTH = 2;
+const CLIENTS_COUNT_LENGTH = 2;
+const DEVICE_INFO_LENGTH =
+ MAGIC.length +
+ DEVICE_NAME_FIELD_LENGTH +
+ ScreenInfo.BUFFER_LENGTH +
+ VideoSettings.BUFFER_LENGTH +
+ CLIENT_ID_LENGTH +
+ CLIENTS_COUNT_LENGTH;
export interface ErrorListener {
OnError(this: ErrorListener, ev: Event | string): void;
@@ -24,80 +33,44 @@ export interface DeviceMessageListener {
OnDeviceMessage(this: DeviceMessageListener, ev: DeviceMessage): void;
}
-interface Touch {
- action: number;
- position: Position;
- buttons: number;
-}
-
-interface TouchOnClient {
- client: {
- width: number;
- height: number;
- };
- touch: Touch;
-}
-
-interface CommonTouchAndMouse {
- clientX: number;
- clientY: number;
- type: string;
- target: EventTarget | null;
- button: number;
-}
-
-export class DeviceConnection {
- private static BUTTONS_MAP: Record = {
- 0: 17, // ?? BUTTON_PRIMARY
- 1: MotionEvent.BUTTON_TERTIARY,
- 2: 26 // ?? BUTTON_SECONDARY
- };
- private static EVENT_ACTION_MAP: Record = {
- 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
- };
- private static multiTouchActive: boolean = false;
- private static multiTouchCenter?: Point;
- private static multiTouchShift: boolean = false;
- private static dirtyPlace: Point[] = [];
- private static hasListeners: boolean = false;
+export class DeviceConnection implements KeyEventListener {
+ private static hasTouchListeners = false;
private static instances: Record = {};
public readonly ws: WebSocket;
private events: ControlEvent[] = [];
private decoders: Set = new Set();
+ private filePushHandlers: Map = new Map();
private errorListener?: ErrorListener;
- private deviceMessageListener?: DeviceMessageListener;
- private name: string = '';
- private static idToPointerMap: Map = new Map();
- private static pointerToIdMap: Map = new Map();
+ private deviceMessageListeners: Set = new Set();
+ private name = '';
+ private requestedVideoSettings?: VideoSettings;
+ private clientId = -1;
+ private clientsCount = -1;
- constructor(readonly url: string) {
- this.url = url;
+ constructor(readonly udid: string, readonly url: string) {
this.ws = new WebSocket(url);
this.ws.binaryType = 'arraybuffer';
this.init();
}
- public static getInstance(url: string): DeviceConnection {
- if (!this.instances[url]) {
- this.instances[url] = new DeviceConnection(url);
+ public static getInstance(udid: string, url: string): DeviceConnection {
+ const key = `${udid}::${url}`;
+ if (!this.instances[key]) {
+ this.instances[key] = new DeviceConnection(udid, url);
}
- return this.instances[url];
+ return this.instances[key];
}
- private static setListeners(): void {
- if (!this.hasListeners) {
+ private static setTouchListeners(): void {
+ if (!this.hasTouchListeners) {
+ TouchHandler.init();
let down = 0;
const supportsPassive = Util.supportsPassive();
const onMouseEvent = (e: MouseEvent | TouchEvent) => {
- Object.values(this.instances).forEach((connection: DeviceConnection) => {
- if (connection.haveConnection()) {
- connection.decoders.forEach(decoder => {
+ for (const key in this.instances) {
+ const connection: DeviceConnection = this.instances[key];
+ if (connection.hasConnection()) {
+ connection.decoders.forEach((decoder) => {
const tag = decoder.getTouchableElement();
if (e.target === tag) {
const screenInfo: ScreenInfo = decoder.getScreenInfo() as ScreenInfo;
@@ -108,12 +81,12 @@ export class DeviceConnection {
let condition = true;
if (e instanceof MouseEvent) {
condition = down > 0;
- events = DeviceConnection.buildTouchEvent(e, screenInfo);
+ events = TouchHandler.buildTouchEvent(e, screenInfo);
} else if (e instanceof TouchEvent) {
- events = DeviceConnection.formatTouchEvent(e, screenInfo, tag);
+ events = TouchHandler.formatTouchEvent(e, screenInfo, tag);
}
if (events && events.length && condition) {
- events.forEach(event => {
+ events.forEach((event) => {
connection.sendEvent(event);
});
}
@@ -124,334 +97,172 @@ export class DeviceConnection {
}
});
}
- });
+ }
};
const options = supportsPassive ? { passive: false } : false;
- document.body.addEventListener('touchstart', (e: TouchEvent): void => {
- onMouseEvent(e);
- }, options);
- document.body.addEventListener('touchend', (e: TouchEvent): void => {
- onMouseEvent(e);
- }, options);
- document.body.addEventListener('touchmove', (e: TouchEvent): void => {
- onMouseEvent(e);
- }, options);
- document.body.addEventListener('touchcancel', (e: TouchEvent): void => {
- onMouseEvent(e);
- }, options);
- document.body.onmousedown = function(e: MouseEvent): void {
+ document.body.addEventListener(
+ 'touchstart',
+ (e: TouchEvent): void => {
+ onMouseEvent(e);
+ },
+ options,
+ );
+ document.body.addEventListener(
+ 'touchend',
+ (e: TouchEvent): void => {
+ onMouseEvent(e);
+ },
+ options,
+ );
+ document.body.addEventListener(
+ 'touchmove',
+ (e: TouchEvent): void => {
+ onMouseEvent(e);
+ },
+ options,
+ );
+ document.body.addEventListener(
+ 'touchcancel',
+ (e: TouchEvent): void => {
+ onMouseEvent(e);
+ },
+ options,
+ );
+ document.body.onmousedown = function (e: MouseEvent): void {
down++;
onMouseEvent(e);
};
- document.body.onmouseup = function(e: MouseEvent): void {
+ document.body.onmouseup = function (e: MouseEvent): void {
onMouseEvent(e);
down--;
};
- document.body.onmousemove = function(e: MouseEvent): void {
+ document.body.onmousemove = function (e: MouseEvent): void {
onMouseEvent(e);
};
- this.hasListeners = true;
- }
- }
-
- private static formatTouchEvent(e: TouchEvent, screenInfo: ScreenInfo, tag: HTMLElement): TouchControlEvent[] | null {
- const events: TouchControlEvent[] = [];
- const touches = e.changedTouches;
- if (touches && touches.length) {
- for (let i = 0, l = touches.length; i < l; i++) {
- const touch = touches[i];
- const pointerId = DeviceConnection.getPointerId(e.type, touch.identifier);
- if (touch.target !== tag) {
- continue;
- }
- const item: CommonTouchAndMouse = {
- clientX: touch.clientX,
- clientY: touch.clientY,
- type: e.type,
- button: 0,
- target: e.target
- }
- const event = this.calculateCoordinates(item, screenInfo);
- if (event) {
- const { action, buttons, position } = event.touch;
- const pressure = touch.force * 255;
- events.push(new TouchControlEvent(action, pointerId, position, pressure, buttons));
- } else {
- console.error(`Failed to format touch`, touch);
- }
- }
- } else {
- console.error('No "touches"', e);
- }
- if (events.length) {
- return events;
- }
- return null;
- }
- private static getPointerId(type: string, identifier: number): number {
- // I'm not sure that we can directly use touch identifier as pointerId
- let pointerId: number;
- if (this.idToPointerMap.has(identifier)) {
- pointerId = this.idToPointerMap.get(identifier) as number;
- if (type === 'touchend' || type === 'touchcancel') {
- this.idToPointerMap.delete(identifier);
- this.pointerToIdMap.delete(pointerId);
- }
- return pointerId;
- } else {
- pointerId = 0;
- while (this.idToPointerMap.has(pointerId)) {
- pointerId++;
- }
- this.idToPointerMap.set(identifier, pointerId);
- this.pointerToIdMap.set(pointerId, identifier);
- return pointerId;
- }
- }
- private static buildTouchEvent(e: MouseEvent, screenInfo: ScreenInfo): TouchControlEvent[] | null {
- const touches = this.getTouch(e, screenInfo);
- if (!touches) {
- return null;
- }
- const target = e.target as HTMLCanvasElement;
- if (this.multiTouchActive) {
- const ctx = target.getContext('2d');
- if (ctx) {
- this.clearCanvas(target);
- touches.forEach(touch => {
- const { point } = touch.position;
- this.drawCircle(ctx, point);
- if (this.multiTouchCenter) {
- this.drawLine(ctx, point, this.multiTouchCenter);
- }
- });
- if (this.multiTouchCenter) {
- this.drawCircle(ctx, this.multiTouchCenter, 5);
- }
- }
- }
- return touches.map((touch: Touch, pointerId: number) => {
- const { action, buttons, position } = touch;
- return new TouchControlEvent(action, pointerId, position, 255, buttons);
- });
- }
-
- private static calculateCoordinates(e: CommonTouchAndMouse, screenInfo: ScreenInfo): TouchOnClient | null {
- const action = this.EVENT_ACTION_MAP[e.type];
- if (typeof action === 'undefined' || !screenInfo) {
- return null;
- }
- const htmlTag = document.getElementsByTagName('html')[0] as HTMLElement;
- const {width, height} = screenInfo.videoSize;
- const target: HTMLElement = e.target as HTMLElement;
- const {scrollTop, scrollLeft} = htmlTag;
- let {clientWidth, clientHeight} = target;
- let touchX = (e.clientX - target.offsetLeft) + scrollLeft;
- let touchY = (e.clientY - target.offsetTop) + scrollTop;
- 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) {
- return null;
- }
- 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) {
- return null;
- }
- 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);
- const buttons = this.BUTTONS_MAP[e.button];
- return {
- client: {
- width: clientWidth,
- height: clientHeight
- },
- touch: {
- action,
- position,
- buttons
- }
- };
- }
-
- private static getTouch(e: MouseEvent, screenInfo: ScreenInfo): Touch[] | null {
- const touchOnClient = this.calculateCoordinates(e, screenInfo);
- if (!touchOnClient) {
- return null;
- }
- const { client, touch } = touchOnClient;
- const result: Touch[] = [touch];
- if (!e.ctrlKey) {
- this.multiTouchActive = false;
- this.multiTouchCenter = undefined;
- this.multiTouchShift = false;
- this.clearCanvas(e.target as HTMLCanvasElement);
- return result;
- }
- const { position, action, buttons } = touch;
- const { point, screenSize } = position;
- const { width, height } = screenSize;
- const { x, y } = point;
- if (!this.multiTouchActive) {
- if (e.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;
- if (this.multiTouchShift && this.multiTouchCenter) {
- const oppoX = 2 * this.multiTouchCenter.x - x;
- const oppoY = 2 * this.multiTouchCenter.y - y;
- if (oppoX <= width && oppoX >= 0 && oppoY <= height && oppoY >= 0) {
- opposite = new Point(oppoX, oppoY);
- }
- } else {
- opposite = new Point(client.width - x, client.height - y);
- }
- if (opposite) {
- result.push({
- action,
- buttons,
- position: new Position(opposite, screenSize)
- });
- }
- return result;
- }
-
- private static drawCircle(ctx: CanvasRenderingContext2D, point: Point, radius: number = CURSOR_RADIUS): void {
- ctx.beginPath();
- ctx.arc(point.x, point.y, radius, 0, Math.PI * 2, true);
- ctx.stroke();
- const l = ctx.lineWidth;
- const topLeft = new Point(point.x - radius - l, point.y - radius - l);
- const bottomRight = new Point(point.x + radius + l, point.y + radius + l);
- this.updateDirty(topLeft, bottomRight);
- }
-
- private static drawLine(ctx: CanvasRenderingContext2D, point1: Point, point2: Point): void {
- ctx.beginPath();
- ctx.moveTo(point1.x, point1.y);
- ctx.lineTo(point2.x, point2.y);
- ctx.stroke();
- }
-
- private static 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);
- }
-
- private static clearCanvas(target: HTMLCanvasElement): void {
- const {clientWidth, clientHeight} = target;
- const ctx = target.getContext('2d');
- if (ctx && this.dirtyPlace.length) {
- const topLeft = this.dirtyPlace[0];
- const bottomRight = this.dirtyPlace[1];
- 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);
+ this.hasTouchListeners = true;
}
}
public addDecoder(decoder: Decoder): void {
- let min: VideoSettings = decoder.getPreferredVideoSetting();
- const { maxSize } = min;
+ let videoSettings: VideoSettings = decoder.getVideoSettings();
+ const { maxSize } = videoSettings;
let playing = false;
- this.decoders.forEach(d => {
+ this.decoders.forEach((d) => {
const state = d.getState();
if (state === Decoder.STATE.PLAYING || state === Decoder.STATE.PAUSED) {
playing = true;
}
const info = d.getScreenInfo() as ScreenInfo;
const videoSize = info.videoSize;
- const {crop, bitrate, frameRate, iFrameInterval, sendFrameMeta, lockedVideoOrientation} =
- d.getVideoSettings() as VideoSettings;
+ const {
+ crop,
+ bitrate,
+ frameRate,
+ iFrameInterval,
+ sendFrameMeta,
+ lockedVideoOrientation,
+ } = d.getVideoSettings() as VideoSettings;
if (videoSize.width < maxSize && videoSize.height < maxSize) {
- min = new VideoSettings({
+ videoSettings = new VideoSettings({
maxSize: Math.max(videoSize.width, videoSize.height),
crop,
bitrate,
frameRate,
iFrameInterval,
sendFrameMeta,
- lockedVideoOrientation
+ lockedVideoOrientation,
});
}
});
if (playing) {
// Will trigger encoding restart
- this.sendEvent(CommandControlEvent.createSetVideoSettingsCommand(min));
+ this.sendNewVideoSetting(videoSettings);
// Decoder will wait for new screenInfo and then start to play
decoder.pause();
}
this.decoders.add(decoder);
- DeviceConnection.setListeners();
+ if (!this.filePushHandlers.has(decoder)) {
+ const element = decoder.getTouchableElement();
+ const handler = new FilePushHandler(element, this);
+ const logger = new DragAndPushLogger(element);
+ handler.addEventListener(logger);
+ this.filePushHandlers.set(decoder, handler);
+ }
+ DeviceConnection.setTouchListeners();
}
public removeDecoder(decoder: Decoder): void {
this.decoders.delete(decoder);
+ const handler = this.filePushHandlers.get(decoder);
+ if (handler) {
+ handler.release();
+ this.filePushHandlers.delete(decoder);
+ }
if (!this.decoders.size) {
this.stop();
}
}
public stop(): void {
- if (this.haveConnection()) {
+ if (this.hasConnection()) {
this.ws.close();
}
- this.decoders.forEach(decoder => decoder.pause());
+ this.decoders.forEach((decoder) => decoder.pause());
delete DeviceConnection.instances[this.url];
this.events.length = 0;
}
public sendEvent(event: ControlEvent): void {
- if (this.haveConnection()) {
+ if (this.hasConnection()) {
this.ws.send(event.toBuffer());
} else {
this.events.push(event);
}
}
+ public sendNewVideoSetting(videoSettings: VideoSettings): void {
+ this.requestedVideoSettings = videoSettings;
+ this.sendEvent(CommandControlEvent.createSetVideoSettingsCommand(videoSettings));
+ }
+
public setErrorListener(listener: ErrorListener): void {
this.errorListener = listener;
}
- public setDeviceMessageListener(listener: DeviceMessageListener): void {
- this.deviceMessageListener = listener;
+ public addEventListener(listener: DeviceMessageListener): void {
+ this.deviceMessageListeners.add(listener);
+ }
+
+ public removeEventListener(listener: DeviceMessageListener): void {
+ this.deviceMessageListeners.delete(listener);
}
public getDeviceName(): string {
return this.name;
}
- private haveConnection(): boolean {
+ public getClientId(): number {
+ return this.clientId;
+ }
+
+ public getClientsCount(): number {
+ return this.clientsCount;
+ }
+
+ public setHandleKeyboardEvents(value: boolean): void {
+ if (value) {
+ KeyInputHandler.addEventListener(this);
+ } else {
+ KeyInputHandler.removeEventListener(this);
+ }
+ }
+
+ public onKeyEvent(event: KeyCodeControlEvent): void {
+ this.sendEvent(event);
+ }
+
+ public hasConnection(): boolean {
return this.ws && this.ws.readyState === this.ws.OPEN;
}
@@ -461,9 +272,6 @@ export class DeviceConnection {
if (this.errorListener) {
this.errorListener.OnError.call(this.errorListener, e);
}
- if (ws.readyState === ws.CLOSED) {
- console.error('WS closed');
- }
};
ws.onopen = () => {
let e = this.events.shift();
@@ -479,18 +287,23 @@ export class DeviceConnection {
const text = Util.utf8ByteArrayToString(magicBytes);
if (text === MAGIC) {
if (data.length === DEVICE_INFO_LENGTH) {
- let nameBytes = new Uint8Array(e.data, MAGIC.length, DEVICE_NAME_FIELD_LENGTH);
+ let offset = MAGIC.length;
+ let nameBytes = new Uint8Array(e.data, offset, DEVICE_NAME_FIELD_LENGTH);
nameBytes = Util.filterTrailingZeroes(nameBytes);
this.name = Util.utf8ByteArrayToString(nameBytes);
- let processedLength = MAGIC.length + DEVICE_NAME_FIELD_LENGTH;
- let temp = new Buffer(new Uint8Array(e.data, processedLength, ScreenInfo.BUFFER_LENGTH));
- processedLength += ScreenInfo.BUFFER_LENGTH;
+ offset += DEVICE_NAME_FIELD_LENGTH;
+ let temp = new Buffer(new Uint8Array(e.data, offset, ScreenInfo.BUFFER_LENGTH));
+ offset += ScreenInfo.BUFFER_LENGTH;
const screenInfo: ScreenInfo = ScreenInfo.fromBuffer(temp);
- temp = new Buffer(new Uint8Array(e.data, processedLength, VideoSettings.BUFFER_LENGTH));
+ temp = new Buffer(new Uint8Array(e.data, offset, VideoSettings.BUFFER_LENGTH));
const videoSettings: VideoSettings = VideoSettings.fromBuffer(temp);
+ offset += VideoSettings.BUFFER_LENGTH;
+ temp = new Buffer(new Uint8Array(e.data, offset, CLIENT_ID_LENGTH + CLIENTS_COUNT_LENGTH));
+ this.clientId = temp.readInt16BE(0);
+ this.clientsCount = temp.readInt16BE(CLIENT_ID_LENGTH);
let min: VideoSettings = VideoSettings.copy(videoSettings) as VideoSettings;
let playing = false;
- this.decoders.forEach(decoder => {
+ this.decoders.forEach((decoder) => {
const STATE = Decoder.STATE;
if (decoder.getState() === STATE.PAUSED) {
decoder.play();
@@ -504,28 +317,36 @@ export class DeviceConnection {
}
const oldSettings = decoder.getVideoSettings();
if (!videoSettings.equals(oldSettings)) {
- decoder.setVideoSettings(videoSettings);
+ decoder.setVideoSettings(
+ videoSettings,
+ videoSettings.equals(this.requestedVideoSettings),
+ );
}
if (!oldInfo) {
- const preferred = decoder.getPreferredVideoSetting();
- const maxSize: number = preferred.maxSize;
+ const maxSize: number = oldSettings.maxSize;
const videoSize: Size = screenInfo.videoSize;
- if (maxSize < videoSize.width || maxSize < videoSize.height) {
- min = preferred;
+ if (
+ maxSize < videoSize.width ||
+ maxSize < videoSize.height ||
+ this.clientsCount === 0
+ ) {
+ min = oldSettings;
}
}
});
if (!min.equals(videoSettings) || !playing) {
- this.sendEvent(CommandControlEvent.createSetVideoSettingsCommand(min));
+ this.sendNewVideoSetting(min);
}
} else {
const message = DeviceMessage.fromBuffer(e.data);
- if (this.deviceMessageListener) {
- this.deviceMessageListener.OnDeviceMessage(message);
+ if (this.deviceMessageListeners.size) {
+ this.deviceMessageListeners.forEach((listener) => {
+ listener.OnDeviceMessage(message);
+ });
}
}
} else {
- this.decoders.forEach(decoder => {
+ this.decoders.forEach((decoder) => {
const STATE = Decoder.STATE;
if (decoder.getState() === STATE.PAUSED) {
decoder.play();
@@ -539,5 +360,9 @@ export class DeviceConnection {
console.error(`Unexpexted message: ${e.data}`);
}
};
+
+ ws.onclose = () => {
+ console.log('WS closed');
+ };
}
}
diff --git a/src/DeviceController.ts b/src/DeviceController.ts
index ae0892e4..8ba26702 100644
--- a/src/DeviceController.ts
+++ b/src/DeviceController.ts
@@ -8,12 +8,12 @@ import CommandControlEvent from './controlEvent/CommandControlEvent';
import ControlEvent from './controlEvent/ControlEvent';
import TextControlEvent from './controlEvent/TextControlEvent';
import DeviceMessage from './DeviceMessage';
+import SvgImage from './ui/SvgImage';
export interface DeviceControllerParams {
url: string;
- name: string;
+ udid: string;
decoder: Decoder;
- videoSettings: VideoSettings;
}
export class DeviceController implements DeviceMessageListener {
@@ -21,38 +21,48 @@ export class DeviceController implements DeviceMessageListener {
public readonly controls: HTMLDivElement;
public readonly deviceView: HTMLDivElement;
public readonly input: HTMLInputElement;
+ private readonly controlButtons: HTMLElement;
+ private readonly deviceConnection: DeviceConnection;
constructor(params: DeviceControllerParams) {
- const decoder = this.decoder = params.decoder;
- const deviceName = params.name;
+ const decoder = (this.decoder = params.decoder);
+ const udid = params.udid;
const decoderName = this.decoder.getName();
- const controlsWrapper = this.controls = document.createElement('div');
- const deviceView = this.deviceView = document.createElement('div');
+ const controlsWrapper = (this.controls = document.createElement('div'));
+ const deviceView = (this.deviceView = document.createElement('div'));
deviceView.className = 'device-view';
- const connection = DeviceConnection.getInstance(params.url);
- const stream = params.videoSettings;
- connection.addDecoder(this.decoder);
- connection.setDeviceMessageListener(this);
+ const connection = this.deviceConnection = DeviceConnection.getInstance(udid, params.url);
+ const videoSettings = decoder.getVideoSettings();
+ connection.addEventListener(this);
const wrapper = document.createElement('div');
- wrapper.className = 'decoder-controls-wrapper';
- const nameSpan = document.createElement('span');
- nameSpan.innerText = `${deviceName} (${decoderName})`;
- wrapper.appendChild(nameSpan);
+ wrapper.className = 'decoder-controls-wrapper menu';
+ const menuCheck = document.createElement('input');
+ menuCheck.type = 'checkbox';
+ menuCheck.checked = true;
+ const menuLabel = document.createElement('label');
+ menuLabel.htmlFor = menuCheck.id = `controls_${udid}_${decoderName}`;
+ // label.innerText = `${deviceName} (${decoderName})`;
+ wrapper.appendChild(menuCheck);
+ wrapper.appendChild(menuLabel);
+ const box = document.createElement('div');
+ box.className = 'box';
+ wrapper.appendChild(box);
const textWrap = document.createElement('div');
- const input = this.input = document.createElement('input');
+ const input = (this.input = document.createElement('input'));
const sendButton = document.createElement('button');
sendButton.innerText = 'Send as keys';
textWrap.appendChild(input);
textWrap.appendChild(sendButton);
- wrapper.appendChild(textWrap);
+ box.appendChild(textWrap);
sendButton.onclick = () => {
if (input.value) {
connection.sendEvent(new TextControlEvent(input.value));
}
};
- const deviceButtons = document.createElement('div');
- deviceButtons.className = 'control-buttons-list';
+
+ this.controlButtons = document.createElement('div');
+ this.controlButtons.className = 'control-buttons-list';
const cmdWrap = document.createElement('div');
const codes = CommandControlEvent.CommandCodes;
for (const command in codes) {
@@ -68,7 +78,7 @@ export class DeviceController implements DeviceMessageListener {
const spoilerCheck = document.createElement('input');
const innerDiv = document.createElement('div');
- const id = `spoiler_video_${deviceName}_${decoderName}_${action}`;
+ const id = `spoiler_video_${udid}_${decoderName}_${action}`;
spoiler.className = 'spoiler';
spoilerCheck.type = 'checkbox';
@@ -84,8 +94,8 @@ export class DeviceController implements DeviceMessageListener {
const bitrateLabel = document.createElement('label');
bitrateLabel.innerText = 'Bitrate:';
bitrateInput = document.createElement('input');
- bitrateInput.placeholder = `bitrate (${stream.bitrate})`;
- bitrateInput.value = stream.bitrate.toString();
+ bitrateInput.placeholder = `bitrate (${videoSettings.bitrate})`;
+ bitrateInput.value = videoSettings.bitrate.toString();
bitrateWrap.appendChild(bitrateLabel);
bitrateWrap.appendChild(bitrateInput);
@@ -93,8 +103,8 @@ export class DeviceController implements DeviceMessageListener {
const framerateLabel = document.createElement('label');
framerateLabel.innerText = 'Framerate:';
frameRateInput = document.createElement('input');
- frameRateInput.placeholder = `framerate (${stream.frameRate})`;
- frameRateInput.value = stream.frameRate.toString();
+ frameRateInput.placeholder = `framerate (${videoSettings.frameRate})`;
+ frameRateInput.value = videoSettings.frameRate.toString();
framerateWrap.appendChild(framerateLabel);
framerateWrap.appendChild(frameRateInput);
@@ -102,8 +112,8 @@ export class DeviceController implements DeviceMessageListener {
const iFrameIntervalLabel = document.createElement('label');
iFrameIntervalLabel.innerText = 'I-Frame Interval:';
iFrameIntervalInput = document.createElement('input');
- iFrameIntervalInput.placeholder = `I-frame interval (${stream.iFrameInterval})`;
- iFrameIntervalInput.value = stream.iFrameInterval.toString();
+ iFrameIntervalInput.placeholder = `I-frame interval (${videoSettings.iFrameInterval})`;
+ iFrameIntervalInput.value = videoSettings.iFrameInterval.toString();
iFrameIntervalWrap.appendChild(iFrameIntervalLabel);
iFrameIntervalWrap.appendChild(iFrameIntervalInput);
@@ -117,7 +127,7 @@ export class DeviceController implements DeviceMessageListener {
}
btn.innerText = CommandControlEvent.CommandNames[action];
btn.onclick = () => {
- let event: CommandControlEvent|undefined;
+ let event: CommandControlEvent | undefined;
if (action === ControlEvent.TYPE_CHANGE_STREAM_PARAMETERS) {
const bitrate = parseInt(bitrateInput.value, 10);
const frameRate = parseInt(frameRateInput.value, 10);
@@ -125,21 +135,20 @@ export class DeviceController implements DeviceMessageListener {
if (isNaN(bitrate) || isNaN(frameRate)) {
return;
}
- const width = document.body.clientWidth & ~15;
- const height = document.body.clientHeight & ~15;
- const maxSize = Math.min(width, height);
- event = CommandControlEvent.createSetVideoSettingsCommand(new VideoSettings({
+ const maxSize = this.getMaxSize();
+ const videoSettings = new VideoSettings({
maxSize,
bitrate,
frameRate,
iFrameInterval,
lockedVideoOrientation: -1,
- sendFrameMeta: false
- }));
+ sendFrameMeta: false,
+ });
+ connection.sendNewVideoSetting(videoSettings);
} else if (action === CommandControlEvent.TYPE_SET_CLIPBOARD) {
const text = input.value;
if (text) {
- event = CommandControlEvent.createSetClipboard(text);
+ event = CommandControlEvent.createSetClipboardCommand(text);
}
} else {
event = new CommandControlEvent(action);
@@ -150,40 +159,78 @@ export class DeviceController implements DeviceMessageListener {
};
}
}
- const list = [{
- code: KeyEvent.KEYCODE_POWER,
- name: 'power'
- },{
- code: KeyEvent.KEYCODE_VOLUME_DOWN,
- name: 'volume-down'
- },{
- code: KeyEvent.KEYCODE_VOLUME_UP,
- name: 'volume-up'
- },{
- code: KeyEvent.KEYCODE_BACK,
- name: 'back'
- },{
- code: KeyEvent.KEYCODE_HOME,
- name: 'home'
- }, {
- code: KeyEvent.KEYCODE_APP_SWITCH,
- name: 'app-switch'
- }];
- list.forEach(item => {
- const {code, name} = item;
+ const list = [
+ {
+ 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,
+ },
+ ];
+ list.forEach((item) => {
+ const { code, icon, title } = item;
const btn = document.createElement('button');
- btn.classList.add('control-button', name);
+ btn.classList.add('control-button');
+ btn.title = title;
+ btn.appendChild(SvgImage.create(icon));
btn.onmousedown = () => {
- const event = new KeyCodeControlEvent(KeyEvent.ACTION_DOWN, code, 0);
+ const event = new KeyCodeControlEvent(KeyEvent.ACTION_DOWN, code, 0, 0);
connection.sendEvent(event);
};
btn.onmouseup = () => {
- const event = new KeyCodeControlEvent(KeyEvent.ACTION_UP, code, 0);
+ const event = new KeyCodeControlEvent(KeyEvent.ACTION_UP, code, 0, 0);
connection.sendEvent(event);
};
- deviceButtons.appendChild(btn);
+ this.controlButtons.appendChild(btn);
});
- wrapper.appendChild(cmdWrap);
+ if (decoder.supportsScreenshot) {
+ const screenshotButton = document.createElement('button');
+ screenshotButton.classList.add('control-button');
+ screenshotButton.title = 'Take screenshot';
+ screenshotButton.appendChild(SvgImage.create(SvgImage.Icon.CAMERA));
+ screenshotButton.onclick = () => {
+ decoder.createScreenshot(connection.getDeviceName());
+ };
+ this.controlButtons.appendChild(screenshotButton);
+ }
+ const captureKeyboardInput = document.createElement('input');
+ captureKeyboardInput.type = 'checkbox';
+ const captureKeyboardLabel = document.createElement('label');
+ captureKeyboardLabel.title = 'Capture keyboard';
+ captureKeyboardLabel.classList.add('control-button');
+ captureKeyboardLabel.appendChild(SvgImage.create(SvgImage.Icon.KEYBOARD));
+ captureKeyboardLabel.htmlFor = captureKeyboardInput.id = `capture_keyboard_${udid}_${decoderName}`;
+ captureKeyboardInput.onclick = (e: MouseEvent) => {
+ const checkbox = e.target as HTMLInputElement;
+ connection.setHandleKeyboardEvents(checkbox.checked);
+ };
+ this.controlButtons.appendChild(captureKeyboardInput);
+ this.controlButtons.appendChild(captureKeyboardLabel);
+ box.appendChild(cmdWrap);
const stop = (ev?: string | Event) => {
if (ev && ev instanceof Event && ev.type === 'error') {
@@ -203,9 +250,9 @@ export class DeviceController implements DeviceMessageListener {
const stopBtn = document.createElement('button') as HTMLButtonElement;
stopBtn.innerText = `Disconnect`;
stopBtn.onclick = stop;
- wrapper.appendChild(stopBtn);
+ box.appendChild(stopBtn);
controlsWrapper.appendChild(wrapper);
- deviceView.appendChild(deviceButtons);
+ deviceView.appendChild(this.controlButtons);
const video = document.createElement('div');
video.className = 'video';
deviceView.appendChild(video);
@@ -213,24 +260,48 @@ export class DeviceController implements DeviceMessageListener {
connection.setErrorListener(new ErrorHandler(stop));
}
+ private getMaxSize(): number {
+ const body = document.body;
+ const width = (body.clientWidth - this.controlButtons.clientWidth) & ~15;
+ const height = body.clientHeight & ~15;
+ return Math.min(width, height);
+ }
+
public start(): void {
- document.body.append(this.deviceView);
+ document.body.appendChild(this.deviceView);
const temp = document.getElementById('controlsWrap');
if (temp) {
temp.appendChild(this.controls);
}
+ const decoder = this.decoder;
+ if (decoder.getPreferredVideoSetting().equals(decoder.getVideoSettings())) {
+ const maxSize = this.getMaxSize();
+ const {
+ bitrate,
+ frameRate,
+ iFrameInterval,
+ lockedVideoOrientation,
+ sendFrameMeta
+ } = decoder.getVideoSettings();
+ const newVideoSettings = new VideoSettings({
+ maxSize,
+ bitrate,
+ frameRate,
+ iFrameInterval,
+ lockedVideoOrientation,
+ sendFrameMeta
+ });
+ decoder.setVideoSettings(newVideoSettings, false);
+ }
+ this.deviceConnection.addDecoder(decoder);
}
public OnDeviceMessage(ev: DeviceMessage): void {
- switch (ev.type) {
- case DeviceMessage.TYPE_CLIPBOARD:
- this.input.value = ev.getText();
- this.input.select();
- document.execCommand('copy');
- break;
- default:
- console.error(`Unknown message type: ${ev.type}`);
+ if (ev.type !== DeviceMessage.TYPE_CLIPBOARD) {
+ return;
}
+ this.input.value = ev.getText();
+ this.input.select();
+ document.execCommand('copy');
}
-
}
diff --git a/src/DeviceMessage.ts b/src/DeviceMessage.ts
index 67c73881..b3075513 100644
--- a/src/DeviceMessage.ts
+++ b/src/DeviceMessage.ts
@@ -1,12 +1,12 @@
import Util from './Util';
export default class DeviceMessage {
- public static TYPE_CLIPBOARD: number = 0;
+ public static TYPE_CLIPBOARD = 0;
+ public static TYPE_PUSH_RESPONSE = 101;
- private static MAGIC: string = 'scrcpy';
+ private static MAGIC = 'scrcpy';
- constructor(public readonly type: number, protected readonly buffer: Buffer) {
- }
+ constructor(public readonly type: number, protected readonly buffer: Buffer) {}
public static fromBuffer(data: ArrayBuffer): DeviceMessage {
const buffer = Buffer.from(data, this.MAGIC.length, data.byteLength - this.MAGIC.length);
@@ -22,12 +22,24 @@ export default class DeviceMessage {
throw Error('Empty buffer');
}
let offset = 1;
- const length = this.buffer.readUInt16BE(offset);
- offset += 2;
+ const length = this.buffer.readInt32BE(offset);
+ offset += 4;
const textBytes = this.buffer.slice(offset, offset + length);
return Util.utf8ByteArrayToString(textBytes);
}
+ public getPushStats(): { id: number; result: number } {
+ if (this.type !== DeviceMessage.TYPE_PUSH_RESPONSE) {
+ throw TypeError(`Wrong message type: ${this.type}`);
+ }
+ if (!this.buffer) {
+ throw Error('Empty buffer');
+ }
+ const id = this.buffer.readInt16BE(1);
+ const result = this.buffer.readInt8(3);
+ return { id, result };
+ }
+
public toString(): string {
let desc: string;
if (this.type === DeviceMessage.TYPE_CLIPBOARD && this.buffer) {
diff --git a/src/DragAndDropHandler.ts b/src/DragAndDropHandler.ts
new file mode 100644
index 00000000..9da3dcc0
--- /dev/null
+++ b/src/DragAndDropHandler.ts
@@ -0,0 +1,88 @@
+export interface DragEventListener {
+ onDragEnter: () => void;
+ onDragLeave: () => void;
+ onFilesDrop: (files: File[]) => void;
+ getElement: () => HTMLElement;
+}
+
+export class DragAndDropHandler {
+ private static readonly listeners: Set = new Set();
+ private static dropHandler = (ev: DragEvent): void => {
+ ev.preventDefault();
+
+ if (!ev.dataTransfer) {
+ return;
+ }
+
+ 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]);
+ }
+ }
+ DragAndDropHandler.listeners.forEach((listener) => {
+ const element = listener.getElement();
+ if (element === ev.target) {
+ listener.onFilesDrop(files);
+ }
+ });
+ };
+ private static dragOverHandler = (ev: DragEvent): void => {
+ ev.preventDefault();
+ };
+ private static dragLeaveHandler = (ev: DragEvent): void => {
+ ev.preventDefault();
+ DragAndDropHandler.listeners.forEach((listener) => {
+ const element = listener.getElement();
+ if (element === ev.target) {
+ listener.onDragLeave();
+ }
+ });
+ };
+ private static dragEnterHandler = (ev: DragEvent): void => {
+ ev.preventDefault();
+ DragAndDropHandler.listeners.forEach((listener) => {
+ const element = listener.getElement();
+ if (element === ev.target) {
+ listener.onDragEnter();
+ }
+ });
+ };
+ 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);
+ }
+}
diff --git a/src/DragAndPushLogger.ts b/src/DragAndPushLogger.ts
new file mode 100644
index 00000000..6395a4bc
--- /dev/null
+++ b/src/DragAndPushLogger.ts
@@ -0,0 +1,125 @@
+import FilePushHandler, { DragAndPushListener, PushUpdateParams } from './FilePushHandler';
+
+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 = new Map();
+ private dirtyMap: Map = new Map();
+ private pushLineMap: Map = new Map();
+ private linePushMap: Map = 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(text) : console.log(text);
+ return;
+ }
+ if (error) {
+ console.error(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.ctx.canvas.width;
+ 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);
+ }
+ }
+
+ onDragEnter(): void {
+ this.logText('Drop APK files here', 1);
+ }
+
+ onDragLeave(): void {
+ this.cleanDirtyLine(1);
+ }
+
+ onDrop(): void {
+ this.cleanDirtyLine(1);
+ }
+
+ onError(error: Error | string): void {
+ const text = typeof error === 'string' ? error : error.message;
+ this.logText(text, 1, true);
+ }
+
+ onFilePushUpdate(data: PushUpdateParams): void {
+ const { pushId, logString, 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}": ${logString}`, line, true, error);
+ }
+}
diff --git a/src/ErrorHandler.ts b/src/ErrorHandler.ts
index ea4a7b9b..eae13277 100644
--- a/src/ErrorHandler.ts
+++ b/src/ErrorHandler.ts
@@ -1,4 +1,3 @@
export default class ErrorHandler {
- constructor(readonly OnError: (ev: string | Event) => void) {
- }
+ constructor(readonly OnError: (ev: string | Event) => void) {}
}
diff --git a/src/FilePushHandler.ts b/src/FilePushHandler.ts
new file mode 100644
index 00000000..c753a21b
--- /dev/null
+++ b/src/FilePushHandler.ts
@@ -0,0 +1,221 @@
+import { DragAndDropHandler, DragEventListener } from './DragAndDropHandler';
+import { DeviceConnection, DeviceMessageListener } from './DeviceConnection';
+import DeviceMessage from './DeviceMessage';
+import CommandControlEvent, { FilePushState } from './controlEvent/CommandControlEvent';
+
+const ALLOWED_TYPES = ['application/vnd.android.package-archive'];
+
+type Resolve = (result: number) => void;
+
+export type PushUpdateParams = { pushId: number; logString: string; fileName: string; error: boolean };
+
+export interface DragAndPushListener {
+ onDragEnter: () => void;
+ onDragLeave: () => void;
+ onDrop: () => void;
+ onFilePushUpdate: (data: PushUpdateParams) => void;
+ onError: (error: Error | string) => void;
+}
+
+export default class FilePushHandler implements DragEventListener, DeviceMessageListener {
+ public static readonly REQUEST_NEW_PUSH_ID = 0; // ignored on server, when state is `NEW_PUSH_ID`
+ public static readonly NEW_PUSH_ID: number = 1;
+ public static readonly NO_ERROR: number = 0;
+ public static readonly ERROR_INVALID_NAME: number = -1;
+ public static readonly ERROR_NO_SPACE: number = -2;
+ public static readonly ERROR_FAILED_TO_DELETE: number = -3;
+ public static readonly ERROR_FAILED_TO_CREATE: number = -4;
+ public static readonly ERROR_FILE_NOT_FOUND: number = -5;
+ public static readonly ERROR_FAILED_TO_WRITE: number = -6;
+ public static readonly ERROR_FILE_IS_BUSY: number = -7;
+ public static readonly ERROR_INVALID_STATE: number = -8;
+ public static readonly ERROR_UNKNOWN_ID: number = -9;
+ public static readonly ERROR_NO_FREE_ID: number = -10;
+ public static readonly ERROR_INCORRECT_SIZE: number = -11;
+
+ private responseWaiter: Map = new Map();
+ private listeners: Set = new Set();
+
+ constructor(private readonly element: HTMLElement, private readonly connection: DeviceConnection) {
+ DragAndDropHandler.addEventListener(this);
+ connection.addEventListener(this);
+ }
+
+ private sendUpdate(params: PushUpdateParams): void {
+ this.listeners.forEach((listener) => {
+ listener.onFilePushUpdate(params);
+ });
+ }
+
+ private logError(pushId: number, fileName: string, code: number): void {
+ const msg = RESPONSE_CODES.get(code) || 'Unknown error';
+ this.sendUpdate({ pushId, fileName, logString: `error: "${msg}"`, error: true });
+ }
+
+ private async pushFile(file: File): Promise {
+ const start = Date.now();
+ const { name: fileName, size: fileSize } = file;
+ if (!this.connection.hasConnection()) {
+ this.listeners.forEach((listener) => {
+ listener.onError('WebSocket is not ready');
+ });
+ return;
+ }
+ const id = FilePushHandler.REQUEST_NEW_PUSH_ID;
+ this.sendUpdate({ pushId: id, fileName, logString: 'begin...', error: false });
+ const newParams = { id, state: FilePushState.NEW };
+ this.connection.sendEvent(CommandControlEvent.createPushFileCommand(newParams));
+ const pushId: number = await this.waitForResponse(id);
+ if (pushId <= 0) {
+ this.logError(pushId, fileName, pushId);
+ }
+
+ const startParams = { id: pushId, fileName, fileSize, state: FilePushState.START };
+ this.connection.sendEvent(CommandControlEvent.createPushFileCommand(startParams));
+ const stream = file.stream();
+ const reader = stream.getReader();
+ const [startResponseCode, result] = await Promise.all([this.waitForResponse(pushId), reader.read()]);
+ if (startResponseCode !== 0) {
+ this.logError(pushId, fileName, startResponseCode);
+ return;
+ }
+ let receivedBytes = 0;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const processData = async ({ done, value }: { done: boolean; value?: any }): Promise => {
+ if (done) {
+ const finishParams = { id: pushId, state: FilePushState.FINISH };
+ this.connection.sendEvent(CommandControlEvent.createPushFileCommand(finishParams));
+ const finishResponseCode = await this.waitForResponse(pushId);
+ if (finishResponseCode !== 0) {
+ this.logError(pushId, fileName, finishResponseCode);
+ } else {
+ this.sendUpdate({ pushId, fileName, logString: 'success!', error: false });
+ }
+ console.log(`File "${fileName}" uploaded in ${Date.now() - start}ms`);
+ return;
+ }
+
+ receivedBytes += value.length;
+ const appendParams = { id: pushId, chunk: value, state: FilePushState.APPEND };
+ this.connection.sendEvent(CommandControlEvent.createPushFileCommand(appendParams));
+
+ const [appendResponseCode, result] = await Promise.all([this.waitForResponse(pushId), reader.read()]);
+ if (appendResponseCode !== 0) {
+ this.logError(pushId, fileName, appendResponseCode);
+ return;
+ }
+ const percent = (receivedBytes * 100) / fileSize;
+ this.sendUpdate({ pushId, fileName, logString: `${percent.toFixed(2)}%`, error: false });
+ return processData(result);
+ };
+ return processData(result);
+ }
+
+ private waitForResponse(pushId: number): Promise {
+ 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);
+ }
+ });
+ }
+
+ public OnDeviceMessage(ev: DeviceMessage): void {
+ if (ev.type !== DeviceMessage.TYPE_PUSH_RESPONSE) {
+ return;
+ }
+ let func: Resolve;
+ let value: number;
+ const stats = ev.getPushStats();
+ const result = stats.result;
+ const id = result === FilePushHandler.NEW_PUSH_ID ? FilePushHandler.REQUEST_NEW_PUSH_ID : stats.id;
+ const idInResponse = stats.id;
+ const resolve = this.responseWaiter.get(id);
+ if (!resolve) {
+ console.warn(`Unexpected push id: "${id}", ${JSON.stringify(stats)}`);
+ 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 (result === FilePushHandler.NEW_PUSH_ID) {
+ value = idInResponse;
+ } else {
+ value = result;
+ }
+ func(value);
+ }
+ public onFilesDrop(files: File[]): void {
+ this.listeners.forEach((listener) => {
+ listener.onDrop();
+ });
+ files.forEach((file: File) => {
+ const { type, name } = file;
+ if (ALLOWED_TYPES.includes(type)) {
+ this.pushFile(file);
+ } else {
+ const errorParams = {
+ pushId: FilePushHandler.REQUEST_NEW_PUSH_ID,
+ fileName: name,
+ logString: `Unsupported type "${type}"`,
+ error: true,
+ };
+ this.sendUpdate(errorParams);
+ }
+ });
+ }
+ public onDragEnter(): void {
+ this.listeners.forEach((listener) => {
+ listener.onDragEnter();
+ });
+ }
+ public onDragLeave(): void {
+ this.listeners.forEach((listener) => {
+ listener.onDragLeave();
+ });
+ }
+ public getElement(): HTMLElement {
+ return this.element;
+ }
+ public release(): void {
+ 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([
+ [FilePushHandler.NEW_PUSH_ID, 'New push id'],
+ [FilePushHandler.NO_ERROR, 'No error'],
+
+ [FilePushHandler.ERROR_INVALID_NAME, 'Invalid name'],
+ [FilePushHandler.ERROR_NO_SPACE, 'No space'],
+ [FilePushHandler.ERROR_FAILED_TO_DELETE, 'Failed to delete existing'],
+ [FilePushHandler.ERROR_FAILED_TO_CREATE, 'Failed to create new file'],
+ [FilePushHandler.ERROR_FILE_NOT_FOUND, 'File not found'],
+ [FilePushHandler.ERROR_FAILED_TO_WRITE, 'Failed to write to file'],
+ [FilePushHandler.ERROR_FILE_IS_BUSY, 'File is busy'],
+ [FilePushHandler.ERROR_INVALID_STATE, 'Invalid state'],
+ [FilePushHandler.ERROR_UNKNOWN_ID, 'Unknown id'],
+ [FilePushHandler.ERROR_NO_FREE_ID, 'No free id'],
+ [FilePushHandler.ERROR_INCORRECT_SIZE, 'Incorrect size'],
+]);
diff --git a/src/KeyInputHandler.ts b/src/KeyInputHandler.ts
new file mode 100644
index 00000000..ac75c983
--- /dev/null
+++ b/src/KeyInputHandler.ts
@@ -0,0 +1,73 @@
+import KeyCodeControlEvent from './controlEvent/KeyCodeControlEvent';
+import KeyEvent from './android/KeyEvent';
+import { KeyToCodeMap } from './KeyToCodeMap';
+
+export interface KeyEventListener {
+ onKeyEvent: (event: KeyCodeControlEvent) => void;
+}
+
+export class KeyInputHandler {
+ private static readonly repeatCounter: Map = new Map();
+ private static readonly listeners: Set = new Set();
+ private static handler = (e: Event): void => {
+ const event = e as KeyboardEvent;
+ const keyCode = KeyToCodeMap.get(event.code);
+ if (!keyCode) {
+ return;
+ }
+ let action: typeof KeyEvent.ACTION_DOWN | typeof KeyEvent.ACTION_DOWN;
+ let repeatCount: number = 0;
+ if (event.type === 'keydown') {
+ action = KeyEvent.ACTION_DOWN;
+ if (event.repeat) {
+ let count = KeyInputHandler.repeatCounter.get(keyCode);
+ if (typeof count !== 'number') {
+ count = 1;
+ } else {
+ count ++;
+ }
+ repeatCount = count;
+ KeyInputHandler.repeatCounter.set(keyCode, count);
+ }
+ } else if (event.type === 'keyup') {
+ action = KeyEvent.ACTION_UP;
+ KeyInputHandler.repeatCounter.delete(keyCode);
+ } else {
+ return;
+ }
+ const metaState =
+ (event.getModifierState('Alt') ? KeyEvent.META_ALT_ON : 0) |
+ (event.getModifierState('Shift') ? KeyEvent.META_SHIFT_ON : 0) |
+ (event.getModifierState('Control') ? KeyEvent.META_CTRL_ON : 0) |
+ (event.getModifierState('Meta') ? KeyEvent.META_META_ON : 0) |
+ (event.getModifierState('CapsLock') ? KeyEvent.META_CAPS_LOCK_ON : 0) |
+ (event.getModifierState('ScrollLock') ? KeyEvent.META_SCROLL_LOCK_ON : 0) |
+ (event.getModifierState('NumLock') ? KeyEvent.META_NUM_LOCK_ON : 0);
+
+ const controlEvent: KeyCodeControlEvent = new KeyCodeControlEvent(action, keyCode, repeatCount, metaState);
+ KeyInputHandler.listeners.forEach((listener) => {
+ listener.onKeyEvent(controlEvent);
+ });
+ e.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();
+ }
+ }
+}
diff --git a/src/KeyToCodeMap.ts b/src/KeyToCodeMap.ts
new file mode 100644
index 00000000..7a8b167a
--- /dev/null
+++ b/src/KeyToCodeMap.ts
@@ -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],
+]);
diff --git a/src/MotionEvent.ts b/src/MotionEvent.ts
index 1fc31666..8caddc01 100644
--- a/src/MotionEvent.ts
+++ b/src/MotionEvent.ts
@@ -1,7 +1,7 @@
export default class MotionEvent {
- public static ACTION_DOWN: number = 0;
- public static ACTION_UP: number = 1;
- public static ACTION_MOVE: number = 2;
+ public static ACTION_DOWN = 0;
+ public static ACTION_UP = 1;
+ public static ACTION_MOVE = 2;
/**
* Button constant: Primary button (left mouse button).
*/
diff --git a/src/Rect.ts b/src/Rect.ts
index d393beba..585f939e 100644
--- a/src/Rect.ts
+++ b/src/Rect.ts
@@ -1,3 +1,10 @@
+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;
@@ -5,33 +12,42 @@ export default class Rect {
this.right = right;
this.bottom = bottom;
}
- public static equals(a?: Rect|null, b?: Rect|null): boolean {
+ 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 {
+ 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 {
+ 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;
+ return this.left === o.left && this.top === o.top && this.right === o.right && this.bottom === o.bottom;
}
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,
+ };
+ }
}
diff --git a/src/ScreenInfo.ts b/src/ScreenInfo.ts
index 838cfaba..c9257f85 100644
--- a/src/ScreenInfo.ts
+++ b/src/ScreenInfo.ts
@@ -3,8 +3,7 @@ import Size from './Size';
export default class ScreenInfo {
public static readonly BUFFER_LENGTH: number = 13;
- constructor(readonly contentRect: Rect, readonly videoSize: Size, readonly rotated: boolean) {
- }
+ constructor(readonly contentRect: Rect, readonly videoSize: Size, readonly rotated: boolean) {}
public static fromBuffer(buffer: Buffer): ScreenInfo {
const left = buffer.readUInt16BE(0);
@@ -14,25 +13,19 @@ export default class ScreenInfo {
const width = buffer.readUInt16BE(8);
const height = buffer.readUInt16BE(10);
const rotated = !!buffer.readUInt8(12);
- return new ScreenInfo(
- new Rect(left, top, right, bottom),
- new Size(width, height),
- rotated
- );
+ return new ScreenInfo(new Rect(left, top, right, bottom), new Size(width, height), rotated);
}
- public equals(o?: ScreenInfo|null): boolean {
+ public equals(o?: ScreenInfo | null): boolean {
if (!o) {
return false;
}
- return this.contentRect.equals(o.contentRect) &&
- this.videoSize.equals(o.videoSize) &&
- this.rotated === o.rotated;
+ return (
+ this.contentRect.equals(o.contentRect) && this.videoSize.equals(o.videoSize) && this.rotated === o.rotated
+ );
}
public toString(): string {
- return `ScreenInfo{contentRect=${
- this.contentRect}, videoSize=${
- this.videoSize}, rotated=${this.rotated}}`;
+ return `ScreenInfo{contentRect=${this.contentRect}, videoSize=${this.videoSize}, rotated=${this.rotated}}`;
}
}
diff --git a/src/Size.ts b/src/Size.ts
index 69a0e703..68d2f939 100644
--- a/src/Size.ts
+++ b/src/Size.ts
@@ -7,14 +7,14 @@ export default class Size {
this.h = height;
}
- public static equals(a?: Size|null, b?: Size|null): boolean {
+ 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 {
+ public static copy(a?: Size | null): Size | null {
if (!a) {
return null;
}
@@ -25,7 +25,7 @@ export default class Size {
return new Size(this.height, this.width);
}
- public equals(o: Size|null): boolean {
+ public equals(o: Size | null): boolean {
if (this === o) {
return true;
}
diff --git a/src/TouchHandler.ts b/src/TouchHandler.ts
new file mode 100644
index 00000000..3490db40
--- /dev/null
+++ b/src/TouchHandler.ts
@@ -0,0 +1,342 @@
+import MotionEvent from './MotionEvent';
+import ScreenInfo from './ScreenInfo';
+import TouchControlEvent from './controlEvent/TouchControlEvent';
+import Size from './Size';
+import Point from './Point';
+import Position from './Position';
+import TouchPointPNG from '../images/multitouch/touch_point.png';
+import CenterPointPNG from '../images/multitouch/center_point.png';
+
+interface Touch {
+ action: number;
+ position: Position;
+ buttons: number;
+}
+
+interface TouchOnClient {
+ client: {
+ width: number;
+ height: number;
+ };
+ touch: Touch;
+}
+
+interface CommonTouchAndMouse {
+ clientX: number;
+ clientY: number;
+ type: string;
+ target: EventTarget | null;
+ button: number;
+}
+
+export default class TouchHandler {
+ private static readonly STROKE_STYLE: string = '#00BEA4';
+ private static BUTTONS_MAP: Record = {
+ 0: 17, // ?? BUTTON_PRIMARY
+ 1: MotionEvent.BUTTON_TERTIARY,
+ 2: 26, // ?? BUTTON_SECONDARY
+ };
+ private static EVENT_ACTION_MAP: Record = {
+ 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,
+ };
+ private static multiTouchActive = false;
+ private static multiTouchCenter?: Point;
+ private static multiTouchShift = false;
+ private static dirtyPlace: Point[] = [];
+ private static idToPointerMap: Map = new Map();
+ private static pointerToIdMap: Map = new Map();
+ private static touchPointRadius = 10;
+ private static centerPointRadius = 5;
+ private static touchPointImage?: HTMLImageElement;
+ private static centerPointImage?: HTMLImageElement;
+ private static pointImagesLoaded = false;
+ private static initialized = false;
+
+ public static init(): void {
+ if (this.initialized) {
+ return;
+ }
+ this.loadImages();
+ this.initialized = true;
+ }
+
+ private static loadImages(): void {
+ const total = 2;
+ let current = 0;
+
+ const onload = (e: Event) => {
+ if (++current === total) {
+ this.pointImagesLoaded = true;
+ }
+ if (e.target === this.touchPointImage) {
+ this.touchPointRadius = this.touchPointImage.width / 2;
+ } else if (e.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;
+ }
+
+ private static getPointerId(type: string, identifier: number): number {
+ // I'm not sure that we can directly use touch identifier as pointerId
+ let pointerId: number;
+ if (this.idToPointerMap.has(identifier)) {
+ pointerId = this.idToPointerMap.get(identifier) as number;
+ if (type === 'touchend' || type === 'touchcancel') {
+ this.idToPointerMap.delete(identifier);
+ this.pointerToIdMap.delete(pointerId);
+ }
+ return pointerId;
+ } else {
+ pointerId = 0;
+ while (this.idToPointerMap.has(pointerId)) {
+ pointerId++;
+ }
+ this.idToPointerMap.set(identifier, pointerId);
+ this.pointerToIdMap.set(pointerId, identifier);
+ return pointerId;
+ }
+ }
+
+ private static calculateCoordinates(e: CommonTouchAndMouse, screenInfo: ScreenInfo): TouchOnClient | null {
+ const action = this.EVENT_ACTION_MAP[e.type];
+ if (typeof action === 'undefined' || !screenInfo) {
+ return null;
+ }
+ const htmlTag = document.getElementsByTagName('html')[0] as HTMLElement;
+ const { width, height } = screenInfo.videoSize;
+ const target: HTMLElement = e.target as HTMLElement;
+ const { scrollTop, scrollLeft } = htmlTag;
+ let { clientWidth, clientHeight } = target;
+ let touchX = e.clientX - target.offsetLeft + scrollLeft;
+ let touchY = e.clientY - target.offsetTop + scrollTop;
+ 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) {
+ return null;
+ }
+ 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) {
+ return null;
+ }
+ 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);
+ const buttons = this.BUTTONS_MAP[e.button];
+ return {
+ client: {
+ width: clientWidth,
+ height: clientHeight,
+ },
+ touch: {
+ action,
+ position,
+ buttons,
+ },
+ };
+ }
+
+ private static getTouch(e: MouseEvent, screenInfo: ScreenInfo): Touch[] | null {
+ const touchOnClient = this.calculateCoordinates(e, screenInfo);
+ if (!touchOnClient) {
+ return null;
+ }
+ const { client, touch } = touchOnClient;
+ const result: Touch[] = [touch];
+ if (!e.ctrlKey) {
+ this.multiTouchActive = false;
+ this.multiTouchCenter = undefined;
+ this.multiTouchShift = false;
+ this.clearCanvas(e.target as HTMLCanvasElement);
+ return result;
+ }
+ const { position, action, buttons } = touch;
+ const { point, screenSize } = position;
+ const { width, height } = screenSize;
+ const { x, y } = point;
+ if (!this.multiTouchActive) {
+ if (e.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;
+ if (this.multiTouchShift && this.multiTouchCenter) {
+ const oppoX = 2 * this.multiTouchCenter.x - x;
+ const oppoY = 2 * this.multiTouchCenter.y - y;
+ if (oppoX <= width && oppoX >= 0 && oppoY <= height && oppoY >= 0) {
+ opposite = new Point(oppoX, oppoY);
+ }
+ } else {
+ opposite = new Point(client.width - x, client.height - y);
+ }
+ if (opposite) {
+ result.push({
+ action,
+ buttons,
+ position: new Position(opposite, screenSize),
+ });
+ }
+ return result;
+ }
+
+ private static drawCircle(ctx: CanvasRenderingContext2D, point: Point, radius: number): void {
+ ctx.beginPath();
+ ctx.arc(point.x, point.y, radius, 0, Math.PI * 2, true);
+ ctx.stroke();
+ }
+
+ private static drawLine(ctx: CanvasRenderingContext2D, point1: Point, point2: Point): void {
+ ctx.beginPath();
+ ctx.moveTo(point1.x, point1.y);
+ ctx.lineTo(point2.x, point2.y);
+ ctx.stroke();
+ }
+
+ private static drawPoint(
+ ctx: CanvasRenderingContext2D,
+ point: Point,
+ radius: number,
+ image?: HTMLImageElement,
+ ): void {
+ let { lineWidth } = ctx;
+ if (this.pointImagesLoaded && image) {
+ radius = image.width / 2;
+ lineWidth = 0;
+ ctx.drawImage(image, point.x - radius, point.y - radius);
+ } else {
+ this.drawCircle(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);
+ }
+
+ private static 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);
+ }
+
+ private static clearCanvas(target: HTMLCanvasElement): void {
+ const { clientWidth, clientHeight } = target;
+ const ctx = target.getContext('2d');
+ 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);
+ }
+ }
+
+ public static formatTouchEvent(
+ e: TouchEvent,
+ screenInfo: ScreenInfo,
+ tag: HTMLElement,
+ ): TouchControlEvent[] | null {
+ const events: TouchControlEvent[] = [];
+ const touches = e.changedTouches;
+ if (touches && touches.length) {
+ for (let i = 0, l = touches.length; i < l; i++) {
+ const touch = touches[i];
+ const pointerId = TouchHandler.getPointerId(e.type, touch.identifier);
+ if (touch.target !== tag) {
+ continue;
+ }
+ const item: CommonTouchAndMouse = {
+ clientX: touch.clientX,
+ clientY: touch.clientY,
+ type: e.type,
+ button: 0,
+ target: e.target,
+ };
+ const event = this.calculateCoordinates(item, screenInfo);
+ if (event) {
+ const { action, buttons, position } = event.touch;
+ const pressure = touch.force * 255;
+ events.push(new TouchControlEvent(action, pointerId, position, pressure, buttons));
+ } else {
+ console.error(`Failed to format touch`, touch);
+ }
+ }
+ } else {
+ console.error('No "touches"', e);
+ }
+ if (events.length) {
+ return events;
+ }
+ return null;
+ }
+
+ public static buildTouchEvent(e: MouseEvent, screenInfo: ScreenInfo): TouchControlEvent[] | null {
+ const touches = this.getTouch(e, screenInfo);
+ if (!touches) {
+ return null;
+ }
+ const target = e.target as HTMLCanvasElement;
+ if (this.multiTouchActive) {
+ const ctx = target.getContext('2d');
+ if (ctx) {
+ this.clearCanvas(target);
+ ctx.strokeStyle = TouchHandler.STROKE_STYLE;
+ touches.forEach((touch) => {
+ const { point } = touch.position;
+ this.drawPoint(ctx, point, this.touchPointRadius, this.touchPointImage);
+ if (this.multiTouchCenter) {
+ this.drawLine(ctx, this.multiTouchCenter, point);
+ }
+ });
+ if (this.multiTouchCenter) {
+ this.drawPoint(ctx, this.multiTouchCenter, this.centerPointRadius, this.centerPointImage);
+ }
+ }
+ }
+ return touches.map((touch: Touch, pointerId: number) => {
+ const { action, buttons, position } = touch;
+ return new TouchControlEvent(action, pointerId, position, 255, buttons);
+ });
+ }
+}
diff --git a/src/UIEventsCode.ts b/src/UIEventsCode.ts
new file mode 100644
index 00000000..504ef484
--- /dev/null
+++ b/src/UIEventsCode.ts
@@ -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';
+}
diff --git a/src/VideoSettings.ts b/src/VideoSettings.ts
index 989f8f81..00d16525 100644
--- a/src/VideoSettings.ts
+++ b/src/VideoSettings.ts
@@ -8,6 +8,7 @@ interface Settings {
iFrameInterval: number;
sendFrameMeta: boolean;
lockedVideoOrientation: number;
+ codecOptions?: string;
}
export default class VideoSettings {
@@ -19,6 +20,7 @@ export default class VideoSettings {
public readonly iFrameInterval: number = 0;
public readonly sendFrameMeta: boolean = false;
public readonly lockedVideoOrientation: number = -1;
+ public readonly codecOptions: string = '-';
constructor(data?: Settings) {
if (data) {
@@ -47,6 +49,7 @@ export default class VideoSettings {
if (left || top || right || bottom) {
crop = new Rect(left, top, right, bottom);
}
+ const codecOptions = '-';
return new VideoSettings({
crop,
bitrate,
@@ -54,11 +57,12 @@ export default class VideoSettings {
frameRate,
iFrameInterval,
lockedVideoOrientation,
- sendFrameMeta
+ sendFrameMeta,
+ codecOptions
});
}
- public static copy(a?: VideoSettings|null): VideoSettings|null {
+ public static copy(a?: VideoSettings | null): VideoSettings | null {
if (!a) {
return null;
}
@@ -69,7 +73,8 @@ export default class VideoSettings {
frameRate: a.frameRate,
iFrameInterval: a.iFrameInterval,
lockedVideoOrientation: a.lockedVideoOrientation,
- sendFrameMeta: a.sendFrameMeta
+ sendFrameMeta: a.sendFrameMeta,
+ codecOptions: a.codecOptions,
});
}
@@ -77,17 +82,20 @@ export default class VideoSettings {
if (!o) {
return false;
}
- return Rect.equals(this.crop, o.crop) &&
+ return (
+ this.codecOptions === o.codecOptions &&
+ Rect.equals(this.crop, o.crop) &&
this.lockedVideoOrientation === o.lockedVideoOrientation &&
this.maxSize === o.maxSize &&
this.bitrate === o.bitrate &&
this.frameRate === o.frameRate &&
- this.iFrameInterval === o.iFrameInterval;
+ this.iFrameInterval === o.iFrameInterval
+ );
}
public toBuffer(): Buffer {
const buffer = new Buffer(VideoSettings.BUFFER_LENGTH);
- const {left = 0, top = 0, right = 0, bottom = 0} = this.crop || {};
+ const { left = 0, top = 0, right = 0, bottom = 0 } = this.crop || {};
let offset = 0;
offset = buffer.writeUInt32BE(this.bitrate, offset);
offset = buffer.writeUInt8(this.frameRate, offset);
@@ -98,11 +106,14 @@ export default class VideoSettings {
offset = buffer.writeUInt16BE(right, offset);
offset = buffer.writeUInt16BE(bottom, offset);
offset = buffer.writeUInt8(this.sendFrameMeta ? 1 : 0, offset);
- buffer.writeInt8(this.lockedVideoOrientation,offset);
+ buffer.writeInt8(this.lockedVideoOrientation, offset);
+ // FIXME: codec options are ignored
+ // should be something like: "codecOptions=`i-frame-interval=${iFrameInterval}`";
return buffer;
}
public toString(): string {
+ // prettier-ignore
return `VideoSettings{bitrate=${
this.bitrate}, frameRate=${
this.frameRate}, iFrameInterval=${
@@ -112,4 +123,17 @@ export default class VideoSettings {
this.sendFrameMeta}, lockedVideoOrientation=${
this.lockedVideoOrientation}}`;
}
+
+ public toJSON(): Settings {
+ return {
+ bitrate: this.bitrate,
+ frameRate: this.frameRate,
+ iFrameInterval: this.iFrameInterval,
+ maxSize: this.maxSize,
+ crop: this.crop,
+ sendFrameMeta: this.sendFrameMeta,
+ lockedVideoOrientation: this.lockedVideoOrientation,
+ codecOptions: this.codecOptions,
+ };
+ }
}
diff --git a/src/client/BaseClient.ts b/src/client/BaseClient.ts
index a62c202a..9191384e 100644
--- a/src/client/BaseClient.ts
+++ b/src/client/BaseClient.ts
@@ -7,6 +7,10 @@ export class BaseClient {
titleTag.innerText = text;
}
+ public setBodyClass(text: string): void {
+ document.body.className = text;
+ }
+
public escapeUdid(udid: string): string {
return 'udid_' + udid.replace(/[. :]/g, '_');
}
diff --git a/src/client/ClientDeviceTracker.ts b/src/client/ClientDeviceTracker.ts
index b1f5c71c..cb6307db 100644
--- a/src/client/ClientDeviceTracker.ts
+++ b/src/client/ClientDeviceTracker.ts
@@ -3,7 +3,6 @@ import { NodeClient } from './NodeClient';
import { Message } from '../common/Message';
import { Device } from '../common/Device';
import { StreamParams } from './ScrcpyClient';
-import { LogsParams } from './ClientLogsProxy';
import { SERVER_PORT } from '../server/Constants';
import { ShellParams } from './ClientShell';
@@ -15,61 +14,62 @@ type MapItem = {
const FIELDS_MAP: MapItem[] = [
{
field: 'product.manufacturer',
- title: 'Manufacturer'
+ title: 'Manufacturer',
},
{
field: 'product.model',
- title: 'Model'
+ title: 'Model',
},
{
field: 'build.version.release',
- title: 'Release'
+ title: 'Release',
},
{
field: 'build.version.sdk',
- title: 'SDK'
+ title: 'SDK',
},
{
field: 'udid',
- title: 'Serial'
+ title: 'Serial',
},
{
field: 'state',
- title: 'State'
+ title: 'State',
},
{
field: 'pid',
- title: 'Pid'
+ title: 'Pid',
},
{
- title: 'Broadway'
+ title: 'Broadway',
},
{
- title: 'Native'
+ title: 'Native',
},
{
- title: 'h264bsd'
+ title: 'h264bsd',
},
{
- title: 'Logs'
+ title: 'tinyh264',
},
{
- title: 'Shell'
- }
+ title: 'Shell',
+ },
];
-type Decoders = 'broadway' | 'native' | 'h264bsd';
+type Decoders = 'broadway' | 'native' | 'h264bsd' | 'tinyh264';
-const DECODERS: Decoders[] = ['broadway', 'native', 'h264bsd' ];
+const DECODERS: Decoders[] = ['broadway', 'native', 'h264bsd', 'tinyh264'];
export class ClientDeviceTracker extends NodeClient {
- public static ACTION: string = 'devicelist';
+ public static ACTION = 'devicelist';
public static start(): ClientDeviceTracker {
return new ClientDeviceTracker(ClientDeviceTracker.ACTION);
}
constructor(action: string) {
super(action);
+ this.setBodyClass('list');
this.setTitle('Device list');
}
@@ -103,7 +103,7 @@ export class ClientDeviceTracker extends NodeClient {
devices = document.createElement('div');
devices.id = 'devices';
devices.className = 'table-wrapper';
- document.body.append(devices);
+ document.body.appendChild(devices);
}
const id = 'devicesList';
let tbody = document.querySelector(`#devices table#${id} tbody`) as Element;
@@ -111,72 +111,74 @@ export class ClientDeviceTracker extends NodeClient {
const table = document.createElement('table');
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
- FIELDS_MAP.forEach(item => {
- const {title} = item;
+ FIELDS_MAP.forEach((item) => {
+ const { title } = item;
const td = document.createElement('th');
td.innerText = title;
td.className = title.toLowerCase();
- headRow.append(td);
+ headRow.appendChild(td);
});
- thead.append(headRow);
- table.append(thead);
+ thead.appendChild(headRow);
+ table.appendChild(thead);
tbody = document.createElement('tbody');
table.id = id;
- table.append(tbody);
+ table.appendChild(tbody);
table.setAttribute('width', '100%');
- devices.append(table);
+ devices.appendChild(table);
} else {
while (tbody.children.length) {
tbody.removeChild(tbody.children[0]);
}
}
- data.forEach(device => {
+ data.forEach((device) => {
const row = document.createElement('tr');
- FIELDS_MAP.forEach(item => {
+ FIELDS_MAP.forEach((item) => {
if (item.field) {
const td = document.createElement('td');
td.innerText = device[item.field].toString();
- row.append(td);
+ row.appendChild(td);
}
});
const isActive = device.state === 'device';
- DECODERS.forEach(decoderName => {
+ DECODERS.forEach((decoderName) => {
const decoderTd = document.createElement('td');
if (isActive) {
- decoderTd.append(ClientDeviceTracker.buildLink({
- showFps: true,
- action: 'stream',
- udid: device.udid,
- decoder: decoderName,
- ip: device.ip,
- port: SERVER_PORT.toString(10)
- }, 'stream'));
+ decoderTd.appendChild(
+ ClientDeviceTracker.buildLink(
+ {
+ showFps: true,
+ action: 'stream',
+ udid: device.udid,
+ decoder: decoderName,
+ ip: device.ip,
+ port: SERVER_PORT.toString(10),
+ },
+ 'stream',
+ ),
+ );
}
- row.append(decoderTd);
+ row.appendChild(decoderTd);
});
- const logsTd = document.createElement('td');
- if (isActive) {
- logsTd.append(ClientDeviceTracker.buildLink({
- action: 'logcat',
- udid: device.udid
- }, 'logs'));
- }
- row.append(logsTd);
const shellTd = document.createElement('td');
if (isActive) {
- shellTd.append(ClientDeviceTracker.buildLink({
- action: 'shell',
- udid: device.udid
- }, 'shell'));
+ shellTd.appendChild(
+ ClientDeviceTracker.buildLink(
+ {
+ action: 'shell',
+ udid: device.udid,
+ },
+ 'shell',
+ ),
+ );
}
- row.append(shellTd);
- tbody.append(row);
+ row.appendChild(shellTd);
+ tbody.appendChild(row);
});
}
- private static buildLink(q: LogsParams | StreamParams | ShellParams, text: string): HTMLAnchorElement {
+ private static buildLink(q: StreamParams | ShellParams, text: string): HTMLAnchorElement {
const hash = `#!${querystring.encode(q)}`;
const a = document.createElement('a');
a.setAttribute('href', `${location.origin}${location.pathname}${hash}`);
diff --git a/src/client/ClientLogsProxy.ts b/src/client/ClientLogsProxy.ts
deleted file mode 100644
index 1cff19bd..00000000
--- a/src/client/ClientLogsProxy.ts
+++ /dev/null
@@ -1,445 +0,0 @@
-import { NodeClient } from './NodeClient';
-import { Message } from '../common/Message';
-import { Filters, FiltersJoin, LogcatServiceMessage, TextFilter } from '../common/LogcatMessage';
-import { AdbKitLogcatEntry } from '../common/AdbKitLogcat';
-import { ParsedUrlQueryInput } from 'querystring';
-import { ACTION, Fields, LogsFilter, PriorityLevel } from '../server/LogsFilter';
-
-const MAX = 1000;
-
-const CLIENT_FILTER_CLASSNAME = 'client-filter';
-const PRIORITY_LEVELS = ['', '', 'VERBOSE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL', 'SILENT'];
-const FILTER_TYPE = ['Tag', 'Message', 'PID', 'TID', 'Priority'];
-const SELECT_FIELD_ID = 'selectFilterField';
-const SELECT_PRIORITY_ID = 'selectFilterPriority';
-const INPUT_TEXT_ID = 'inputFilterText';
-
-export interface LogsParams extends ParsedUrlQueryInput {
- action: 'logcat';
- udid: string;
-}
-
-export class ClientLogsProxy extends NodeClient {
- public static readonly defaultPriority: PriorityLevel = PriorityLevel.ERROR;
- public static ACTION: string = 'logcat';
- public static start(params: LogsParams): ClientLogsProxy {
- return new ClientLogsProxy(params.action, params.udid);
- }
- private readonly escapedUdid: string;
- private cache: AdbKitLogcatEntry[] = [];
- private entryToRowMap: WeakMap = new WeakMap();
- private rowToEntryMap: WeakMap = new WeakMap();
- private filters: Filters = {
- priority: ClientLogsProxy.defaultPriority
- };
-
- constructor(action: string, private readonly udid: string) {
- super(action);
- this.ws.onopen = this.onSocketOpen.bind(this);
- this.escapedUdid = this.escapeUdid(udid);
- document.body.className = 'body-logcat';
- this.setTitle(`Logcat ${udid}`);
- this.createFilterInputs();
- ClientLogsProxy.getOrCreateTbody(this.escapedUdid);
- this.buildFiltersButtons(CLIENT_FILTER_CLASSNAME);
- }
- protected onSocketOpen = (): void => {
- this.startLogcat(this.udid);
- };
-
- protected onSocketClose(e: CloseEvent): void {
- console.log(`Connection closed: ${e.reason}`);
- }
-
- protected onSocketMessage(e: MessageEvent): void {
- let message: Message;
- try {
- message = JSON.parse(e.data);
- } catch (error) {
- console.error(error.message);
- console.log(e.data);
- return;
- }
- if (message.type !== ClientLogsProxy.ACTION) {
- console.log(`Unknown message type: ${message.type}`);
- return;
- }
- const logcatMessage: LogcatServiceMessage = message.data as LogcatServiceMessage;
- if (logcatMessage.type === 'error') {
- console.error(JSON.stringify(logcatMessage.event));
- } else if (logcatMessage.type === 'entry') {
- this.appendLog(logcatMessage);
- } else {
- console.log(JSON.stringify(logcatMessage));
- }
- }
-
- private onClickFilterButtons(ev: MouseEvent): void {
- if (!ev || !ev.target) {
- return;
- }
- const e: Element = ev.target as Element;
- const type = e.getAttribute('data-filter');
- const text = e.getAttribute('data-text');
- const priorityText = e.getAttribute('data-priority');
- const priority = (priorityText ? parseInt(priorityText, 10) : 0) as PriorityLevel;
- if (typeof type !== 'string' || typeof text !== 'string') {
- return;
- }
- let updated = false;
- if (type === Fields.Priority) {
- if (this.filters.priority === priority) {
- this.filters.priority = ClientLogsProxy.defaultPriority;
- updated = true;
- }
- } else {
- updated = LogsFilter.updateFilter(ACTION.REMOVE, priority, text, type, this.filters);
- }
- if (updated) {
- this.applyFilters();
- this.buildFiltersButtons(CLIENT_FILTER_CLASSNAME);
- }
- }
-
- public startLogcat(name: string): void {
- if (!name || !this.ws || this.ws.readyState !== this.ws.OPEN) {
- return;
- }
- const message: Message = {
- id: 1,
- type: 'logcat',
- data: {
- type: 'start',
- udid: name
- }
- };
- this.ws.send(JSON.stringify(message));
- }
-
- private static padNum(number: number): string {
- if (number < 10) {
- return '0' + number;
- }
- return '' + number;
- }
-
- private static formatDate(date: Date): string {
- const mo = date.getMonth();
- const da = date.getDate();
- const ho = date.getHours();
- const mi = date.getMinutes();
- const se = date.getSeconds();
- const ms = (date.getMilliseconds() * 100).toString().substr(0, 3);
- return `${this.padNum(mo)}-${this.padNum(da)} ${this.padNum(ho)}:${this.padNum(mi)}:${this.padNum(se)}.${ms}`;
- }
-
- private createFilterInputs(): void {
- const wrapper = document.createElement('div');
- wrapper.className = 'controls-add-filter';
- const parag = document.createElement('p');
- const label = document.createElement('label');
- const cInputId = INPUT_TEXT_ID;
- label.setAttribute('for', cInputId);
- label.innerText = 'Add filter:';
- parag.append(label);
- const selectField = document.createElement('select');
- selectField.id = SELECT_FIELD_ID;
- FILTER_TYPE.forEach(type => {
- const option = document.createElement('option');
- option.value = type.toLowerCase();
- option.innerText = type;
- selectField.append(option);
- });
- parag.append(selectField);
- const selectPriority = document.createElement('select');
- selectPriority.id = SELECT_PRIORITY_ID;
- PRIORITY_LEVELS.forEach((level: string, idx: number) => {
- if (!level) {
- return;
- }
- const option = document.createElement('option');
- option.value = idx.toString();
- option.innerText = level;
- selectPriority.append(option);
- if (idx === PriorityLevel.VERBOSE) {
- option.selected = true;
- }
- });
- parag.append(selectPriority);
-
- const input = document.createElement('input');
- input.id = cInputId;
- parag.append(input);
- selectField.onchange = () => {
- if (selectField.options[selectField.selectedIndex].value.toLowerCase() === Fields.Priority) {
- input.style.display = 'none';
- } else {
- input.style.display = 'initial';
- }
- };
- const buttonToClient = document.createElement('button');
- buttonToClient.id = 'cFilterButton';
- buttonToClient.className = 'button-add-filter button-add-filter-client';
- buttonToClient.innerText = 'to client';
- buttonToClient.onclick = () => {
- const type = FILTER_TYPE[selectField.selectedIndex].toLowerCase();
- const priority = LogsFilter.priorityFromName(selectPriority.selectedOptions[0].text);
- this.addFilter(type, input.value.trim(), priority);
- input.value = '';
- };
- parag.append(buttonToClient);
- // const buttonToServer = document.createElement('button');
- // buttonToServer.id = 'sFilterButton';
- // buttonToServer.className = 'button-add-filter button-add-filter-server';
- // buttonToServer.innerText = 'to server';
- // buttonToServer.onclick = () => {
- // console.error('Not implemented');
- // input.value = '';
- // };
- // parag.append(buttonToServer);
- wrapper.append(parag);
- document.body.append(wrapper);
- }
-
- private addFilter(type: string, input: string, priority: PriorityLevel): void {
- let updated: boolean = false;
- if (type === Fields.Priority) {
- if (this.filters.priority !== priority) {
- this.filters.priority = priority;
- updated = true;
- }
- } else {
- if (!input) {
- return;
- }
- updated = LogsFilter.updateFilter(ACTION.ADD, priority, input, type, this.filters);
- }
-
- if (updated) {
- this.applyFilters();
- this.buildFiltersButtons(CLIENT_FILTER_CLASSNAME);
- }
- }
-
- private buildFiltersButtons(className: string): void {
- const p = this.filters.priority;
- const list: Element[] = [
- ClientLogsProxy.createFilterButton(Fields.Priority, className, '', p)
- ];
- const buttons = this.getOrCreateButtonsWrapper();
- ClientLogsProxy.createButtonsForFilter(Fields.PID, className, list, this.filters.pid);
- ClientLogsProxy.createButtonsForFilter(Fields.TID, className, list, this.filters.tid);
- ClientLogsProxy.createButtonsForFilter(Fields.Tag, className, list, this.filters.tag);
- ClientLogsProxy.createButtonsForFilter(Fields.Message, className, list, this.filters.message);
- while (buttons.children.length) {
- buttons.removeChild(buttons.children[0]);
- }
- list.forEach(e => {
- buttons.append(e);
- });
- }
-
- private static createButtonsForFilter(type: Fields, className: string, list: Element[], filter?: FiltersJoin): void {
- if (!filter) {
- return;
- }
- if (filter instanceof Map) {
- for (const [value, priority] of filter.entries()) {
- list.push(ClientLogsProxy.createFilterButton(type, className, value.toString(), priority));
- }
- return;
- }
- filter.forEach((e: TextFilter) => {
- list.push(ClientLogsProxy.createFilterButton(type, className, e.value.toString(), e.priority));
- });
- }
-
- private static createFilterButton(type: string, className: string, text: string, priority: PriorityLevel): Element {
- const e = document.createElement('div');
- if (text) {
- e.innerText = `${type}: ${text} (${LogsFilter.priorityToLetter(priority)})`;
- } else {
- e.innerText = `${type}: ${LogsFilter.priorityToName(priority)}`;
- }
- e.className = `filter-button filter-${type} ${className}`;
- e.setAttribute('data-filter', type);
- e.setAttribute('data-text', text);
- e.setAttribute('data-priority', priority.toString(10));
- return e;
- }
-
- private static getOrCreateWrapper(): Element {
- let wrapper = document.getElementById('logcat');
- if (!wrapper) {
- wrapper = document.createElement('div');
- wrapper.id = 'logcat';
- wrapper.className = 'table-wrapper';
- document.body.append(wrapper);
- }
- return wrapper;
- }
-
- private getOrCreateButtonsWrapper(): Element {
- let buttons = document.getElementById('buttons');
- if (!buttons) {
- buttons = document.createElement('div');
- buttons.id = 'buttons';
- buttons.className = 'buttons-wrapper';
- if (document.body.children.length) {
- document.body.insertBefore(buttons, document.body.children[0]);
- } else {
- document.body.append(buttons);
- }
- buttons.onclick = this.onClickFilterButtons.bind(this);
- }
- return buttons;
- }
-
- private static getOrCreateTbody(udid: string): Element {
- const wrapper = this.getOrCreateWrapper();
- let tbody = document.querySelector(`#logcat table#${udid} tbody`);
- if (!tbody) {
- const table = document.createElement('table');
- const thead = document.createElement('thead');
- const headRow = document.createElement('tr');
- ['Date', 'PID', 'TID', 'P', 'Tag', 'Message'].forEach(name => {
- const td = document.createElement('th');
- td.innerText = name;
- td.className = name.toLowerCase();
- headRow.append(td);
- });
- thead.append(headRow);
- table.append(thead);
- tbody = document.createElement('tbody') as HTMLTableSectionElement;
- table.id = udid;
- table.append(tbody);
- table.setAttribute('width', '100%');
- wrapper.append(table);
- tbody.addEventListener('mouseup', () => {
- const selection = window.getSelection();
- const text = selection?.toString().trim();
- if (selection && text) {
- let el = selection.anchorNode?.parentElement;
- while (el && el.tagName !== 'TD') {
- el = el.parentElement;
- }
- if (el) {
- this.prepareAsFilter(el.className, text);
- }
- }
- });
- }
- return tbody;
- }
-
- private static prepareAsFilter(type: string, text: string): void {
- const lowerCase = type.toLowerCase();
- const valid = FILTER_TYPE.filter(t => t.toLowerCase() === lowerCase);
- if (valid.length) {
- const selectField = document.getElementById(SELECT_FIELD_ID) as HTMLSelectElement;
- const input = document.getElementById(INPUT_TEXT_ID) as HTMLInputElement;
- if (!selectField || ! input) {
- return;
- }
- const optField = Array.from(selectField.options).filter(o => o.value === type)[0];
- if (!optField) {
- return;
- }
- selectField.selectedIndex = optField.index;
- input.value = text;
- input.style.display = 'initial';
- }
- }
-
- private applyFilters(): void {
- let count = 0;
- const rows: Element[] = [];
- for (let i = this.cache.length - 1; i >= 0 && count <= MAX; i--) {
- const entry = this.cache[i];
- if (!LogsFilter.filterEvent(this.filters, entry)) {
- continue;
- }
- count++;
- let row = this.entryToRowMap.get(entry);
- if (!row) {
- row = ClientLogsProxy.createRow(entry);
- this.entryToRowMap.set(entry, row);
- this.rowToEntryMap.set(row, entry);
- }
- rows.push(row);
- }
- const tbody = ClientLogsProxy.getOrCreateTbody(this.escapedUdid);
- let l = tbody.children.length;
- for (let i = 0; i < l; i++) {
- const row = tbody.children[i];
- if (!rows.includes(row)) {
- i--;
- tbody.removeChild(row);
- l = tbody.children.length;
- const e = this.rowToEntryMap.get(row);
- if (e) {
- this.entryToRowMap.delete(e);
- }
- this.rowToEntryMap.delete(row);
- }
- }
- rows.forEach(row => {
- tbody.append(row);
- });
- }
-
- private static createRow(entry: AdbKitLogcatEntry): Element {
- const row = document.createElement('tr');
- const dateTd = document.createElement('td');
- dateTd.innerText = ClientLogsProxy.formatDate(new Date(entry.date));
- dateTd.className = 'date';
- row.append(dateTd);
- const pid = document.createElement('td');
- pid.innerText = `[${entry.pid}]`;
- pid.className = 'pid';
- row.append(pid);
- const tid = document.createElement('td');
- tid.innerText = `[${entry.tid}]`;
- tid.className = 'tid';
- row.append(tid);
- const priority = document.createElement('td');
- priority.innerText = LogsFilter.priorityToLetter(entry.priority);
- priority.className = 'p';
- row.append(priority);
- const tag = document.createElement('td');
- tag.innerHTML = `${entry.tag}
`;
- tag.className = 'tag';
- row.append(tag);
- const message = document.createElement('td');
- message.className = 'message';
- message.innerHTML = `${entry.message}
`;
- row.append(message);
- return row;
- }
-
- private appendLog(logcatMessage: LogcatServiceMessage): void {
- const entry: AdbKitLogcatEntry = logcatMessage.event as AdbKitLogcatEntry;
- this.cache.push(entry);
- if (!LogsFilter.filterEvent(this.filters, entry)) {
- return;
- }
- const tbody = ClientLogsProxy.getOrCreateTbody(this.escapeUdid(logcatMessage.udid));
- const row = ClientLogsProxy.createRow(entry);
- if (tbody.children.length) {
- tbody.insertBefore(row, tbody.children[0]);
- } else {
- tbody.append(row);
- }
- this.entryToRowMap.set(entry, row);
- this.rowToEntryMap.set(row, entry);
- while (tbody.children.length > MAX) {
- const last = tbody.children[tbody.children.length - 1];
- tbody.removeChild(last);
- const msg = this.rowToEntryMap.get(last);
- if (msg) {
- this.entryToRowMap.delete(msg);
- }
- this.rowToEntryMap.delete(last);
- }
- }
-}
diff --git a/src/client/ClientShell.ts b/src/client/ClientShell.ts
index e40776a5..3a468866 100644
--- a/src/client/ClientShell.ts
+++ b/src/client/ClientShell.ts
@@ -11,7 +11,7 @@ export interface ShellParams extends ParsedUrlQueryInput {
}
export class ClientShell extends NodeClient {
- public static ACTION: string = 'shell';
+ public static ACTION = 'shell';
public static start(params: ShellParams): ClientShell {
return new ClientShell(params.action, params.udid);
}
@@ -23,7 +23,7 @@ export class ClientShell extends NodeClient {
super(action);
this.ws.onopen = this.onSocketOpen.bind(this);
this.setTitle(`Shell ${udid}`);
- document.body.className = 'body-shell';
+ this.setBodyClass('shell');
this.term = new Terminal();
this.term.loadAddon(new AttachAddon(this.ws));
this.fitAddon = new FitAddon();
@@ -58,14 +58,14 @@ export class ClientShell extends NodeClient {
type: 'start',
rows,
cols,
- udid
- }
+ udid,
+ },
};
this.ws.send(JSON.stringify(message));
}
protected buildWebSocketUrl(): string {
- const proto = (location.protocol === 'https:') ? 'wss' : 'ws';
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
return `${proto}://${location.host}/?action=${this.action}&`;
}
@@ -75,17 +75,20 @@ export class ClientShell extends NodeClient {
container = document.createElement('div');
container.className = 'terminal-container';
container.id = udid;
- document.body.append(container);
+ document.body.appendChild(container);
}
return container;
}
private updateTerminalSize(): void {
- // tslint:disable-next-line:no-any
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
const term: any = this.term;
const terminalContainer: HTMLElement = ClientShell.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 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;
diff --git a/src/client/NodeClient.ts b/src/client/NodeClient.ts
index 1fe6d766..92e7d81f 100644
--- a/src/client/NodeClient.ts
+++ b/src/client/NodeClient.ts
@@ -1,9 +1,9 @@
import { BaseClient } from './BaseClient';
export abstract class NodeClient extends BaseClient {
- public static ACTION: string = 'unknown';
+ public static ACTION = 'unknown';
- // tslint:disable-next-line:no-any
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
public static start(..._rest: any[]): void {
throw Error('Not implemented');
}
@@ -26,7 +26,7 @@ export abstract class NodeClient extends BaseClient {
}
protected buildWebSocketUrl(): string {
- const proto = (location.protocol === 'https:') ? 'wss' : 'ws';
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
return `${proto}://${location.host}/?action=${this.action}`;
}
diff --git a/src/client/ScrcpyClient.ts b/src/client/ScrcpyClient.ts
index 9acf4028..6396a054 100644
--- a/src/client/ScrcpyClient.ts
+++ b/src/client/ScrcpyClient.ts
@@ -1,17 +1,13 @@
import NativeDecoder from '../decoder/NativeDecoder';
import { DeviceController } from '../DeviceController';
-import { BroadwayDecoder, CANVAS_TYPE } from '../decoder/BroadwayDecoder';
+import BroadwayDecoder from '../decoder/BroadwayDecoder';
import H264bsdDecoder from '../decoder/H264bsdDecoder';
import { ParsedUrlQueryInput } from 'querystring';
import { BaseClient } from './BaseClient';
import Decoder from '../decoder/Decoder';
+import Tinyh264Decoder from '../decoder/Tinyh264Decoder';
-export interface Arguments {
- url: string;
- name: string;
-}
-
-export type Decoders = 'broadway' | 'h264bsd' | 'native';
+export type Decoders = 'broadway' | 'h264bsd' | 'native' | 'tinyh264';
export interface StreamParams extends ParsedUrlQueryInput {
action: 'stream';
@@ -23,7 +19,7 @@ export interface StreamParams extends ParsedUrlQueryInput {
}
export class ScrcpyClient extends BaseClient {
- public static ACTION: string = 'stream';
+ public static ACTION = 'stream';
private static instance?: ScrcpyClient;
public static start(params: StreamParams): ScrcpyClient {
this.getOrCreateControlsWrapper();
@@ -32,7 +28,8 @@ export class ScrcpyClient extends BaseClient {
if (decoder) {
decoder.showFps = !!params.showFps;
}
- client.setTitle(`WS scrcpy ${params.decoder} ${params.udid}`);
+ client.setBodyClass('stream');
+ client.setTitle(`${params.udid} stream`);
return client;
}
@@ -51,72 +48,40 @@ export class ScrcpyClient extends BaseClient {
if (!controlsWrap) {
controlsWrap = document.createElement('div');
controlsWrap.id = 'controlsWrap';
- document.body.append(controlsWrap);
+ document.body.appendChild(controlsWrap);
}
return controlsWrap;
}
- public static startNative(params: Arguments): Decoder {
- const {url, name} = params;
- const tag = NativeDecoder.createElement();
- const decoder = new NativeDecoder(tag);
- const controller = new DeviceController({
- url,
- name,
- decoder,
- videoSettings: NativeDecoder.preferredVideoSettings
- });
- controller.start();
- return decoder;
- }
-
- public static startBroadway(params: Arguments): Decoder {
- const {url, name} = params;
- const tag = BroadwayDecoder.createElement();
- const decoder = new BroadwayDecoder(tag, CANVAS_TYPE.WEBGL);
- const controller = new DeviceController({
- url,
- name,
- decoder,
- videoSettings: BroadwayDecoder.preferredVideoSettings
- });
- controller.start();
- return decoder;
- }
-
- public static startH264bsd(params: Arguments): Decoder {
- const {url, name} = params;
- const tag = H264bsdDecoder.createElement();
- const decoder = new H264bsdDecoder(tag);
- const controller = new DeviceController({
- url,
- name,
- decoder,
- videoSettings: H264bsdDecoder.preferredVideoSettings
- });
- controller.start();
- return decoder;
- }
-
- public startStream(name: string, decoderName: string, url: string): Decoder | undefined {
- if (!url || !name) {
+ public startStream(udid: string, decoderName: string, url: string): Decoder | undefined {
+ if (!url || !udid) {
return;
}
- let decoder: Decoder;
+ let decoderClass: new (udid: string) => Decoder;
switch (decoderName) {
case 'native':
- decoder = ScrcpyClient.startNative({url, name});
+ decoderClass = NativeDecoder;
break;
case 'broadway':
- decoder = ScrcpyClient.startBroadway({url, name});
+ decoderClass = BroadwayDecoder;
break;
case 'h264bsd':
- decoder = ScrcpyClient.startH264bsd({url, name});
+ decoderClass = H264bsdDecoder;
+ break;
+ case 'tinyh264':
+ decoderClass = Tinyh264Decoder;
break;
default:
return;
}
- console.log(decoderName, name, url);
+ const decoder = new decoderClass(udid);
+ const controller = new DeviceController({
+ url,
+ udid,
+ decoder,
+ });
+ controller.start();
+ console.log(decoder.getName(), udid, url);
return decoder;
}
}
diff --git a/src/common/Message.d.ts b/src/common/Message.d.ts
index 2fb6de22..3f04434e 100644
--- a/src/common/Message.d.ts
+++ b/src/common/Message.d.ts
@@ -1,11 +1,11 @@
import { Device } from './Device';
-import { LogcatClientMessage, LogcatServiceMessage } from './LogcatMessage';
+import { LogcatClientMessage, LogcatServiceMessage } from 'adbkit/LogcatMessage';
import { XtermClientMessage } from './XtermMessage';
export enum MessageTypes {
'devicelist',
'logcat',
- 'shell'
+ 'shell',
}
export interface Message {
diff --git a/src/common/XtermMessage.d.ts b/src/common/XtermMessage.d.ts
index 42baf232..5e96c121 100644
--- a/src/common/XtermMessage.d.ts
+++ b/src/common/XtermMessage.d.ts
@@ -1,6 +1,6 @@
export enum XtermServiceActions {
start,
- stop
+ stop,
}
export interface XtermServiceParameters {
diff --git a/src/controlEvent/CommandControlEvent.ts b/src/controlEvent/CommandControlEvent.ts
index 9acc395c..804c4a82 100644
--- a/src/controlEvent/CommandControlEvent.ts
+++ b/src/controlEvent/CommandControlEvent.ts
@@ -2,8 +2,24 @@ import ControlEvent from './ControlEvent';
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 default class CommandControlEvent extends ControlEvent {
- public static PAYLOAD_LENGTH: number = 0;
+ public static PAYLOAD_LENGTH = 0;
public static CommandCodes: Record = {
TYPE_EXPAND_NOTIFICATION_PANEL: ControlEvent.TYPE_EXPAND_NOTIFICATION_PANEL,
@@ -11,7 +27,7 @@ export default class CommandControlEvent extends ControlEvent {
TYPE_GET_CLIPBOARD: ControlEvent.TYPE_GET_CLIPBOARD,
TYPE_SET_CLIPBOARD: ControlEvent.TYPE_SET_CLIPBOARD,
TYPE_ROTATE_DEVICE: ControlEvent.TYPE_ROTATE_DEVICE,
- TYPE_CHANGE_STREAM_PARAMETERS: ControlEvent.TYPE_CHANGE_STREAM_PARAMETERS
+ TYPE_CHANGE_STREAM_PARAMETERS: ControlEvent.TYPE_CHANGE_STREAM_PARAMETERS,
};
public static CommandNames: Record = {
@@ -20,7 +36,7 @@ export default class CommandControlEvent extends ControlEvent {
7: 'Get clipboard',
8: 'Set clipboard',
10: 'Rotate device',
- 11: 'Change video settings'
+ 101: 'Change video settings',
};
public static createSetVideoSettingsCommand(videoSettings: VideoSettings): CommandControlEvent {
@@ -36,21 +52,110 @@ export default class CommandControlEvent extends ControlEvent {
return event;
}
- public static createSetClipboard(text: string): CommandControlEvent {
+ public static createSetClipboardCommand(text: string, paste: boolean = false): CommandControlEvent {
const event = new CommandControlEvent(ControlEvent.TYPE_SET_CLIPBOARD);
- const temp = Util.stringToUtf8ByteArray(text);
- let offset = CommandControlEvent.PAYLOAD_LENGTH + 1;
- const buffer = new Buffer(offset + 2 + temp.length);
- buffer.writeUInt8(event.type, 0);
- buffer.writeUInt16BE(temp.length, offset);
- offset += 2;
- temp.forEach((byte, index) => {
+ const textBytes: Uint8Array | null = text ? Util.stringToUtf8ByteArray(text) : null;
+ const textLength = textBytes ? textBytes.length : 0;
+ let offset = 0;
+ const buffer = new Buffer(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 createPushFileCommand(params: FilePushParams): CommandControlEvent {
+ 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) {
+ return this.createPushFileChunkCommand(id, chunk as Uint8Array);
+ }
+ 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): CommandControlEvent {
+ const event = new CommandControlEvent(ControlEvent.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 = CommandControlEvent.PAYLOAD_LENGTH;
+
+ const buffer = new Buffer(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): CommandControlEvent {
+ const event = new CommandControlEvent(ControlEvent.TYPE_PUSH_FILE);
+ const typeField = 1;
+ const idField = 2;
+ const stateField = 1;
+ const chunkLengthField = 4;
+ const chunkLength = chunk.byteLength;
+ let offset = CommandControlEvent.PAYLOAD_LENGTH;
+
+ const buffer = new Buffer(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) {
+ const event = new CommandControlEvent(ControlEvent.TYPE_PUSH_FILE);
+ const typeField = 1;
+ const idField = 2;
+ const stateField = 1;
+ let offset = CommandControlEvent.PAYLOAD_LENGTH;
+ const buffer = new Buffer(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;
+ }
+
private buffer?: Buffer;
constructor(readonly type: number) {
diff --git a/src/controlEvent/ControlEvent.ts b/src/controlEvent/ControlEvent.ts
index a3c90289..6d91ddf4 100644
--- a/src/controlEvent/ControlEvent.ts
+++ b/src/controlEvent/ControlEvent.ts
@@ -1,19 +1,19 @@
export default class ControlEvent {
- public static TYPE_KEYCODE: number = 0;
- public static TYPE_TEXT: number = 1;
- public static TYPE_MOUSE: number = 2;
- public static TYPE_SCROLL: number = 3;
- public static TYPE_BACK_OR_SCREEN_ON: number = 4;
- public static TYPE_EXPAND_NOTIFICATION_PANEL: number = 5;
- public static TYPE_COLLAPSE_NOTIFICATION_PANEL: number = 6;
- public static TYPE_GET_CLIPBOARD: number = 7;
- public static TYPE_SET_CLIPBOARD: number = 8;
- public static TYPE_SET_SCREEN_POWER_MODE: number = 9;
- public static TYPE_ROTATE_DEVICE: number = 10;
- public static TYPE_CHANGE_STREAM_PARAMETERS: number = 11;
+ public static TYPE_KEYCODE = 0;
+ public static TYPE_TEXT = 1;
+ public static TYPE_MOUSE = 2;
+ public static TYPE_SCROLL = 3;
+ public static TYPE_BACK_OR_SCREEN_ON = 4;
+ public static TYPE_EXPAND_NOTIFICATION_PANEL = 5;
+ public static TYPE_COLLAPSE_NOTIFICATION_PANEL = 6;
+ public static TYPE_GET_CLIPBOARD = 7;
+ public static TYPE_SET_CLIPBOARD = 8;
+ public static TYPE_SET_SCREEN_POWER_MODE = 9;
+ public static TYPE_ROTATE_DEVICE = 10;
+ public static TYPE_CHANGE_STREAM_PARAMETERS = 101;
+ public static TYPE_PUSH_FILE = 102;
- constructor(readonly type: number) {
- }
+ constructor(readonly type: number) {}
public toBuffer(): Buffer {
throw Error('Not implemented');
diff --git a/src/controlEvent/KeyCodeControlEvent.ts b/src/controlEvent/KeyCodeControlEvent.ts
index 5ac17763..90f6a42a 100644
--- a/src/controlEvent/KeyCodeControlEvent.ts
+++ b/src/controlEvent/KeyCodeControlEvent.ts
@@ -2,9 +2,9 @@ import { Buffer } from 'buffer';
import ControlEvent from './ControlEvent';
export default class KeyCodeControlEvent extends ControlEvent {
- public static PAYLOAD_LENGTH: number = 9;
+ public static PAYLOAD_LENGTH = 13;
- constructor(readonly action: number, readonly keycode: number, readonly metaState: number) {
+ constructor(readonly action: number, readonly keycode: number, readonly repeat: number, readonly metaState: number) {
super(ControlEvent.TYPE_KEYCODE);
}
@@ -13,10 +13,12 @@ export default class KeyCodeControlEvent extends ControlEvent {
*/
public toBuffer(): Buffer {
const buffer = new Buffer(KeyCodeControlEvent.PAYLOAD_LENGTH + 1);
- buffer.writeUInt8(this.type, 0);
- buffer.writeUInt8(this.action, 1);
- buffer.writeUInt32BE(this.keycode, 2);
- buffer.writeUInt32BE(this.metaState, 6);
+ 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);
return buffer;
}
diff --git a/src/controlEvent/MotionControlEvent.ts b/src/controlEvent/MotionControlEvent.ts
index bb062b57..73dd4089 100644
--- a/src/controlEvent/MotionControlEvent.ts
+++ b/src/controlEvent/MotionControlEvent.ts
@@ -2,7 +2,7 @@ import ControlEvent from './ControlEvent';
import Position from '../Position';
export default class MotionControlEvent extends ControlEvent {
- public static PAYLOAD_LENGTH: number = 17;
+ public static PAYLOAD_LENGTH = 17;
constructor(readonly action: number, readonly buttons: number, readonly position: Position) {
super(ControlEvent.TYPE_MOUSE);
diff --git a/src/controlEvent/ScrollControlEvent.ts b/src/controlEvent/ScrollControlEvent.ts
index a11197f1..58b32428 100644
--- a/src/controlEvent/ScrollControlEvent.ts
+++ b/src/controlEvent/ScrollControlEvent.ts
@@ -2,7 +2,7 @@ import ControlEvent from './ControlEvent';
import Position from '../Position';
export default class ScrollControlEvent extends ControlEvent {
- public static PAYLOAD_LENGTH: number = 20;
+ public static PAYLOAD_LENGTH = 20;
constructor(readonly position: Position, readonly hScroll: number, readonly vScroll: number) {
super(ControlEvent.TYPE_SCROLL);
diff --git a/src/controlEvent/TouchControlEvent.ts b/src/controlEvent/TouchControlEvent.ts
index 0c47411e..64858769 100644
--- a/src/controlEvent/TouchControlEvent.ts
+++ b/src/controlEvent/TouchControlEvent.ts
@@ -2,13 +2,15 @@ import ControlEvent from './ControlEvent';
import Position from '../Position';
export default class TouchControlEvent extends ControlEvent {
- public static PAYLOAD_LENGTH: number = 28;
+ public static PAYLOAD_LENGTH = 28;
- constructor(readonly action: number,
- readonly pointerId: number,
- readonly position: Position,
- readonly pressure: number,
- readonly buttons: number) {
+ constructor(
+ readonly action: number,
+ readonly pointerId: number,
+ readonly position: Position,
+ readonly pressure: number,
+ readonly buttons: number,
+ ) {
super(ControlEvent.TYPE_MOUSE);
}
@@ -20,7 +22,7 @@ export default class TouchControlEvent extends ControlEvent {
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(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);
@@ -32,11 +34,6 @@ export default class TouchControlEvent extends ControlEvent {
}
public toString(): string {
- return `TouchControlEvent{action=${
- this.action}, pointerId=${
- this.pointerId}, position=${
- this.position}, pressure=${
- this.pressure}, buttons=${
- this.buttons}}`;
+ return `TouchControlEvent{action=${this.action}, pointerId=${this.pointerId}, position=${this.position}, pressure=${this.pressure}, buttons=${this.buttons}}`;
}
}
diff --git a/src/decoder/BroadwayDecoder.ts b/src/decoder/BroadwayDecoder.ts
index d08d1a39..77d1dbbc 100644
--- a/src/decoder/BroadwayDecoder.ts
+++ b/src/decoder/BroadwayDecoder.ts
@@ -1,156 +1,48 @@
-import Decoder from './Decoder';
import Size from '../Size';
import YUVCanvas from '../h264-live-player/YUVCanvas';
import YUVWebGLCanvas from '../h264-live-player/YUVWebGLCanvas';
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import Avc from '../Decoder';
import VideoSettings from '../VideoSettings';
import Canvas from '../h264-live-player/Canvas';
-import ScreenInfo from '../ScreenInfo';
+import CanvasCommon from './CanvasCommon';
-export const CANVAS_TYPE: Record = {
- WEBGL: 'webgl',
- YUV: 'YUVWebGLCanvas',
- CANVAS: 'YUVCanvas'
-};
-
-export class BroadwayDecoder extends Decoder {
+export default class BroadwayDecoder extends CanvasCommon {
public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
lockedVideoOrientation: -1,
bitrate: 500000,
frameRate: 24,
iFrameInterval: 5,
maxSize: 480,
- sendFrameMeta: false
+ sendFrameMeta: false,
});
- 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;
- }
- protected TAG: string = 'BroadwayDecoder';
+
+ protected canvas?: Canvas;
private avc?: Avc;
- private canvas?: Canvas;
- private framesList: Uint8Array[] = [];
+ public readonly supportsScreenshot: boolean = true;
- constructor(protected tag: HTMLCanvasElement, private canvastype: string) {
- super(tag);
+ constructor(udid: string) {
+ super(udid, 'BroadwayDecoder');
this.avc = new Avc();
}
- private static isIFrame(frame: Uint8Array): boolean {
- return frame && frame.length > 4 && frame[4] === 0x65;
- }
-
protected initCanvas(width: number, height: number): void {
- const canvasFactory = this.canvastype === 'webgl' || this.canvastype === 'YUVWebGLCanvas'
- ? YUVWebGLCanvas
- : YUVCanvas;
- if (this.canvas) {
- const parent = this.tag.parentNode;
- if (parent) {
- const tag = BroadwayDecoder.createElement(this.tag.id);
- tag.className = this.tag.className;
- parent.replaceChild(tag, this.tag);
- parent.append(this.touchableCanvas);
- this.tag = tag;
- }
+ super.initCanvas(width, height);
+ if (CanvasCommon.hasWebGLSupport()) {
+ this.canvas = new YUVWebGLCanvas(this.tag, new Size(width, height));
+ } else {
+ this.canvas = new YUVCanvas(this.tag, new Size(width, height));
}
- this.tag.onerror = function(e: Event | string): void {
- console.error(e);
- };
- this.tag.oncontextmenu = function(e: MouseEvent): void {
- e.preventDefault();
- };
- this.canvas = new canvasFactory(this.tag, new Size(width, height));
this.avc = new Avc();
this.avc.onPictureDecoded = this.canvas.decode.bind(this.canvas);
- this.tag.width = width;
- this.tag.height = height;
- // if (this.parentElement) {
- // this.parentElement.style.height = `${height}px`;
- // this.parentElement.style.width = `${width}px`;
- // }
}
- private shiftFrame(): void {
- this.updateFps(false);
- if (this.getState() !== Decoder.STATE.PLAYING) {
- return;
- }
-
- const frame = this.framesList.shift();
-
- if (frame) {
- this.decode(frame);
- this.updateFps(true);
- }
- requestAnimationFrame(this.shiftFrame.bind(this));
- }
-
- public decode(data: Uint8Array): void {
- // let naltype = 'invalid frame';
- //
- // if (data.length > 4) {
- // if (data[4] == 0x65) {
- // naltype = 'I frame';
- // } else if (data[4] == 0x41) {
- // naltype = 'P frame';
- // } else if (data[4] == 0x67) {
- // naltype = 'SPS';
- // } else if (data[4] == 0x68) {
- // naltype = 'PPS';
- // }
- // }
- // log('Passed ' + naltype + ' to decoder');
+ protected decode(data: Uint8Array): void {
this.avc.decode(data);
}
- public play(): void {
- super.play();
- if (this.getState() !== Decoder.STATE.PLAYING || !this.screenInfo) {
- return;
- }
- if (!this.canvas) {
- const {width, height} = this.screenInfo.videoSize;
- this.initCanvas(width, height);
- }
- requestAnimationFrame(this.shiftFrame.bind(this));
- }
-
- 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);
- }
-
public getPreferredVideoSetting(): VideoSettings {
return BroadwayDecoder.preferredVideoSettings;
}
-
- public pushFrame(frame: Uint8Array): void {
- if (BroadwayDecoder.isIFrame(frame)) {
- if (this.videoSettings) {
- const {frameRate} = this.videoSettings;
- if (this.framesList.length > frameRate / 2) {
- console.log('Dropping frames', this.framesList.length);
- this.framesList = [];
- }
- }
- }
- this.framesList.push(frame);
- }
-
- private clearState(): void {
- this.framesList = [];
- }
}
diff --git a/src/decoder/CanvasCommon.ts b/src/decoder/CanvasCommon.ts
new file mode 100644
index 00000000..bb8cbd0c
--- /dev/null
+++ b/src/decoder/CanvasCommon.ts
@@ -0,0 +1,124 @@
+import Decoder from './Decoder';
+import ScreenInfo from '../ScreenInfo';
+import VideoSettings from '../VideoSettings';
+
+export default abstract class CanvasCommon extends Decoder {
+ protected framesList: Uint8Array[] = [];
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ protected canvas?: any;
+
+ 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 (e) {
+ gl = null;
+ }
+ }
+ console.log('WebGL support:' + !!gl);
+ 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, name = 'Canvas', protected tag: HTMLCanvasElement = CanvasCommon.createElement()) {
+ super(udid, name, tag);
+ }
+
+ protected abstract decode(data: Uint8Array): void;
+ public abstract getPreferredVideoSetting(): VideoSettings;
+
+ private shiftFrame = (): void => {
+ this.updateFps(false);
+ if (this.getState() !== Decoder.STATE.PLAYING) {
+ return;
+ }
+
+ const frame = this.framesList.shift();
+
+ if (frame) {
+ this.decode(frame);
+ this.updateFps(true);
+ }
+ requestAnimationFrame(this.shiftFrame);
+ };
+
+ 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 = CanvasCommon.createElement(this.tag.id);
+ tag.className = this.tag.className;
+ parent.replaceChild(tag, this.tag);
+ parent.appendChild(this.touchableCanvas);
+ this.tag = tag;
+ }
+ }
+ this.tag.onerror = function (e: Event | string): void {
+ console.error(e);
+ };
+ this.tag.oncontextmenu = function (e: MouseEvent): void {
+ e.preventDefault();
+ };
+ this.tag.width = width;
+ this.tag.height = height;
+ }
+
+ public play(): void {
+ super.play();
+ if (this.getState() !== Decoder.STATE.PLAYING || !this.screenInfo) {
+ return;
+ }
+ if (!this.canvas) {
+ const { width, height } = this.screenInfo.videoSize;
+ this.initCanvas(width, height);
+ }
+ requestAnimationFrame(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);
+ }
+
+ public pushFrame(frame: Uint8Array): void {
+ if (Decoder.isIFrame(frame)) {
+ if (this.videoSettings) {
+ const { frameRate } = this.videoSettings;
+ if (this.framesList.length > frameRate / 2) {
+ console.log(this.name, 'Dropping frames', this.framesList.length);
+ this.framesList = [];
+ }
+ }
+ }
+ this.framesList.push(frame);
+ }
+
+ protected clearState(): void {
+ this.framesList = [];
+ }
+}
diff --git a/src/decoder/Decoder.ts b/src/decoder/Decoder.ts
index fb179fe4..0ff47822 100644
--- a/src/decoder/Decoder.ts
+++ b/src/decoder/Decoder.ts
@@ -1,25 +1,90 @@
import VideoSettings from '../VideoSettings';
import ScreenInfo from '../ScreenInfo';
+import Rect from '../Rect';
export default abstract class Decoder {
public static STATE: Record = {
PLAYING: 1,
PAUSED: 2,
- STOPPED: 3
+ STOPPED: 3,
};
- protected TAG: string = 'Decoder';
protected screenInfo?: ScreenInfo;
- protected videoSettings?: VideoSettings;
+ protected videoSettings: VideoSettings;
protected parentElement?: HTMLElement;
protected touchableCanvas: HTMLCanvasElement;
- protected fpsCurrentValue: number = 0;
+ protected fpsCurrentValue = 0;
protected fpsCounter: number[] = [];
private state: number = Decoder.STATE.STOPPED;
- public showFps: boolean = true;
-
- protected constructor(protected tag: HTMLElement) {
+ public showFps = true;
+ public readonly supportsScreenshot: boolean = false;
+
+ constructor(
+ protected udid: string,
+ protected name: string = 'Decoder',
+ protected tag: HTMLElement = document.createElement('div'),
+ ) {
this.touchableCanvas = document.createElement('canvas');
this.touchableCanvas.className = 'touch-layer';
+ const preferred = this.getPreferredVideoSetting();
+ this.videoSettings = Decoder.getVideoSettingFromStorage(preferred, this.name, udid);
+ }
+
+ protected static isIFrame(frame: Uint8Array): boolean {
+ return frame && frame.length > 4 && frame[4] === 0x65;
+ }
+
+ private static getStorageKey(decoderName: string, udid: string): string {
+ const { innerHeight, innerWidth } = window;
+ return `${decoderName}:${udid}:${innerWidth}x${innerHeight}`;
+ }
+
+ private static getVideoSettingFromStorage(
+ preferred: VideoSettings,
+ decoderName: string,
+ deviceName: string,
+ ): VideoSettings {
+ if (!window.localStorage) {
+ return preferred;
+ }
+ const key = this.getStorageKey(decoderName, deviceName);
+ const saved = window.localStorage.getItem(key);
+ if (!saved) {
+ return preferred;
+ }
+ const parsed = JSON.parse(saved);
+ const { crop, bitrate, maxSize, frameRate, iFrameInterval, sendFrameMeta, lockedVideoOrientation } = parsed;
+ return new VideoSettings({
+ crop: crop ? new Rect(crop.left, crop.top, crop.right, crop.bottom) : preferred.crop,
+ bitrate: !isNaN(bitrate) ? bitrate : preferred.bitrate,
+ maxSize: !isNaN(maxSize) ? maxSize : preferred.maxSize,
+ frameRate: !isNaN(frameRate) ? frameRate : preferred.frameRate,
+ iFrameInterval: !isNaN(iFrameInterval) ? iFrameInterval : preferred.iFrameInterval,
+ sendFrameMeta: typeof sendFrameMeta === 'boolean' ? sendFrameMeta : preferred.sendFrameMeta,
+ lockedVideoOrientation: !isNaN(lockedVideoOrientation)
+ ? lockedVideoOrientation
+ : preferred.lockedVideoOrientation,
+ });
+ }
+
+ private static putVideoSettingsToStorage(
+ decoderName: string,
+ deviceName: string,
+ videoSettings: VideoSettings,
+ ): void {
+ if (!window.localStorage) {
+ return;
+ }
+ const key = this.getStorageKey(decoderName, deviceName);
+ window.localStorage.setItem(key, JSON.stringify(videoSettings));
+ }
+
+ 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 {
@@ -51,16 +116,19 @@ export default abstract class Decoder {
public setParent(parent: HTMLElement): void {
this.parentElement = parent;
- parent.append(this.tag);
- parent.append(this.touchableCanvas);
+ parent.appendChild(this.tag);
+ parent.appendChild(this.touchableCanvas);
}
- public getVideoSettings(): VideoSettings|undefined {
+ public getVideoSettings(): VideoSettings {
return this.videoSettings;
}
- public setVideoSettings(videoSettings: VideoSettings): void {
+ public setVideoSettings(videoSettings: VideoSettings, saveToStorage: boolean): void {
this.videoSettings = videoSettings;
+ if (saveToStorage) {
+ Decoder.putVideoSettingsToStorage(this.name, this.udid, videoSettings);
+ }
}
public getScreenInfo(): ScreenInfo | undefined {
@@ -68,10 +136,9 @@ export default abstract class Decoder {
}
public setScreenInfo(screenInfo: ScreenInfo): void {
- console.log(`${this.TAG}.setScreenInfo(${screenInfo})`);
this.pause();
this.screenInfo = screenInfo;
- const {width, height} = screenInfo.videoSize;
+ const { width, height } = screenInfo.videoSize;
this.touchableCanvas.width = width;
this.touchableCanvas.height = height;
if (this.parentElement) {
@@ -81,7 +148,7 @@ export default abstract class Decoder {
}
public getName(): string {
- return this.TAG;
+ return this.name;
}
protected updateFps(pushNew: boolean): void {
diff --git a/src/decoder/H264bsdDecoder.ts b/src/decoder/H264bsdDecoder.ts
index f16cb5a1..9aa10fa7 100644
--- a/src/decoder/H264bsdDecoder.ts
+++ b/src/decoder/H264bsdDecoder.ts
@@ -1,44 +1,28 @@
-import Decoder from './Decoder';
import VideoSettings from '../VideoSettings';
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { H264bsdCanvas } from '../h264bsd_canvas.js';
-import ScreenInfo from '../ScreenInfo';
-import H264bsdWorker from './H264bsdWorker';
+import H264bsdWorker from '../h264bsd/H264bsdWorker';
+import CanvasCommon from './CanvasCommon';
-export default class H264bsdDecoder extends Decoder {
+export default class H264bsdDecoder extends CanvasCommon {
public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
lockedVideoOrientation: -1,
bitrate: 500000,
frameRate: 24,
iFrameInterval: 5,
maxSize: 480,
- sendFrameMeta: false
+ sendFrameMeta: false,
});
- 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;
- }
- protected TAG: string = 'H264bsdDecoder';
+ protected canvas?: H264bsdCanvas;
private worker?: H264bsdWorker;
- private display?: H264bsdCanvas;
- private framesList: Uint8Array[] = [];
- private running: boolean = false;
- private readonly bindedOnMessage: (e: MessageEvent) => void;
-
- constructor(protected tag: HTMLCanvasElement) {
- super(tag);
- this.bindedOnMessage = this.onWorkerMessage.bind(this);
- }
+ public readonly supportsScreenshot: boolean = true;
- private static isIFrame(frame: Uint8Array): boolean {
- return frame && frame.length > 4 && frame[4] === 0x65;
+ constructor(udid: string) {
+ super(udid, 'H264bsdDecoder');
}
- private onWorkerMessage(e: MessageEvent): void {
+ private onWorkerMessage = (e: MessageEvent): void => {
const message = e.data;
if (!message.hasOwnProperty('type')) {
return;
@@ -58,11 +42,12 @@ export default class H264bsdDecoder extends Decoder {
// Posted when onPictureReady is called on the worker
case 'pictureReady':
- this.display.drawNextOutputPicture(
+ this.canvas.drawNextOutputPicture(
message.width,
message.height,
message.croppingParams,
- new Uint8Array(message.data));
+ new Uint8Array(message.data),
+ );
break;
// Posted after all of the queued data has been decoded
@@ -83,116 +68,47 @@ export default class H264bsdDecoder extends Decoder {
default:
throw Error(`Wrong message type "${message.type}"`);
}
- }
+ };
private initWorker(): void {
this.worker = H264bsdWorker.getInstance();
- this.worker.worker.addEventListener('message', this.bindedOnMessage);
+ this.worker.worker.addEventListener('message', this.onWorkerMessage);
}
- private initCanvas(width: number, height: number): void {
- if (this.display) {
- const parent = this.tag.parentNode;
- if (parent) {
- const tag = H264bsdDecoder.createElement(this.tag.id);
- tag.className = this.tag.className;
- parent.replaceChild(tag, this.tag);
- parent.append(this.touchableCanvas);
- this.tag = tag;
- }
- }
- this.display = new H264bsdCanvas(this.tag);
- this.tag.onerror = function(e: Event | string): void {
- console.error(e);
- };
- this.tag.oncontextmenu = function(e: MouseEvent): void {
- e.preventDefault();
- };
- this.tag.width = width;
- this.tag.height = height;
- // if (this.parentElement) {
- // this.parentElement.style.height = `${height}px`;
- // this.parentElement.style.width = `${width}px`;
- // }
+ protected initCanvas(width: number, height: number): void {
+ super.initCanvas(width, height);
+ this.canvas = new H264bsdCanvas(this.tag);
}
- private shiftFrame(): void {
- this.updateFps(false);
- if (!this.running) {
- return;
- }
-
- const frame = this.framesList.shift();
-
- if (frame) {
- this.decode(frame);
- this.updateFps(true);
- }
-
- requestAnimationFrame(this.shiftFrame.bind(this));
- }
-
- private decode(data: Uint8Array): void {
+ protected decode(data: Uint8Array): void {
if (!this.worker || !this.worker.isDecoderReady()) {
return;
}
- this.worker.worker.postMessage({
- type: 'queueInput',
- data: data.buffer
- }, [data.buffer]);
+ this.worker.worker.postMessage(
+ {
+ type: 'queueInput',
+ data: data.buffer,
+ },
+ [data.buffer],
+ );
}
public play(): void {
super.play();
- if (this.getState() !== Decoder.STATE.PLAYING || !this.screenInfo) {
- return;
- }
- if (!this.display) {
- const {width, height} = this.screenInfo.videoSize;
- this.initCanvas(width, height);
- }
if (!this.worker) {
this.initWorker();
}
- this.running = true;
- requestAnimationFrame(this.shiftFrame.bind(this));
}
public stop(): void {
super.stop();
- this.clearState();
if (this.worker && this.worker.worker) {
- this.worker.worker.removeEventListener('message', this.bindedOnMessage);
+ this.worker.worker.removeEventListener('message', this.onWorkerMessage);
delete this.worker;
}
}
- public setScreenInfo(screenInfo: ScreenInfo): void {
- super.setScreenInfo(screenInfo);
- this.clearState();
- const {width, height} = screenInfo.videoSize;
- this.initCanvas(width, height);
- }
-
public getPreferredVideoSetting(): VideoSettings {
return H264bsdDecoder.preferredVideoSettings;
}
-
- public pushFrame(frame: Uint8Array): void {
- if (H264bsdDecoder.isIFrame(frame)) {
- console.log(this.TAG, 'IFrame');
- if (this.videoSettings) {
- const {frameRate} = this.videoSettings;
- if (this.framesList.length > frameRate / 2) {
- console.log('Dropping frames', this.framesList.length);
- this.framesList = [];
- }
- }
- }
- this.framesList.push(frame);
- }
-
- private clearState(): void {
- this.framesList = [];
- }
}
diff --git a/src/decoder/NativeDecoder.ts b/src/decoder/NativeDecoder.ts
index 6b7c83e1..0ed9235d 100644
--- a/src/decoder/NativeDecoder.ts
+++ b/src/decoder/NativeDecoder.ts
@@ -9,41 +9,58 @@ export default class NativeDecoder extends Decoder {
frameRate: 60,
iFrameInterval: 10,
maxSize: 720,
- sendFrameMeta: false
+ sendFrameMeta: false,
});
- private static DEFAULT_FRAMES_PER_FRAGMENT: number = 1;
- private static DEFAULT_FRAMES_PER_SECOND: number = 60;
+ 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;
}
- protected TAG: string = 'NativeDecoder';
private converter?: VideoConverter;
+ public fpf: number = NativeDecoder.DEFAULT_FRAMES_PER_FRAGMENT;
+ public readonly supportsScreenshot: boolean = true;
- constructor(protected tag: HTMLVideoElement, private fpf: number = NativeDecoder.DEFAULT_FRAMES_PER_FRAGMENT) {
- super(tag);
- tag.onerror = function(e: Event | string): void {
+ constructor(udid: string, protected tag: HTMLVideoElement = NativeDecoder.createElement()) {
+ super(udid, 'NativeDecoder', tag);
+ tag.onerror = function (e: Event | string): void {
console.error(e);
};
- tag.oncontextmenu = function(e: MouseEvent): boolean {
+ tag.oncontextmenu = function (e: MouseEvent): boolean {
e.preventDefault();
return false;
};
}
- private static createConverter(tag: HTMLVideoElement,
- fps: number = NativeDecoder.DEFAULT_FRAMES_PER_SECOND,
- fpf: number = NativeDecoder.DEFAULT_FRAMES_PER_FRAGMENT): VideoConverter {
+ private static createConverter(
+ tag: HTMLVideoElement,
+ fps: number = NativeDecoder.DEFAULT_FRAMES_PER_SECOND,
+ fpf: number = NativeDecoder.DEFAULT_FRAMES_PER_FRAGMENT,
+ ): VideoConverter {
console.log(`Create new VideoConverter(fps=${fps}, fpf=${fpf})`);
return new VideoConverter(tag, fps, fpf);
}
+ 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() !== Decoder.STATE.PLAYING || !this.screenInfo) {
@@ -69,7 +86,7 @@ export default class NativeDecoder extends Decoder {
this.stopConverter();
}
- public setVideoSettings(videoSettings: VideoSettings): void {
+ public setVideoSettings(videoSettings: VideoSettings, saveToStorage: boolean): void {
if (this.videoSettings && this.videoSettings.frameRate !== videoSettings.frameRate) {
const state = this.getState();
if (this.converter) {
@@ -80,7 +97,7 @@ export default class NativeDecoder extends Decoder {
this.play();
}
}
- this.videoSettings = videoSettings;
+ super.setVideoSettings(videoSettings, saveToStorage);
}
public getPreferredVideoSetting(): VideoSettings {
diff --git a/src/decoder/Tinyh264Decoder.ts b/src/decoder/Tinyh264Decoder.ts
new file mode 100644
index 00000000..94cbf712
--- /dev/null
+++ b/src/decoder/Tinyh264Decoder.ts
@@ -0,0 +1,113 @@
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
+import Worker from '../tinyh264/H264NALDecoder.worker';
+import VideoSettings from '../VideoSettings';
+import YUVWebGLCanvas from '../tinyh264/YUVWebGLCanvas';
+import YUVCanvas from '../tinyh264/YUVCanvas';
+import CanvasCommon from './CanvasCommon';
+
+type WorkerMessage = {
+ type: string;
+ width: number;
+ height: number;
+ data: ArrayBuffer;
+ renderStateId: number;
+};
+
+export default class Tinyh264Decoder extends CanvasCommon {
+ private static videoStreamId = 1;
+ public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
+ lockedVideoOrientation: -1,
+ bitrate: 500000,
+ frameRate: 24,
+ iFrameInterval: 5,
+ maxSize: 480,
+ sendFrameMeta: false,
+ });
+
+ private worker?: Worker;
+ private isDecoderReady = false;
+ protected canvas?: YUVWebGLCanvas | YUVCanvas;
+ public readonly supportsScreenshot: boolean = true;
+
+ constructor(udid: string) {
+ super(udid, 'Tinyh264Decoder');
+ }
+
+ private onWorkerMessage = (e: MessageEvent): void => {
+ const message: WorkerMessage = e.data;
+ switch (message.type) {
+ case 'pictureReady':
+ const { width, height, data } = message;
+ if (this.canvas) {
+ this.canvas.decode(new Uint8Array(data), width, height);
+ }
+ break;
+ case 'decoderReady':
+ this.isDecoderReady = true;
+ break;
+ default:
+ console.error(this.name, Error(`Wrong message type "${message.type}"`));
+ }
+ };
+
+ private initWorker(): void {
+ this.worker = new Worker();
+ this.worker.addEventListener('message', this.onWorkerMessage);
+ }
+
+ protected initCanvas(width: number, height: number): void {
+ super.initCanvas(width, height);
+
+ if (CanvasCommon.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: Tinyh264Decoder.videoStreamId,
+ },
+ [data.buffer],
+ );
+ }
+
+ public play(): void {
+ super.play();
+ if (!this.worker) {
+ this.initWorker();
+ }
+ }
+
+ public stop(): void {
+ super.stop();
+ if (this.worker) {
+ this.worker.worker.removeEventListener('message', this.onWorkerMessage);
+ this.worker.postMessage({ type: 'release', renderStateId: Tinyh264Decoder.videoStreamId });
+ delete this.worker;
+ }
+ }
+
+ public getPreferredVideoSetting(): VideoSettings {
+ return Tinyh264Decoder.preferredVideoSettings;
+ }
+
+ protected clearState(): void {
+ super.clearState();
+ if (this.worker) {
+ this.worker.postMessage({ type: 'release', renderStateId: Tinyh264Decoder.videoStreamId });
+ Tinyh264Decoder.videoStreamId++;
+ }
+ }
+}
diff --git a/src/h264-live-player/Texture.ts b/src/h264-live-player/Texture.ts
index 67da8b12..a062ef62 100644
--- a/src/h264-live-player/Texture.ts
+++ b/src/h264-live-player/Texture.ts
@@ -4,13 +4,20 @@ import Program from './Program';
export default class Texture {
public readonly texture: WebGLTexture | null;
+ public readonly format: GLenum;
private textureIDs: number[];
- constructor(readonly gl: WebGLRenderingContext, readonly size: Size, readonly format?: GLenum) {
+ static create (gl: WebGLRenderingContext, format: number): Texture {
+ return new Texture(gl, undefined, format);
+ }
+
+ constructor(readonly gl: WebGLRenderingContext, readonly size?: Size, format?: GLenum) {
this.texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.texture);
this.format = format ? format : gl.LUMINANCE;
- gl.texImage2D(gl.TEXTURE_2D, 0, this.format, size.width, size.height, 0, this.format, gl.UNSIGNED_BYTE, null);
+ if (size) {
+ gl.texImage2D(gl.TEXTURE_2D, 0, this.format, size.width, size.height, 0, this.format, gl.UNSIGNED_BYTE, null);
+ }
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -18,22 +25,30 @@ export default class Texture {
this.textureIDs = [gl.TEXTURE0, gl.TEXTURE1, gl.TEXTURE2];
}
- public fill(textureData: Uint8Array, useTexSubImage2D?: boolean): void {
- if (!this.format) {
- return;
+ public fill(textureData: Uint8Array, useTexSubImage2D?: boolean, w?: number, h?: number): void {
+ if (typeof w === 'undefined' || typeof h === 'undefined') {
+ if (!this.size) {
+ return;
+ }
+ w = this.size.w;
+ h = this.size.h;
}
const gl = this.gl;
- assert(textureData.length >= this.size.w * this.size.h,
- 'Texture size mismatch, data:' + textureData.length + ', texture: ' + this.size.w * this.size.h);
+ assert(textureData.length >= w * h,
+ 'Texture size mismatch, data:' + textureData.length + ', texture: ' + w * h);
gl.bindTexture(gl.TEXTURE_2D, this.texture);
if (useTexSubImage2D) {
- gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, this.size.w, this.size.h, this.format, gl.UNSIGNED_BYTE, textureData);
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, w, h, this.format, gl.UNSIGNED_BYTE, textureData);
} else {
// texImage2D seems to be faster, thus keeping it as the default
- gl.texImage2D(gl.TEXTURE_2D, 0, this.format, this.size.w, this.size.h, 0, this.format, gl.UNSIGNED_BYTE, textureData);
+ gl.texImage2D(gl.TEXTURE_2D, 0, this.format, w, h, 0, this.format, gl.UNSIGNED_BYTE, textureData);
}
}
+ public image2dBuffer (buffer: Uint8Array, width: number, height: number) {
+ this.fill(buffer, false, width, height);
+ }
+
public bind(n: number, program: Program, name: string): void {
const gl = this.gl;
if (!program.program) {
diff --git a/src/h264-live-player/WebGLCanvas.ts b/src/h264-live-player/WebGLCanvas.ts
index 306709ca..5b44e0e3 100644
--- a/src/h264-live-player/WebGLCanvas.ts
+++ b/src/h264-live-player/WebGLCanvas.ts
@@ -223,7 +223,9 @@ export default abstract class WebGLCanvas extends Canvas {
protected onInitWebGL(): void {
try {
- this.gl = this.canvas.getContext('experimental-webgl') as WebGLRenderingContext;
+ this.gl = this.canvas.getContext('experimental-webgl', {
+ preserveDrawingBuffer: true
+ }) as WebGLRenderingContext;
} catch (e) {
}
diff --git a/src/h264-live-player/YUVWebGLCanvas.ts b/src/h264-live-player/YUVWebGLCanvas.ts
index cfa15b88..ba343909 100644
--- a/src/h264-live-player/YUVWebGLCanvas.ts
+++ b/src/h264-live-player/YUVWebGLCanvas.ts
@@ -68,7 +68,6 @@ export default class YUVWebGLCanvas extends WebGLCanvas {
if (!this.gl) {
return;
}
- console.log('creatingTextures: size: ' + this.size);
this.YTexture = new Texture(this.gl, this.size);
this.UTexture = new Texture(this.gl, this.size.getHalfSize());
this.VTexture = new Texture(this.gl, this.size.getHalfSize());
diff --git a/src/decoder/H264bsdWorker.ts b/src/h264bsd/H264bsdWorker.ts
similarity index 95%
rename from src/decoder/H264bsdWorker.ts
rename to src/h264bsd/H264bsdWorker.ts
index fed51011..d0eceedf 100644
--- a/src/decoder/H264bsdWorker.ts
+++ b/src/h264bsd/H264bsdWorker.ts
@@ -7,7 +7,7 @@ export default class H264bsdWorker {
return this.instance;
}
- private decoderReady: boolean = false;
+ private decoderReady = false;
public readonly worker: Worker;
private constructor() {
this.worker = new Worker('h264bsd_worker.js');
diff --git a/src/index.ts b/src/index.ts
index 79abf338..25fd8db8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,16 +1,13 @@
import * as querystring from 'querystring';
import { ClientDeviceTracker } from './client/ClientDeviceTracker';
-import { ClientLogsProxy, LogsParams } from './client/ClientLogsProxy';
import { ScrcpyClient, StreamParams } from './client/ScrcpyClient';
import { ShellParams, ClientShell } from './client/ClientShell';
-window.onload = function(): void {
+window.onload = function (): void {
const hash = location.hash.replace(/^#!/, '');
const parsedQuery = querystring.parse(hash);
const action = parsedQuery.action;
- if (action === ClientLogsProxy.ACTION && typeof parsedQuery.udid === 'string') {
- ClientLogsProxy.start(parsedQuery as LogsParams);
- } else if (action === ScrcpyClient.ACTION && typeof parsedQuery.udid === 'string') {
+ if (action === ScrcpyClient.ACTION && typeof parsedQuery.udid === 'string') {
ScrcpyClient.start(parsedQuery as StreamParams);
} else if (action === ClientShell.ACTION && typeof parsedQuery.udid === 'string') {
ClientShell.start(parsedQuery as ShellParams);
diff --git a/src/public/icons.png b/src/public/icons.png
deleted file mode 100644
index 1282aad8..00000000
Binary files a/src/public/icons.png and /dev/null differ
diff --git a/src/public/scrcpy-server.jar b/src/public/scrcpy-server.jar
index cd138ef6..ea1f0a1b 100644
Binary files a/src/public/scrcpy-server.jar and b/src/public/scrcpy-server.jar differ
diff --git a/src/public/style.css b/src/public/style.css
index b1639e5a..8563b528 100644
--- a/src/public/style.css
+++ b/src/public/style.css
@@ -1,5 +1,50 @@
+:root {
+ --main-bg-color: hsl(0, 0%, 100%);
+ --stream-bg-color: hsl(0, 0%, 85%);
+ --shell-bg-color: hsl(0, 0%, 0%);
+ --row-bg-color_hover: hsl(218, 67%, 95%);
+ --table-header-bg-color: hsl(0, 0%, 95%);
+ --controls-bg-color: hsla(210, 14%, 53%, 0.5);
+ --control-buttons-bg-color: hsl(0, 0%, 95%);
+ --text-color: hsl(0, 0%, 20%);
+ --link-color: hsl(240, 100%, 47%);
+ --link-color_visited: hsl(271, 68%, 32%);
+ --svg-checkbox-bg-color: hsl(172, 100%, 37%);
+ --svg-button-fill: hsl(199, 17%, 46%);
+ --table-border-color: hsl(0, 0%, 82%);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --main-bg-color: hsl(0, 0%, 14%);
+ --stream-bg-color: hsl(0, 0%, 20%);
+ --shell-bg-color: hsl(0, 0%, 0%);
+ --row-bg-color_hover: hsl(218, 17%, 18%);
+ --table-header-bg-color: hsl(0, 0%, 20%);
+ --controls-bg-color: hsla(210, 14%, 43%, 0.5);
+ --control-buttons-bg-color: hsl(201, 18%, 19%);
+ --text-color: hsl(0, 0%, 90%);
+ --link-color: hsl(210, 63%, 47%);
+ /*--link-color_visited: hsl(241, 31%, 47%);*/
+ --link-color_visited: hsl(267, 31%, 47%);
+ /*--link-color_visited: hsl(267, 95%, 76%);*/
+ --svg-checkbox-bg-color: hsl(172, 100%, 27%);
+ --svg-button-fill: hsl(0, 0%, 100%);
+ --table-border-color: hsl(0, 0%, 32%);
+ }
+}
+
+a {
+ color: var(--link-color);
+}
+
+a:visited {
+ color: var(--link-color_visited);
+}
+
body {
- background-color: #f0f0f0;
+ color: var(--text-color);
+ background-color: var(--main-bg-color);
position: absolute;
margin: 0;
height: 100%;
@@ -7,13 +52,12 @@ body {
overflow: hidden;
}
-.body-logcat {
- display: flex;
- flex-flow: column;
+body.shell {
+ background-color: var(--shell-bg-color);
}
-.body-shell {
- background-color: black;
+body.stream {
+ background-color: var(--stream-bg-color);
}
.terminal-container {
@@ -31,66 +75,12 @@ body {
margin: 20px;
}
-.hidden {
- display: none;
-}
-
-.visible {
- display: initial;
-}
-
-.buttons-wrapper {
- max-height: 5%;
-}
-
-.filter-button {
- font-family: monospace;
- border: solid 1px black;
- width: auto;
- display: inline-block;
- border-radius: 1em;
- margin: 2px;
- padding: 2px 5px;
-}
-
-.filter-button:hover {
- border-width: 2px;
- border-style: dotted;
- padding: 1px 4px;
- cursor: pointer;
-}
-
-.client-filter {
- border-color: hsl(120, 33%, 50%);
-}
-
-#filterInput {
- margin: 0 5px;
-}
-
-pre.might-overflow {
- text-overflow: ellipsis;
- overflow : hidden;
- white-space: nowrap;
- margin: 0.5em 0;
- padding: 0;
-}
-
-pre.might-overflow:hover {
- text-overflow: clip;
- word-break: break-all;
- overflow-x: visible;
- white-space: pre;
- margin: 0;
- padding: 0.5em 0;
-}
.table-wrapper {
position: relative;
bottom: 0;
left: 0;
width: 100%;
- overflow-y: scroll;
}
.table-wrapper table {
@@ -106,57 +96,33 @@ pre.might-overflow:hover {
.table-wrapper table td, .table-wrapper table th {
border-style: solid;
border-width: 1px;
- border-color: black;
+ border-color: var(--table-border-color);
}
.table-wrapper table tr:hover {
- background-color: hsl(110, 14%, 53%);
+ background-color: var(--row-bg-color_hover)
}
.table-wrapper table th {
position: sticky;
top: -1px;
- background-color: hsl(210, 14%, 53%);
-}
-
-.table-wrapper table .date {
- white-space: nowrap;
- width: 7%;
-}
-
-.table-wrapper table .pid, .table-wrapper table .tid {
- width: 5%;
-}
-
-.table-wrapper table .p {
- width: 3%;
-}
-
-.table-wrapper table .tag {
- max-width: 20em;
- width: 10%;
-}
-
-.table-wrapper table .message {
- width: 70%;
- text-overflow: ellipsis;
- white-space: pre;
- max-width: 0;
- overflow: hidden;
+ background-color: var(--table-header-bg-color);
}
#controlsWrap {
- float: left;
- position: relative;
+ position: fixed;
top: 0;
+ left: 0;
+ z-index: 2;
+ background-color: var(--controls-bg-color)
}
.decoder-controls-wrapper {
- padding: 10px;
- border: solid 1px hsl(210, 14%, 53%);
+ padding: 5px;
}
.device-view {
+ z-index: 1;
float: right;
}
@@ -203,65 +169,64 @@ pre.might-overflow:hover {
display: none;
}
-.control-buttons-list {
- float: right;
- width: 52px;
-}
-
-.control-button {
- margin: 5px 11px;
- padding: 0;
- width: 30px;
- height: 30px;
- border: none;
- background-image: url('icons.png');
- opacity: 0.75;
+.menu > input ~ .box {
+ display: none;
}
-.control-button:hover {
- opacity: 1;
+.menu > input:checked ~ .box {
+ display: block;
}
-.control-button.menu {
- background-position: 0 0;
+.menu > label::before {
+ content: '≡';
+ margin: 5px;
}
-.control-button.app-switch {
- background-position: -30px 0;
+.menu > input:checked ~ label::before {
+ content: '◃';
}
-.control-button.home {
- background-position: -60px 0;
+.menu > input:checked ~ div {
+ display: block;
+ padding: 10px;
}
-.control-button.back {
- background-position: -90px 0;
+.menu > input {
+ display: none;
}
-.control-button.zoom {
- background-position: 0 -30px;
+.control-buttons-list {
+ float: right;
+ width: 52px;
+ background-color: var(--control-buttons-bg-color);
}
-.control-button.screen-shot {
- background-position: -30px -30px;
+.control-button {
+ margin: 5px 11px;
+ padding: 0;
+ width: 30px;
+ height: 30px;
+ border: none;
+ opacity: 0.75;
+ background-color: var(--control-buttons-bg-color);
}
-.control-button.rotate-clockwise {
- background-position: -60px -30px;
+.control-button:hover {
+ opacity: 1;
}
-.control-button.rotate-counter-clockwise {
- background-position: -90px -30px;
+.control-buttons-list > input[type=checkbox] {
+ display: none;
}
-.control-button.volume-down {
- background-position: 0 -60px;
+.control-buttons-list > label {
+ display: inline-block;
}
-.control-button.volume-up {
- background-position: -30px -60px;
+.control-button > svg {
+ fill: var(--svg-button-fill);
}
-.control-button.power {
- background-position: -60px -60px;
+.control-buttons-list > input[type=checkbox]:checked + label > svg {
+ fill: var(--svg-checkbox-bg-color);
}
diff --git a/src/server/Constants.ts b/src/server/Constants.ts
index dcd49f46..13790373 100644
--- a/src/server/Constants.ts
+++ b/src/server/Constants.ts
@@ -1,3 +1,39 @@
-export const SERVER_PACKAGE: string = 'com.genymobile.scrcpy.Server';
-export const SERVER_PORT: number = 8886;
-export const SERVER_VERSION: string = '1.13';
+export const SERVER_PACKAGE = 'com.genymobile.scrcpy.Server';
+export const SERVER_PORT = 8886;
+export const SERVER_VERSION = '1.15.1-ws';
+
+const LOG_LEVEL = 'ERROR';
+const MAX_SIZE = 0;
+const BITRATE = 8000000;
+const MAX_FPS = 60;
+const LOCKED_SCREEN_ORIENTATION = -1;
+const TUNNEL_FORWARD = false;
+const CROP = '-';
+const SEND_META_FRAME = false;
+const CONTROL = true; // If control is enabled, synchronize Android clipboard to the computer automatically
+const DISPLAY_ID = 0;
+const SHOW_TOUCHES = false;
+const STAY_AWAKE = false;
+const CODEC_OPTIONS = '-';
+const SERVER_TYPE = 'web';
+
+const ARGUMENTS = [
+ SERVER_VERSION,
+ LOG_LEVEL,
+ MAX_SIZE,
+ BITRATE,
+ MAX_FPS,
+ LOCKED_SCREEN_ORIENTATION,
+ TUNNEL_FORWARD,
+ CROP,
+ SEND_META_FRAME,
+ CONTROL,
+ DISPLAY_ID,
+ SHOW_TOUCHES,
+ STAY_AWAKE,
+ CODEC_OPTIONS,
+ SERVER_TYPE,
+ SERVER_PORT
+];
+
+export const ARGS_STRING = `/ ${SERVER_PACKAGE} ${ARGUMENTS.join(' ')} 2>&1 > /dev/null`;
diff --git a/src/server/LogcatCollector.ts b/src/server/LogcatCollector.ts
deleted file mode 100644
index 45742065..00000000
--- a/src/server/LogcatCollector.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-// @ts-ignore
-import adbkit from 'adbkit';
-import { AdbKitClient } from '../common/AdbKit';
-import { AdbKitLogcatEntry, AdbKitLogcatReader } from '../common/AdbKitLogcat';
-import Timeout = NodeJS.Timeout;
-
-export interface LogcatCollectorEventsListener {
- onError(collector: LogcatCollector, error: Error): void;
- onEnd(collector: LogcatCollector): void;
- onFinish(collector: LogcatCollector): void;
- onEntry(collector: LogcatCollector, entry: AdbKitLogcatEntry): void;
-}
-
-export class LogcatCollector {
- private static collectorMap: Map = new Map();
- private static readonly RELEASE_TIMEOUT: number = 60 * 1000;
-
- private readonly cache: AdbKitLogcatEntry[] = [];
- private client: AdbKitClient;
- private reader?: AdbKitLogcatReader;
- private initialized: boolean = false;
- private listeners: Set = new Set();
- private releaseTimeout?: Timeout;
-
- constructor(private readonly udid: string) {
- this.client = adbkit.createClient() as AdbKitClient;
- }
-
- public static async getCollector(udid: string): Promise {
- let collector: LogcatCollector | undefined = this.collectorMap.get(udid);
- if (!collector) {
- collector = new LogcatCollector(udid);
- this.collectorMap.set(udid, collector);
- }
- return collector;
- }
-
- public async init(): Promise {
- if (this.initialized) {
- return;
- }
- this.reader = await this.client.openLogcat(this.udid);
- this.reader.addListener('error', this.onError);
- this.reader.addListener('end', this.onEnd);
- this.reader.addListener('finish', this.onFinish);
- this.reader.addListener('entry', this.onEntry);
- this.initialized = true;
- }
-
- private onError = (err: Error): void => {
- for (const listener of this.listeners.values()) {
- listener.onError(this, err);
- }
- };
-
- private onEnd = (): void => {
- for (const listener of this.listeners.values()) {
- listener.onEnd(this);
- }
- };
-
- private onFinish = (): void => {
- for (const listener of this.listeners.values()) {
- listener.onFinish(this);
- }
- };
-
- private onEntry = (entry: AdbKitLogcatEntry): void => {
- this.cache.push(entry);
- for (const listener of this.listeners.values()) {
- listener.onEntry(this, entry);
- }
- };
-
- public getEntries(): AdbKitLogcatEntry[] {
- return this.cache.slice(0);
- }
-
- public addEventsListener(listener: LogcatCollectorEventsListener): void {
- this.listeners.add(listener);
- if (this.releaseTimeout) {
- clearTimeout(this.releaseTimeout);
- delete this.releaseTimeout;
- }
- }
-
- public removeEventsListener(listener: LogcatCollectorEventsListener): void {
- this.listeners.delete(listener);
- if (!this.listeners.size) {
- this.releaseTimeout = setTimeout(this.release.bind(this), LogcatCollector.RELEASE_TIMEOUT);
- }
- }
-
- public release(): void {
- if (!this.reader) {
- return;
- }
- this.reader.removeListener('error', this.onError);
- this.reader.removeListener('end', this.onEnd);
- this.reader.removeListener('finish', this.onFinish);
- this.reader.removeListener('entry', this.onEntry);
- this.reader.end();
- delete this.reader;
- }
-}
diff --git a/src/server/LogsFilter.ts b/src/server/LogsFilter.ts
deleted file mode 100644
index 019ed3f1..00000000
--- a/src/server/LogsFilter.ts
+++ /dev/null
@@ -1,245 +0,0 @@
-// @ts-ignore
-import * as logcat from 'adbkit-logcat';
-import { Filters, FiltersMap, TextFilter, FiltersArray } from '../common/LogcatMessage';
-import { AdbKitLogcatEntry } from '../common/AdbKitLogcat';
-
-const REGEXP = /\/(.*)\/(mi|m|im|i)?/;
-
-export enum ACTION {
- ADD,
- REMOVE
-}
-
-export enum PriorityLevel {
- UNKNOWN = 0,
- DEFAULT = 1,
- VERBOSE = 2,
- DEBUG = 3,
- INFO = 4,
- WARN = 5,
- ERROR = 6,
- FATAL = 7,
- SILENT = 8
-}
-
-export enum Fields {
- Priority = 'priority',
- Tag = 'tag',
- Message = 'message',
- PID = 'pid',
- TID = 'tid'
-}
-
-export class LogsFilter {
- public static filterEvent(filters: Filters, event: AdbKitLogcatEntry): boolean {
- if (!this.checkInMap(filters.priority, event.priority, event.pid.toString(10), filters.pid)) {
- return false;
- }
- if (!this.checkInMap(filters.priority, event.priority, event.tid.toString(10), filters.tid)) {
- return false;
- }
- if (!this.checkInMap(filters.priority, event.priority, event.tag, filters.tag)) {
- return false;
- }
- if (!this.checkText(event.priority, event.message, filters.message)) {
- return false;
- }
- if (!filters.pid && !filters.tid && !filters.tag && !filters.message) {
- return filters.priority <= event.priority;
- }
- return true;
- }
-
- private static checkInMap(defaultPriority: PriorityLevel, priority: PriorityLevel, value: string, filterList: FiltersMap): boolean {
- if (typeof filterList === 'undefined') {
- return true;
- }
- const stored: PriorityLevel | undefined = filterList.get(value);
- const wanted = typeof stored !== 'undefined' ? stored : defaultPriority;
- return wanted <= priority;
- }
-
- private static checkText(priority: PriorityLevel, value: string, filterList: FiltersArray): boolean {
- if (typeof filterList === 'undefined' || !filterList.length) {
- return true;
- }
- return filterList.every(filter => {
- const isRegExp = filter.value instanceof RegExp;
- if ((!isRegExp && value.includes(filter.value as string))
- || (isRegExp && !!value.match(filter.value))) {
- return filter.priority <= priority;
- }
- return true;
- });
- }
-
- private static updateFiltersMap(action: ACTION,
- priority: PriorityLevel,
- value: string,
- filterMap: FiltersMap): FiltersMap | null {
- const result: FiltersMap = filterMap || new Map();
- const stored: PriorityLevel | undefined = result.get(value);
- if (typeof filterMap === 'undefined'
- || typeof stored === 'undefined'
- || stored !== priority) {
- if (action === ACTION.ADD) {
- result.set(value, priority);
- return result;
- } else {
- return null;
- }
- }
- if (action === ACTION.ADD) {
- return null;
- } else {
- result.delete(value);
- if (!result.size) {
- return undefined;
- }
- return result;
- }
- }
-
- private static updateFiltersArray(action: ACTION,
- priority: PriorityLevel,
- value: string | RegExp,
- filterArray: FiltersArray): FiltersArray | null {
- const result: TextFilter[] = [];
-
- if (typeof filterArray === 'undefined' || !filterArray.length) {
- return [{
- priority,
- value
- }];
- }
- let foundSame = false;
- let changed = false;
- filterArray.forEach(filter => {
- let bothRegExp = false;
- let sameRegExp = false;
- if (value instanceof RegExp && filter.value instanceof RegExp) {
- bothRegExp = true;
- if (value.toString() === filter.value.toString()) {
- sameRegExp = true;
- }
- }
- if ((bothRegExp && sameRegExp) || (!bothRegExp && filter.value === value)) {
- if (filter.priority === priority) {
- foundSame = true;
- } else {
- if (action === ACTION.ADD) {
- result.push({
- value: filter.value,
- priority
- });
- changed = true;
- }
- }
- return;
- }
- result.push(filter);
- });
- if (action === ACTION.ADD) {
- if (foundSame) {
- return null;
- }
- if (!changed) {
- result.push({
- value,
- priority
- });
- }
- }
- if (action === ACTION.REMOVE) {
- if (foundSame) {
- if (!result.length) {
- return undefined;
- }
- }
- return null;
- }
- return result;
- }
-
- private static tryAsRegexp(value: string): RegExp | null {
- const match = value.match(REGEXP);
- if (!match) {
- return null;
- }
- let temp;
- try {
- temp = new RegExp(match[1], match[2]);
- } catch (e) {
- return null;
- }
- if (value === temp.toString()) {
- return temp;
- }
- return null;
- }
-
- public static updateFilter(action: ACTION, priority: PriorityLevel, value: string, type: string, filters: Filters): boolean {
- let updated = false;
- const tempRe = this.tryAsRegexp(value);
- const num = parseInt(value, 10);
- switch (type) {
- case Fields.TID: {
- if (isNaN(num)) {
- return false;
- }
- const newFilter = LogsFilter.updateFiltersMap(action, priority, num.toString(10), filters.tid);
- if (newFilter !== null) {
- filters.tid = newFilter;
- updated = true;
- }
- break;
- }
- case Fields.PID: {
- if (isNaN(num)) {
- return false;
- }
- const newFilter = LogsFilter.updateFiltersMap(action, priority, num.toString(10), filters.pid);
- if (newFilter !== null) {
- filters.pid = newFilter;
- updated = true;
- }
- break;
- }
- case Fields.Tag: {
- const newFilter = LogsFilter.updateFiltersMap(action, priority, value, filters.tag);
- if (newFilter !== null) {
- filters.tag = newFilter;
- updated = true;
- }
- break;
- }
- case Fields.Message: {
- const str = tempRe ? tempRe : value;
- const newFilter = LogsFilter.updateFiltersArray(action, priority, str, filters.message);
- if (newFilter !== null) {
- filters.message = newFilter;
- updated = true;
- }
- break;
- }
- default:
- throw Error('Unknown filter type');
- }
- return updated;
- }
-
- // Reexport logcat.Priority methods
-
- public static priorityFromName(str: string): PriorityLevel {
- return logcat.Priority.fromName(str) as PriorityLevel;
- }
- public static priorityToName(priority: PriorityLevel): string {
- return logcat.Priority.toName(priority) as string;
- }
- public static priorityFromLetter(letter: string): PriorityLevel {
- return logcat.Priority.fromLetter(letter) as PriorityLevel;
- }
- public static priorityToLetter (priority: PriorityLevel): string {
- return logcat.Priority.toLetter(priority) as string;
- }
-}
diff --git a/src/server/ServerDeviceConnection.ts b/src/server/ServerDeviceConnection.ts
index d76f4bac..c1a57b21 100644
--- a/src/server/ServerDeviceConnection.ts
+++ b/src/server/ServerDeviceConnection.ts
@@ -1,32 +1,32 @@
-// @ts-ignore
import ADB from 'adbkit';
-// @ts-ignore
import { EventEmitter } from 'events';
import { spawn } from 'child_process';
import * as path from 'path';
import { Device } from '../common/Device';
-import { AdbKitChangesSet, AdbKitClient, AdbKitDevice, AdbKitTracker, PushTransfer } from '../common/AdbKit';
-import { SERVER_PACKAGE, SERVER_PORT, SERVER_VERSION } from './Constants';
+import { AdbKitChangesSet, AdbKitClient, AdbKitDevice, AdbKitTracker, PushTransfer } from 'adbkit';
+import { SERVER_PACKAGE, SERVER_VERSION, ARGS_STRING } from './Constants';
+import Timeout = NodeJS.Timeout;
const TEMP_PATH = '/data/local/tmp/';
const FILE_DIR = path.join(__dirname, '../public');
const FILE_NAME = 'scrcpy-server.jar';
-const ARGS = `/ ${SERVER_PACKAGE} ${SERVER_VERSION} 0 8000000 60 -1 false - false false 0 web ${SERVER_PORT} 2>&1 > /dev/null`;
const GET_SHELL_PROCESSES = 'for DIR in /proc/*; do [ -d "$DIR" ] && echo $DIR; done';
-const CHECK_CMDLINE = `[ -f "$a/cmdline" ] && grep -av find "$a/cmdline" |grep -sae app_process.*${SERVER_PACKAGE} |grep ${SERVER_VERSION} 2>&1 > /dev/null && echo $a;`;
+const CHECK_CMDLINE = `[ -f "$a/cmdline" ] && grep -av find "$a/cmdline" |grep -sae '^app_process.*${SERVER_PACKAGE}' |grep ${SERVER_VERSION} 2>&1 > /dev/null && echo $a;`;
const CMD = 'for a in `' + GET_SHELL_PROCESSES + '`; do ' + CHECK_CMDLINE + ' done; exit 0';
export class ServerDeviceConnection extends EventEmitter {
public static readonly UPDATE_EVENT: string = 'update';
private static instance: ServerDeviceConnection;
- private pendingUpdate: boolean = false;
+ private pendingUpdate = false;
private cache: Device[] = [];
private deviceMap: Map = new Map();
private clientMap: Map = new Map();
private client: AdbKitClient = ADB.createClient();
private tracker?: AdbKitTracker;
- private initialized: boolean = false;
+ private initialized = false;
+ private restartTimeoutId?: Timeout;
+ private waitAfterError = 1000;
public static getInstance(): ServerDeviceConnection {
if (!this.instance) {
this.instance = new ServerDeviceConnection();
@@ -49,7 +49,7 @@ export class ServerDeviceConnection extends EventEmitter {
if (this.tracker) {
return this.tracker;
}
- const tracker = this.tracker = await this.client.trackDevices();
+ const tracker = (this.tracker = await this.client.trackDevices());
if (tracker.deviceList && tracker.deviceList.length) {
this.cache = await this.mapDevicesToDescriptors(tracker.deviceList);
}
@@ -84,12 +84,30 @@ export class ServerDeviceConnection extends EventEmitter {
this.cache = Array.from(this.deviceMap.values());
this.emit(ServerDeviceConnection.UPDATE_EVENT, this.cache);
});
+ tracker.on('end', this.restartTracker);
+ tracker.on('error', this.restartTracker);
return tracker;
}
+ restartTracker = (): void => {
+ if (this.restartTimeoutId) {
+ return;
+ }
+ console.log(`Device tracker is down. Will try to restart in ${this.waitAfterError}ms`);
+ this.restartTimeoutId = setTimeout(() => {
+ delete this.restartTimeoutId;
+ delete this.tracker;
+ this.waitAfterError *= 1.2;
+ this.pendingUpdate = false;
+ this.updateDeviceList();
+ }, this.waitAfterError);
+ };
+
private async mapDevicesToDescriptors(list: AdbKitDevice[]): Promise {
- const all = await Promise.all(list.map(device => this.getDescriptor(device)));
- list.forEach((device: AdbKitDevice, idx: number) => {this.deviceMap.set(device.id, all[idx]);});
+ const all = await Promise.all(list.map((device) => this.getDescriptor(device)));
+ list.forEach((device: AdbKitDevice, idx: number) => {
+ this.deviceMap.set(device.id, all[idx]);
+ });
return all;
}
@@ -106,7 +124,7 @@ export class ServerDeviceConnection extends EventEmitter {
}
private async getDescriptor(device: AdbKitDevice): Promise {
- const {id: udid, type: state} = device;
+ const { id: udid, type: state } = device;
if (state === 'offline') {
return {
'build.version.release': '',
@@ -117,7 +135,7 @@ export class ServerDeviceConnection extends EventEmitter {
pid: -1,
ip: '0.0.0.0',
state,
- udid
+ udid,
};
}
const client = this.getOrCreateClient(udid);
@@ -133,12 +151,15 @@ export class ServerDeviceConnection extends EventEmitter {
'build.version.release': props['ro.build.version.release'],
'build.version.sdk': props['ro.build.version.sdk'],
state,
- udid
+ udid,
};
try {
const stream = await client.shell(udid, `ip route |grep ${wifi} |grep -v default`);
const buffer = await ADB.util.readAll(stream);
- const temp = buffer.toString().split(' ').filter((i: string) => !!i);
+ const temp = buffer
+ .toString()
+ .split(' ')
+ .filter((i: string) => !!i);
descriptor.ip = temp[8];
let pid = await this.getPID(device);
let count = 0;
@@ -163,12 +184,13 @@ export class ServerDeviceConnection extends EventEmitter {
}
private async getPID(device: AdbKitDevice): Promise {
- const {id: udid} = device;
+ const { id: udid } = device;
const client = this.getOrCreateClient(udid);
await client.waitBootComplete(udid);
const stream = await client.shell(udid, CMD);
const buffer = await ADB.util.readAll(stream);
- const shellProcessesArray = buffer.toString()
+ const shellProcessesArray = buffer
+ .toString()
.split('\n')
.filter((str: string) => str.trim().length)
.map((str: string) => {
@@ -185,27 +207,28 @@ export class ServerDeviceConnection extends EventEmitter {
}
private async copyServer(device: AdbKitDevice): Promise {
- const {id: udid} = device;
+ const { id: udid } = device;
const client = this.getOrCreateClient(udid);
- await client.waitBootComplete(udid); const src = path.join(FILE_DIR, FILE_NAME);
+ await client.waitBootComplete(udid);
+ const src = path.join(FILE_DIR, FILE_NAME);
const dst = TEMP_PATH + FILE_NAME; // don't use path.join(): will not work on win host
return client.push(udid, src, dst);
}
private spawnServer(device: AdbKitDevice): void {
- const {id: udid} = device;
- const command = `CLASSPATH=${TEMP_PATH}${FILE_NAME} nohup app_process ${ARGS}`;
+ const { id: udid } = device;
+ const command = `CLASSPATH=${TEMP_PATH}${FILE_NAME} nohup app_process ${ARGS_STRING}`;
const adb = spawn('adb', ['-s', `${udid}`, 'shell', command], { stdio: ['ignore', 'pipe', 'pipe'] });
- adb.stdout.on('data', data => {
+ adb.stdout.on('data', (data) => {
console.log(`[${udid}] stdout: ${data}`);
});
- adb.stderr.on('data', data => {
+ adb.stderr.on('data', (data) => {
console.error(`[${udid}] stderr: ${data}`);
});
- adb.on('close', code => {
+ adb.on('close', (code) => {
console.log(`[${udid}] adb process exited with code ${code}`);
});
}
@@ -219,13 +242,13 @@ export class ServerDeviceConnection extends EventEmitter {
this.pendingUpdate = false;
};
this.initTracker()
- .then(tracker => {
+ .then((tracker) => {
if (tracker && tracker.deviceList && tracker.deviceList.length) {
return this.mapDevicesToDescriptors(tracker.deviceList);
}
return [] as Device[];
})
- .then(list => {
+ .then((list) => {
this.cache = list;
this.emit(ServerDeviceConnection.UPDATE_EVENT, this.cache);
})
diff --git a/src/server/ServiceDeviceTracker.ts b/src/server/ServiceDeviceTracker.ts
index 48f6282c..ce06f1ad 100644
--- a/src/server/ServiceDeviceTracker.ts
+++ b/src/server/ServiceDeviceTracker.ts
@@ -10,7 +10,8 @@ export class ServiceDeviceTracker extends ReleasableService {
constructor(ws: WebSocket) {
super(ws);
- this.sdc.init()
+ this.sdc
+ .init()
.then(() => {
this.sdc.addListener(ServerDeviceConnection.UPDATE_EVENT, this.buildAndSendMessage);
this.buildAndSendMessage(this.sdc.getDevices());
@@ -24,7 +25,7 @@ export class ServiceDeviceTracker extends ReleasableService {
const msg: Message = {
id: -1,
type: 'devicelist',
- data: list
+ data: list,
};
this.sendMessage(msg);
};
diff --git a/src/server/ServiceLogsProxy.ts b/src/server/ServiceLogsProxy.ts
deleted file mode 100644
index 9edc015c..00000000
--- a/src/server/ServiceLogsProxy.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-// @ts-ignore
-import * as logcat from 'adbkit-logcat';
-import { AdbKitLogcatEntry, AdbKitLogcatReaderEvents } from '../common/AdbKitLogcat';
-import { Message } from '../common/Message';
-import { Filters, LogcatClientMessage } from '../common/LogcatMessage';
-import { LogcatCollector, LogcatCollectorEventsListener } from './LogcatCollector';
-import WebSocket from 'ws';
-import { ReleasableService } from './ReleasableService';
-import { LogsFilter, PriorityLevel } from './LogsFilter';
-
-interface ReaderProperties {
- messageId: number;
- udid: string;
- filters: Filters;
-}
-
-const DEFAULT_FILTERS: Filters = {
- priority: PriorityLevel.VERBOSE
-};
-
-const EVENT_TYPE_LOGCAT = 'logcat';
-
-export class ServiceLogsProxy extends ReleasableService implements LogcatCollectorEventsListener {
- private activeCollectorsMap: Map = new Map();
- private collectorProperties: WeakMap = new Map();
-
- constructor(ws: WebSocket) {
- super(ws);
- }
-
- public static createService(ws: WebSocket): ReleasableService {
- return new ServiceLogsProxy(ws);
- }
-
- public onError = (collector: LogcatCollector, error: Error): void => {
- this.buildAndSendMessage(collector, 'error', error);
- };
- public onEnd = (collector: LogcatCollector): void => {
- this.buildAndSendMessage(collector, 'end');
- };
- public onFinish = (collector: LogcatCollector): void => {
- this.buildAndSendMessage(collector, 'finish');
- };
- public onEntry = (collector: LogcatCollector, entry: AdbKitLogcatEntry): void => {
- this.buildAndSendMessage(collector, 'entry', entry);
- };
-
- private buildAndSendMessage(collector: LogcatCollector,
- type: keyof typeof AdbKitLogcatReaderEvents,
- event?: AdbKitLogcatEntry | Error): void {
- const properties: ReaderProperties | undefined = this.collectorProperties.get(collector);
- if (!properties) {
- return;
- }
- const msg: Message = {
- id: properties.messageId,
- type: EVENT_TYPE_LOGCAT,
- data: {
- udid: properties.udid,
- type,
- event
- }
- };
- if (type === 'entry' && event && !LogsFilter.filterEvent(properties.filters, event as AdbKitLogcatEntry)) {
- return;
- }
- this.sendMessage(msg);
- }
-
- protected onSocketMessage(event: WebSocket.MessageEvent): void {
- console.log(`Received message: ${event.data}`);
- let data;
- try {
- data = JSON.parse(event.data.toString());
- } catch (e) {
- console.error(e.message);
- return;
- }
- this.handleMessage(data as Message)
- .catch((e: Error) => {
- console.error(e.message);
- });
- }
-
- private handleMessage = async (message: Message): Promise => {
- if (message.type !== EVENT_TYPE_LOGCAT) {
- return;
- }
- const data: LogcatClientMessage = message.data as LogcatClientMessage;
- const {type, udid} = data;
- if (type === 'start') {
- if (this.activeCollectorsMap.has(udid)) {
- console.error(`Reader for "${udid}" is already active`);
- return;
- }
- const collector = await LogcatCollector.getCollector(udid);
- await collector.init();
- const props: ReaderProperties = {
- udid,
- messageId: message.id,
- filters: DEFAULT_FILTERS
- };
- this.collectorProperties.set(collector, props);
- collector.addEventsListener(this);
- collector.getEntries().forEach(entry => {
- this.onEntry(collector, entry);
- });
- return;
- }
- if (type === 'stop') {
- const collector = this.activeCollectorsMap.get(udid);
- if (!collector) {
- console.error(`Reader for "${udid}" is not active`);
- return;
- }
- collector.removeEventsListener(this);
- this.collectorProperties.delete(collector);
- }
- };
-
- public release(): void {
- super.release();
- this.activeCollectorsMap.forEach(collector => {
- collector.removeEventsListener(this);
- this.collectorProperties.delete(collector);
- });
- this.activeCollectorsMap.clear();
- }
-}
diff --git a/src/server/ServiceShell.ts b/src/server/ServiceShell.ts
index f455e9f2..f3c5abf4 100644
--- a/src/server/ServiceShell.ts
+++ b/src/server/ServiceShell.ts
@@ -12,13 +12,13 @@ const EVENT_TYPE_SHELL = 'shell';
export class ServiceShell extends ReleasableService {
private term?: IPty;
- private initialized: boolean = false;
+ private initialized = false;
constructor(ws: WebSocket) {
super(ws);
}
public static createTerminal(ws: WebSocket, params: XtermServiceParameters): IPty {
- // tslint:disable-next-line:no-any
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
const env = Object.assign({}, process.env) as any;
env['COLORTERM'] = 'truecolor';
const { cols = 80, rows = 24 } = params;
@@ -30,9 +30,10 @@ export class ServiceShell extends ReleasableService {
rows,
cwd,
env,
- encoding: null
+ encoding: null,
});
const send = USE_BINARY ? this.bufferUtf8(ws, 5) : this.buffer(ws, 5);
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Documentation is incorrect for `encoding: null`
term.on('data', send);
term.on('exit', () => {
@@ -55,10 +56,9 @@ export class ServiceShell extends ReleasableService {
console.error(e.message);
return;
}
- this.handleMessage(data as Message)
- .catch((e: Error) => {
- console.error(e.message);
- });
+ this.handleMessage(data as Message).catch((e: Error) => {
+ console.error(e.message);
+ });
}
private handleMessage = async (message: Message): Promise => {
@@ -66,7 +66,7 @@ export class ServiceShell extends ReleasableService {
return;
}
const data: XtermClientMessage = message.data as XtermClientMessage;
- const {type} = data;
+ const { type } = data;
if (type === 'start') {
this.term = ServiceShell.createTerminal(this.ws, data);
this.initialized = true;
diff --git a/src/server/index.ts b/src/server/index.ts
index b599df06..ab4aa103 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -7,7 +7,6 @@ import * as path from 'path';
import * as querystring from 'querystring';
import * as readline from 'readline';
import { IncomingMessage, ServerResponse, STATUS_CODES } from 'http';
-import { ServiceLogsProxy } from './ServiceLogsProxy';
import { ServiceDeviceTracker } from './ServiceDeviceTracker';
import { ServiceShell } from './ServiceShell';
@@ -20,7 +19,7 @@ const map: Record = {
'.css': 'text/css',
'.jar': 'application/java-archive',
'.json': 'application/json',
- '.jpg': 'image/jpeg'
+ '.jpg': 'image/jpeg',
};
const PUBLIC_DIR = path.join(__dirname, '../public');
@@ -31,10 +30,7 @@ const server = http.createServer((req: IncomingMessage, res: ServerResponse) =>
return;
}
const parsedUrl = url.parse(req.url);
- let pathname = path.join(
- PUBLIC_DIR,
- (parsedUrl.pathname || '.').replace(/^(\.)+/, '.')
- );
+ let pathname = path.join(PUBLIC_DIR, (parsedUrl.pathname || '.').replace(/^(\.)+/, '.'));
fs.stat(pathname, (statErr, stat) => {
if (statErr) {
if (statErr.code === 'ENOENT') {
@@ -57,7 +53,7 @@ const server = http.createServer((req: IncomingMessage, res: ServerResponse) =>
res.end(`Error getting the file: ${statErr}.`);
} else {
// if the file is found, set Content-type and send data
- res.setHeader('Content-type', map[ext] || 'text/plain' );
+ res.setHeader('Content-type', map[ext] || 'text/plain');
res.end(data);
}
});
@@ -76,9 +72,6 @@ wss.on('connection', async (ws: WebSocket, req) => {
ws.close(4002, `Missing required parameter "action"`);
}
switch (parsedQuery.action) {
- case 'logcat':
- ServiceLogsProxy.createService(ws);
- break;
case 'devicelist':
ServiceDeviceTracker.createService(ws);
break;
@@ -107,9 +100,9 @@ function printListeningMsg(): void {
};
formatAddress(os.hostname(), false);
Object.keys(os.networkInterfaces())
- .map(key => os.networkInterfaces()[key])
- .forEach(info => {
- info.forEach(iface => {
+ .map((key) => os.networkInterfaces()[key])
+ .forEach((info) => {
+ info.forEach((iface) => {
const ipv4 = iface.family === 'IPv4';
const ipv6 = iface.family === 'IPv6';
if (!ipv4 && !ipv6) {
@@ -122,12 +115,14 @@ function printListeningMsg(): void {
}
if (process.platform === 'win32') {
- readline.createInterface({
- input: process.stdin,
- output: process.stdout
- }).on('SIGINT', () => {
- process.exit();
- });
+ readline
+ .createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ })
+ .on('SIGINT', () => {
+ process.exit();
+ });
}
process.on('SIGINT', () => {
diff --git a/src/tinyh264/Canvas.ts b/src/tinyh264/Canvas.ts
new file mode 100644
index 00000000..7d58063f
--- /dev/null
+++ b/src/tinyh264/Canvas.ts
@@ -0,0 +1,4 @@
+export default abstract class Canvas {
+ constructor(protected readonly canvas: HTMLCanvasElement) {}
+ public abstract decode(buffer: Uint8Array, width: number, height: number): void;
+}
diff --git a/src/tinyh264/H264NALDecoder.worker.ts b/src/tinyh264/H264NALDecoder.worker.ts
new file mode 100644
index 00000000..879684ae
--- /dev/null
+++ b/src/tinyh264/H264NALDecoder.worker.ts
@@ -0,0 +1,3 @@
+import { init } from 'tinyh264';
+
+init();
diff --git a/src/tinyh264/ShaderCompiler.ts b/src/tinyh264/ShaderCompiler.ts
new file mode 100644
index 00000000..a28d0f2f
--- /dev/null
+++ b/src/tinyh264/ShaderCompiler.ts
@@ -0,0 +1,39 @@
+/**
+ * Represents a WebGL shader object and provides a mechanism to load shaders from HTML
+ * script tags.
+ */
+
+export default class ShaderCompiler {
+ /**
+ * @param {WebGLRenderingContext}gl
+ * @param {{type: string, source: string}}script
+ * @return {WebGLShader}
+ */
+ static compile(gl: WebGLRenderingContext, script: { type: string; source: string }): WebGLShader | null {
+ let shader: WebGLShader | null;
+ // Now figure out what type of shader script we have, based on its MIME type.
+ if (script.type === 'x-shader/x-fragment') {
+ shader = gl.createShader(gl.FRAGMENT_SHADER);
+ } else if (script.type === 'x-shader/x-vertex') {
+ shader = gl.createShader(gl.VERTEX_SHADER);
+ } else {
+ throw new Error('Unknown shader type: ' + script.type);
+ }
+ if (!shader) {
+ throw new Error('Failed to create shader');
+ }
+
+ // Send the source to the shader object.
+ gl.shaderSource(shader, script.source);
+
+ // Compile the shader program.
+ gl.compileShader(shader);
+
+ // See if it compiled successfully.
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ throw new Error('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(shader));
+ }
+
+ return shader;
+ }
+}
diff --git a/src/tinyh264/ShaderProgram.ts b/src/tinyh264/ShaderProgram.ts
new file mode 100644
index 00000000..40660f40
--- /dev/null
+++ b/src/tinyh264/ShaderProgram.ts
@@ -0,0 +1,64 @@
+export default class ShaderProgram {
+ public program: WebGLProgram | null;
+ /**
+ * @param {WebGLRenderingContext}gl
+ */
+ constructor(private gl: WebGLRenderingContext) {
+ this.program = this.gl.createProgram();
+ }
+
+ /**
+ * @param {WebGLShader}shader
+ */
+ attach(shader: WebGLShader): void {
+ if (!this.program) {
+ throw Error(`Program type is ${typeof this.program}`);
+ }
+ this.gl.attachShader(this.program, shader);
+ }
+
+ link(): void {
+ if (!this.program) {
+ throw Error(`Program type is ${typeof this.program}`);
+ }
+ this.gl.linkProgram(this.program);
+ // If creating the shader program failed, alert.
+ if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) {
+ console.error('Unable to initialize the shader program.');
+ }
+ }
+
+ use(): void {
+ this.gl.useProgram(this.program);
+ }
+
+ /**
+ * @param {string}name
+ * @return {number}
+ */
+ getAttributeLocation(name: string): number {
+ if (!this.program) {
+ throw Error(`Program type is ${typeof this.program}`);
+ }
+ return this.gl.getAttribLocation(this.program, name);
+ }
+
+ /**
+ * @param {string}name
+ * @return {WebGLUniformLocation | null}
+ */
+ getUniformLocation(name: string): WebGLUniformLocation | null {
+ if (!this.program) {
+ throw Error(`Program type is ${typeof this.program}`);
+ }
+ return this.gl.getUniformLocation(this.program, name);
+ }
+
+ /**
+ * @param {WebGLUniformLocation}uniformLocation
+ * @param {Array}array
+ */
+ setUniformM4(uniformLocation: WebGLUniformLocation, array: number[]): void {
+ this.gl.uniformMatrix4fv(uniformLocation, false, array);
+ }
+}
diff --git a/src/tinyh264/ShaderSources.ts b/src/tinyh264/ShaderSources.ts
new file mode 100644
index 00000000..f5309f9b
--- /dev/null
+++ b/src/tinyh264/ShaderSources.ts
@@ -0,0 +1,50 @@
+/**
+ * @type {{type: string, source: string}}
+ */
+export const vertexQuad = {
+ type: 'x-shader/x-vertex',
+ source: `
+ precision mediump float;
+
+ uniform mat4 u_projection;
+ attribute vec2 a_position;
+ attribute vec2 a_texCoord;
+ varying vec2 v_texCoord;
+ void main(){
+ v_texCoord = a_texCoord;
+ gl_Position = u_projection * vec4(a_position, 0.0, 1.0);
+ }
+`,
+};
+
+/**
+ * @type {{type: string, source: string}}
+ */
+export const fragmentYUV = {
+ type: 'x-shader/x-fragment',
+ source: `
+ precision lowp float;
+
+ varying vec2 v_texCoord;
+
+ uniform sampler2D yTexture;
+ uniform sampler2D uTexture;
+ uniform sampler2D vTexture;
+
+ const mat4 conversion = mat4(
+ 1.0, 0.0, 1.402, -0.701,
+ 1.0, -0.344, -0.714, 0.529,
+ 1.0, 1.772, 0.0, -0.886,
+ 0.0, 0.0, 0.0, 0.0
+ );
+
+ void main(void) {
+ float yChannel = texture2D(yTexture, v_texCoord).x;
+ float uChannel = texture2D(uTexture, v_texCoord).x;
+ float vChannel = texture2D(vTexture, v_texCoord).x;
+ vec4 channels = vec4(yChannel, uChannel, vChannel, 1.0);
+ vec3 rgb = (channels * conversion).xyz;
+ gl_FragColor = vec4(rgb, 1.0);
+ }
+`,
+};
diff --git a/src/tinyh264/YUVCanvas.ts b/src/tinyh264/YUVCanvas.ts
new file mode 100644
index 00000000..1bfbbc4b
--- /dev/null
+++ b/src/tinyh264/YUVCanvas.ts
@@ -0,0 +1,45 @@
+import Canvas from './Canvas';
+
+export default class YUVCanvas extends Canvas {
+ private canvasCtx: CanvasRenderingContext2D;
+ private canvasBuffer: ImageData | null = null;
+
+ constructor(canvas: HTMLCanvasElement) {
+ super(canvas);
+ this.canvasCtx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
+ }
+ public decode(buffer: Uint8Array, width: number, height: number): void {
+ if (!buffer) {
+ return;
+ }
+ if (!this.canvasBuffer) {
+ this.canvasBuffer = this.canvasCtx.createImageData(width, height);
+ }
+
+ const lumaSize = width * height;
+ const chromaSize = lumaSize >> 2;
+
+ const ybuf = buffer.subarray(0, lumaSize);
+ const ubuf = buffer.subarray(lumaSize, lumaSize + chromaSize);
+ const vbuf = buffer.subarray(lumaSize + chromaSize, lumaSize + 2 * chromaSize);
+
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const yIndex = x + y * width;
+ const uIndex = ~~(y / 2) * ~~(width / 2) + ~~(x / 2);
+ const vIndex = ~~(y / 2) * ~~(width / 2) + ~~(x / 2);
+ const R = 1.164 * (ybuf[yIndex] - 16) + 1.596 * (vbuf[vIndex] - 128);
+ const G = 1.164 * (ybuf[yIndex] - 16) - 0.813 * (vbuf[vIndex] - 128) - 0.391 * (ubuf[uIndex] - 128);
+ const B = 1.164 * (ybuf[yIndex] - 16) + 2.018 * (ubuf[uIndex] - 128);
+
+ const rgbIndex = yIndex * 4;
+ this.canvasBuffer.data[rgbIndex + 0] = R;
+ this.canvasBuffer.data[rgbIndex + 1] = G;
+ this.canvasBuffer.data[rgbIndex + 2] = B;
+ this.canvasBuffer.data[rgbIndex + 3] = 0xff;
+ }
+ }
+
+ this.canvasCtx.putImageData(this.canvasBuffer, 0, 0);
+ }
+}
diff --git a/src/tinyh264/YUVSurfaceShader.ts b/src/tinyh264/YUVSurfaceShader.ts
new file mode 100644
index 00000000..d2bb4fb6
--- /dev/null
+++ b/src/tinyh264/YUVSurfaceShader.ts
@@ -0,0 +1,145 @@
+import ShaderProgram from './ShaderProgram';
+import ShaderCompiler from './ShaderCompiler';
+import { fragmentYUV, vertexQuad } from './ShaderSources';
+import Texture from '../h264-live-player/Texture';
+
+type ShaderArguments = {
+ yTexture: WebGLUniformLocation | null;
+ uTexture: WebGLUniformLocation | null;
+ vTexture: WebGLUniformLocation | null;
+ u_projection: WebGLUniformLocation | null;
+ a_position: number;
+ a_texCoord: number;
+};
+
+export default class YUVSurfaceShader {
+ /**
+ *
+ * @param {WebGLRenderingContext} gl
+ * @returns {YUVSurfaceShader}
+ */
+ static create(gl: WebGLRenderingContext): YUVSurfaceShader {
+ const program = this._initShaders(gl);
+ const shaderArgs = this._initShaderArgs(gl, program);
+ const vertexBuffer = this._initBuffers(gl);
+
+ return new YUVSurfaceShader(gl, vertexBuffer as WebGLBuffer, shaderArgs, program);
+ }
+
+ static _initShaders(gl: WebGLRenderingContext): ShaderProgram {
+ const program = new ShaderProgram(gl);
+ program.attach(ShaderCompiler.compile(gl, vertexQuad) as WebGLShader);
+ program.attach(ShaderCompiler.compile(gl, fragmentYUV) as WebGLShader);
+ program.link();
+ program.use();
+
+ return program;
+ }
+
+ static _initShaderArgs(gl: WebGLRenderingContext, program: ShaderProgram): ShaderArguments {
+ // find shader arguments
+ const shaderArgs: ShaderArguments = {
+ yTexture: program.getUniformLocation('yTexture'),
+ uTexture: program.getUniformLocation('uTexture'),
+ vTexture: program.getUniformLocation('vTexture'),
+ u_projection: program.getUniformLocation('u_projection'),
+ a_position: program.getAttributeLocation('a_position'),
+ a_texCoord: program.getAttributeLocation('a_texCoord'),
+ };
+
+ gl.enableVertexAttribArray(shaderArgs.a_position);
+ gl.enableVertexAttribArray(shaderArgs.a_texCoord);
+
+ return shaderArgs;
+ }
+
+ static _initBuffers(gl: WebGLRenderingContext): WebGLBuffer | null {
+ // Create vertex buffer object.
+ return gl.createBuffer();
+ }
+
+ constructor(
+ private gl: WebGLRenderingContext,
+ private vertexBuffer: WebGLBuffer,
+ private shaderArgs: ShaderArguments,
+ private program: ShaderProgram,
+ ) {}
+
+ /**
+ *
+ * @param {Texture} textureY
+ * @param {Texture} textureU
+ * @param {Texture} textureV
+ */
+ setTexture(textureY: Texture, textureU: Texture, textureV: Texture): void {
+ const gl = this.gl;
+
+ gl.uniform1i(this.shaderArgs.yTexture, 0);
+ gl.uniform1i(this.shaderArgs.uTexture, 1);
+ gl.uniform1i(this.shaderArgs.vTexture, 2);
+
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, textureY.texture);
+
+ gl.activeTexture(gl.TEXTURE1);
+ gl.bindTexture(gl.TEXTURE_2D, textureU.texture);
+
+ gl.activeTexture(gl.TEXTURE2);
+ gl.bindTexture(gl.TEXTURE_2D, textureV.texture);
+ }
+
+ use(): void {
+ this.program.use();
+ }
+
+ release(): void {
+ this.gl.useProgram(null);
+ }
+
+ /**
+ * @param {{w:number, h:number}}encodedFrameSize
+ * @param {{maxXTexCoord:number, maxYTexCoord:number}} h264RenderState
+ */
+ updateShaderData(
+ encodedFrameSize: { w: number; h: number },
+ h264RenderState: { maxXTexCoord: number; maxYTexCoord: number },
+ ): void {
+ const { w, h } = encodedFrameSize;
+ this.gl.viewport(0, 0, w, h);
+ // prettier-ignore
+ this.program.setUniformM4(this.shaderArgs.u_projection as WebGLUniformLocation, [
+ 2.0 / w, 0, 0, 0,
+ 0, 2.0 / -h, 0, 0,
+ 0, 0, 1, 0,
+ -1, 1, 0, 1
+ ])
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
+ // prettier-ignore
+ this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array([
+ // First triangle
+ // top left:
+ 0, 0, 0, 0,
+ // top right:
+ w, 0, h264RenderState.maxXTexCoord, 0,
+ // bottom right:
+ w, h, h264RenderState.maxXTexCoord, h264RenderState.maxYTexCoord,
+
+ // Second triangle
+ // bottom right:
+ w, h, h264RenderState.maxXTexCoord, h264RenderState.maxYTexCoord,
+ // bottom left:
+ 0, h, 0, h264RenderState.maxYTexCoord,
+ // top left:
+ 0, 0, 0, 0
+ ]), this.gl.DYNAMIC_DRAW);
+ this.gl.vertexAttribPointer(this.shaderArgs.a_position, 2, this.gl.FLOAT, false, 16, 0);
+ this.gl.vertexAttribPointer(this.shaderArgs.a_texCoord, 2, this.gl.FLOAT, false, 16, 8);
+ }
+
+ draw(): void {
+ const gl = this.gl;
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);
+ gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 6);
+ gl.bindTexture(gl.TEXTURE_2D, null);
+ }
+}
diff --git a/src/tinyh264/YUVWebGLCanvas.ts b/src/tinyh264/YUVWebGLCanvas.ts
new file mode 100644
index 00000000..e9a765c2
--- /dev/null
+++ b/src/tinyh264/YUVWebGLCanvas.ts
@@ -0,0 +1,66 @@
+/**
+ * based on tinyh264 demo: https://github.com/udevbe/tinyh264/tree/master/demo
+ */
+
+import YUVSurfaceShader from './YUVSurfaceShader';
+import Texture from '../h264-live-player/Texture';
+import Canvas from './Canvas';
+
+export default class YUVWebGLCanvas extends Canvas {
+ private yTexture: Texture;
+ private uTexture: Texture;
+ private vTexture: Texture;
+ private yuvSurfaceShader: YUVSurfaceShader;
+
+ constructor(canvas: HTMLCanvasElement) {
+ super(canvas);
+ const gl = canvas.getContext('experimental-webgl', {
+ preserveDrawingBuffer: true,
+ }) as WebGLRenderingContext | null;
+ if (!gl) {
+ throw new Error('Unable to initialize WebGL. Your browser may not support it.');
+ }
+ this.yuvSurfaceShader = YUVSurfaceShader.create(gl);
+ this.yTexture = Texture.create(gl, gl.LUMINANCE);
+ this.uTexture = Texture.create(gl, gl.LUMINANCE);
+ this.vTexture = Texture.create(gl, gl.LUMINANCE);
+ }
+
+ decode(buffer: Uint8Array, width: number, height: number): void {
+ this.canvas.width = width;
+ this.canvas.height = height;
+
+ // the width & height returned are actually padded, so we have to use the frame size to get the real image dimension
+ // when uploading to texture
+ const stride = width; // stride
+ // height is padded with filler rows
+
+ // if we knew the size of the video before encoding, we could cut out the black filler pixels. We don't, so just set
+ // it to the size after encoding
+ const sourceWidth = width;
+ const sourceHeight = height;
+ const maxXTexCoord = sourceWidth / stride;
+ const maxYTexCoord = sourceHeight / height;
+
+ const lumaSize = stride * height;
+ const chromaSize = lumaSize >> 2;
+
+ const yBuffer = buffer.subarray(0, lumaSize);
+ const uBuffer = buffer.subarray(lumaSize, lumaSize + chromaSize);
+ const vBuffer = buffer.subarray(lumaSize + chromaSize, lumaSize + 2 * chromaSize);
+
+ const chromaHeight = height >> 1;
+ const chromaStride = stride >> 1;
+
+ // we upload the entire image, including stride padding & filler rows. The actual visible image will be mapped
+ // from texture coordinates as to crop out stride padding & filler rows using maxXTexCoord and maxYTexCoord.
+
+ this.yTexture.image2dBuffer(yBuffer, stride, height);
+ this.uTexture.image2dBuffer(uBuffer, chromaStride, chromaHeight);
+ this.vTexture.image2dBuffer(vBuffer, chromaStride, chromaHeight);
+
+ this.yuvSurfaceShader.setTexture(this.yTexture, this.uTexture, this.vTexture);
+ this.yuvSurfaceShader.updateShaderData({ w: width, h: height }, { maxXTexCoord, maxYTexCoord });
+ this.yuvSurfaceShader.draw();
+ }
+}
diff --git a/src/ui/SvgImage.ts b/src/ui/SvgImage.ts
new file mode 100644
index 00000000..41a2b097
--- /dev/null
+++ b/src/ui/SvgImage.ts
@@ -0,0 +1,59 @@
+import KeyboardSVG from '../../images/skin-light/ic_keyboard_678_48dp.svg';
+import MoreSVG from '../../images/skin-light/ic_more_horiz_678_48dp.svg';
+import CameraSVG from '../../images/skin-light/ic_photo_camera_678_48dp.svg';
+import PowerSVG from '../../images/skin-light/ic_power_settings_new_678_48px.svg';
+import VolumeDownSVG from '../../images/skin-light/ic_volume_down_678_48px.svg';
+import VolumeUpSVG from '../../images/skin-light/ic_volume_up_678_48px.svg';
+import BackSVG from '../../images/skin-light/System_Back_678.svg';
+import HomeSVG from '../../images/skin-light/System_Home_678.svg';
+import OverviewSVG from '../../images/skin-light/System_Overview_678.svg';
+
+enum Icon {
+ BACK,
+ HOME,
+ OVERVIEW,
+ POWER,
+ VOLUME_UP,
+ VOLUME_DOWN,
+ MORE,
+ CAMERA,
+ KEYBOARD,
+}
+
+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;
+ 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;
+ }
+}
diff --git a/src/vendor/h264bsd_canvas.js b/src/vendor/h264bsd_canvas.js
index 6243a47c..56b32463 100644
--- a/src/vendor/h264bsd_canvas.js
+++ b/src/vendor/h264bsd_canvas.js
@@ -57,7 +57,9 @@ H264bsdCanvas.prototype.initContextGL = function() {
var contextName = validContextNames[nameIndex];
try {
- gl = canvas.getContext(contextName);
+ gl = canvas.getContext(contextName, {
+ preserveDrawingBuffer: true
+ });
} catch (e) {
gl = null;
}
diff --git a/tsconfig.json b/tsconfig.json
index f223e696..a321b4a8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,9 +2,9 @@
"compilerOptions": {
"esModuleInterop": true,
"baseUrl": ".",
- "paths": { "*": ["types/*"] },
+ "paths": { "*": ["typings/*"] },
/* Basic Options */
- "target": "es2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */
+ "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd' or 'es2015'. */
"lib": ["es2017", "dom"], /* Specify library files to be included in the compilation: */
// "allowJs": true, /* Allow javascript files to be compiled. */
@@ -18,7 +18,7 @@
"removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
"importHelpers": true, /* Import emit helpers from 'tslib'. */
- // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
+ "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
@@ -54,6 +54,7 @@
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
},
"include":[
+ "./typings/**/*",
"./src/**/*"
]
}
diff --git a/src/common/AdbKitLogcat.d.ts b/typings/adbkit/AdbKitLogcat.d.ts
similarity index 93%
rename from src/common/AdbKitLogcat.d.ts
rename to typings/adbkit/AdbKitLogcat.d.ts
index 06cf158c..d19fe9fb 100644
--- a/src/common/AdbKitLogcat.d.ts
+++ b/typings/adbkit/AdbKitLogcat.d.ts
@@ -1,5 +1,5 @@
import { EventEmitter } from 'events';
-import { PriorityLevel } from '../server/LogsFilter';
+import { PriorityLevel } from './PriorityLevel';
export interface AdbKitLogcatEntry {
date: Date;
@@ -14,7 +14,7 @@ export enum AdbKitLogcatReaderEvents {
'error',
'end',
'finish',
- 'entry'
+ 'entry',
}
declare interface PriorityMethods {
diff --git a/src/common/LogcatMessage.d.ts b/typings/adbkit/LogcatMessage.d.ts
similarity index 80%
rename from src/common/LogcatMessage.d.ts
rename to typings/adbkit/LogcatMessage.d.ts
index 96ef0239..b0f08b51 100644
--- a/src/common/LogcatMessage.d.ts
+++ b/typings/adbkit/LogcatMessage.d.ts
@@ -1,9 +1,9 @@
-import { AdbKitLogcatEntry, AdbKitLogcatReaderEvents } from './AdbKitLogcat';
-import { PriorityLevel } from '../server/LogsFilter';
+import { AdbKitLogcatEntry, AdbKitLogcatReaderEvents } from 'adbkit/AdbKitLogcat';
+import { PriorityLevel } from './PriorityLevel';
export type FiltersMap = Map | undefined;
export interface TextFilter {
- value: (string | RegExp);
+ value: string | RegExp;
priority: PriorityLevel;
}
export type FiltersArray = TextFilter[] | undefined;
@@ -26,7 +26,7 @@ export interface LogcatServiceMessage {
export enum LogcatServiceActions {
start,
stop,
- filter
+ filter,
}
export interface LogcatClientMessage {
diff --git a/typings/adbkit/PriorityLevel.d.ts b/typings/adbkit/PriorityLevel.d.ts
new file mode 100644
index 00000000..ed6ca531
--- /dev/null
+++ b/typings/adbkit/PriorityLevel.d.ts
@@ -0,0 +1,11 @@
+export enum PriorityLevel {
+ UNKNOWN = 0,
+ DEFAULT = 1,
+ VERBOSE = 2,
+ DEBUG = 3,
+ INFO = 4,
+ WARN = 5,
+ ERROR = 6,
+ FATAL = 7,
+ SILENT = 8,
+}
diff --git a/src/common/AdbKit.d.ts b/typings/adbkit/index.d.ts
similarity index 60%
rename from src/common/AdbKit.d.ts
rename to typings/adbkit/index.d.ts
index 5617062d..f47e2901 100644
--- a/src/common/AdbKit.d.ts
+++ b/typings/adbkit/index.d.ts
@@ -5,7 +5,7 @@ import { Socket } from 'net';
type Callback = (err: Error | null, result?: T) => void;
-interface PushTransfer extends EventEmitter {}
+type PushTransfer = EventEmitter;
export interface AdbKitTracker extends EventEmitter {
deviceList: AdbKitDevice[];
@@ -21,8 +21,18 @@ export interface AdbKitClient {
listDevices(): Promise;
trackDevices(): Promise;
getProperties(serial: string): Promise>;
- openLogcat(serial: string, options?: {clear?: boolean}, callback?: Callback): AdbKitLogcatReader;
- push(serial: string, contents: string | Stream, path: string, mode?: number, callback?: Callback): Promise;
+ openLogcat(
+ serial: string,
+ options?: { clear?: boolean },
+ callback?: Callback,
+ ): AdbKitLogcatReader;
+ push(
+ serial: string,
+ contents: string | Stream,
+ path: string,
+ mode?: number,
+ callback?: Callback,
+ ): Promise;
shell(serial: string, command: string, callback?: Callback): Promise;
waitBootComplete(serial: string): Promise;
}
@@ -32,3 +42,10 @@ export interface AdbKitChangesSet {
removed: AdbKitDevice[];
changed: AdbKitDevice[];
}
+
+declare module 'adbkit' {
+ const createClient: () => AdbKitClient;
+ const util: {
+ readAll: (stream: any, callback?: (err: Error | null, output?: Buffer) => any) => Promise;
+ };
+}
diff --git a/typings/custom_png.d.ts b/typings/custom_png.d.ts
new file mode 100644
index 00000000..885ace8f
--- /dev/null
+++ b/typings/custom_png.d.ts
@@ -0,0 +1,4 @@
+declare module '*.png' {
+ const content: any;
+ export default content;
+}
diff --git a/typings/custom_svg.d.ts b/typings/custom_svg.d.ts
new file mode 100644
index 00000000..60bd434c
--- /dev/null
+++ b/typings/custom_svg.d.ts
@@ -0,0 +1,4 @@
+declare module '*.svg' {
+ const content: any;
+ export default content;
+}
diff --git a/typings/tinyh264.d.ts b/typings/tinyh264.d.ts
new file mode 100644
index 00000000..7b21f908
--- /dev/null
+++ b/typings/tinyh264.d.ts
@@ -0,0 +1 @@
+export const init: () => void;
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 00000000..88de9231
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,41 @@
+const path = require('path')
+
+module.exports = {
+ entry: './build/index.js',
+ output: {
+ filename: 'bundle.js',
+ path: path.resolve(__dirname, 'dist/public'),
+ },
+ externals: ['fs'],
+ module: {
+ rules: [
+ {
+ test: /\.worker\.js$/,
+ use: { loader: 'worker-loader' }
+ },
+ {
+ test: /\.svg$/,
+ loader: 'svg-inline-loader'
+ },
+ {
+ test: /\.(png|jpe?g|gif)$/i,
+ use: [
+ {
+ loader: 'file-loader',
+ },
+ ],
+ },
+ {
+ test: /\.(asset)$/i,
+ use: [
+ {
+ loader: 'file-loader',
+ options: {
+ name: '[name]',
+ },
+ },
+ ],
+ }
+ ]
+ }
+}