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 @@ + + + + Artboard 1 + Created with Sketch. + + + + + + + + + + + + + + + + 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 @@ + + + + System_Home + Created with Sketch. + + + + + + + + + + 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 @@ + + + + System_Overview + Created with Sketch. + + + + + + + + + + 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]', + }, + }, + ], + } + ] + } +}